Compare commits

...
6 Commits
Author SHA1 Message Date
LaurensBartelds 62af83ce53 feat: add MyExams, MyResults, Settings, and Workspace components
Release Pipeline / build-and-release (push) Failing after 41s
- Implemented MyExams component for managing user exams, including loading, deleting, and creating new exams.
- Created MyResults component to display user results with options to review and delete results.
- Developed Settings component for updating user display name and changing password.
- Introduced Workspace component to serve as a container for MyExams, MyResults, and Settings, with navigation between tabs.
2026-04-25 20:20:23 +02:00
LaurensBartelds ad777ef1c3 feat: implement setup wizard for initial configuration and user registration 2026-04-25 16:48:23 +02:00
LaurensBartelds f7b93f8612 feat: enhance user registration with email verification configuration and status handling 2026-04-25 16:31:27 +02:00
LaurensBartelds 4f33444653 fix: update exam route parameter format and improve question index calculation
Release Pipeline / build-and-release (push) Successful in 5m29s
2026-04-25 16:18:07 +02:00
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
26 changed files with 5417 additions and 1646 deletions

No files matched your search

+2
View File
@@ -2,3 +2,5 @@ backend/brainwave.db-shm
backend/brainwave.db-wal backend/brainwave.db-wal
.claude/settings.local.json .claude/settings.local.json
.superpowers/ .superpowers/
backend/db/brainwave.db-shm
backend/db/brainwave.db-wal
+3 -1
View File
@@ -1,15 +1,17 @@
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import fs from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { runMigrations } from './migrations.js' import { runMigrations } from './migrations.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db') const DB_PATH = path.join(__dirname, '..', '..', 'db', 'brainwave.db')
let db: Database.Database | null = null let db: Database.Database | null = null
export function getDb(): Database.Database { export function getDb(): Database.Database {
if (!db) { if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
db = new Database(DB_PATH) db = new Database(DB_PATH)
db.pragma('journal_mode = WAL') db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
+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);
} }
} }
+2
View File
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js' import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js' import resultRoutes from './routes/results.js'
import adminRoutes from './routes/admin.js' import adminRoutes from './routes/admin.js'
import setupRoutes from './routes/setup.js'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -24,6 +25,7 @@ getDb()
console.log('✓ Database initialized') console.log('✓ Database initialized')
// Routes // Routes
app.use('/api/setup', setupRoutes)
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes) app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes) app.use('/api/results', resultRoutes)
+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;
+97 -12
View File
@@ -2,8 +2,9 @@ import { Router } from 'express'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js' import { getDb } from '../db/database.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js' import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { needsSetup } from './setup.js'
const router = Router() const router = Router()
const BCRYPT_ROUNDS = 12 const BCRYPT_ROUNDS = 12
@@ -13,6 +14,11 @@ const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
// ─── Register ──────────────────────────────────────────── // ─── Register ────────────────────────────────────────────
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
if (needsSetup()) {
res.status(403).json({ error: 'Initial setup is required before registration' })
return
}
const { email, password, displayName } = req.body const { email, password, displayName } = req.body
if (!email || !password || !displayName) { if (!email || !password || !displayName) {
@@ -40,20 +46,35 @@ router.post('/register', async (req, res) => {
} }
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS) const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const verifyToken = generateVerificationToken() const emailConfigured = isEmailConfigured()
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
let verifyToken: string | null = null
let verifyExpires: number | null = null
let emailVerified = 1
if (emailConfigured) {
verifyToken = generateVerificationToken()
verifyExpires = Date.now() + VERIFY_EXPIRY_MS
emailVerified = 0
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires) INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires, email_verified)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires) `).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires, emailVerified)
await sendVerificationEmail(email, verifyToken) if (emailConfigured) {
await sendVerificationEmail(email, verifyToken!)
res.status(201).json({ res.status(201).json({
message: 'Account created. Please check your email to verify your account.', message: 'Account created. Please check your email to verify your account.',
userId: result.lastInsertRowid, userId: result.lastInsertRowid,
}) })
} else {
res.status(201).json({
message: 'Account created and verified. You can now log in.',
userId: result.lastInsertRowid,
})
}
} catch (err) { } catch (err) {
console.error('Register error:', err) console.error('Register error:', err)
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: 'Internal server error' })
@@ -245,4 +266,68 @@ router.get('/me', requireAuth, (req, res) => {
}) })
}) })
// ─── Update Current User ─────────────────────────────────
router.patch('/me', requireAuth, (req, res) => {
try {
const { displayName } = req.body
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
db.prepare(`
UPDATE users SET display_name = ?, updated_at = unixepoch() WHERE id = ?
`).run(displayName.trim(), req.user!.userId)
res.json({ message: 'Profile updated successfully' })
} catch (err) {
console.error('Update profile error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Change Password ─────────────────────────────────────
router.post('/change-password', requireAuth, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
res.status(400).json({ error: 'Current password and new password are required' })
return
}
if (typeof newPassword !== 'string' || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 8 characters' })
return
}
const db = getDb()
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(
req.user!.userId
) as Record<string, unknown> | undefined
if (!user) {
res.status(404).json({ error: 'User not found' })
return
}
const valid = await bcrypt.compare(currentPassword as string, user['password_hash'] as string)
if (!valid) {
res.status(401).json({ error: 'Current password is incorrect' })
return
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
db.prepare(`
UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?
`).run(passwordHash, req.user!.userId)
res.json({ message: 'Password changed successfully' })
} catch (err) {
console.error('Change password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router export default router
+29
View File
@@ -76,6 +76,35 @@ router.post('/', requireAuth, (req, res) => {
} }
}) })
// ─── Update Exam ─────────────────────────────────────────
router.put('/:id', requireAuth, (req, res) => {
try {
const { exam } = req.body
if (!exam || !exam.title || !exam.questions?.length) {
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
return
}
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(exam.title, exam.subtitle ?? null, JSON.stringify(exam), Number(req.params['id']), req.user!.userId)
if (result.changes === 0) {
res.status(404).json({ error: 'Exam not found' })
return
}
res.json({ message: 'Exam updated successfully' })
} catch (err) {
console.error('Update exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Delete Exam ───────────────────────────────────────── // ─── Delete Exam ─────────────────────────────────────────
router.delete('/:id', requireAuth, (req, res) => { router.delete('/:id', requireAuth, (req, res) => {
try { try {
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js'
import { signToken } from '../utils/tokens.js'
const router = Router()
const BCRYPT_ROUNDS = 12
function needsSetup(): boolean {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
return row.cnt === 0
}
// ─── Check Setup Status ──────────────────────────────────
router.get('/status', (_req, res) => {
try {
res.json({ needsSetup: needsSetup() })
} catch (err) {
console.error('Setup status error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Perform Initial Setup ───────────────────────────────
router.post('/', async (req, res) => {
try {
if (!needsSetup()) {
res.status(403).json({ error: 'Setup has already been completed' })
return
}
const {
email,
password,
displayName,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
// Validate admin credentials
if (!email || !password || !displayName) {
res.status(400).json({ error: 'Email, password, and display name are required' })
return
}
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
res.status(400).json({ error: 'Invalid email address' })
return
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' })
return
}
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
// Create admin user (auto-verified since no SMTP yet)
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified)
VALUES (?, ?, ?, 'admin', 1)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim())
const userId = result.lastInsertRowid as number
// Save settings
const settings: Record<string, string> = {
app_url: typeof appUrl === 'string' && appUrl.trim() ? appUrl.trim() : 'http://localhost:5173',
}
if (typeof smtpHost === 'string' && smtpHost.trim()) {
settings.smtp_host = smtpHost.trim()
settings.smtp_port = String(Number(smtpPort) || 587)
settings.smtp_user = typeof smtpUser === 'string' ? smtpUser : ''
settings.smtp_pass = typeof smtpPass === 'string' ? smtpPass : ''
settings.smtp_from = typeof smtpFrom === 'string' && smtpFrom.trim() ? smtpFrom.trim() : 'BrainWave <noreply@brainwave.local>'
settings.smtp_secure = typeof smtpSecure === 'string' ? smtpSecure : 'auto'
}
const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`)
for (const [key, value] of Object.entries(settings)) {
upsert.run(key, value)
}
// Generate JWT and log admin in immediately
const token = signToken({
userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
})
res.status(201).json({
message: 'Setup completed successfully',
token,
user: {
id: userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
},
})
} catch (err) {
console.error('Setup error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router
export { needsSetup }
+145 -41
View File
@@ -1,46 +1,119 @@
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 ?? "";
}
export function isEmailConfigured(): boolean {
return !!getSetting("smtp_host").trim();
}
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 +129,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 +158,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,
);
} }
+92 -35
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,
@@ -17,7 +18,7 @@ import {
clearHistory, clearHistory,
} from "./utils/storage"; } from "./utils/storage";
import { apiFetch, isAuthenticated } from "./utils/api"; import { apiFetch, isAuthenticated } from "./utils/api";
import { slugify } from "./utils/slug"; import { getExamUrlId } from "./utils/slug";
import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { AuthProvider, useAuth } from "./contexts/AuthContext";
import Landing from "./components/Landing"; import Landing from "./components/Landing";
import ExamRoute from "./components/ExamRoute"; import ExamRoute from "./components/ExamRoute";
@@ -27,6 +28,9 @@ import AuthModal from "./components/AuthModal";
import EmailVerify from "./components/EmailVerify"; import EmailVerify from "./components/EmailVerify";
import ResetPassword from "./components/ResetPassword"; import ResetPassword from "./components/ResetPassword";
import AdminDashboard from "./components/AdminDashboard"; import AdminDashboard from "./components/AdminDashboard";
import SetupWizard from "./components/SetupWizard";
import Workspace from "./components/Workspace";
import ExamEditor from "./components/ExamEditor";
const DEFAULT_TWEAKS: Tweaks = { const DEFAULT_TWEAKS: Tweaks = {
theme: "light", theme: "light",
@@ -40,7 +44,17 @@ const withNotes = (state: ExamState): ExamState => ({
}); });
function AppInner() { function AppInner() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
if (needsSetup) {
return (
<SetupWizard
onComplete={() => {
void refreshUser()
}}
/>
)
}
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(null); const [exam, setExam] = useState<Exam | null>(null);
@@ -112,19 +126,40 @@ function AppInner() {
saveState({ exam, state: examState }); saveState({ exam, state: examState });
}, [exam, examState, pathname]); }, [exam, examState, pathname]);
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
useEffect(() => {
if (pathname.startsWith("/review/") && !exam) {
const reviewExam = localStorage.getItem('brainwave.review.exam')
const reviewState = localStorage.getItem('brainwave.review.state')
if (reviewExam && reviewState) {
try {
const parsedExam = JSON.parse(reviewExam) as Exam
const parsedState = JSON.parse(reviewState) as ExamState
setExam(parsedExam)
setExamState(withNotes(parsedState))
localStorage.removeItem('brainwave.review.exam')
localStorage.removeItem('brainwave.review.state')
} catch {
// ignore parse errors
}
}
}
}, [pathname, exam]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value })); setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = ( const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
nextState, (nextState) => {
) => { setExamState((prev) => {
setExamState((prev) => { if (!prev) return prev;
if (!prev) return prev; return typeof nextState === "function"
return typeof nextState === "function" ? (nextState as (prevState: ExamState) => ExamState)(prev)
? (nextState as (prevState: ExamState) => ExamState)(prev) : nextState;
: nextState; });
}); },
}; [],
);
const activateExam = (data: Exam, state: ExamState) => { const activateExam = (data: Exam, state: ExamState) => {
setExam(data); setExam(data);
@@ -136,26 +171,30 @@ 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/${getExamUrlId(data)}/q1`);
}; };
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/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
); );
} }
}; };
@@ -235,19 +274,25 @@ 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);
navigate(`/review/${slugify(entry.title)}`); setExamState(withNotes(data.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
}) })
.catch(() => { .catch(() => {
setExam(entry.exam); flushSync(() => {
setExamState(withNotes(entry.state)); setExam(entry.exam);
navigate(`/review/${slugify(entry.title)}`); setExamState(withNotes(entry.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
}); });
} else { } else {
setExam(entry.exam); flushSync(() => {
setExamState(withNotes(entry.state)); setExam(entry.exam);
navigate(`/review/${slugify(entry.title)}`); setExamState(withNotes(entry.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
} }
}; };
@@ -360,6 +405,15 @@ function AppInner() {
<div className="user-menu-email">{user.email}</div> <div className="user-menu-email">{user.email}</div>
</div> </div>
<div className="user-menu-divider" /> <div className="user-menu-divider" />
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
navigate("/workspace");
}}
>
My Workspace
</button>
{user.role === "admin" && ( {user.role === "admin" && (
<button <button
className="user-menu-item" className="user-menu-item"
@@ -401,7 +455,7 @@ function AppInner() {
} }
/> />
<Route <Route
path="/exam/:slug/q:n" path="/exam/:slug/:n"
element={ element={
<ExamRoute <ExamRoute
exam={exam} exam={exam}
@@ -455,6 +509,9 @@ function AppInner() {
) )
} }
/> />
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
<Route path="/workspace/:tab" element={<Workspace />} />
<Route path="/exam-editor/:id" element={<ExamEditor />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+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>
) );
} }
+764
View File
@@ -0,0 +1,764 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam, Question, QuestionType, Choice, MatchItem } from '../types'
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
{ value: 'multiple-choice', label: 'Multiple Choice' },
{ value: 'multiple-select', label: 'Multiple Select' },
{ value: 'short-answer', label: 'Short Answer' },
{ value: 'translation', label: 'Translation' },
{ value: 'math-input', label: 'Math Input' },
{ value: 'essay', label: 'Essay' },
{ value: 'match', label: 'Match' },
{ value: 'order', label: 'Order' },
]
const BLANK_EXAM: Exam = {
title: 'Untitled Exam',
subtitle: '',
questions: [],
}
function makeBlankQuestion(type: QuestionType, index: number): Question {
const id = `q${index + 1}`
const base: Question = { id, type, prompt: '', points: 1 }
switch (type) {
case 'multiple-choice':
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: 'a' }
case 'multiple-select':
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: [] }
case 'short-answer':
case 'translation':
case 'math-input':
return { ...base, answer: '', acceptedForms: [], caseSensitive: false }
case 'essay':
return { ...base, minWords: 0, rubric: [] }
case 'match':
return { ...base, left: [], right: [], answer: {} }
case 'order':
return { ...base, items: [], answer: [] }
default:
return base
}
}
export default function ExamEditor() {
const { id: paramId } = useParams<{ id: string }>()
const navigate = useNavigate()
const isNew = paramId === 'new'
const examId = isNew ? null : Number(paramId)
const [exam, setExam] = useState<Exam>(BLANK_EXAM)
const [selectedIdx, setSelectedIdx] = useState(0)
const [loading, setLoading] = useState(!isNew)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(isNew)
// Load existing exam
useEffect(() => {
if (isNew) return
setLoading(true)
apiFetch<{ exam: Exam }>(`/api/exams/${examId}`)
.then((data) => {
setExam(data.exam)
setHasChanges(false)
})
.catch((e) => setError((e as Error).message))
.finally(() => setLoading(false))
}, [examId, isNew])
const updateExam = useCallback((fn: (draft: Exam) => void) => {
setExam((prev) => {
const draft = structuredClone(prev)
fn(draft)
return draft
})
setHasChanges(true)
setSaveMsg(null)
}, [])
const addQuestion = (type: QuestionType) => {
updateExam((draft) => {
const q = makeBlankQuestion(type, draft.questions.length)
draft.questions.push(q)
})
setSelectedIdx(exam.questions.length)
}
const removeQuestion = (idx: number) => {
updateExam((draft) => {
draft.questions.splice(idx, 1)
})
if (selectedIdx >= idx && selectedIdx > 0) {
setSelectedIdx(selectedIdx - 1)
}
}
const saveExam = async () => {
if (!exam.title.trim()) {
setError('Exam title is required')
return
}
if (exam.questions.length === 0) {
setError('Add at least one question')
return
}
setSaving(true)
setError(null)
try {
if (isNew) {
const data = await apiFetch<{ id: number }>('/api/exams', {
method: 'POST',
body: JSON.stringify({ exam }),
})
setSaveMsg('Exam created successfully')
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await apiFetch(`/api/exams/${examId}`, {
method: 'PUT',
body: JSON.stringify({ exam }),
})
setSaveMsg('Exam saved')
setHasChanges(false)
}
setTimeout(() => setSaveMsg(null), 3000)
} catch (e) {
setError((e as Error).message)
} finally {
setSaving(false)
}
}
const selectedQuestion = exam.questions[selectedIdx]
const truncate = (s: string, n = 40) => (s.length > n ? s.slice(0, n) + '…' : s)
return (
<div className="exam-editor-shell">
{/* Header */}
<header className="exam-editor-header">
<div className="exam-editor-header-left">
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
← Back
</button>
<input
className="exam-editor-title-input"
value={exam.title}
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
placeholder="Exam title"
/>
</div>
<div className="exam-editor-header-right">
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
{hasChanges && <span className="exam-editor-unsaved">Unsaved changes</span>}
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
{saving ? 'Saving…' : isNew ? 'Create Exam' : 'Save'}
</button>
</div>
</header>
{error && (
<div className="workspace-alert workspace-alert-error exam-editor-alert">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="exam-editor-loading">Loading…</div>
) : (
<div className="exam-editor-body">
{/* Sidebar */}
<aside className="exam-editor-sidebar">
<div className="sidebar-section-label">Questions</div>
<div className="nav-list">
{exam.questions.map((q, i) => (
<button
key={q.id}
className={`nav-item${selectedIdx === i ? ' active' : ''}`}
onClick={() => setSelectedIdx(i)}
>
<span className="nav-item-num">{i + 1}</span>
<span className="nav-item-title">{truncate(q.prompt) || 'Untitled'}</span>
<span
className="exam-editor-q-remove"
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
title="Remove"
>
✕
</span>
</button>
))}
</div>
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>Add Question</div>
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((t) => (
<button
key={t.value}
className="exam-editor-type-btn"
onClick={() => addQuestion(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
</aside>
{/* Main Editor */}
<main className="exam-editor-main">
{selectedQuestion ? (
<QuestionEditor
question={selectedQuestion}
onChange={(fn) => {
updateExam((draft) => {
const q = draft.questions[selectedIdx]
if (q) fn(q)
})
}}
/>
) : (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No questions yet</div>
<div className="workspace-empty-desc">Select a question type from the sidebar to add your first question.</div>
</div>
)}
</main>
</div>
)}
</div>
)
}
/* ─── Question Editor ─── */
interface QEProps {
question: Question
onChange: (fn: (q: Question) => void) => void
}
function QuestionEditor({ question, onChange }: QEProps) {
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
onChange((q) => { q[key] = value })
}
return (
<div className="question-form">
<div className="question-form-header">
<span className="mono faint" style={{ fontSize: 12 }}>Question Editor</span>
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
</div>
<div className="question-form-row">
<label className="question-form-label">Prompt</label>
<textarea
className="textarea"
value={question.prompt}
onChange={(e) => update('prompt', e.target.value)}
rows={3}
placeholder="Enter the question prompt..."
/>
</div>
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Category (optional)</label>
<input
className="input"
value={question.category ?? ''}
onChange={(e) => update('category', e.target.value || undefined)}
placeholder="e.g. Algebra"
/>
</div>
<div style={{ width: 100 }}>
<label className="question-form-label">Points</label>
<input
className="input"
type="number"
min={1}
value={question.points ?? 1}
onChange={(e) => update('points', Math.max(1, Number(e.target.value)))}
/>
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Explanation (optional)</label>
<textarea
className="textarea"
value={question.explanation ?? ''}
onChange={(e) => update('explanation', e.target.value || undefined)}
rows={2}
placeholder="Explanation shown after grading..."
/>
</div>
{/* Type-specific editors */}
{question.type === 'multiple-choice' && <MultipleChoiceEditor q={question} onChange={onChange} />}
{question.type === 'multiple-select' && <MultipleSelectEditor q={question} onChange={onChange} />}
{(question.type === 'short-answer' || question.type === 'translation' || question.type === 'math-input') && (
<TextAnswerEditor q={question} onChange={onChange} />
)}
{question.type === 'translation' && (
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Source Language</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder="e.g. English" />
</div>
<div>
<label className="question-form-label">Target Language</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder="e.g. Spanish" />
</div>
</div>
)}
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
</div>
)
}
/* ─── Multiple Choice ─── */
function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const choices = q.choices ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
const addChoice = () => {
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
setChoices([...choices, { id: nextId, text: '' }])
}
const removeChoice = (idx: number) => {
const next = choices.filter((_, i) => i !== idx)
// Renumber
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
setChoices(renumbered)
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
}
const updateChoice = (idx: number, text: string) => {
const next = [...choices]
next[idx] = { ...next[idx], text }
setChoices(next)
}
return (
<div className="question-form-section">
<label className="question-form-label">Choices</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
<input
type="radio"
name="mc-answer"
checked={q.answer === c.id}
onChange={() => setAnswer(c.id)}
title="Mark as correct"
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
<input
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
</button>
</div>
)
}
/* ─── Multiple Select ─── */
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const choices = q.choices ?? []
const answer = (q.answer as string[]) ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
const toggleAnswer = (id: string) => {
const next = answer.includes(id) ? answer.filter((a) => a !== id) : [...answer, id]
onChange((q) => { q.answer = next })
}
const addChoice = () => {
const nextId = String.fromCharCode(97 + choices.length)
setChoices([...choices, { id: nextId, text: '' }])
}
const removeChoice = (idx: number) => {
const removedId = choices[idx].id
const next = choices.filter((_, i) => i !== idx)
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
setChoices(renumbered)
onChange((q) => { q.answer = (q.answer as string[]).filter((a) => a !== removedId) })
}
const updateChoice = (idx: number, text: string) => {
const next = [...choices]
next[idx] = { ...next[idx], text }
setChoices(next)
}
return (
<div className="question-form-section">
<label className="question-form-label">Choices (check correct answers)</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
<input
type="checkbox"
checked={answer.includes(c.id)}
onChange={() => toggleAnswer(c.id)}
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
<input
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
</button>
</div>
)
}
/* ─── Text Answer (short, translation, math) ─── */
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const accepted = q.acceptedForms ?? []
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">Correct Answer</label>
<input
className="input"
value={(q.answer as string) ?? ''}
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
placeholder="Enter the correct answer"
/>
</div>
<div className="question-form-row">
<label className="question-form-label">Accepted Forms (optional alternatives)</label>
{accepted.map((form, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={form}
onChange={(e) => {
const next = [...accepted]
next[i] = e.target.value
onChange((q) => { q.acceptedForms = next })
}}
placeholder="Alternative answer"
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
const next = accepted.filter((_, idx) => idx !== i)
onChange((q) => { q.acceptedForms = next })
}}
>
✕
</button>
</div>
))}
<button
className="btn btn-sm"
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Alternative
</button>
</div>
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
<label className="question-form-check">
<input
type="checkbox"
checked={q.caseSensitive ?? false}
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
/>
Case sensitive
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignorePunctuation ?? false}
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
/>
Ignore punctuation
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignoreAccents ?? false}
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
/>
Ignore accents
</label>
</div>
</div>
)
}
/* ─── Essay ─── */
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const rubric = q.rubric ?? []
return (
<div className="question-form-section">
<div className="question-form-row-inline">
<div style={{ width: 140 }}>
<label className="question-form-label">Min Words</label>
<input
className="input"
type="number"
min={0}
value={q.minWords ?? 0}
onChange={(e) => onChange((q) => { q.minWords = Number(e.target.value) })}
/>
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Rubric Criteria</label>
{rubric.map((item, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item}
onChange={(e) => {
const next = [...rubric]
next[i] = e.target.value
onChange((q) => { q.rubric = next })
}}
placeholder="Criterion"
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
const next = rubric.filter((_, idx) => idx !== i)
onChange((q) => { q.rubric = next })
}}
>
✕
</button>
</div>
))}
<button
className="btn btn-sm"
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Criterion
</button>
</div>
</div>
)
}
/* ─── Match ─── */
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const left = q.left ?? []
const right = q.right ?? []
const answer = (q.answer as Record<string, string>) ?? {}
const addLeft = () => {
const id = `l${left.length + 1}`
onChange((q) => { q.left = [...left, { id, text: '' }] })
}
const addRight = () => {
const id = `r${right.length + 1}`
onChange((q) => { q.right = [...right, { id, text: '' }] })
}
const removeLeft = (idx: number) => {
const removed = left[idx]
onChange((q) => {
q.left = left.filter((_, i) => i !== idx)
const ans = { ...(q.answer as Record<string, string>) }
delete ans[removed.id]
q.answer = ans
})
}
const removeRight = (idx: number) => {
const removed = right[idx]
onChange((q) => {
q.right = right.filter((_, i) => i !== idx)
const ans = { ...(q.answer as Record<string, string>) }
for (const k of Object.keys(ans)) {
if (ans[k] === removed.id) delete ans[k]
}
q.answer = ans
})
}
return (
<div className="question-form-section">
<div className="match-editor-grid">
<div>
<label className="question-form-label">Left Column</label>
{left.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...left]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.left = next })
}}
placeholder={`Left item ${i + 1}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>+ Add Left</button>
</div>
<div>
<label className="question-form-label">Right Column</label>
{right.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...right]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.right = next })
}}
placeholder={`Right item ${i + 1}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>+ Add Right</button>
</div>
</div>
{left.length > 0 && right.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Pairings</label>
{left.map((l) => (
<div key={l.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span style={{ minWidth: 120, fontSize: 14 }}>{l.text || l.id}</span>
<span className="faint">→</span>
<select
className="input"
style={{ width: 200 }}
value={answer[l.id] ?? ''}
onChange={(e) => {
const next = { ...answer, [l.id]: e.target.value }
if (!e.target.value) delete next[l.id]
onChange((q) => { q.answer = next })
}}
>
<option value="">— Select —</option>
{right.map((r) => (
<option key={r.id} value={r.id}>{r.text || r.id}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
)
}
/* ─── Order ─── */
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const items = q.items ?? []
const answer = (q.answer as string[]) ?? []
const addItem = () => {
const id = `s${items.length + 1}`
onChange((q) => { q.items = [...items, { id, text: '' }] })
}
const removeItem = (idx: number) => {
const removed = items[idx]
onChange((q) => {
q.items = items.filter((_, i) => i !== idx)
q.answer = answer.filter((a) => a !== removed.id)
})
}
const moveItem = (idx: number, dir: -1 | 1) => {
const next = [...items]
const swap = idx + dir
if (swap < 0 || swap >= next.length) return
;[next[idx], next[swap]] = [next[swap], next[idx]]
onChange((q) => { q.items = next })
}
return (
<div className="question-form-section">
<label className="question-form-label">Items (drag not implemented, use arrows to reorder)</label>
{items.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span className="mono faint" style={{ fontSize: 12, width: 24 }}>{i + 1}</span>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...items]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.items = next })
}}
placeholder={`Item ${i + 1}`}
/>
<div className="order-editor-controls">
<button className="btn btn-ghost btn-sm" disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
<button className="btn btn-ghost btn-sm" disabled={i === items.length - 1} onClick={() => moveItem(i, 1)}>↓</button>
<button className="btn btn-ghost btn-sm" onClick={() => removeItem(i)}>✕</button>
</div>
</div>
))}
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>+ Add Item</button>
{items.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Order</label>
<div className="choice-editor-row">
{items.map((item, i) => (
<span key={item.id} className="mono faint" style={{ fontSize: 13, marginRight: 4 }}>
{i + 1}. {item.text || item.id}
</span>
))}
</div>
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
The correct order is the order shown above. Reorder items to set the correct sequence.
</p>
{/* Sync answer to current item order */}
<button
className="btn btn-sm"
style={{ marginTop: 8 }}
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
>
Set Current Order as Correct
</button>
</div>
)}
</div>
)
}
+6 -3
View File
@@ -1,9 +1,10 @@
"use no memo";
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useEffect } from 'react' import { useEffect } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import ExamRunner from './ExamRunner' import ExamRunner from './ExamRunner'
import { loadState } from '../utils/storage' import { loadState } from '../utils/storage'
import { slugify } from '../utils/slug' import { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types' import type { Exam, ExamState } from '../types'
interface Props { interface Props {
@@ -19,13 +20,13 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
const { slug, n } = useParams<{ slug: string; n: string }>() const { slug, n } = useParams<{ slug: string; n: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const questionIdx = Math.max(0, (Number(n) || 1) - 1) const questionIdx = Math.max(0, (Number((n ?? '').replace(/^q/, '')) || 1) - 1)
// Deep link: restore from localStorage if no exam is active on mount // Deep link: restore from localStorage if no exam is active on mount
useEffect(() => { useEffect(() => {
if (!exam) { if (!exam) {
const saved = loadState() const saved = loadState()
if (saved?.exam && slugify(saved.exam.title) === slug) { if (saved?.exam && getExamUrlId(saved.exam) === slug) {
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx }) onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
} else { } else {
navigate('/', { replace: true }) navigate('/', { replace: true })
@@ -44,12 +45,14 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
return ( return (
<ExamRunner <ExamRunner
key={n}
exam={exam} exam={exam}
state={examState} state={examState}
setState={setExamState} setState={setExamState}
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)} onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
onSubmit={onSubmit} onSubmit={onSubmit}
onExit={onExit} onExit={onExit}
n={n}
/> />
) )
} }
+6 -1
View File
@@ -1,3 +1,4 @@
"use no memo";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { Exam, ExamState, Question } from "../types"; import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading"; import { gradeQuestion } from "../utils/grading";
@@ -13,6 +14,7 @@ interface Props {
onSubmit: () => void; onSubmit: () => void;
onExit: () => void; onExit: () => void;
onNavigate?: (i: number) => void; onNavigate?: (i: number) => void;
n?: string;
} }
export default function ExamRunner({ export default function ExamRunner({
@@ -22,8 +24,11 @@ export default function ExamRunner({
onSubmit, onSubmit,
onExit, onExit,
onNavigate, onNavigate,
n,
}: Readonly<Props>) { }: Readonly<Props>) {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state; const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
const { answers, flags, startedAt, feedbackMode } = state;
const currentIdx = urlIdx;
const q = exam.questions[currentIdx]; const q = exam.questions[currentIdx];
const total = exam.questions.length; const total = exam.questions.length;
const notes = state.notes ?? ""; const notes = state.notes ?? "";
+5 -1
View File
@@ -395,7 +395,11 @@ Set a 20-minute time limit. Include explanations for each answer."
if (f) void handleFile(f) if (f) void handleFile(f)
}} }}
> >
<div className="dropzone-icon" /> <div className="dropzone-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<h3>Drop your exam JSON here</h3> <h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p> <p>or click to browse · max 5 MB</p>
<input <input
+137
View File
@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam } from '../types'
interface SavedExam {
id: number
title: string
subtitle: string | null
question_count: number
created_at: number
updated_at: number
}
export default function MyExams() {
const navigate = useNavigate()
const [exams, setExams] = useState<SavedExam[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const loadExams = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<SavedExam[]>('/api/exams')
setExams(data)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadExams()
}, [loadExams])
const deleteExam = async (id: number) => {
try {
await apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
setDeleteConfirm(null)
void loadExams()
} catch (e) {
setError((e as Error).message)
}
}
const createNewExam = () => {
navigate('/exam-editor/new')
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">My Exams</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ New Exam
</button>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
) : exams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No saved exams yet</div>
<div className="workspace-empty-desc">
Upload an exam from the home page, or create a new one to get started.
</div>
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
Create Your First Exam
</button>
</div>
) : (
<div className="workspace-list">
{exams.map((exam) => (
<div key={exam.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{exam.title}</div>
{exam.subtitle && (
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} question{exam.question_count !== 1 ? 's' : ''}</span>
<span>·</span>
<span>Updated {formatDate(exam.updated_at)}</span>
</div>
</div>
<div className="workspace-card-actions">
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
>
Edit
</button>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
Confirm
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title="Delete exam"
>
✕
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface ResultItem {
id: number
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
completed_at: number
exam_id: number | null
}
export default function MyResults() {
const navigate = useNavigate()
const [results, setResults] = useState<ResultItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const loadResults = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<{ results: ResultItem[]; total: number }>('/api/results')
setResults(data.results)
setTotal(data.total)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadResults()
}, [loadResults])
const deleteResult = async (id: number) => {
try {
await apiFetch(`/api/results/${id}`, { method: 'DELETE' })
setDeleteConfirm(null)
void loadResults()
} catch (e) {
setError((e as Error).message)
}
}
const reviewResult = async (item: ResultItem) => {
try {
const data = await apiFetch<{
exam: Exam
state: ExamState
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
passingScore: number
elapsedMs: number
completedAt: number
}>(`/api/results/${item.id}`)
// Store in session for review - we'll navigate to review route
// Use localStorage as a bridge since review expects exam/examState in App state
localStorage.setItem('brainwave.review.exam', JSON.stringify(data.exam))
localStorage.setItem('brainwave.review.state', JSON.stringify({
...data.state,
completedAt: data.completedAt * 1000,
}))
navigate(`/review/${getExamUrlId(data.exam)}`)
} catch (e) {
setError((e as Error).message)
}
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const formatTime = (ms: number) => {
const m = Math.floor(ms / 60000)
const s = Math.floor((ms % 60000) / 1000)
return `${m}m ${s}s`
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Results</h2>
</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} attempt{total !== 1 ? 's' : ''}</div>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
) : results.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">✦</div>
<div className="workspace-empty-title">No attempts yet</div>
<div className="workspace-empty-desc">
Take an exam to see your results here.
</div>
</div>
) : (
<div className="workspace-list">
{results.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
const passed = pct >= (r.passing_score ?? 60)
return (
<div key={r.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{r.title}</div>
<div className="workspace-card-meta">
<span>{formatDate(r.completed_at)}</span>
<span>·</span>
<span>{formatTime(r.elapsed_ms)}</span>
<span>·</span>
<span>{r.correct_count}/{r.graded_count} correct</span>
</div>
</div>
<div className="workspace-card-actions" style={{ gap: 8 }}>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
Review
</button>
{deleteConfirm === r.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
Confirm
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(r.id)}
title="Delete result"
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useState, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
export default function Settings() {
const { user, refreshUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
const [savingName, setSavingName] = useState(false)
const [nameMsg, setNameMsg] = useState<{ ok: boolean; text: string } | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [savingPassword, setSavingPassword] = useState(false)
const [passwordMsg, setPasswordMsg] = useState<{ ok: boolean; text: string } | null>(null)
const updateDisplayName = useCallback(async () => {
const trimmed = displayName.trim()
if (!trimmed || trimmed.length > 100) {
setNameMsg({ ok: false, text: 'Display name is required (max 100 characters)' })
return
}
setSavingName(true)
setNameMsg(null)
try {
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ displayName: trimmed }),
})
setNameMsg({ ok: true, text: 'Display name updated' })
await refreshUser()
} catch (e) {
setNameMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingName(false)
}
}, [displayName, refreshUser])
const changePassword = useCallback(async () => {
if (newPassword.length < 8) {
setPasswordMsg({ ok: false, text: 'New password must be at least 8 characters' })
return
}
if (newPassword !== confirmPassword) {
setPasswordMsg({ ok: false, text: 'Passwords do not match' })
return
}
setSavingPassword(true)
setPasswordMsg(null)
try {
await apiFetch('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
setPasswordMsg({ ok: true, text: 'Password changed successfully' })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (e) {
setPasswordMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingPassword(false)
}
}, [currentPassword, newPassword, confirmPassword])
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Settings</h2>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Profile</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-display-name">
<span className="workspace-setting-title">Display Name</span>
<span className="workspace-setting-desc">Your name shown throughout the app</span>
</label>
<input
id="settings-display-name"
className="input workspace-setting-input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-email">
<span className="workspace-setting-title">Email</span>
<span className="workspace-setting-desc">Your email address</span>
</label>
<input
id="settings-email"
className="input workspace-setting-input"
value={user?.email ?? ''}
readOnly
disabled
style={{ opacity: 0.6 }}
/>
</div>
{nameMsg && (
<div className={`workspace-alert ${nameMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
{nameMsg.text}
</div>
)}
<div className="workspace-settings-footer">
<button
className="btn btn-primary"
onClick={() => void updateDisplayName()}
disabled={savingName || displayName.trim() === user?.displayName}
>
{savingName ? 'Saving…' : 'Save Profile'}
</button>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Security</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-current-password">
<span className="workspace-setting-title">Current Password</span>
</label>
<input
id="settings-current-password"
className="input workspace-setting-input"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-new-password">
<span className="workspace-setting-title">New Password</span>
<span className="workspace-setting-desc">Minimum 8 characters</span>
</label>
<input
id="settings-new-password"
className="input workspace-setting-input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-confirm-password">
<span className="workspace-setting-title">Confirm New Password</span>
</label>
<input
id="settings-confirm-password"
className="input workspace-setting-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{passwordMsg && (
<div className={`workspace-alert ${passwordMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
{passwordMsg.text}
</div>
)}
<div className="workspace-settings-footer">
<button
className="btn btn-primary"
onClick={() => void changePassword()}
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{savingPassword ? 'Changing…' : 'Change Password'}
</button>
</div>
</div>
</div>
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
import { useState } from 'react'
import { apiFetch, setToken } from '../utils/api'
interface Props {
onComplete: () => void
}
export default function SetupWizard({ onComplete }: Readonly<Props>) {
const [step, setStep] = useState(1)
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState('587')
const [smtpUser, setSmtpUser] = useState('')
const [smtpPass, setSmtpPass] = useState('')
const [smtpFrom, setSmtpFrom] = useState('')
const [smtpSecure, setSmtpSecure] = useState('auto')
const [appUrl, setAppUrl] = useState(window.location.origin)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const validateStep1 = () => {
if (!email.trim() || !email.includes('@')) {
setError('Please enter a valid email address')
return false
}
if (!displayName.trim()) {
setError('Display name is required')
return false
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return false
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return false
}
return true
}
const handleNext = () => {
setError(null)
if (validateStep1()) setStep(2)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}>('/api/setup', {
method: 'POST',
body: JSON.stringify({
email,
password,
displayName: displayName.trim(),
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
smtpSecure,
appUrl,
}),
})
setToken(data.token)
onComplete()
} catch (err) {
setError(
err instanceof Error ? err.message : 'Setup failed. Please try again.',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="setup-page">
<div className="setup-card">
<div className="brand-mark" style={{ margin: '0 auto 16px' }} />
<h1 className="setup-title">Welcome to BrainWave</h1>
<p className="setup-subtitle">
Let's get your instance set up. This only takes a minute.
</p>
<div className="setup-steps">
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
<span className="setup-step-num">1</span>
<span className="setup-step-label">Admin Account</span>
</div>
<div className="setup-step-line" />
<div className={`setup-step ${step >= 2 ? 'active' : ''}`}>
<span className="setup-step-num">2</span>
<span className="setup-step-label">Email Settings</span>
</div>
</div>
{error && <div className="auth-error">{error}</div>}
{step === 1 && (
<>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-email">
Email
</label>
<input
id="setup-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
autoComplete="email"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-name">
Display Name
</label>
<input
id="setup-name"
className="input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
required
autoComplete="name"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-password">
Password
</label>
<input
id="setup-password"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-confirm">
Confirm Password
</label>
<input
id="setup-confirm"
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<button
className="btn btn-primary auth-submit"
onClick={handleNext}
style={{ marginTop: 8 }}
>
Continue →
</button>
</>
)}
{step === 2 && (
<form onSubmit={(e) => void handleSubmit(e)}>
<p className="setup-hint">
Configure your outgoing email server so BrainWave can send
verification and password-reset emails.
</p>
<p className="setup-hint">
You can leave these blank and configure them later in the admin
dashboard.
</p>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-host">
SMTP Host
</label>
<input
id="smtp-host"
className="input"
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-port">
SMTP Port
</label>
<input
id="smtp-port"
className="input"
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="587"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-user">
SMTP Username
</label>
<input
id="smtp-user"
className="input"
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-pass">
SMTP Password
</label>
<input
id="smtp-pass"
className="input"
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="••••••••"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-from">
From Address
</label>
<input
id="smtp-from"
className="input"
type="text"
value={smtpFrom}
onChange={(e) => setSmtpFrom(e.target.value)}
placeholder="BrainWave <noreply@brainwave.local>"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-secure">
Security
</label>
<select
id="smtp-secure"
className="input"
value={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.value)}
>
<option value="auto">Auto (use SSL on port 465)</option>
<option value="true">SSL/TLS</option>
<option value="false">None / STARTTLS</option>
</select>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="app-url">
App URL
</label>
<input
id="app-url"
className="input"
type="url"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://brainwave.example.com"
required
/>
</div>
<div className="setup-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => setStep(1)}
>
← Back
</button>
<button
className="btn btn-primary auth-submit"
type="submit"
disabled={submitting}
>
{submitting ? 'Setting up…' : 'Complete Setup'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
import MyExams from './MyExams'
import MyResults from './MyResults'
import Settings from './Settings'
type TabKey = 'exams' | 'results' | 'settings'
const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'exams', label: 'My Exams', icon: '◇' },
{ key: 'results', label: 'Results', icon: '✦' },
{ key: 'settings', label: 'Settings', icon: '⚙' },
]
export default function Workspace() {
const { user, loading: authLoading } = useAuth()
const navigate = useNavigate()
const { tab } = useParams<{ tab: string }>()
const activeTab = (tab as TabKey) ?? 'exams'
const [examCount, setExamCount] = useState(0)
const [resultCount, setResultCount] = useState(0)
const loadCounts = useCallback(async () => {
try {
const exams = await apiFetch<{ id: number }[]>('/api/exams')
setExamCount(exams.length)
const results = await apiFetch<{ results: unknown[]; total: number }>('/api/results')
setResultCount(results.total)
} catch {
// silently ignore
}
}, [])
useEffect(() => {
if (user) void loadCounts()
}, [user, loadCounts])
if (authLoading) return null
if (!user) return <Navigate to="/" replace />
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
return (
<div className="workspace-shell">
<aside className="workspace-sidebar">
<div className="workspace-profile">
<div className="workspace-avatar">
{user.displayName.charAt(0).toUpperCase()}
</div>
<div className="workspace-name">{user.displayName}</div>
<div className="workspace-email">{user.email}</div>
</div>
<nav className="workspace-nav">
{TABS.map((t) => (
<button
key={t.key}
className={`workspace-nav-item${activeTab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
>
<span className="workspace-nav-icon">{t.icon}</span>
<span className="workspace-nav-label">{t.label}</span>
{t.key === 'exams' && examCount > 0 && (
<span className="workspace-nav-badge">{examCount}</span>
)}
{t.key === 'results' && resultCount > 0 && (
<span className="workspace-nav-badge">{resultCount}</span>
)}
</button>
))}
</nav>
</aside>
<main className="workspace-main">
{activeTab === 'exams' && <MyExams />}
{activeTab === 'results' && <MyResults />}
{activeTab === 'settings' && <Settings />}
</main>
</div>
)
}
+19 -6
View File
@@ -12,6 +12,8 @@ export interface User {
interface AuthContextValue { interface AuthContextValue {
user: User | null user: User | null
loading: boolean loading: boolean
needsSetup: boolean
setUser: (user: User | null) => void
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<string> register: (email: string, password: string, displayName: string) => Promise<string>
logout: () => void logout: () => void
@@ -23,14 +25,25 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
try { try {
// Always check setup status first
const setupStatus = await apiFetch<{ needsSetup: boolean }>('/api/setup/status')
if (setupStatus.needsSetup) {
setNeedsSetup(true)
setUser(null)
setLoading(false)
return
}
setNeedsSetup(false)
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me') const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' }) setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
} catch { } catch {
@@ -68,7 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []) }, [])
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}> <AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
+2511 -1271
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -68,6 +68,7 @@ export interface Question {
} }
export interface Exam { export interface Exam {
id?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
author?: string; author?: string;
+15
View File
@@ -5,3 +5,18 @@ export function slugify(title: string): string {
.replace(/-+/g, '-') .replace(/-+/g, '-')
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
} }
export function getExamUrlId(exam: { id?: string; title: string }): string {
const slug = slugify(exam.title)
if (exam.id) return `${slug}-${exam.id}`
return slug
}
export function extractExamSlug(examId: string): string {
const lastDash = examId.lastIndexOf('-')
if (lastDash === -1) return examId
// Check if the part after the last dash looks like a generated id (8+ chars of a-z0-9)
const suffix = examId.slice(lastDash + 1)
if (/^[a-z0-9]{8,}$/.test(suffix)) return examId.slice(0, lastDash)
return examId
}
+11 -1
View File
@@ -13,4 +13,14 @@ export const validateExam = (data: unknown): string | null => {
return null return null
} }
export const parseExam = (data: unknown): Exam => data as Exam function generateId(): string {
return Math.random().toString(36).slice(2, 10)
}
export const parseExam = (data: unknown): Exam => {
const exam = data as Exam
if (!exam.id) {
exam.id = generateId()
}
return exam
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
export default defineConfig({ export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": { "/api": {