New admin panel - People → Study Users
This commit is contained in:
@@ -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),
|
||||
|
||||
+243
-1
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user