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

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

No files matched your search

+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 {
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
View File
@@ -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
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']
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,
);
}