From 49c9994156099b906843265d43aba95f7d40e7a2 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 3 Jun 2026 12:29:05 -0400 Subject: [PATCH] =?UTF-8?q?New=20admin=20panel=20-=20People=20=E2=86=92=20?= =?UTF-8?q?Study=20Users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 87 +++++++++++++++++ src/AdminPage.tsx | 244 +++++++++++++++++++++++++++++++++++++++++++++- src/App.css | 134 +++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 4ef2b0c..72bb03d 100644 --- a/server.js +++ b/server.js @@ -3908,6 +3908,93 @@ app.delete('/api/study-account', requireStudyAuth, async (req, res) => { res.json({ ok: true }) }) +// ── Admin: Study User Management ────────────────────────────────────────── + +app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => { + const catalog = getStudyCatalog() + const users = await Promise.all(studyUsers.map(async user => { + let noteCount = 0 + try { + const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}') + const notes = JSON.parse(notesRaw) + noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length + } catch { /* ignore */ } + + const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => { + const study = catalog.find(s => s.slug === slug) + return study ? { slug, title: study.title } : { slug, title: slug } + }) + + return { + id: user.id, + username: user.username, + displayName: user.displayName ?? '', + createdAt: user.createdAt ?? null, + lastLoginAt: user.lastLoginAt ?? null, + enrolledStudies, + noteCount, + subscribeNewsletter: user.subscribeNewsletter !== false, + studyRemindersEnabled: user.studyRemindersEnabled === true, + } + })) + + res.json({ users }) +}) + +app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => { + const user = studyUsers.find(u => u.id === req.params.id) + if (!user) { res.status(404).json({ message: 'User not found.' }); return } + + const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {} + + if (typeof displayName === 'string') { + user.displayName = displayName.trim().slice(0, 80) + } + + if (typeof newPassword === 'string') { + if (newPassword.length < 8 || newPassword.length > 200) { + res.status(400).json({ message: 'Password must be 8–200 characters.' }); return + } + user.passwordHash = hashStudyPassword(newPassword) + // Revoke all active sessions so they must log in with new password + for (const [token, session] of studySessions) { + if (session.userId === user.id) studySessions.delete(token) + } + } + + if (typeof addEnrollment === 'string' && addEnrollment.trim()) { + const slug = addEnrollment.trim() + if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = [] + if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug) + } + + if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) { + const slug = removeEnrollment.trim() + user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug) + } + + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) +}) + +app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => { + const user = studyUsers.find(u => u.id === req.params.id) + if (!user) { res.status(404).json({ message: 'User not found.' }); return } + + for (const [token, session] of studySessions) { + if (session.userId === user.id) studySessions.delete(token) + } + + studyUsers = studyUsers.filter(u => u.id !== user.id) + queueStudyUsersWrite() + + studyNotesCache.delete(user.id) + try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ } + + res.json({ ok: true }) +}) + app.get('/api/admin-auth/status', async (req, res) => { res.json({ authenticated: isValidAdminSession(req), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 9a60460..f3570fc 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [expandedId, setExpandedId] = useState(null) + const [editDisplayName, setEditDisplayName] = useState>({}) + const [newPassword, setNewPassword] = useState>({}) + const [msg, setMsg] = useState>({}) + const [confirmDeleteId, setConfirmDeleteId] = useState(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) { + 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

Loading study users…

+ + return ( +
+
+

Study Users

+

Manage student accounts — reset passwords, update display names, adjust enrollments, or delete accounts.

+
+ +
+ setSearch(e.target.value)} + /> +
+ + {filtered.length === 0 &&

{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}

} + +
+ {filtered.map(user => { + const isExpanded = expandedId === user.id + const unenrolledStudies = studies.filter(s => !user.enrolledStudies.find(e => e.slug === s.slug)) + return ( +
+ + + {isExpanded && ( +
+
+ Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'} + Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'} + Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'} + Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} +
+ + {/* Display Name */} +
+

Display Name

+
+ setEditDisplayName(prev => ({ ...prev, [user.id]: e.target.value }))} + placeholder="Display name" + maxLength={80} + /> + +
+
+ + {/* Password Reset */} +
+

Reset Password

+
+ setNewPassword(prev => ({ ...prev, [user.id]: e.target.value }))} + placeholder="New password (min 8 chars)" + autoComplete="off" + /> + +
+

This immediately signs them out of all active sessions.

+
+ + {/* Enrollments */} +
+

Enrollments

+ {user.enrolledStudies.length === 0 + ?

Not enrolled in any studies.

+ : ( +
    + {user.enrolledStudies.map(e => ( +
  • + {e.title} + +
  • + ))} +
+ ) + } + {unenrolledStudies.length > 0 && ( +
+ +
+ )} +
+ + {msg[user.id] &&

{msg[user.id]}

} + + {/* Delete */} +
+ {confirmDeleteId === user.id ? ( +
+ Delete this account permanently? + + +
+ ) : ( + + )} +
+
+ )} +
+ ) + })} +
+
+ ) +} + 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) { )} + {/* STUDY USERS */} + {adminView === 'study-users' && } + {/* ANALYTICS */} {adminView === 'analytics' && (