diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index a481382..dc0e79c 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -7,6 +7,7 @@ export function runMigrations(db: Database.Database): void { email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', email_verified INTEGER DEFAULT 0, verify_token TEXT, verify_expires INTEGER, @@ -42,7 +43,43 @@ export function runMigrations(db: Database.Database): void { completed_at INTEGER DEFAULT (unixepoch()) ); + CREATE TABLE IF NOT EXISTS site_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id); CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id); `) + + // ── Add role column if missing (existing databases) ── + const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[] + if (!cols.some(c => c.name === 'role')) { + db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) + } + + // ── Promote first user to admin if no admins exist ── + const adminCount = db.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`).get() as { cnt: number } + if (adminCount.cnt === 0) { + db.prepare(`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`).run() + } + + // ── Seed default settings ── + const defaults: Record = { + site_name: 'BrainWave', + max_upload_mb: '5', + allow_guest_mode: 'true', + registration_enabled: 'true', + default_theme: 'light', + maintenance_mode: 'false', + maintenance_message: 'We are currently performing maintenance. Please check back soon.', + } + const upsert = db.prepare(` + INSERT INTO site_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO NOTHING + `) + for (const [key, value] of Object.entries(defaults)) { + upsert.run(key, value) + } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 2346e83..3d9393f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import { getDb, closeDb } from './db/database.js' import authRoutes from './routes/auth.js' import examRoutes from './routes/exams.js' import resultRoutes from './routes/results.js' +import adminRoutes from './routes/admin.js' const PORT = Number(process.env['PORT'] ?? '3001') @@ -20,6 +21,7 @@ console.log('✓ Database initialized') app.use('/api/auth', authRoutes) app.use('/api/exams', examRoutes) app.use('/api/results', resultRoutes) +app.use('/api/admin', adminRoutes) // Health check app.get('/api/health', (_req, res) => { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 67b7ca8..01f4725 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,9 +1,11 @@ import type { Request, Response, NextFunction } from 'express' import { verifyToken, type JwtPayload } from '../utils/tokens.js' -declare module 'express' { - interface Request { - user?: JwtPayload +declare global { + namespace Express { + interface Request { + user?: JwtPayload + } } } @@ -21,3 +23,11 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo res.status(401).json({ error: 'Invalid or expired token' }) } } + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + if (req.user?.role !== 'admin') { + res.status(403).json({ error: 'Admin access required' }) + return + } + next() +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..6b2c9fc --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,190 @@ +import { Router } from 'express' +import { getDb } from '../db/database.js' +import { requireAuth, requireAdmin } from '../middleware/auth.js' + +const router = Router() + +// All admin routes require authentication + admin role +router.use(requireAuth, requireAdmin) + +// ─── Dashboard Stats ───────────────────────────────────── +router.get('/stats', (_req, res) => { + try { + const db = getDb() + const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number } + const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number } + const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number } + const avgScore = db.prepare( + 'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results' + ).get() as { avg: number } + const recentResults = db.prepare(` + SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at, + u.display_name, u.email + FROM exam_results er + JOIN users u ON u.id = er.user_id + ORDER BY er.completed_at DESC + LIMIT 10 + `).all() + + res.json({ + totalUsers: users.cnt, + totalExams: exams.cnt, + totalResults: results.cnt, + averageScore: Math.round(avgScore.avg * 10) / 10, + recentResults, + }) + } catch (err) { + console.error('Admin stats error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// ─── Get All Settings ──────────────────────────────────── +router.get('/settings', (_req, res) => { + try { + const db = getDb() + const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[] + const settings: Record = {} + for (const row of rows) { + settings[row.key] = row.value + } + res.json({ settings }) + } catch (err) { + console.error('Admin get settings error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// ─── Update Settings ───────────────────────────────────── +router.put('/settings', (req, res) => { + try { + const { settings } = req.body as { settings: Record } + if (!settings || typeof settings !== 'object') { + res.status(400).json({ error: 'Settings object is required' }) + return + } + + const validKeys = [ + 'site_name', 'max_upload_mb', 'allow_guest_mode', + 'registration_enabled', 'default_theme', 'maintenance_mode', + 'maintenance_message', + ] + + const db = getDb() + const update = db.prepare(` + UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ? + `) + + const updateMany = db.transaction((entries: [string, string][]) => { + for (const [key, value] of entries) { + if (!validKeys.includes(key)) continue + update.run(String(value), key) + } + }) + + updateMany(Object.entries(settings)) + res.json({ message: 'Settings updated successfully' }) + } catch (err) { + console.error('Admin update settings error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// ─── List All Users ────────────────────────────────────── +router.get('/users', (req, res) => { + try { + const limit = Math.min(Number(req.query['limit']) || 50, 200) + const offset = Number(req.query['offset']) || 0 + const search = (req.query['search'] as string) || '' + + const db = getDb() + let query = ` + SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at, + (SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count, + (SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count + FROM users u + ` + let countQuery = 'SELECT COUNT(*) AS total FROM users' + const params: unknown[] = [] + const countParams: unknown[] = [] + + if (search) { + const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?` + query += where + countQuery += where.replace('u.', '') + params.push(`%${search}%`, `%${search}%`) + countParams.push(`%${search}%`, `%${search}%`) + } + + query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?' + params.push(limit, offset) + + const users = db.prepare(query).all(...params) + const countRow = db.prepare(countQuery).get(...countParams) as { total: number } + + res.json({ users, total: countRow.total }) + } catch (err) { + console.error('Admin list users error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// ─── Change User Role ──────────────────────────────────── +router.patch('/users/:id/role', (req, res) => { + try { + const userId = Number(req.params['id']) + const { role } = req.body as { role: string } + + if (!['user', 'admin'].includes(role)) { + res.status(400).json({ error: 'Role must be "user" or "admin"' }) + return + } + + // Prevent self-demotion + if (userId === req.user!.userId && role !== 'admin') { + res.status(400).json({ error: 'You cannot remove your own admin role' }) + return + } + + const db = getDb() + const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId) + + if (result.changes === 0) { + res.status(404).json({ error: 'User not found' }) + return + } + + res.json({ message: `User role updated to ${role}` }) + } catch (err) { + console.error('Admin change role error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +// ─── Delete User ───────────────────────────────────────── +router.delete('/users/:id', (req, res) => { + try { + const userId = Number(req.params['id']) + + // Prevent self-deletion + if (userId === req.user!.userId) { + res.status(400).json({ error: 'You cannot delete your own account' }) + return + } + + const db = getDb() + const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId) + + if (result.changes === 0) { + res.status(404).json({ error: 'User not found' }) + return + } + + res.json({ message: 'User deleted' }) + } catch (err) { + console.error('Admin delete user error:', err) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e8dfcac..ae0a054 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -95,6 +95,7 @@ router.post('/login', async (req, res) => { userId: user['id'] as number, email: user['email'] as string, displayName: user['display_name'] as string, + role: (user['role'] as 'user' | 'admin') ?? 'user', }) res.json({ @@ -103,6 +104,7 @@ router.post('/login', async (req, res) => { id: user['id'], email: user['email'], displayName: user['display_name'], + role: user['role'] ?? 'user', }, }) } catch (err) { @@ -225,7 +227,7 @@ router.post('/reset-password', async (req, res) => { // ─── Get Current User ──────────────────────────────────── router.get('/me', requireAuth, (req, res) => { const db = getDb() - const user = db.prepare('SELECT id, email, display_name, created_at FROM users WHERE id = ?').get( + const user = db.prepare('SELECT id, email, display_name, role, created_at FROM users WHERE id = ?').get( req.user!.userId ) as Record | undefined @@ -238,6 +240,7 @@ router.get('/me', requireAuth, (req, res) => { id: user['id'], email: user['email'], displayName: user['display_name'], + role: user['role'] ?? 'user', createdAt: user['created_at'], }) }) diff --git a/backend/src/utils/tokens.ts b/backend/src/utils/tokens.ts index ab09c50..9127e12 100644 --- a/backend/src/utils/tokens.ts +++ b/backend/src/utils/tokens.ts @@ -7,6 +7,7 @@ export interface JwtPayload { userId: number email: string displayName: string + role: 'user' | 'admin' } export function signToken(payload: JwtPayload): string { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8be9a25..56fba7f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import TweaksPanel from './components/TweaksPanel' import AuthModal from './components/AuthModal' import EmailVerify from './components/EmailVerify' import ResetPassword from './components/ResetPassword' +import AdminDashboard from './components/AdminDashboard' const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' } @@ -38,6 +39,8 @@ function AppInner() { setScreen('verify') } else if (path === '/reset-password') { setScreen('reset-password') + } else if (path === '/admin') { + setScreen('admin') } }, []) @@ -263,16 +266,21 @@ function AppInner() { {user.displayName.charAt(0).toUpperCase()} {userMenuOpen && ( -
-
-
{user.displayName}
-
{user.email}
+
+
+
{user.displayName}
+
{user.email}
+
+
+ {user.role === 'admin' && ( + + )} +
-
- -
)}
)} @@ -282,6 +290,9 @@ function AppInner() { {screen === 'landing' && ( )} + {screen === 'admin' && user?.role === 'admin' && ( + + )} {screen === 'exam' && exam && examState && ( )} diff --git a/frontend/src/components/AdminDashboard.tsx b/frontend/src/components/AdminDashboard.tsx new file mode 100644 index 0000000..e8a9cc2 --- /dev/null +++ b/frontend/src/components/AdminDashboard.tsx @@ -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) { + const [tab, setTab] = useState('overview') + const [stats, setStats] = useState(null) + const [settings, setSettings] = useState(null) + const [settingsDraft, setSettingsDraft] = useState(null) + const [users, setUsers] = useState([]) + const [usersTotal, setUsersTotal] = useState(0) + const [userSearch, setUserSearch] = useState('') + const [saving, setSaving] = useState(false) + const [saveMsg, setSaveMsg] = useState(null) + const [error, setError] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const loadStats = useCallback(async () => { + try { + const data = await apiFetch('/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 ( +
+
+
+
Administration
+

Dashboard

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+ {(['overview', 'settings', 'users'] as AdminTab[]).map((t) => ( + + ))} +
+ + {/* ─── Overview Tab ─── */} + {tab === 'overview' && ( +
+
+
+
⊕
+
+
{stats?.totalUsers ?? '—'}
+
Total Users
+
+
+
+
◇
+
+
{stats?.totalExams ?? '—'}
+
Saved Exams
+
+
+
+
✦
+
+
{stats?.totalResults ?? '—'}
+
Exams Taken
+
+
+
+
%
+
+
+ {stats ? `${stats.averageScore}%` : '—'} +
+
Avg. Score
+
+
+
+ +
+
+

Recent Activity

+
+
+ {!stats?.recentResults?.length && ( +
No recent activity yet
+ )} + {stats?.recentResults?.map((r) => { + const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0 + return ( +
+
+
+ {r.display_name.charAt(0).toUpperCase()} +
+
+
{r.display_name}
+
{r.email}
+
+
+
{r.title}
+
= 60 ? 'pass' : 'fail'}`}>{pct}%
+
{formatDate(r.completed_at)}
+
+ ) + })} +
+
+
+ )} + + {/* ─── Settings Tab ─── */} + {tab === 'settings' && settingsDraft && ( +
+ {saveMsg && ( +
{saveMsg}
+ )} + +
+
+

General

+
+
+
+ + updateDraft('site_name', e.target.value)} + /> +
+
+ + updateDraft('max_upload_mb', e.target.value)} + /> +
+
+ +
+ + +
+
+
+
+ +
+
+

Access Control

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+

Maintenance

+
+
+
+ + +
+ {settingsDraft.maintenance_mode === 'true' && ( +
+ +