feat: implement admin dashboard with site settings, user management, and activity monitoring

This commit is contained in:
LaurensBartelds committed 2026-04-25 00:14:57 +02:00
1 parent 4eeacc38c5
commit 107ebf4d47
5 files changed
+2607 -1579

No files matched your search

+492 -155
View File
@@ -1,155 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { apiFetch } from '../utils/api'
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
site_name: string;
max_upload_mb: string;
allow_guest_mode: string;
registration_enabled: string;
default_theme: string;
maintenance_mode: string;
maintenance_message: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: string;
app_url: string;
}
interface DashboardStats {
totalUsers: number
totalExams: number
totalResults: number
averageScore: number
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
}[]
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
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'
type AdminTab = "overview" | "settings" | "users";
interface Props {
onExit: () => void
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 [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 [testEmailTo, setTestEmailTo] = useState("");
const [testEmailMsg, setTestEmailMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const loadStats = useCallback(async () => {
try {
const data = await apiFetch<DashboardStats>('/api/admin/stats')
setStats(data)
const data = await apiFetch<DashboardStats>("/api/admin/stats");
setStats(data);
} catch (e) {
setError((e as Error).message)
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)
const data = await apiFetch<{ settings: SiteSettings }>(
"/api/admin/settings",
);
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}, [])
}, []);
const loadUsers = useCallback(async (search = '') => {
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)
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)
setError((e as Error).message);
}
}, [])
}, []);
useEffect(() => {
void loadStats()
void loadSettings()
void loadUsers()
}, [loadStats, loadSettings, loadUsers])
void loadStats();
void loadSettings();
void loadUsers();
}, [loadStats, loadSettings, loadUsers]);
const saveSettings = async () => {
if (!settingsDraft) return
setSaving(true)
setSaveMsg(null)
if (!settingsDraft) return;
setSaving(true);
setSaveMsg(null);
try {
await apiFetch('/api/admin/settings', {
method: 'PUT',
await apiFetch("/api/admin/settings", {
method: "PUT",
body: JSON.stringify({ settings: settingsDraft }),
})
setSettings(settingsDraft)
setSaveMsg('Settings saved successfully')
setTimeout(() => setSaveMsg(null), 3000)
});
setSettings(settingsDraft);
setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
} finally {
setSaving(false)
setSaving(false);
}
}
};
const changeRole = async (userId: number, newRole: 'user' | 'admin') => {
const changeRole = async (userId: number, newRole: "user" | "admin") => {
try {
await apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH',
method: "PATCH",
body: JSON.stringify({ role: newRole }),
})
await loadUsers(userSearch)
});
await loadUsers(userSearch);
} catch (e) {
setError((e as Error).message)
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()
await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
setDeleteConfirm(null);
await loadUsers(userSearch);
await loadStats();
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}
};
const sendTestEmail = async () => {
if (!settingsDraft) return;
setTestEmailSending(true);
setTestEmailMsg(null);
try {
const smtp = {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
};
await apiFetch<{ message: string }>("/api/admin/test-email", {
method: "POST",
body: JSON.stringify({ to: testEmailTo || undefined, smtp }),
});
setTestEmailMsg({
ok: true,
text: "Test email sent successfully! Check your inbox.",
});
} catch (e) {
setTestEmailMsg({ ok: false, text: (e as Error).message });
} finally {
setTestEmailSending(false);
setTimeout(() => setTestEmailMsg(null), 6000);
}
};
const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault()
void loadUsers(userSearch)
}
e.preventDefault();
void loadUsers(userSearch);
};
const updateDraft = (key: keyof SiteSettings, value: string) => {
setSettingsDraft((d) => d ? { ...d, [key]: value } : d)
}
setSettingsDraft((d) => (d ? { ...d, [key]: value } : d));
};
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft)
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' })
}
const d = new Date(ts * 1000);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div className="admin">
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{error && (
<div className="admin-alert admin-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="admin-alert-close">×</button>
<button onClick={() => setError(null)} className="admin-alert-close">
×
</button>
</div>
)}
<div className="admin-tabs">
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => (
{(["overview", "settings", "users"] as AdminTab[]).map((t) => (
<button
key={t}
className={`admin-tab${tab === t ? ' active' : ''}`}
className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
>
<span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'}
{t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
</span>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
@@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
{/* ─── Overview Tab ─── */}
{tab === 'overview' && (
{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-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-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-value">
{stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">Exams Taken</div>
</div>
</div>
@@ -214,7 +275,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-icon avg-icon">%</div>
<div className="admin-stat-info">
<div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : '—'}
{stats ? `${stats.averageScore}%` : "—"}
</div>
<div className="admin-stat-label">Avg. Score</div>
</div>
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<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
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">
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{r.display_name.charAt(0).toUpperCase()}
</div>
<div>
<div className="admin-activity-name">{r.display_name}</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
className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
>
{pct}%
</div>
<div className="admin-activity-date">
{formatDate(r.completed_at)}
</div>
</div>
)
);
})}
</div>
</div>
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)}
{/* ─── Settings Tab ─── */}
{tab === 'settings' && settingsDraft && (
{tab === "settings" && settingsDraft && (
<div className="admin-section fade-in">
{saveMsg && (
<div className="admin-alert admin-alert-success">{saveMsg}</div>
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-site-name">
<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>
<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)}
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
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"
@@ -289,23 +372,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
min="1"
max="50"
value={settingsDraft.max_upload_mb}
onChange={(e) => updateDraft('max_upload_mb', e.target.value)}
onChange={(e) => updateDraft("max_upload_mb", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-default-theme">
<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>
<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>
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>
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "dark")}
>
☾ Dark
</button>
</div>
</div>
</div>
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<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>
<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')}
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'}
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>
<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')}
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'}
aria-checked={settingsDraft.registration_enabled === "true"}
>
<span className="admin-switch-thumb" />
</button>
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Email / SMTP</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-app-url"
>
<span className="admin-setting-title">App URL</span>
<span className="admin-setting-desc">
Base URL used in email links (e.g.
https://brainwave.example.com)
</span>
</label>
<input
id="setting-app-url"
className="input admin-setting-input"
value={settingsDraft.app_url}
onChange={(e) => updateDraft("app_url", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-host"
>
<span className="admin-setting-title">SMTP Host</span>
<span className="admin-setting-desc">
Hostname of your SMTP server
</span>
</label>
<input
id="setting-smtp-host"
className="input admin-setting-input"
placeholder="smtp.example.com"
value={settingsDraft.smtp_host}
onChange={(e) => updateDraft("smtp_host", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-port"
>
<span className="admin-setting-title">SMTP Port</span>
<span className="admin-setting-desc">
Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)
</span>
</label>
<input
id="setting-smtp-port"
className="input admin-setting-input"
type="number"
min="1"
max="65535"
value={settingsDraft.smtp_port}
onChange={(e) => updateDraft("smtp_port", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-user"
>
<span className="admin-setting-title">Username</span>
<span className="admin-setting-desc">
SMTP authentication username
</span>
</label>
<input
id="setting-smtp-user"
className="input admin-setting-input"
autoComplete="off"
value={settingsDraft.smtp_user}
onChange={(e) => updateDraft("smtp_user", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-pass"
>
<span className="admin-setting-title">Password</span>
<span className="admin-setting-desc">
SMTP authentication password
</span>
</label>
<input
id="setting-smtp-pass"
className="input admin-setting-input"
type="password"
autoComplete="new-password"
value={settingsDraft.smtp_pass}
onChange={(e) => updateDraft("smtp_pass", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-from"
>
<span className="admin-setting-title">From Address</span>
<span className="admin-setting-desc">
The sender name and address shown in emails
</span>
</label>
<input
id="setting-smtp-from"
className="input admin-setting-input"
placeholder="BrainWave <noreply@example.com>"
value={settingsDraft.smtp_from}
onChange={(e) => updateDraft("smtp_from", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">TLS Mode</span>
<span className="admin-setting-desc">
Auto detects from port (465→SSL). Use explicit override if
needed.
</span>
</label>
<div className="admin-setting-toggle-group">
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
Auto
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
Force TLS
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
Off
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">Send Test Email</span>
<span className="admin-setting-desc">
Sends a test email using the settings above (uses draft
values, no need to save first)
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder="Recipient address (defaults to your account email)"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
/>
<button
className="btn btn-sm"
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? "Sending…" : "Send Test"}
</button>
</div>
{testEmailMsg && (
<div
className={`admin-alert ${testEmailMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testEmailMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Maintenance</h3>
@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<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>
<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')}
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'}
aria-checked={settingsDraft.maintenance_mode === "true"}
>
<span className="admin-switch-thumb" />
</button>
</div>
{settingsDraft.maintenance_mode === 'true' && (
{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
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)}
onChange={(e) =>
updateDraft("maintenance_message", e.target.value)
}
rows={3}
/>
</div>
@@ -397,14 +707,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void saveSettings()}
disabled={saving || !hasChanges}
>
{saving ? 'Saving…' : 'Save Settings'}
{saving ? "Saving…" : "Save Settings"}
</button>
</div>
</div>
)}
{/* ─── Users Tab ─── */}
{tab === 'users' && (
{tab === "users" && (
<div className="admin-section fade-in">
<div className="admin-users-toolbar">
<form onSubmit={handleUserSearch} className="admin-search-form">
@@ -414,9 +724,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
/>
<button className="btn btn-sm" type="submit">Search</button>
<button className="btn btn-sm" type="submit">
Search
</button>
</form>
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div>
<div className="admin-users-count mono">
{usersTotal} user{usersTotal !== 1 ? "s" : ""}
</div>
</div>
<div className="admin-panel">
@@ -442,7 +756,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{u.display_name.charAt(0).toUpperCase()}
</div>
<div>
<div className="admin-user-name">{u.display_name}</div>
<div className="admin-user-name">
{u.display_name}
</div>
<div className="admin-user-email">{u.email}</div>
</div>
</div>
@@ -453,25 +769,44 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</span>
</td>
<td>
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} />
<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 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')}
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>
<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
@@ -488,7 +823,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="admin-empty">No users found</td>
<td colSpan={7} className="admin-empty">
No users found
</td>
</tr>
)}
</tbody>
@@ -498,5 +835,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
)}
</div>
)
);
}