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
+87
View File
@@ -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 8200 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),
+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
+134
View File
@@ -6319,3 +6319,137 @@
margin-bottom: 1.5rem;
}
/* ── Admin: Study Users ── */
.admin-study-users-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-study-user-card {
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 8px;
overflow: hidden;
background: #0e0e0b;
}
.admin-study-user-card--open {
border-color: rgba(201, 168, 76, 0.35);
}
.admin-study-user-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
gap: 1rem;
}
.admin-study-user-header:hover {
background: rgba(201, 168, 76, 0.04);
}
.admin-study-user-header-left {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.admin-study-user-email {
font-size: 0.8rem;
color: #5a5440;
}
.admin-study-user-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.admin-study-user-pill {
font-size: 0.75rem;
background: rgba(201, 168, 76, 0.1);
color: #c9a84c;
padding: 0.15rem 0.5rem;
border-radius: 12px;
white-space: nowrap;
}
.admin-study-user-body {
padding: 0 1rem 1rem;
border-top: 1px solid rgba(201, 168, 76, 0.1);
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-study-user-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
font-size: 0.82rem;
color: #8a7f5a;
padding-top: 0.75rem;
}
.admin-study-user-section h4 {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #c9a84c;
margin: 0 0 0.5rem;
}
.admin-study-user-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.admin-study-user-row input,
.admin-study-user-row select {
flex: 1 1 200px;
padding: 0.45rem 0.65rem;
border-radius: 6px;
border: 1px solid rgba(201, 168, 76, 0.25);
background: #0a0a08;
color: #f0e9cc;
font-size: 0.88rem;
}
.admin-study-user-enrollments {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-study-user-enrollments li {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.88rem;
padding: 0.3rem 0.5rem;
background: rgba(201, 168, 76, 0.05);
border-radius: 5px;
}
.admin-study-user-msg {
font-size: 0.85rem;
color: #c9a84c;
margin: 0;
padding: 0.4rem 0.75rem;
background: rgba(201, 168, 76, 0.08);
border-radius: 6px;
}