Compare commits

...
2 Commits
Author SHA1 Message Date
LaurensBartelds 29d9264cfb feat: optimize state updates using flushSync for smoother exam transitions
Release Pipeline / build-and-release (push) Successful in 5m52s
2026-04-25 13:30:34 +02:00
LaurensBartelds 107ebf4d47 feat: implement admin dashboard with site settings, user management, and activity monitoring 2026-04-25 00:14:57 +02:00
6 changed files with 2635 additions and 1596 deletions

No files matched your search

+31 -17
View File
@@ -1,4 +1,4 @@
import type Database from 'better-sqlite3' import type Database from "better-sqlite3";
export function runMigrations(db: Database.Database): void { export function runMigrations(db: Database.Database): void {
db.exec(` 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_saved_exams_user ON saved_exams(user_id);
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id); CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
`) `);
// ── Add role column if missing (existing databases) ── // ── Add role column if missing (existing databases) ──
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[] const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
if (!cols.some(c => c.name === 'role')) { name: string;
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) }[];
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 ── // ── 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) { 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 ── // ── Seed default settings ──
const defaults: Record<string, string> = { const defaults: Record<string, string> = {
site_name: 'BrainWave', site_name: "BrainWave",
max_upload_mb: '5', max_upload_mb: "5",
allow_guest_mode: 'true', allow_guest_mode: "true",
registration_enabled: 'true', registration_enabled: "true",
default_theme: 'light', default_theme: "light",
maintenance_mode: 'false', maintenance_mode: "false",
maintenance_message: 'We are currently performing maintenance. Please check back soon.', maintenance_message:
} "We are currently performing maintenance. Please check back soon.",
smtp_host: "",
smtp_port: "587",
smtp_user: "",
smtp_pass: "",
smtp_from: "BrainWave <noreply@brainwave.local>",
smtp_secure: "auto",
app_url: "http://localhost:5173",
};
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?) INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING ON CONFLICT(key) DO NOTHING
`) `);
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
upsert.run(key, value) upsert.run(key, value);
} }
} }
+177 -99
View File
@@ -1,30 +1,44 @@
import { Router } from 'express' import { Router } from "express";
import { getDb } from '../db/database.js' import { getDb } from "../db/database.js";
import { requireAuth, requireAdmin } from '../middleware/auth.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 // All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin) router.use(requireAuth, requireAdmin);
// ─── Dashboard Stats ───────────────────────────────────── // ─── Dashboard Stats ─────────────────────────────────────
router.get('/stats', (_req, res) => { router.get("/stats", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number } const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number } cnt: number;
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number } };
const avgScore = db.prepare( const exams = db
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results' .prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
).get() as { avg: number } .get() as { cnt: number };
const recentResults = db.prepare(` 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, SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
u.display_name, u.email u.display_name, u.email
FROM exam_results er FROM exam_results er
JOIN users u ON u.id = er.user_id JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC ORDER BY er.completed_at DESC
LIMIT 10 LIMIT 10
`).all() `,
)
.all();
res.json({ res.json({
totalUsers: users.cnt, totalUsers: users.cnt,
@@ -32,159 +46,223 @@ router.get('/stats', (_req, res) => {
totalResults: results.cnt, totalResults: results.cnt,
averageScore: Math.round(avgScore.avg * 10) / 10, averageScore: Math.round(avgScore.avg * 10) / 10,
recentResults, recentResults,
}) });
} catch (err) { } catch (err) {
console.error('Admin stats error:', err) console.error("Admin stats error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Get All Settings ──────────────────────────────────── // ─── Get All Settings ────────────────────────────────────
router.get('/settings', (_req, res) => { router.get("/settings", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[] const rows = db
const settings: Record<string, string> = {} .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) { for (const row of rows) {
settings[row.key] = row.value settings[row.key] = row.value;
} }
res.json({ settings }) res.json({ settings });
} catch (err) { } catch (err) {
console.error('Admin get settings error:', err) console.error("Admin get settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Update Settings ───────────────────────────────────── // ─── Update Settings ─────────────────────────────────────
router.put('/settings', (req, res) => { router.put("/settings", (req, res) => {
try { try {
const { settings } = req.body as { settings: Record<string, string> } const { settings } = req.body as { settings: Record<string, string> };
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== "object") {
res.status(400).json({ error: 'Settings object is required' }) res.status(400).json({ error: "Settings object is required" });
return return;
} }
const validKeys = [ const validKeys = [
'site_name', 'max_upload_mb', 'allow_guest_mode', "site_name",
'registration_enabled', 'default_theme', 'maintenance_mode', "max_upload_mb",
'maintenance_message', "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(` const update = db.prepare(`
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ? UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
`) `);
const updateMany = db.transaction((entries: [string, string][]) => { const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue if (!validKeys.includes(key)) continue;
update.run(String(value), key) update.run(String(value), key);
} }
}) });
updateMany(Object.entries(settings)) updateMany(Object.entries(settings));
res.json({ message: 'Settings updated successfully' }) res.json({ message: "Settings updated successfully" });
} catch (err) { } catch (err) {
console.error('Admin update settings error:', err) console.error("Admin update settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── List All Users ────────────────────────────────────── // ─── List All Users ──────────────────────────────────────
router.get('/users', (req, res) => { router.get("/users", (req, res) => {
try { try {
const limit = Math.min(Number(req.query['limit']) || 50, 200) const limit = Math.min(Number(req.query["limit"]) || 50, 200);
const offset = Number(req.query['offset']) || 0 const offset = Number(req.query["offset"]) || 0;
const search = (req.query['search'] as string) || '' const search = (req.query["search"] as string) || "";
const db = getDb() const db = getDb();
let query = ` let query = `
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at, 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 exam_results WHERE user_id = u.id) AS results_count,
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count (SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
FROM users u FROM users u
` `;
let countQuery = 'SELECT COUNT(*) AS total FROM users' let countQuery = "SELECT COUNT(*) AS total FROM users";
const params: unknown[] = [] const params: unknown[] = [];
const countParams: unknown[] = [] const countParams: unknown[] = [];
if (search) { if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?` const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
query += where query += where;
countQuery += where.replace('u.', '') countQuery += where.replace("u.", "");
params.push(`%${search}%`, `%${search}%`) params.push(`%${search}%`, `%${search}%`);
countParams.push(`%${search}%`, `%${search}%`) countParams.push(`%${search}%`, `%${search}%`);
} }
query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?' query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset) params.push(limit, offset);
const users = db.prepare(query).all(...params) const users = db.prepare(query).all(...params);
const countRow = db.prepare(countQuery).get(...countParams) as { total: number } const countRow = db.prepare(countQuery).get(...countParams) as {
total: number;
};
res.json({ users, total: countRow.total }) res.json({ users, total: countRow.total });
} catch (err) { } catch (err) {
console.error('Admin list users error:', err) console.error("Admin list users error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Change User Role ──────────────────────────────────── // ─── Change User Role ────────────────────────────────────
router.patch('/users/:id/role', (req, res) => { router.patch("/users/:id/role", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
const { role } = req.body as { role: string } const { role } = req.body as { role: string };
if (!['user', 'admin'].includes(role)) { if (!["user", "admin"].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' }) res.status(400).json({ error: 'Role must be "user" or "admin"' });
return return;
} }
// Prevent self-demotion // Prevent self-demotion
if (userId === req.user!.userId && role !== 'admin') { if (userId === req.user!.userId && role !== "admin") {
res.status(400).json({ error: 'You cannot remove your own admin role' }) res.status(400).json({ error: "You cannot remove your own admin role" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId) const result = db
.prepare(
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
)
.run(role, userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: `User role updated to ${role}` }) res.json({ message: `User role updated to ${role}` });
} catch (err) { } catch (err) {
console.error('Admin change role error:', err) console.error("Admin change role error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Delete User ───────────────────────────────────────── // ─── Delete User ─────────────────────────────────────────
router.delete('/users/:id', (req, res) => { router.delete("/users/:id", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
// Prevent self-deletion // Prevent self-deletion
if (userId === req.user!.userId) { if (userId === req.user!.userId) {
res.status(400).json({ error: 'You cannot delete your own account' }) res.status(400).json({ error: "You cannot delete your own account" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId) const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: 'User deleted' }) res.json({ message: "User deleted" });
} catch (err) { } catch (err) {
console.error('Admin delete user error:', err) console.error("Admin delete user error:", err);
res.status(500).json({ error: 'Internal server error' }) 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 <noreply@brainwave.local>",
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;
+141 -41
View File
@@ -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'] export interface SmtpConfig {
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587') host: string;
const SMTP_USER = process.env['SMTP_USER'] port: number;
const SMTP_PASS = process.env['SMTP_PASS'] user: string;
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>' pass: string;
from: string;
const isDev = !SMTP_HOST secure: boolean;
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
} }
async function sendMail(to: string, subject: string, html: string): Promise<void> { // ─── App URL ──────────────────────────────────────────────────────────────────
const t = getTransporter()
if (!t) { export function getAppUrl(): string {
console.log('\n══════════════════════════════════════════════════') try {
console.log(`📧 EMAIL (dev mode) → ${to}`) const db = getDb();
console.log(` Subject: ${subject}`) const row = db
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`) .prepare("SELECT value FROM site_settings WHERE key = ?")
console.log('══════════════════════════════════════════════════\n') .get("app_url") as { value: string } | undefined;
return 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<void> { // ─── SMTP Config ──────────────────────────────────────────────────────────────
const link = `${APP_URL}/verify?token=${token}`
await sendMail(to, 'Verify your BrainWave account', ` 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 <noreply@brainwave.local>";
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<void> {
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<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/verify?token=${token}`;
await sendMail(
to,
"Verify your BrainWave account",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -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. This link expires in 24 hours. If you didn't create an account, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
} }
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> { export async function sendPasswordResetEmail(
const link = `${APP_URL}/reset-password?token=${token}` to: string,
await sendMail(to, 'Reset your BrainWave password', ` token: string,
): Promise<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/reset-password?token=${token}`;
await sendMail(
to,
"Reset your BrainWave password",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -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. This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
}
export async function sendTestEmail(
to: string,
overrideConfig?: SmtpConfig,
): Promise<void> {
await sendMail(
to,
"Test email from BrainWave",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">This is a test email from BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
If you're reading this, your SMTP configuration is working correctly.
</p>
<p style="font-size: 13px; color: #888; margin-top: 32px;">
Sent at: ${new Date().toUTCString()}
</p>
</div>
`,
overrideConfig,
);
} }
+28 -17
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { import {
useNavigate, useNavigate,
useLocation, useLocation,
@@ -136,15 +137,17 @@ function AppInner() {
tweaks.feedbackMode === "auto" tweaks.feedbackMode === "auto"
? (data.feedbackMode ?? "end") ? (data.feedbackMode ?? "end")
: tweaks.feedbackMode; : tweaks.feedbackMode;
setExam(data); flushSync(() => {
setExamState({ setExam(data);
currentIdx: 0, setExamState({
answers: {}, currentIdx: 0,
flags: {}, answers: {},
notes: "", flags: {},
revealed: {}, notes: "",
startedAt: Date.now(), revealed: {},
feedbackMode: fm, startedAt: Date.now(),
feedbackMode: fm,
});
}); });
navigate(`/exam/${slugify(data.title)}/q1`); navigate(`/exam/${slugify(data.title)}/q1`);
}; };
@@ -152,8 +155,10 @@ function AppInner() {
const resume = () => { const resume = () => {
const saved = loadState(); const saved = loadState();
if (saved?.exam && saved?.state) { if (saved?.exam && saved?.state) {
setExam(saved.exam); flushSync(() => {
setExamState(withNotes(saved.state)); setExam(saved.exam);
setExamState(withNotes(saved.state));
});
navigate( navigate(
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`, `/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
); );
@@ -235,18 +240,24 @@ function AppInner() {
completedAt: number; completedAt: number;
}>(`/api/results/${remoteId}`) }>(`/api/results/${remoteId}`)
.then((data) => { .then((data) => {
setExam(data.exam); flushSync(() => {
setExamState(withNotes(data.state)); setExam(data.exam);
setExamState(withNotes(data.state));
});
navigate(`/review/${slugify(entry.title)}`); navigate(`/review/${slugify(entry.title)}`);
}) })
.catch(() => { .catch(() => {
setExam(entry.exam); flushSync(() => {
setExamState(withNotes(entry.state)); setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`); navigate(`/review/${slugify(entry.title)}`);
}); });
} else { } else {
setExam(entry.exam); flushSync(() => {
setExamState(withNotes(entry.state)); setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`); navigate(`/review/${slugify(entry.title)}`);
} }
}; };
+492 -155
View File
@@ -1,155 +1,208 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { apiFetch } from '../utils/api' import { apiFetch } from "../utils/api";
interface SiteSettings { interface SiteSettings {
site_name: string site_name: string;
max_upload_mb: string max_upload_mb: string;
allow_guest_mode: string allow_guest_mode: string;
registration_enabled: string registration_enabled: string;
default_theme: string default_theme: string;
maintenance_mode: string maintenance_mode: string;
maintenance_message: 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 { interface DashboardStats {
totalUsers: number totalUsers: number;
totalExams: number totalExams: number;
totalResults: number totalResults: number;
averageScore: number averageScore: number;
recentResults: { recentResults: {
id: number id: number;
title: string title: string;
score_points: number score_points: number;
total_points: number total_points: number;
completed_at: number completed_at: number;
display_name: string display_name: string;
email: string email: string;
}[] }[];
} }
interface AdminUser { interface AdminUser {
id: number id: number;
email: string email: string;
display_name: string display_name: string;
role: 'user' | 'admin' role: "user" | "admin";
email_verified: number email_verified: number;
created_at: number created_at: number;
results_count: number results_count: number;
exams_count: number exams_count: number;
} }
type AdminTab = 'overview' | 'settings' | 'users' type AdminTab = "overview" | "settings" | "users";
interface Props { interface Props {
onExit: () => void onExit: () => void;
} }
export default function AdminDashboard({ onExit }: Readonly<Props>) { export default function AdminDashboard({ onExit }: Readonly<Props>) {
const [tab, setTab] = useState<AdminTab>('overview') const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null) const [settings, setSettings] = useState<SiteSettings | null>(null);
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null) const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([]);
const [usersTotal, setUsersTotal] = useState(0) const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('') const [userSearch, setUserSearch] = useState("");
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | 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 () => { const loadStats = useCallback(async () => {
try { try {
const data = await apiFetch<DashboardStats>('/api/admin/stats') const data = await apiFetch<DashboardStats>("/api/admin/stats");
setStats(data) setStats(data);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
try { try {
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings') const data = await apiFetch<{ settings: SiteSettings }>(
setSettings(data.settings) "/api/admin/settings",
setSettingsDraft(data.settings) );
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadUsers = useCallback(async (search = '') => { const loadUsers = useCallback(async (search = "") => {
try { try {
const q = search ? `?search=${encodeURIComponent(search)}` : '' const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`) const data = await apiFetch<{ users: AdminUser[]; total: number }>(
setUsers(data.users) `/api/admin/users${q}`,
setUsersTotal(data.total) );
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
void loadStats() void loadStats();
void loadSettings() void loadSettings();
void loadUsers() void loadUsers();
}, [loadStats, loadSettings, loadUsers]) }, [loadStats, loadSettings, loadUsers]);
const saveSettings = async () => { const saveSettings = async () => {
if (!settingsDraft) return if (!settingsDraft) return;
setSaving(true) setSaving(true);
setSaveMsg(null) setSaveMsg(null);
try { try {
await apiFetch('/api/admin/settings', { await apiFetch("/api/admin/settings", {
method: 'PUT', method: "PUT",
body: JSON.stringify({ settings: settingsDraft }), body: JSON.stringify({ settings: settingsDraft }),
}) });
setSettings(settingsDraft) setSettings(settingsDraft);
setSaveMsg('Settings saved successfully') setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000) setTimeout(() => setSaveMsg(null), 3000);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
const changeRole = async (userId: number, newRole: 'user' | 'admin') => { const changeRole = async (userId: number, newRole: "user" | "admin") => {
try { try {
await apiFetch(`/api/admin/users/${userId}/role`, { await apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH', method: "PATCH",
body: JSON.stringify({ role: newRole }), body: JSON.stringify({ role: newRole }),
}) });
await loadUsers(userSearch) await loadUsers(userSearch);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const deleteUser = async (userId: number) => { const deleteUser = async (userId: number) => {
try { try {
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' }) await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
setDeleteConfirm(null) setDeleteConfirm(null);
await loadUsers(userSearch) await loadUsers(userSearch);
await loadStats() await loadStats();
} catch (e) { } 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) => { const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
void loadUsers(userSearch) void loadUsers(userSearch);
} };
const updateDraft = (key: keyof SiteSettings, value: string) => { 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 formatDate = (ts: number) => {
const d = new Date(ts * 1000) const d = new Date(ts * 1000);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString("en-US", {
} month: "short",
day: "numeric",
year: "numeric",
});
};
return ( return (
<div className="admin"> <div className="admin">
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{error && ( {error && (
<div className="admin-alert admin-alert-error"> <div className="admin-alert admin-alert-error">
<span>{error}</span> <span>{error}</span>
<button onClick={() => setError(null)} className="admin-alert-close">×</button> <button onClick={() => setError(null)} className="admin-alert-close">
×
</button>
</div> </div>
)} )}
<div className="admin-tabs"> <div className="admin-tabs">
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => ( {(["overview", "settings", "users"] as AdminTab[]).map((t) => (
<button <button
key={t} key={t}
className={`admin-tab${tab === t ? ' active' : ''}`} className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
<span className="admin-tab-icon"> <span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'} {t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
</span> </span>
{t.charAt(0).toUpperCase() + t.slice(1)} {t.charAt(0).toUpperCase() + t.slice(1)}
</button> </button>
@@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
{/* ─── Overview Tab ─── */} {/* ─── Overview Tab ─── */}
{tab === 'overview' && ( {tab === "overview" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon users-icon">⊕</div> <div className="admin-stat-icon users-icon">⊕</div>
<div className="admin-stat-info"> <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 className="admin-stat-label">Total Users</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon exams-icon">◇</div> <div className="admin-stat-icon exams-icon">◇</div>
<div className="admin-stat-info"> <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 className="admin-stat-label">Saved Exams</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon results-icon">✦</div> <div className="admin-stat-icon results-icon">✦</div>
<div className="admin-stat-info"> <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 className="admin-stat-label">Exams Taken</div>
</div> </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-icon avg-icon">%</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value"> <div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : '—'} {stats ? `${stats.averageScore}%` : "—"}
</div> </div>
<div className="admin-stat-label">Avg. Score</div> <div className="admin-stat-label">Avg. Score</div>
</div> </div>
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-empty">No recent activity yet</div> <div className="admin-empty">No recent activity yet</div>
)} )}
{stats?.recentResults?.map((r) => { {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 ( return (
<div key={r.id} className="admin-activity-row"> <div key={r.id} className="admin-activity-row">
<div className="admin-activity-user"> <div className="admin-activity-user">
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{r.display_name.charAt(0).toUpperCase()} {r.display_name.charAt(0).toUpperCase()}
</div> </div>
<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 className="admin-activity-email">{r.email}</div>
</div> </div>
</div> </div>
<div className="admin-activity-exam">{r.title}</div> <div className="admin-activity-exam">{r.title}</div>
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div> <div
<div className="admin-activity-date">{formatDate(r.completed_at)}</div> className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
>
{pct}%
</div>
<div className="admin-activity-date">
{formatDate(r.completed_at)}
</div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)} )}
{/* ─── Settings Tab ─── */} {/* ─── Settings Tab ─── */}
{tab === 'settings' && settingsDraft && ( {tab === "settings" && settingsDraft && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
{saveMsg && ( {saveMsg && (
<div className="admin-alert admin-alert-success">{saveMsg}</div> <div className="admin-alert admin-alert-success">{saveMsg}</div>
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
<div className="admin-panel-body admin-settings-form"> <div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row"> <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-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> </label>
<input <input
id="setting-site-name" id="setting-site-name"
className="input admin-setting-input" className="input admin-setting-input"
value={settingsDraft.site_name} value={settingsDraft.site_name}
onChange={(e) => updateDraft('site_name', e.target.value)} onChange={(e) => updateDraft("site_name", e.target.value)}
/> />
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-max-upload"> <label
<span className="admin-setting-title">Max Upload Size (MB)</span> className="admin-setting-label"
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span> 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> </label>
<input <input
id="setting-max-upload" id="setting-max-upload"
@@ -289,23 +372,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
min="1" min="1"
max="50" max="50"
value={settingsDraft.max_upload_mb} 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>
<div className="admin-setting-row"> <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-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> </label>
<div className="admin-setting-toggle-group"> <div className="admin-setting-toggle-group">
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'light')} onClick={() => updateDraft("default_theme", "light")}
>☀ Light</button> >
☀ Light
</button>
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'dark')} onClick={() => updateDraft("default_theme", "dark")}
>☾ Dark</button> >
☾ Dark
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span> <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> </label>
<button <button
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"allow_guest_mode",
settingsDraft.allow_guest_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.allow_guest_mode === 'true'} aria-checked={settingsDraft.allow_guest_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Registration Enabled</span> <span className="admin-setting-title">
<span className="admin-setting-desc">Allow new users to create accounts</span> Registration Enabled
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"registration_enabled",
settingsDraft.registration_enabled === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.registration_enabled === 'true'} aria-checked={settingsDraft.registration_enabled === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
</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">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Maintenance</h3> <h3>Maintenance</h3>
@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span> <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> </label>
<button <button
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"maintenance_mode",
settingsDraft.maintenance_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.maintenance_mode === 'true'} aria-checked={settingsDraft.maintenance_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
{settingsDraft.maintenance_mode === 'true' && ( {settingsDraft.maintenance_mode === "true" && (
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-maint-msg"> <label
<span className="admin-setting-title">Maintenance Message</span> className="admin-setting-label"
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span> 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> </label>
<textarea <textarea
id="setting-maint-msg" id="setting-maint-msg"
className="textarea admin-setting-textarea" className="textarea admin-setting-textarea"
value={settingsDraft.maintenance_message} value={settingsDraft.maintenance_message}
onChange={(e) => updateDraft('maintenance_message', e.target.value)} onChange={(e) =>
updateDraft("maintenance_message", e.target.value)
}
rows={3} rows={3}
/> />
</div> </div>
@@ -397,14 +707,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void saveSettings()} onClick={() => void saveSettings()}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? 'Saving…' : 'Save Settings'} {saving ? "Saving…" : "Save Settings"}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ─── Users Tab ─── */} {/* ─── Users Tab ─── */}
{tab === 'users' && ( {tab === "users" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-users-toolbar"> <div className="admin-users-toolbar">
<form onSubmit={handleUserSearch} className="admin-search-form"> <form onSubmit={handleUserSearch} className="admin-search-form">
@@ -414,9 +724,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
value={userSearch} value={userSearch}
onChange={(e) => setUserSearch(e.target.value)} 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> </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>
<div className="admin-panel"> <div className="admin-panel">
@@ -442,7 +756,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{u.display_name.charAt(0).toUpperCase()} {u.display_name.charAt(0).toUpperCase()}
</div> </div>
<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 className="admin-user-email">{u.email}</div>
</div> </div>
</div> </div>
@@ -453,25 +769,44 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</span> </span>
</td> </td>
<td> <td>
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} /> <span
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
/>
</td> </td>
<td className="mono">{u.exams_count}</td> <td className="mono">{u.exams_count}</td>
<td className="mono">{u.results_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> <td>
<div className="admin-actions"> <div className="admin-actions">
<select <select
className="admin-role-select" className="admin-role-select"
value={u.role} 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="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
{deleteConfirm === u.id ? ( {deleteConfirm === u.id ? (
<div className="admin-delete-confirm"> <div className="admin-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button> <button
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</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> </div>
) : ( ) : (
<button <button
@@ -488,7 +823,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
))} ))}
{users.length === 0 && ( {users.length === 0 && (
<tr> <tr>
<td colSpan={7} className="admin-empty">No users found</td> <td colSpan={7} className="admin-empty">
No users found
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -498,5 +835,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
)} )}
</div> </div>
) );
} }
+1766 -1267
View File
File diff suppressed because it is too large. Load diff