Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d9264cfb | ||
|
|
107ebf4d47 |
No files matched your search
@@ -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<string, string> = {
|
||||
site_name: 'BrainWave',
|
||||
max_upload_mb: '5',
|
||||
allow_guest_mode: 'true',
|
||||
registration_enabled: 'true',
|
||||
default_theme: 'light',
|
||||
maintenance_mode: 'false',
|
||||
maintenance_message: 'We are currently performing maintenance. Please check back soon.',
|
||||
}
|
||||
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 <noreply@brainwave.local>",
|
||||
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);
|
||||
}
|
||||
}
|
||||
+177
-99
@@ -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<string, string> = {}
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM site_settings ORDER BY key")
|
||||
.all() as { key: string; value: string }[];
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
settings[row.key] = row.value
|
||||
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<string, string> }
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
res.status(400).json({ error: 'Settings object is required' })
|
||||
return
|
||||
const { settings } = req.body as { settings: Record<string, string> };
|
||||
if (!settings || typeof settings !== "object") {
|
||||
res.status(400).json({ error: "Settings object is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const validKeys = [
|
||||
'site_name', 'max_upload_mb', 'allow_guest_mode',
|
||||
'registration_enabled', 'default_theme', 'maintenance_mode',
|
||||
'maintenance_message',
|
||||
]
|
||||
"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 <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
@@ -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 <noreply@brainwave.local>'
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <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;">
|
||||
<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;">
|
||||
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
`)
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
|
||||
const link = `${APP_URL}/reset-password?token=${token}`
|
||||
await sendMail(to, 'Reset your BrainWave password', `
|
||||
export async function sendPasswordResetEmail(
|
||||
to: string,
|
||||
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;">
|
||||
<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;">
|
||||
@@ -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.
|
||||
</p>
|
||||
</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
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import {
|
||||
useNavigate,
|
||||
useLocation,
|
||||
@@ -136,15 +137,17 @@ function AppInner() {
|
||||
tweaks.feedbackMode === "auto"
|
||||
? (data.feedbackMode ?? "end")
|
||||
: tweaks.feedbackMode;
|
||||
setExam(data);
|
||||
setExamState({
|
||||
currentIdx: 0,
|
||||
answers: {},
|
||||
flags: {},
|
||||
notes: "",
|
||||
revealed: {},
|
||||
startedAt: Date.now(),
|
||||
feedbackMode: fm,
|
||||
flushSync(() => {
|
||||
setExam(data);
|
||||
setExamState({
|
||||
currentIdx: 0,
|
||||
answers: {},
|
||||
flags: {},
|
||||
notes: "",
|
||||
revealed: {},
|
||||
startedAt: Date.now(),
|
||||
feedbackMode: fm,
|
||||
});
|
||||
});
|
||||
navigate(`/exam/${slugify(data.title)}/q1`);
|
||||
};
|
||||
@@ -152,8 +155,10 @@ function AppInner() {
|
||||
const resume = () => {
|
||||
const saved = loadState();
|
||||
if (saved?.exam && saved?.state) {
|
||||
setExam(saved.exam);
|
||||
setExamState(withNotes(saved.state));
|
||||
flushSync(() => {
|
||||
setExam(saved.exam);
|
||||
setExamState(withNotes(saved.state));
|
||||
});
|
||||
navigate(
|
||||
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
|
||||
);
|
||||
@@ -235,18 +240,24 @@ function AppInner() {
|
||||
completedAt: number;
|
||||
}>(`/api/results/${remoteId}`)
|
||||
.then((data) => {
|
||||
setExam(data.exam);
|
||||
setExamState(withNotes(data.state));
|
||||
flushSync(() => {
|
||||
setExam(data.exam);
|
||||
setExamState(withNotes(data.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
})
|
||||
.catch(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
});
|
||||
} else {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
+1766
-1267
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user