Public Access
feat: implement admin dashboard with site settings, user management, and activity monitoring
This commit is contained in:
1 parent
c4c1e1a45a
commit
53abe32795
12 files changed
+1226
-18
No files matched your search
@@ -0,0 +1,502 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { apiFetch } from '../utils/api'
|
||||
|
||||
interface SiteSettings {
|
||||
site_name: string
|
||||
max_upload_mb: string
|
||||
allow_guest_mode: string
|
||||
registration_enabled: string
|
||||
default_theme: string
|
||||
maintenance_mode: string
|
||||
maintenance_message: string
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number
|
||||
totalExams: number
|
||||
totalResults: number
|
||||
averageScore: number
|
||||
recentResults: {
|
||||
id: number
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
completed_at: number
|
||||
display_name: string
|
||||
email: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string
|
||||
role: 'user' | 'admin'
|
||||
email_verified: number
|
||||
created_at: number
|
||||
results_count: number
|
||||
exams_count: number
|
||||
}
|
||||
|
||||
type AdminTab = 'overview' | 'settings' | 'users'
|
||||
|
||||
interface Props {
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
const [tab, setTab] = useState<AdminTab>('overview')
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [settings, setSettings] = useState<SiteSettings | null>(null)
|
||||
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null)
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [usersTotal, setUsersTotal] = useState(0)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<DashboardStats>('/api/admin/stats')
|
||||
setStats(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings')
|
||||
setSettings(data.settings)
|
||||
setSettingsDraft(data.settings)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadUsers = useCallback(async (search = '') => {
|
||||
try {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`)
|
||||
setUsers(data.users)
|
||||
setUsersTotal(data.total)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats()
|
||||
void loadSettings()
|
||||
void loadUsers()
|
||||
}, [loadStats, loadSettings, loadUsers])
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settingsDraft) return
|
||||
setSaving(true)
|
||||
setSaveMsg(null)
|
||||
try {
|
||||
await apiFetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings: settingsDraft }),
|
||||
})
|
||||
setSettings(settingsDraft)
|
||||
setSaveMsg('Settings saved successfully')
|
||||
setTimeout(() => setSaveMsg(null), 3000)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = async (userId: number, newRole: 'user' | 'admin') => {
|
||||
try {
|
||||
await apiFetch(`/api/admin/users/${userId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
})
|
||||
await loadUsers(userSearch)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async (userId: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
||||
setDeleteConfirm(null)
|
||||
await loadUsers(userSearch)
|
||||
await loadStats()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
void loadUsers(userSearch)
|
||||
}
|
||||
|
||||
const updateDraft = (key: keyof SiteSettings, value: string) => {
|
||||
setSettingsDraft((d) => d ? { ...d, [key]: value } : d)
|
||||
}
|
||||
|
||||
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft)
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<div className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<div className="admin-eyebrow">Administration</div>
|
||||
<h1 className="admin-title">Dashboard</h1>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||||
← Back to site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="admin-alert admin-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="admin-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-tabs">
|
||||
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`admin-tab${tab === t ? ' active' : ''}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
<span className="admin-tab-icon">
|
||||
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'}
|
||||
</span>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ─── Overview Tab ─── */}
|
||||
{tab === 'overview' && (
|
||||
<div className="admin-section fade-in">
|
||||
<div className="admin-stats-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon users-icon">⊕</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalUsers ?? '—'}</div>
|
||||
<div className="admin-stat-label">Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon exams-icon">◇</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalExams ?? '—'}</div>
|
||||
<div className="admin-stat-label">Saved Exams</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon results-icon">✦</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalResults ?? '—'}</div>
|
||||
<div className="admin-stat-label">Exams Taken</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon avg-icon">%</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">
|
||||
{stats ? `${stats.averageScore}%` : '—'}
|
||||
</div>
|
||||
<div className="admin-stat-label">Avg. Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>Recent Activity</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body">
|
||||
{!stats?.recentResults?.length && (
|
||||
<div className="admin-empty">No recent activity yet</div>
|
||||
)}
|
||||
{stats?.recentResults?.map((r) => {
|
||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
||||
return (
|
||||
<div key={r.id} className="admin-activity-row">
|
||||
<div className="admin-activity-user">
|
||||
<div className="admin-activity-avatar">
|
||||
{r.display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-activity-name">{r.display_name}</div>
|
||||
<div className="admin-activity-email">{r.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-activity-exam">{r.title}</div>
|
||||
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||
<div className="admin-activity-date">{formatDate(r.completed_at)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Settings Tab ─── */}
|
||||
{tab === 'settings' && settingsDraft && (
|
||||
<div className="admin-section fade-in">
|
||||
{saveMsg && (
|
||||
<div className="admin-alert admin-alert-success">{saveMsg}</div>
|
||||
)}
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>General</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-site-name">
|
||||
<span className="admin-setting-title">Site Name</span>
|
||||
<span className="admin-setting-desc">The name displayed throughout the application</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-site-name"
|
||||
className="input admin-setting-input"
|
||||
value={settingsDraft.site_name}
|
||||
onChange={(e) => updateDraft('site_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-max-upload">
|
||||
<span className="admin-setting-title">Max Upload Size (MB)</span>
|
||||
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-max-upload"
|
||||
className="input admin-setting-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={settingsDraft.max_upload_mb}
|
||||
onChange={(e) => updateDraft('max_upload_mb', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-default-theme">
|
||||
<span className="admin-setting-title">Default Theme</span>
|
||||
<span className="admin-setting-desc">Default theme for new visitors</span>
|
||||
</label>
|
||||
<div className="admin-setting-toggle-group">
|
||||
<button
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`}
|
||||
onClick={() => updateDraft('default_theme', 'light')}
|
||||
>☀ Light</button>
|
||||
<button
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`}
|
||||
onClick={() => updateDraft('default_theme', 'dark')}
|
||||
>☾ Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>Access Control</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Allow Guest Mode</span>
|
||||
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.allow_guest_mode === 'true'}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Registration Enabled</span>
|
||||
<span className="admin-setting-desc">Allow new users to create accounts</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.registration_enabled === 'true'}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>Maintenance</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Maintenance Mode</span>
|
||||
<span className="admin-setting-desc">Take the site offline for non-admin users</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.maintenance_mode === 'true'}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{settingsDraft.maintenance_mode === 'true' && (
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-maint-msg">
|
||||
<span className="admin-setting-title">Maintenance Message</span>
|
||||
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="setting-maint-msg"
|
||||
className="textarea admin-setting-textarea"
|
||||
value={settingsDraft.maintenance_message}
|
||||
onChange={(e) => updateDraft('maintenance_message', e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-settings-footer">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setSettingsDraft(settings)}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void saveSettings()}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Users Tab ─── */}
|
||||
{tab === 'users' && (
|
||||
<div className="admin-section fade-in">
|
||||
<div className="admin-users-toolbar">
|
||||
<form onSubmit={handleUserSearch} className="admin-search-form">
|
||||
<input
|
||||
className="input admin-search-input"
|
||||
placeholder="Search by name or email…"
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-sm" type="submit">Search</button>
|
||||
</form>
|
||||
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Verified</th>
|
||||
<th>Exams</th>
|
||||
<th>Results</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="admin-user-cell">
|
||||
<div className="admin-user-avatar-sm">
|
||||
{u.display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-user-name">{u.display_name}</div>
|
||||
<div className="admin-user-email">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-role-badge ${u.role}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} />
|
||||
</td>
|
||||
<td className="mono">{u.exams_count}</td>
|
||||
<td className="mono">{u.results_count}</td>
|
||||
<td className="admin-date-cell">{formatDate(u.created_at)}</td>
|
||||
<td>
|
||||
<div className="admin-actions">
|
||||
<select
|
||||
className="admin-role-select"
|
||||
value={u.role}
|
||||
onChange={(e) => void changeRole(u.id, e.target.value as 'user' | 'admin')}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{deleteConfirm === u.id ? (
|
||||
<div className="admin-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(u.id)}
|
||||
title="Delete user"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="admin-empty">No users found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { apiFetch, ApiError } from '../utils/api'
|
||||
|
||||
interface Props {
|
||||
@@ -8,8 +8,12 @@ interface Props {
|
||||
export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [message, setMessage] = useState('')
|
||||
const called = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (called.current) return
|
||||
called.current = true
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
|
||||
|
||||
Reference in new issue
Block a user