diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index dc0e79c..91e2335 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -1,4 +1,4 @@ -import type Database from 'better-sqlite3' +import type Database from "better-sqlite3"; export function runMigrations(db: Database.Database): void { db.exec(` @@ -51,35 +51,49 @@ export function runMigrations(db: Database.Database): void { 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'`) + 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 } + 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() + 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.', - } + 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.", + smtp_host: "", + smtp_port: "587", + smtp_user: "", + smtp_pass: "", + smtp_from: "BrainWave ", + smtp_secure: "auto", + app_url: "http://localhost:5173", + }; 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) + upsert.run(key, value); } } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 6b2c9fc..070343c 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,30 +1,44 @@ -import { Router } from 'express' -import { getDb } from '../db/database.js' -import { requireAuth, requireAdmin } from '../middleware/auth.js' +import { Router } from "express"; +import { getDb } from "../db/database.js"; +import { requireAuth, requireAdmin } from "../middleware/auth.js"; +import { sendTestEmail } from "../utils/email.js"; +import type { SmtpConfig } from "../utils/email.js"; -const router = Router() +const router = Router(); // All admin routes require authentication + admin role -router.use(requireAuth, requireAdmin) +router.use(requireAuth, requireAdmin); // ─── Dashboard Stats ───────────────────────────────────── -router.get('/stats', (_req, res) => { +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(` + 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() + `, + ) + .all(); res.json({ totalUsers: users.cnt, @@ -32,159 +46,223 @@ router.get('/stats', (_req, res) => { 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' }) + console.error("Admin stats error:", err); + res.status(500).json({ error: "Internal server error" }); } -}) +}); // ─── Get All Settings ──────────────────────────────────── -router.get('/settings', (_req, res) => { +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 = {} + 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 + settings[row.key] = row.value; } - res.json({ settings }) + res.json({ settings }); } catch (err) { - console.error('Admin get settings error:', err) - res.status(500).json({ error: 'Internal server error' }) + console.error("Admin get settings error:", err); + res.status(500).json({ error: "Internal server error" }); } -}) +}); // ─── Update Settings ───────────────────────────────────── -router.put('/settings', (req, res) => { +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 { 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', - ] + "site_name", + "max_upload_mb", + "allow_guest_mode", + "registration_enabled", + "default_theme", + "maintenance_mode", + "maintenance_message", + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_pass", + "smtp_from", + "smtp_secure", + "app_url", + ]; - const db = getDb() + 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) + if (!validKeys.includes(key)) continue; + update.run(String(value), key); } - }) + }); - updateMany(Object.entries(settings)) - res.json({ message: 'Settings updated successfully' }) + 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' }) + console.error("Admin update settings error:", err); + res.status(500).json({ error: "Internal server error" }); } -}) +}); // ─── List All Users ────────────────────────────────────── -router.get('/users', (req, res) => { +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 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() + 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[] = [] + `; + 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}%`) + 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) + 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 } + const users = db.prepare(query).all(...params); + const countRow = db.prepare(countQuery).get(...countParams) as { + total: number; + }; - res.json({ users, total: countRow.total }) + res.json({ users, total: countRow.total }); } catch (err) { - console.error('Admin list users error:', err) - res.status(500).json({ error: 'Internal server error' }) + 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) => { +router.patch("/users/:id/role", (req, res) => { try { - const userId = Number(req.params['id']) - const { role } = req.body as { role: string } + 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 + 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 + 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) + 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.status(404).json({ error: "User not found" }); + return; } - res.json({ message: `User role updated to ${role}` }) + 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' }) + console.error("Admin change role error:", err); + res.status(500).json({ error: "Internal server error" }); } -}) +}); // ─── Delete User ───────────────────────────────────────── -router.delete('/users/:id', (req, res) => { +router.delete("/users/:id", (req, res) => { try { - const userId = Number(req.params['id']) + 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 + 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) + 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.status(404).json({ error: "User not found" }); + return; } - res.json({ message: 'User deleted' }) + res.json({ message: "User deleted" }); } catch (err) { - console.error('Admin delete user error:', err) - res.status(500).json({ error: 'Internal server error' }) + console.error("Admin delete user error:", err); + res.status(500).json({ error: "Internal server error" }); } -}) +}); -export default router +// ─── Test Email ─────────────────────────────────────────── +router.post("/test-email", async (req, res) => { + try { + // The body may contain optional SMTP override settings (for testing before saving) + // and a `to` field (defaults to the requesting admin's email) + const { to, smtp } = req.body as { + to?: string; + smtp?: { + host: string; + port: string; + user: string; + pass: string; + from: string; + secure: string; + }; + }; + + const recipient = to || req.user!.email; + + let overrideConfig: SmtpConfig | undefined; + if (smtp?.host) { + const port = Number(smtp.port ?? "587"); + overrideConfig = { + host: smtp.host, + port, + user: smtp.user ?? "", + pass: smtp.pass ?? "", + from: smtp.from ?? "BrainWave ", + secure: + smtp.secure === "true" || (smtp.secure === "auto" && port === 465), + }; + } + + await sendTestEmail(recipient, overrideConfig); + res.json({ message: `Test email sent to ${recipient}` }); + } catch (err) { + console.error("Test email error:", err); + res + .status(500) + .json({ + error: err instanceof Error ? err.message : "Failed to send test email", + }); + } +}); + +export default router; diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts index de090af..b171b0f 100644 --- a/backend/src/utils/email.ts +++ b/backend/src/utils/email.ts @@ -1,46 +1,115 @@ -import nodemailer from 'nodemailer' +import nodemailer from "nodemailer"; +import { getDb } from "../db/database.js"; -const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173' +// ─── Types ──────────────────────────────────────────────────────────────────── -const SMTP_HOST = process.env['SMTP_HOST'] -const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587') -const SMTP_USER = process.env['SMTP_USER'] -const SMTP_PASS = process.env['SMTP_PASS'] -const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave ' - -const isDev = !SMTP_HOST - -let transporter: nodemailer.Transporter | null = null - -function getTransporter(): nodemailer.Transporter | null { - if (isDev) return null - if (!transporter) { - transporter = nodemailer.createTransport({ - host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_PORT === 465, - auth: { user: SMTP_USER, pass: SMTP_PASS }, - }) - } - return transporter +export interface SmtpConfig { + host: string; + port: number; + user: string; + pass: string; + from: string; + secure: boolean; } -async function sendMail(to: string, subject: string, html: string): Promise { - const t = getTransporter() - if (!t) { - console.log('\n══════════════════════════════════════════════════') - console.log(`📧 EMAIL (dev mode) → ${to}`) - console.log(` Subject: ${subject}`) - console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`) - console.log('══════════════════════════════════════════════════\n') - return +// ─── App URL ────────────────────────────────────────────────────────────────── + +export function getAppUrl(): string { + try { + const db = getDb(); + const row = db + .prepare("SELECT value FROM site_settings WHERE key = ?") + .get("app_url") as { value: string } | undefined; + return ( + (row?.value?.trim() || process.env["APP_URL"]) ?? "http://localhost:5173" + ); + } catch { + return process.env["APP_URL"] ?? "http://localhost:5173"; } - await t.sendMail({ from: SMTP_FROM, to, subject, html }) } -export async function sendVerificationEmail(to: string, token: string): Promise { - const link = `${APP_URL}/verify?token=${token}` - await sendMail(to, 'Verify your BrainWave account', ` +// ─── SMTP Config ────────────────────────────────────────────────────────────── + +function getSetting(key: string): string { + const db = getDb(); + const row = db + .prepare("SELECT value FROM site_settings WHERE key = ?") + .get(key) as { value: string } | undefined; + return row?.value ?? ""; +} + +function getSmtpConfig(): SmtpConfig | null { + const host = getSetting("smtp_host").trim(); + if (!host) return null; + + const portStr = getSetting("smtp_port").trim() || "587"; + const port = Number(portStr); + const user = getSetting("smtp_user").trim(); + const pass = getSetting("smtp_pass").trim(); + const from = + getSetting("smtp_from").trim() || "BrainWave "; + const secureStr = getSetting("smtp_secure").trim() || "auto"; + + let secure: boolean; + if (secureStr === "true") { + secure = true; + } else if (secureStr === "false") { + secure = false; + } else { + // 'auto': derive from port + secure = port === 465; + } + + return { host, port, user, pass, from, secure }; +} + +// ─── Transporter ───────────────────────────────────────────────────────────── + +function buildTransporter(cfg: SmtpConfig): nodemailer.Transporter { + return nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + auth: cfg.user ? { user: cfg.user, pass: cfg.pass } : undefined, + }); +} + +// ─── Core Send ──────────────────────────────────────────────────────────────── + +async function sendMail( + to: string, + subject: string, + html: string, + overrideConfig?: SmtpConfig, +): Promise { + const cfg = overrideConfig ?? getSmtpConfig(); + + if (!cfg) { + // Dev mode — no SMTP configured, log to console + console.log("\n══════════════════════════════════════════════════"); + console.log(`📧 EMAIL (dev mode) → ${to}`); + console.log(` Subject: ${subject}`); + console.log(` Body:\n${html.replace(/<[^>]*>/g, "")}`); + console.log("══════════════════════════════════════════════════\n"); + return; + } + + const transporter = buildTransporter(cfg); + await transporter.sendMail({ from: cfg.from, to, subject, html }); +} + +// ─── Exported Email Functions ───────────────────────────────────────────────── + +export async function sendVerificationEmail( + to: string, + token: string, +): Promise { + const appUrl = getAppUrl(); + const link = `${appUrl}/verify?token=${token}`; + await sendMail( + to, + "Verify your BrainWave account", + `

