New admin panel - People → Study Users

This commit is contained in:
nmemmert
2026-06-03 12:29:05 -04:00
parent 4983524ea4
commit 49c9994156
3 changed files with 464 additions and 1 deletions
+243 -1
View File
@@ -120,6 +120,244 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
)
}
interface StudyUserRecord {
id: string
username: string
displayName: string
createdAt: string | null
lastLoginAt: string | null
enrolledStudies: Array<{ slug: string; title: string }>
noteCount: number
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
}
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
const [users, setUsers] = useState<StudyUserRecord[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [editDisplayName, setEditDisplayName] = useState<Record<string, string>>({})
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
const [msg, setMsg] = useState<Record<string, string>>({})
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
function flashMsg(id: string, text: string) {
setMsg(prev => ({ ...prev, [id]: text }))
setTimeout(() => setMsg(prev => { const n = { ...prev }; delete n[id]; return n }), 4000)
}
useEffect(() => {
fetch('/api/admin/study-users')
.then(r => r.json())
.then((d: { users: StudyUserRecord[] }) => { setUsers(d.users ?? []); setLoading(false) })
.catch(() => setLoading(false))
}, [])
async function patchUser(id: string, body: Record<string, string>) {
const res = await fetch(`/api/admin/study-users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
if (!res.ok) throw new Error(data.message ?? 'Error')
return data
}
async function handleSaveDisplayName(user: StudyUserRecord) {
try {
const name = editDisplayName[user.id] ?? user.displayName
await patchUser(user.id, { displayName: name })
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, displayName: name } : u))
flashMsg(user.id, 'Display name updated.')
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
async function handleResetPassword(user: StudyUserRecord) {
const pw = newPassword[user.id] ?? ''
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
try {
await patchUser(user.id, { newPassword: pw })
setNewPassword(prev => { const n = { ...prev }; delete n[user.id]; return n })
flashMsg(user.id, 'Password reset. Their active sessions have been signed out.')
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
async function handleEnroll(user: StudyUserRecord, slug: string) {
try {
const data = await patchUser(user.id, { addEnrollment: slug })
setUsers(prev => prev.map(u => {
if (u.id !== user.id) return u
const study = studies.find(s => s.slug === slug)
const already = u.enrolledStudies.find(e => e.slug === slug)
return { ...u, enrolledStudies: already ? u.enrolledStudies : [...u.enrolledStudies, { slug, title: study?.title ?? slug }] }
}))
flashMsg(user.id, `Enrolled in ${slug}.`)
return data
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
async function handleUnenroll(user: StudyUserRecord, slug: string) {
try {
await patchUser(user.id, { removeEnrollment: slug })
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, enrolledStudies: u.enrolledStudies.filter(e => e.slug !== slug) } : u))
flashMsg(user.id, `Removed from ${slug}.`)
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
async function handleDelete(id: string) {
try {
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
setUsers(prev => prev.filter(u => u.id !== id))
setConfirmDeleteId(null)
setExpandedId(null)
} catch { /* ignore */ }
}
const filtered = users.filter(u =>
u.username.toLowerCase().includes(search.toLowerCase()) ||
u.displayName.toLowerCase().includes(search.toLowerCase())
)
if (loading) return <p className="admin-stats-note">Loading study users</p>
return (
<section className="admin-panel-section" aria-label="Study Users">
<div className="admin-panel-head">
<h2>Study Users</h2>
<p>Manage student accounts reset passwords, update display names, adjust enrollments, or delete accounts.</p>
</div>
<div className="admin-field" style={{ maxWidth: '360px', marginBottom: '1.25rem' }}>
<input
type="search"
placeholder="Search by email or display name…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{filtered.length === 0 && <p className="admin-stats-note">{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}</p>}
<div className="admin-study-users-list">
{filtered.map(user => {
const isExpanded = expandedId === user.id
const unenrolledStudies = studies.filter(s => !user.enrolledStudies.find(e => e.slug === s.slug))
return (
<div key={user.id} className={`admin-study-user-card${isExpanded ? ' admin-study-user-card--open' : ''}`}>
<button
type="button"
className="admin-study-user-header"
onClick={() => {
setExpandedId(isExpanded ? null : user.id)
setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName }))
}}
>
<div className="admin-study-user-header-left">
<strong>{user.displayName || user.username}</strong>
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
</div>
<div className="admin-study-user-header-right">
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
<span className="admin-study-user-pill">{user.noteCount} notes</span>
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
</div>
</button>
{isExpanded && (
<div className="admin-study-user-body">
<div className="admin-study-user-meta">
<span>Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}</span>
<span>Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'}</span>
<span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span>
<span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}</span>
</div>
{/* Display Name */}
<div className="admin-study-user-section">
<h4>Display Name</h4>
<div className="admin-study-user-row">
<input
type="text"
value={editDisplayName[user.id] ?? user.displayName}
onChange={e => setEditDisplayName(prev => ({ ...prev, [user.id]: e.target.value }))}
placeholder="Display name"
maxLength={80}
/>
<button type="button" className="btn-admin-save" onClick={() => handleSaveDisplayName(user)}>Save</button>
</div>
</div>
{/* Password Reset */}
<div className="admin-study-user-section">
<h4>Reset Password</h4>
<div className="admin-study-user-row">
<input
type="text"
value={newPassword[user.id] ?? ''}
onChange={e => setNewPassword(prev => ({ ...prev, [user.id]: e.target.value }))}
placeholder="New password (min 8 chars)"
autoComplete="off"
/>
<button type="button" className="btn-admin-save" onClick={() => handleResetPassword(user)}>Reset</button>
</div>
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>This immediately signs them out of all active sessions.</p>
</div>
{/* Enrollments */}
<div className="admin-study-user-section">
<h4>Enrollments</h4>
{user.enrolledStudies.length === 0
? <p className="admin-stats-note">Not enrolled in any studies.</p>
: (
<ul className="admin-study-user-enrollments">
{user.enrolledStudies.map(e => (
<li key={e.slug}>
<span>{e.title}</span>
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => handleUnenroll(user, e.slug)}>Remove</button>
</li>
))}
</ul>
)
}
{unenrolledStudies.length > 0 && (
<div className="admin-study-user-row" style={{ marginTop: '0.5rem' }}>
<select
defaultValue=""
onChange={e => { if (e.target.value) { handleEnroll(user, e.target.value); e.target.value = '' } }}
>
<option value="">Enroll in a study</option>
{unenrolledStudies.map(s => <option key={s.slug} value={s.slug}>{s.title}</option>)}
</select>
</div>
)}
</div>
{msg[user.id] && <p className="admin-study-user-msg">{msg[user.id]}</p>}
{/* Delete */}
<div className="admin-study-user-section" style={{ borderTop: '1px solid rgba(201,168,76,0.1)', paddingTop: '0.75rem', marginTop: '0.5rem' }}>
{confirmDeleteId === user.id ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontSize: '0.85rem', color: '#e05c5c' }}>Delete this account permanently?</span>
<button type="button" className="btn-admin-remove" onClick={() => handleDelete(user.id)}>Yes, Delete</button>
<button type="button" className="btn-admin-reset" onClick={() => setConfirmDeleteId(null)}>Cancel</button>
</div>
) : (
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.82rem' }} onClick={() => setConfirmDeleteId(user.id)}>Delete Account</button>
)}
</div>
</div>
)}
</div>
)
})}
</div>
</section>
)
}
interface Props {
content: SiteContent
onSave: (c: SiteContent) => void
@@ -319,7 +557,7 @@ type AdminView =
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts'
| 'emails' | 'subscribers' | 'contacts' | 'study-users'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
interface AdminSectionLink {
@@ -358,6 +596,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
{ value: 'emails', label: 'Emails' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' },
{ value: 'analytics', label: 'Analytics' },
{ value: 'assets', label: 'Asset Manager' },
],
@@ -4437,6 +4676,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{/* STUDY USERS */}
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
{/* ANALYTICS */}
{adminView === 'analytics' && (
<AnalyticsPanel