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),