Welcome to BrainWave

@@ -56,12 +125,20 @@ export async function sendVerificationEmail(to: string, token: string): Promise< This link expires in 24 hours. If you didn't create an account, you can ignore this email.

- `) + `, + ); } -export async function sendPasswordResetEmail(to: string, token: string): Promise { - const link = `${APP_URL}/reset-password?token=${token}` - await sendMail(to, 'Reset your BrainWave password', ` +export async function sendPasswordResetEmail( + to: string, + token: string, +): Promise { + const appUrl = getAppUrl(); + const link = `${appUrl}/reset-password?token=${token}`; + await sendMail( + to, + "Reset your BrainWave password", + `

Password Reset

@@ -77,5 +154,28 @@ export async function sendPasswordResetEmail(to: string, token: string): Promise This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.

- `) + `, + ); +} + +export async function sendTestEmail( + to: string, + overrideConfig?: SmtpConfig, +): Promise { + await sendMail( + to, + "Test email from BrainWave", + ` +
+

This is a test email from BrainWave

+

+ If you're reading this, your SMTP configuration is working correctly. +

+

+ Sent at: ${new Date().toUTCString()} +

+
+ `, + overrideConfig, + ); } diff --git a/frontend/src/components/AdminDashboard.tsx b/frontend/src/components/AdminDashboard.tsx index e8a9cc2..9c7c0dc 100644 --- a/frontend/src/components/AdminDashboard.tsx +++ b/frontend/src/components/AdminDashboard.tsx @@ -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) { - 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 [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 [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('/api/admin/stats') - setStats(data) + const data = await apiFetch("/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 (
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly) { {error && (
{error} - +
)}
- {(['overview', 'settings', 'users'] as AdminTab[]).map((t) => ( + {(["overview", "settings", "users"] as AdminTab[]).map((t) => ( @@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly) {
{/* ─── Overview Tab ─── */} - {tab === 'overview' && ( + {tab === "overview" && (
⊕
-
{stats?.totalUsers ?? '—'}
+
+ {stats?.totalUsers ?? "—"} +
Total Users
◇
-
{stats?.totalExams ?? '—'}
+
+ {stats?.totalExams ?? "—"} +
Saved Exams
✦
-
{stats?.totalResults ?? '—'}
+
+ {stats?.totalResults ?? "—"} +
Exams Taken
@@ -214,7 +275,7 @@ export default function AdminDashboard({ onExit }: Readonly) {
%
- {stats ? `${stats.averageScore}%` : '—'} + {stats ? `${stats.averageScore}%` : "—"}
Avg. Score
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly) {
No recent activity yet
)} {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 (
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly) { {r.display_name.charAt(0).toUpperCase()}
-
{r.display_name}
+
+ {r.display_name} +
{r.email}
{r.title}
-
= 60 ? 'pass' : 'fail'}`}>{pct}%
-
{formatDate(r.completed_at)}
+
= 60 ? "pass" : "fail"}`} + > + {pct}% +
+
+ {formatDate(r.completed_at)} +
- ) + ); })}
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly) { )} {/* ─── Settings Tab ─── */} - {tab === 'settings' && settingsDraft && ( + {tab === "settings" && settingsDraft && (
{saveMsg && (
{saveMsg}
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly) {
-
-
-
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly) {
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly) {
+
+
+

Email / SMTP

+
+
+
+ + updateDraft("app_url", e.target.value)} + /> +
+
+ + updateDraft("smtp_host", e.target.value)} + /> +
+
+ + updateDraft("smtp_port", e.target.value)} + /> +
+
+ + updateDraft("smtp_user", e.target.value)} + /> +
+
+ + updateDraft("smtp_pass", e.target.value)} + /> +
+
+ + updateDraft("smtp_from", e.target.value)} + /> +
+
+ +
+ + + +
+
+
+
+ Send Test Email + + Sends a test email using the settings above (uses draft + values, no need to save first) + +
+
+ setTestEmailTo(e.target.value)} + type="email" + /> + +
+ {testEmailMsg && ( +
+ {testEmailMsg.text} +
+ )} +
+
+
+

Maintenance

@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly) {
- {settingsDraft.maintenance_mode === 'true' && ( + {settingsDraft.maintenance_mode === "true" && (
-