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
@@ -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<string, string> = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<string, string> = {}
|
||||
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<string, string> }
|
||||
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
|
||||
@@ -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<string, unknown> | 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'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface JwtPayload {
|
||||
userId: number
|
||||
email: string
|
||||
displayName: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
|
||||
export function signToken(payload: JwtPayload): string {
|
||||
|
||||
+20
-9
@@ -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()}
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-name">{user.displayName}</div>
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-name">{user.displayName}</div>
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
{user.role === 'admin' && (
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); setScreen('admin'); globalThis.history.pushState({}, '', '/admin') }}>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
)}
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -282,6 +290,9 @@ function AppInner() {
|
||||
{screen === 'landing' && (
|
||||
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
||||
)}
|
||||
{screen === 'admin' && user?.role === 'admin' && (
|
||||
<AdminDashboard onExit={exit} />
|
||||
)}
|
||||
{screen === 'exam' && exam && examState && (
|
||||
<ExamRunner exam={exam} state={examState} setState={setExamStateStrict} onSubmit={submit} onExit={exit} />
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
id: number
|
||||
email: string
|
||||
displayName: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
@@ -30,8 +31,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiFetch<{ id: number; email: string; displayName: string }>('/api/auth/me')
|
||||
setUser({ id: data.id, email: data.email, displayName: data.displayName })
|
||||
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
|
||||
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
|
||||
} catch {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
@@ -50,7 +51,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
setUser({ ...data.user, role: data.user.role ?? 'user' })
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
|
||||
|
||||
@@ -1348,3 +1348,450 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- Admin Dashboard ---------- */
|
||||
@keyframes admin-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in { animation: admin-fade-in 300ms ease; }
|
||||
|
||||
.admin {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 120px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.admin-header-left { display: flex; flex-direction: column; gap: 4px; }
|
||||
.admin-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: clamp(32px, 5vw, 48px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Admin Tabs ── */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--paper-2);
|
||||
padding: 4px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 32px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--ink-3);
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.admin-tab:hover:not(.active) { color: var(--ink-2); background: var(--paper-3); }
|
||||
.admin-tab.active {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.admin-tab-icon { font-size: 15px; }
|
||||
|
||||
/* ── Stat Cards ── */
|
||||
.admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.admin-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 22px 20px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
.admin-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.admin-stat-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-stat-icon.users-icon { background: oklch(0.92 0.05 265); color: oklch(0.48 0.18 265); }
|
||||
.admin-stat-icon.exams-icon { background: oklch(0.92 0.05 150); color: oklch(0.48 0.16 150); }
|
||||
.admin-stat-icon.results-icon { background: oklch(0.92 0.05 45); color: oklch(0.50 0.16 45); }
|
||||
.admin-stat-icon.avg-icon { background: oklch(0.92 0.05 320); color: oklch(0.48 0.16 320); }
|
||||
[data-theme="dark"] .admin-stat-icon.users-icon { background: oklch(0.28 0.06 265); color: oklch(0.78 0.14 265); }
|
||||
[data-theme="dark"] .admin-stat-icon.exams-icon { background: oklch(0.28 0.06 150); color: oklch(0.78 0.12 150); }
|
||||
[data-theme="dark"] .admin-stat-icon.results-icon { background: oklch(0.28 0.06 45); color: oklch(0.78 0.14 45); }
|
||||
[data-theme="dark"] .admin-stat-icon.avg-icon { background: oklch(0.28 0.06 320); color: oklch(0.78 0.12 320); }
|
||||
.admin-stat-info { display: flex; flex-direction: column; }
|
||||
.admin-stat-value {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Panels ── */
|
||||
.admin-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.admin-panel-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.admin-panel-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.admin-panel-body { padding: 0; }
|
||||
.admin-section { /* just a wrapper for fade-in */ }
|
||||
|
||||
/* ── Activity ── */
|
||||
.admin-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1.5fr auto auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.admin-activity-row:last-child { border-bottom: none; }
|
||||
.admin-activity-user { display: flex; align-items: center; gap: 12px; }
|
||||
.admin-activity-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-activity-name { font-weight: 500; font-size: 13.5px; }
|
||||
.admin-activity-email { font-size: 12px; color: var(--ink-3); }
|
||||
.admin-activity-exam {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.admin-activity-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--ink-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Settings Form ── */
|
||||
.admin-settings-form { padding: 0; }
|
||||
.admin-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.admin-setting-row:last-child { border-bottom: none; }
|
||||
.admin-setting-label { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.admin-setting-title { font-weight: 500; font-size: 14px; }
|
||||
.admin-setting-desc { font-size: 12.5px; color: var(--ink-3); line-height: 1.4; }
|
||||
.admin-setting-input { max-width: 260px; }
|
||||
.admin-setting-textarea { max-width: 400px; min-height: 80px; }
|
||||
|
||||
/* Toggle group */
|
||||
.admin-setting-toggle-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--paper-2);
|
||||
padding: 3px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.admin-toggle-btn {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--ink-3);
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.admin-toggle-btn:hover:not(.active) { color: var(--ink-2); }
|
||||
.admin-toggle-btn.active {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Switch */
|
||||
.admin-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--line-3);
|
||||
background: var(--paper-3);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.admin-switch.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.admin-switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 200ms ease;
|
||||
box-shadow: 0 1px 3px oklch(0 0 0 / 0.15);
|
||||
}
|
||||
.admin-switch.on .admin-switch-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Settings footer */
|
||||
.admin-settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* ── Alerts ── */
|
||||
.admin-alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
animation: admin-fade-in 200ms ease;
|
||||
}
|
||||
.admin-alert-error {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
border: 1px solid var(--wrong);
|
||||
}
|
||||
.admin-alert-success {
|
||||
background: var(--correct-weak);
|
||||
color: var(--correct);
|
||||
border: 1px solid var(--correct);
|
||||
}
|
||||
.admin-alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.admin-alert-close:hover { opacity: 1; }
|
||||
|
||||
/* ── Users Table ── */
|
||||
.admin-users-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
.admin-search-input { flex: 1; padding: 8px 12px; }
|
||||
.admin-users-count { font-size: 12.5px; color: var(--ink-3); }
|
||||
|
||||
.admin-table-wrap { overflow-x: auto; }
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.admin-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-3);
|
||||
font-weight: 500;
|
||||
background: var(--paper-2);
|
||||
border-bottom: 1px solid var(--line-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table tr:hover td { background: var(--paper-2); }
|
||||
|
||||
.admin-user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.admin-user-avatar-sm {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid; place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-user-name { font-weight: 500; }
|
||||
.admin-user-email { font-size: 12px; color: var(--ink-3); }
|
||||
.admin-date-cell {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-role-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.admin-role-badge.user {
|
||||
background: var(--paper-3);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-role-badge.admin {
|
||||
background: oklch(0.92 0.05 265);
|
||||
color: oklch(0.48 0.18 265);
|
||||
}
|
||||
[data-theme="dark"] .admin-role-badge.admin {
|
||||
background: oklch(0.28 0.06 265);
|
||||
color: oklch(0.78 0.14 265);
|
||||
}
|
||||
|
||||
.admin-verified-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.admin-verified-dot.yes { background: var(--correct); }
|
||||
.admin-verified-dot.no { background: var(--line-3); }
|
||||
|
||||
.admin-role-select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-delete-confirm { display: flex; gap: 6px; }
|
||||
.admin-btn-danger {
|
||||
background: var(--wrong) !important;
|
||||
color: white !important;
|
||||
border-color: var(--wrong) !important;
|
||||
}
|
||||
.admin-btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.admin-btn-danger-ghost {
|
||||
color: var(--wrong) !important;
|
||||
}
|
||||
.admin-btn-danger-ghost:hover {
|
||||
background: var(--wrong-weak) !important;
|
||||
}
|
||||
|
||||
/* ── Admin Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.admin-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.admin-activity-row { grid-template-columns: 1fr auto; }
|
||||
.admin-activity-exam, .admin-activity-date { display: none; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.admin { padding: 32px 16px 80px; }
|
||||
.admin-stats-grid { grid-template-columns: 1fr; }
|
||||
.admin-tabs { max-width: 100%; }
|
||||
.admin-setting-row { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.admin-setting-input, .admin-setting-textarea { max-width: 100%; }
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface GradeResult {
|
||||
points: number
|
||||
}
|
||||
|
||||
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password'
|
||||
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
export type FontSize = 'small' | 'medium' | 'large'
|
||||
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
|
||||
|
||||
Reference in new issue
Block a user