Compare commits

...
8 Commits
Author SHA1 Message Date
LaurensBartelds 95cf0db5a4 feat: add retake attempt functionality and update translations for retake button
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m24s
2026-04-27 20:22:17 +02:00
LaurensBartelds 028c732716 feat: add redo functionality for failed exam questions and update translations
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s
2026-04-27 20:15:28 +02:00
LaurensBartelds acbcfde617 feat: add storage management features including user storage quota and usage tracking
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
- Updated database schema to include `storage_quota_bytes` and `plan` for users.
- Implemented storage usage calculations in `storageRepo` and added tests for storage functionality.
- Created `StorageManager` component to display and manage user storage.
- Enhanced user settings to show storage details and warnings for high usage.
- Added translations for new storage-related strings in English and Dutch.
- Updated styles for storage overview and details in CSS.
2026-04-27 15:29:43 +02:00
LaurensBartelds c002f42459 Merge pull request 'feat: implement retake exam functionality and update translations for exam status' (#2) from fixes into main
Test / backend (push) Successful in 17s
Test / frontend (push) Successful in 34s
Reviewed-on: #2
2026-04-27 13:08:22 +02:00
LaurensBartelds 6dd87972f6 feat: update settings management to use site_name and add batch update schema
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 33s
2026-04-27 13:06:01 +02:00
LaurensBartelds 1b5fd04040 feat: enhance security with token blacklisting and input sanitization; add helmet for security headers
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 24s
2026-04-27 12:52:57 +02:00
LaurensBartelds 4935251125 feat: implement retake exam functionality and update translations for exam status
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
2026-04-27 11:28:01 +02:00
LaurensBartelds f794dacd2d feat: add JWT_SECRET to deployment and create .env file for environment variables
Release Pipeline / build-and-release (push) Successful in 5m4s
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 23s
2026-04-27 01:02:17 +02:00
46 changed files with 1172 additions and 136 deletions

No files matched your search

+3 -4
View File
@@ -27,10 +27,8 @@ jobs:
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory - name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file.
run: | run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \ printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
"${{ secrets.ANSIBLE_HOST }}" \ "${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini "${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
@@ -55,7 +53,8 @@ jobs:
run: | run: |
ansible-playbook ansible/deploy.yml \ ansible-playbook ansible/deploy.yml \
-i /tmp/inventory.ini \ -i /tmp/inventory.ini \
-e "artifact_path=${WORKSPACE}" -e "artifact_path=${WORKSPACE}" \
-e "jwt_secret=${{ secrets.JWT_SECRET }}"
- name: Cleanup - name: Cleanup
if: always() if: always()
+8
View File
@@ -63,6 +63,10 @@ jobs:
- "3001:3001" - "3001:3001"
volumes: volumes:
- brainwave-db:/app/backend/db - brainwave-db:/app/backend/db
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped restart: unless-stopped
volumes: volumes:
brainwave-db: brainwave-db:
@@ -77,6 +81,10 @@ jobs:
- "3001:3001" - "3001:3001"
volumes: volumes:
- brainwave-db:/app/backend/db - brainwave-db:/app/backend/db
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped restart: unless-stopped
volumes: volumes:
brainwave-db: brainwave-db:
+1
View File
@@ -7,3 +7,4 @@ backend/db/brainwave.db-wal
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
brainwave.db-shm brainwave.db-shm
brainwave.db-wal brainwave.db-wal
node_modules/
+6
View File
@@ -24,6 +24,12 @@
src: "{{ artifact_path }}/docker-compose.arm64.yml" src: "{{ artifact_path }}/docker-compose.arm64.yml"
dest: "{{ deploy_dir }}/docker-compose.yml" dest: "{{ deploy_dir }}/docker-compose.yml"
- name: Create .env file
ansible.builtin.copy:
content: "JWT_SECRET={{ jwt_secret }}\n"
dest: "{{ deploy_dir }}/.env"
mode: "0600"
- name: Load Docker image - name: Load Docker image
ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz
register: docker_load_result register: docker_load_result
+10
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"zod": "^3.24.2" "zod": "^3.24.2"
@@ -1862,6 +1863,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"zod": "^3.24.2" "zod": "^3.24.2"
+1 -1
View File
@@ -28,7 +28,7 @@ export const config = {
jwtSecret: env('JWT_SECRET'), jwtSecret: env('JWT_SECRET'),
dbPath: env('DB_PATH', './db/brainwave.db'), dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'), corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '10mb'), bodyLimit: env('BODY_LIMIT', '100kb'),
email: { email: {
smtpHost: env('SMTP_HOST', ''), smtpHost: env('SMTP_HOST', ''),
+11 -5
View File
@@ -4,17 +4,23 @@ import path from 'path'
import { config } from '../config.js' import { config } from '../config.js'
import { runMigrations } from './migrations.js' import { runMigrations } from './migrations.js'
const DB_PATH = path.isAbsolute(config.dbPath) const DB_PATH = config.dbPath === ':memory:'
? config.dbPath ? ':memory:'
: path.join(process.cwd(), config.dbPath) : path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
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 }) if (DB_PATH !== ':memory:') {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
}
db = new Database(DB_PATH) db = new Database(DB_PATH)
db.pragma('journal_mode = WAL') if (DB_PATH !== ':memory:') {
db.pragma('journal_mode = WAL')
}
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
runMigrations(db) runMigrations(db)
} }
+10
View File
@@ -61,6 +61,16 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`); db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
} }
// ── Add storage_quota_bytes if missing ──
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
}
// ── Add plan if missing ──
if (!cols.some((c) => c.name === "plan")) {
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
}
// ── Promote first user to admin if no admins exist ── // ── Promote first user to admin if no admins exist ──
const adminCount = db const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`) .prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
+18 -1
View File
@@ -1,6 +1,8 @@
import 'dotenv/config' import 'dotenv/config'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { getDb, closeDb } from './db/database.js' import { getDb, closeDb } from './db/database.js'
import authRoutes from './routes/auth.js' import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js' import examRoutes from './routes/exams.js'
@@ -20,9 +22,25 @@ const PORT = config.port
const app = express() const app = express()
// Security headers
app.use(helmet())
// CORS
app.use(cors({ origin: config.corsOrigin })) app.use(cors({ origin: config.corsOrigin }))
// Body parsing with reasonable limit
app.use(express.json({ limit: config.bodyLimit })) app.use(express.json({ limit: config.bodyLimit }))
// Global API rate limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api/', globalLimiter)
// Request logging // Request logging
app.use((req, res, next) => { app.use((req, res, next) => {
const start = Date.now() const start = Date.now()
@@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => {
res.status(overall === 'healthy' ? 200 : 503).json({ res.status(overall === 'healthy' ? 200 : 503).json({
status: overall, status: overall,
timestamp: Date.now(), timestamp: Date.now(),
version: process.env['npm_package_version'] ?? '0.0.1',
checks, checks,
}) })
}) })
+9 -1
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { verifyToken, type JwtPayload } from '../utils/tokens.js' import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
declare global { declare global {
namespace Express { namespace Express {
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
try { try {
const token = header.slice(7) const token = header.slice(7)
req.user = verifyToken(token) req.user = verifyToken(token)
// Check if the token has been blacklisted (e.g., after logout)
if (isTokenBlacklisted(hashToken(token))) {
res.status(401).json({ error: 'Token has been revoked' })
return
}
next() next()
} catch { } catch {
res.status(401).json({ error: 'Invalid or expired token' }) res.status(401).json({ error: 'Invalid or expired token' })
+10 -4
View File
@@ -17,15 +17,21 @@ export function errorHandler(
_next: any _next: any
) { ) {
const statusCode = err instanceof AppError ? err.statusCode : 500; const statusCode = err instanceof AppError ? err.statusCode : 500;
const message = err.message || 'Internal Server Error'; const isOperational = err instanceof AppError && err.isOperational;
console.error(`[Error] ${statusCode} - ${message}`); // Log full details server-side
if (!(err instanceof AppError) || !err.isOperational) { console.error(`[Error] ${statusCode} - ${err.message}`);
if (!isOperational) {
console.error(err); console.error(err);
} }
// Never leak internal error details to the client for unexpected 500s
const clientMessage = statusCode === 500 && !isOperational
? 'Internal Server Error'
: (err.message || 'Internal Server Error');
res.status(statusCode).json({ res.status(statusCode).json({
status: 'error', status: 'error',
message, message: clientMessage,
}); });
} }
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
// Set env vars before any module that reads them at import time
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
process.env.DB_PATH = ':memory:'
// Dynamic imports ensure env vars are set before module initialisation
const { getDb, closeDb } = await import('../db/database.js')
const userRepo = await import('./userRepo.js')
const storageRepo = await import('./storageRepo.js')
describe('storageRepo', () => {
it('returns zero usage for a new user', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-test@example.com',
passwordHash: 'hash',
displayName: 'Storage Test',
emailVerified: 1,
})
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.usedBytes).toBe(0)
expect(usage.examBytes).toBe(0)
expect(usage.resultBytes).toBe(0)
})
it('calculates exam JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-exam@example.com',
passwordHash: 'hash',
displayName: 'Exam Storage',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
db.prepare(`
INSERT INTO saved_exams (user_id, title, exam_json)
VALUES (?, ?, ?)
`).run(userId, 'Test Exam', examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson.length)
expect(usage.usedBytes).toBe(examJson.length)
})
it('calculates result JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-result@example.com',
passwordHash: 'hash',
displayName: 'Result Storage',
emailVerified: 1,
})
const db = getDb()
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
const examJson = JSON.stringify({ title: 'Result Exam' })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
})
it('aggregates multiple exams and results', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-multi@example.com',
passwordHash: 'hash',
displayName: 'Multi Storage',
emailVerified: 1,
})
const db = getDb()
const examJson1 = JSON.stringify({ title: 'Exam 1' })
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 1', examJson1)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 2', examJson2)
const stateJson = JSON.stringify({ answers: [] })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
})
it('lists storage items sorted by size desc', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-items@example.com',
passwordHash: 'hash',
displayName: 'Items Storage',
emailVerified: 1,
})
const db = getDb()
const smallExam = JSON.stringify({ title: 'Small' })
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Small Exam', smallExam)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Large Exam', largeExam)
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
const items = storageRepo.listStorageItems(userId)
expect(items.length).toBe(3)
expect(items[0].title).toBe('Large Exam')
expect(items[0].type).toBe('exam')
expect(items[1].title).toBe('Result')
expect(items[1].type).toBe('result')
expect(items[2].title).toBe('Small Exam')
expect(items[2].type).toBe('exam')
})
it('does not include other users data', () => {
const { id: userA } = userRepo.createUser({
email: 'user-a@example.com',
passwordHash: 'hash',
displayName: 'User A',
emailVerified: 1,
})
const { id: userB } = userRepo.createUser({
email: 'user-b@example.com',
passwordHash: 'hash',
displayName: 'User B',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'A Exam' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userA, 'A Exam', examJson)
const usageA = storageRepo.getUserStorageUsage(userA)
const usageB = storageRepo.getUserStorageUsage(userB)
expect(usageA.usedBytes).toBe(examJson.length)
expect(usageB.usedBytes).toBe(0)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { getDb } from '../db/database.js'
export interface StorageUsage {
usedBytes: number
examBytes: number
resultBytes: number
}
export interface StorageItem {
id: number
title: string
type: 'exam' | 'result'
sizeBytes: number
createdAt: number
}
export function getUserStorageUsage(userId: number): StorageUsage {
const db = getDb()
const examRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
FROM saved_exams
WHERE user_id = ?
`).get(userId) as { total: number }
const resultRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
FROM exam_results
WHERE user_id = ?
`).get(userId) as { total: number }
return {
usedBytes: examRow.total + resultRow.total,
examBytes: examRow.total,
resultBytes: resultRow.total,
}
}
export function listStorageItems(userId: number): StorageItem[] {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
FROM saved_exams
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const results = db.prepare(`
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
FROM exam_results
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const items: StorageItem[] = [
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
]
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
return items
}
+23 -7
View File
@@ -11,6 +11,8 @@ export interface DbUser {
verify_expires: number | null verify_expires: number | null
reset_token: string | null reset_token: string | null
reset_expires: number | null reset_expires: number | null
storage_quota_bytes: number
plan: string
created_at: number created_at: number
updated_at: number updated_at: number
} }
@@ -23,6 +25,8 @@ export interface CreateUserData {
emailVerified?: number emailVerified?: number
verifyToken?: string | null verifyToken?: string | null
verifyExpires?: number | null verifyExpires?: number | null
storageQuotaBytes?: number
plan?: string
} }
export interface UpdateUserData { export interface UpdateUserData {
@@ -34,13 +38,15 @@ export interface UpdateUserData {
verifyExpires?: number | null verifyExpires?: number | null
resetToken?: string | null resetToken?: string | null
resetExpires?: number | null resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
} }
export function createUser(data: CreateUserData): { id: number } { export function createUser(data: CreateUserData): { id: number } {
const db = getDb() const db = getDb()
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires) INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
data.email.toLowerCase().trim(), data.email.toLowerCase().trim(),
data.passwordHash, data.passwordHash,
@@ -48,7 +54,9 @@ export function createUser(data: CreateUserData): { id: number } {
data.role ?? 'user', data.role ?? 'user',
data.emailVerified ?? 1, data.emailVerified ?? 1,
data.verifyToken ?? null, data.verifyToken ?? null,
data.verifyExpires ?? null data.verifyExpires ?? null,
data.storageQuotaBytes ?? 1073741824,
data.plan ?? 'free'
) )
return { id: result.lastInsertRowid as number } return { id: result.lastInsertRowid as number }
} }
@@ -63,14 +71,14 @@ export function getUserById(id: number): DbUser | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
} }
export function getUserByVerifyToken(token: string): DbUser | undefined { export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb() const db = getDb()
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
} }
export function getUserByResetToken(token: string): DbUser | undefined { export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb() const db = getDb()
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
} }
export function updateUser(id: number, data: UpdateUserData): boolean { export function updateUser(id: number, data: UpdateUserData): boolean {
@@ -110,6 +118,14 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
fields.push('reset_expires = ?') fields.push('reset_expires = ?')
values.push(data.resetExpires) values.push(data.resetExpires)
} }
if (data.storageQuotaBytes !== undefined) {
fields.push('storage_quota_bytes = ?')
values.push(data.storageQuotaBytes)
}
if (data.plan !== undefined) {
fields.push('plan = ?')
values.push(data.plan)
}
if (fields.length === 0) return false if (fields.length === 0) return false
+6 -5
View File
@@ -6,7 +6,7 @@ import { AppError } from '../middleware/error.js'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import * as resultRepo from '../repositories/resultRepo.js' import * as resultRepo from '../repositories/resultRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js'
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js' import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
import type { Request } from 'express' import type { Request } from 'express'
const router = Router() const router = Router()
@@ -124,17 +124,18 @@ router.get(
router.get( router.get(
'/settings', '/settings',
asyncHandler(async (_req, res) => { asyncHandler(async (_req, res) => {
res.json(settingsRepo.getAllSettings()) res.json({ settings: settingsRepo.getAllSettings() })
}) })
) )
// PUT /api/admin/settings // PUT /api/admin/settings
router.put( router.put(
'/settings', '/settings',
validateBody(updateSettingSchema), validateBody(updateSettingsBatchSchema),
asyncHandler(async (req: Request, res) => { asyncHandler(async (req: Request, res) => {
const { key, value } = req.body const { settings } = req.body
settingsRepo.setSetting(key, value) const entries = Object.entries(settings) as Array<[string, string]>
settingsRepo.setSettingsBatch(entries)
res.json({ success: true }) res.json({ success: true })
}) })
) )
+19
View File
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js' import * as authService from '../services/authService.js'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import { hashToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import { import {
registerSchema, registerSchema,
loginSchema, loginSchema,
@@ -113,6 +115,23 @@ router.post(
}) })
) )
// POST /api/auth/logout
router.post(
'/logout',
requireAuth,
asyncHandler(async (req: Request, res) => {
const header = req.headers.authorization
if (header?.startsWith('Bearer ')) {
const token = header.slice(7)
const tokenHash = hashToken(token)
// Blacklist the token so it cannot be reused after logout
// Expiry is 24h from now (matching JWT expiry)
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
}
res.json({ success: true })
})
)
// DELETE /api/auth/me // DELETE /api/auth/me
router.delete( router.delete(
'/me', '/me',
+3 -2
View File
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import * as examRepo from '../repositories/examRepo.js' import * as examRepo from '../repositories/examRepo.js'
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js' import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
import { sanitizeExam } from '../utils/sanitizeExam.js'
import type { Request } from 'express' import type { Request } from 'express'
const router = Router() const router = Router()
@@ -41,7 +42,7 @@ router.post(
'/', '/',
validateBody(saveExamSchema), validateBody(saveExamSchema),
asyncHandler(async (req: Request, res) => { asyncHandler(async (req: Request, res) => {
const exam = req.body.exam const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const { id } = examRepo.createExam( const { id } = examRepo.createExam(
req.user!.userId, req.user!.userId,
exam.title, exam.title,
@@ -59,7 +60,7 @@ router.put(
validateBody(updateExamSchema), validateBody(updateExamSchema),
asyncHandler(async (req: Request, res) => { asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id) const id = Number(req.params.id)
const exam = req.body.exam const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const updated = examRepo.updateExam( const updated = examRepo.updateExam(
id, id,
req.user!.userId, req.user!.userId,
+55 -43
View File
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
import { validateBody } from '../middleware/validate.js' import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import { getDb } from '../db/database.js'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js'
import { setupSchema } from '../validators/setupSchemas.js' import { setupSchema } from '../validators/setupSchemas.js'
@@ -13,7 +14,7 @@ const router = Router()
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
function getSiteTitle(): string | null { function getSiteTitle(): string | null {
return settingsRepo.getSetting('site_title') ?? null return settingsRepo.getSetting('site_name') ?? null
} }
// GET /api/setup/status // GET /api/setup/status
@@ -33,54 +34,65 @@ router.post(
'/', '/',
validateBody(setupSchema), validateBody(setupSchema),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
if (getSiteTitle()) { const db = getDb()
throw new AppError(400, 'Setup already completed')
}
if (userRepo.countUsers() > 0) { // Atomic transaction prevents race conditions between concurrent setup requests
throw new AppError(400, 'Users already exist') const tx = db.transaction(() => {
} const title = settingsRepo.getSetting('site_name')
if (title) {
throw new AppError(400, 'Setup already completed')
}
const { const userCount = userRepo.countUsers()
email, if (userCount > 0) {
password, throw new AppError(400, 'Users already exist')
displayName, }
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) const {
email,
password,
displayName,
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const { id } = userRepo.createUser({ const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
email,
passwordHash, const { id } = userRepo.createUser({
displayName, email,
role: 'admin', passwordHash,
emailVerified: 1, displayName,
role: 'admin',
emailVerified: 1,
})
const settings: Array<[string, string]> = [
['site_name', siteTitle?.trim() || 'BrainWave'],
['site_language', language || 'en'],
['maintenance_mode', 'false'],
]
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
if (appUrl !== undefined) settings.push(['app_url', appUrl])
settingsRepo.setSettingsBatch(settings)
return { id, email, displayName }
}) })
const settings: Array<[string, string]> = [ const { id, email, displayName } = tx()
['site_title', siteTitle?.trim() || 'BrainWave'],
['site_language', language || 'en'],
['maintenance_mode', 'false'],
]
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
if (appUrl !== undefined) settings.push(['app_url', appUrl])
settingsRepo.setSettingsBatch(settings)
const token = signToken({ const token = signToken({
userId: id, userId: id,
+26 -9
View File
@@ -1,8 +1,9 @@
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js' import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import * as storageRepo from '../repositories/storageRepo.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js' import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
@@ -22,7 +23,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS) const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const role = isFirstUser ? 'admin' : 'user' const role = isFirstUser ? 'admin' : 'user'
const verifyToken = generateVerificationToken() const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
const { id } = userRepo.createUser({ const { id } = userRepo.createUser({
@@ -31,14 +33,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
displayName: input.displayName, displayName: input.displayName,
role, role,
emailVerified: 0, emailVerified: 0,
verifyToken, verifyToken: verifyTokenHash,
verifyExpires, verifyExpires,
}) })
const user = userRepo.getUserById(id)! const user = userRepo.getUserById(id)!
// Don't await — fire and forget so registration isn't blocked by email failures // Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, verifyToken).catch(() => {}) sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return { return {
token: signToken({ token: signToken({
@@ -67,6 +69,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
throw new AppError(401, 'Invalid email or password') throw new AppError(401, 'Invalid email or password')
} }
if (!user.email_verified) {
throw new AppError(403, 'Please verify your email before logging in')
}
return { return {
token: signToken({ token: signToken({
userId: user.id, userId: user.id,
@@ -88,12 +94,20 @@ export function getMe(userId: number) {
if (!user) { if (!user) {
throw new AppError(404, 'User not found') throw new AppError(404, 'User not found')
} }
const usage = storageRepo.getUserStorageUsage(userId)
const usedPercent = user.storage_quota_bytes > 0
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
: 0
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.display_name, displayName: user.display_name,
role: user.role, role: user.role,
emailVerified: !!user.email_verified, emailVerified: !!user.email_verified,
storageQuotaBytes: user.storage_quota_bytes,
usedBytes: usage.usedBytes,
usedPercent,
plan: user.plan,
} }
} }
@@ -118,7 +132,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
} }
export async function verifyEmail(input: VerifyEmailInput) { export async function verifyEmail(input: VerifyEmailInput) {
const user = userRepo.getUserByVerifyToken(input.token) const tokenHash = hashToken(input.token)
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
if (!user || !user.verify_expires || Date.now() > user.verify_expires) { if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
throw new AppError(400, 'Invalid or expired verification token') throw new AppError(400, 'Invalid or expired verification token')
} }
@@ -141,17 +156,19 @@ export async function forgotPassword(email?: string) {
return return
} }
const resetToken = generateVerificationToken() const rawResetToken = generateVerificationToken()
const resetTokenHash = hashToken(rawResetToken)
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, { resetToken, resetExpires }) userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
// Fire and forget // Fire and forget
sendPasswordResetEmail(user.email, resetToken).catch(() => {}) sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
} }
export async function resetPassword(input: ResetPasswordInput) { export async function resetPassword(input: ResetPasswordInput) {
const user = userRepo.getUserByResetToken(input.token) const tokenHash = hashToken(input.token)
const user = userRepo.getUserByResetTokenHash(tokenHash)
if (!user || !user.reset_expires || Date.now() > user.reset_expires) { if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
throw new AppError(400, 'Invalid or expired reset token') throw new AppError(400, 'Invalid or expired reset token')
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* Server-side sanitization for user-provided exam content.
* Removes script tags and event handlers from string fields.
*/
function sanitizeString(value: unknown): unknown {
if (typeof value !== 'string') return value
return value
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*\/>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
}
function sanitizeObject(obj: unknown): unknown {
if (typeof obj === 'string') return sanitizeString(obj)
if (Array.isArray(obj)) return obj.map(sanitizeObject)
if (obj && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(obj)) {
result[key] = sanitizeObject(val)
}
return result
}
return obj
}
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
return sanitizeObject(exam) as Record<string, unknown>
}
+46
View File
@@ -0,0 +1,46 @@
/**
* In-memory token blacklist with SQLite persistence.
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
*/
import { getDb } from '../db/database.js'
const memoryCache = new Set<string>()
let initialized = false
function initCache(): void {
if (initialized) return
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS token_blacklist (
token_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
`)
// Load recent blacklisted tokens into memory
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
.all(Date.now()) as { token_hash: string }[]
for (const row of rows) {
memoryCache.add(row.token_hash)
}
initialized = true
}
export function isTokenBlacklisted(tokenHash: string): boolean {
initCache()
return memoryCache.has(tokenHash)
}
export function blacklistToken(tokenHash: string, expiresAt: number): void {
initCache()
memoryCache.add(tokenHash)
const db = getDb()
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
.run(tokenHash, expiresAt)
}
export function cleanupExpiredTokens(): void {
const db = getDb()
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
}
+4
View File
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
export function generateVerificationToken(): string { export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex') return crypto.randomBytes(32).toString('hex')
} }
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
+26 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas' import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
describe('updateUserRoleSchema', () => { describe('updateUserRoleSchema', () => {
it('accepts valid role change', () => { it('accepts valid role change', () => {
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
describe('updateSettingSchema', () => { describe('updateSettingSchema', () => {
it('accepts valid setting', () => { it('accepts valid setting', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' }) const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
@@ -30,7 +30,30 @@ describe('updateSettingSchema', () => {
}) })
it('rejects empty value', () => { it('rejects empty value', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' }) const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' })
expect(result.success).toBe(false)
})
})
describe('updateSettingsBatchSchema', () => {
it('accepts valid batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 'BrainWave', maintenance_mode: 'false' },
})
expect(result.success).toBe(true)
})
it('rejects invalid key in batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 'BrainWave', unknown_key: 'value' },
})
expect(result.success).toBe(false)
})
it('rejects empty value in batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: '' },
})
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
}) })
+26 -1
View File
@@ -5,11 +5,36 @@ export const updateUserRoleSchema = z.object({
role: z.enum(['user', 'admin']), role: z.enum(['user', 'admin']),
}) })
const ALLOWED_SETTING_KEYS = [
'site_name',
'site_language',
'maintenance_mode',
'maintenance_message',
'allow_guest_mode',
'registration_enabled',
'default_theme',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_pass',
'smtp_from',
'smtp_secure',
'app_url',
'max_upload_mb',
] as const
export const updateSettingSchema = z.object({ export const updateSettingSchema = z.object({
key: z.string().min(1), key: z.enum(ALLOWED_SETTING_KEYS),
value: z.string().min(1), value: z.string().min(1),
}) })
export const updateSettingsBatchSchema = z.object({
settings: z.record(z.string().min(1)).refine(
(obj) => Object.keys(obj).every((k) => ALLOWED_SETTING_KEYS.includes(k as typeof ALLOWED_SETTING_KEYS[number])),
{ message: 'One or more setting keys are not allowed' }
),
})
export const listUsersQuerySchema = z.object({ export const listUsersQuerySchema = z.object({
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)), page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
search: z.string().optional(), search: z.string().optional(),
+5 -5
View File
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
it('accepts valid registration', () => { it('accepts valid registration', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'Password123!',
displayName: 'Test User', displayName: 'Test User',
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'not-an-email', email: 'not-an-email',
password: 'password123', password: 'Password123!',
displayName: 'Test User', displayName: 'Test User',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
it('rejects empty display name', () => { it('rejects empty display name', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'Password123!',
displayName: '', displayName: '',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
describe('resetPasswordSchema', () => { describe('resetPasswordSchema', () => {
it('accepts valid reset', () => { it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' }) const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
it('accepts valid change', () => { it('accepts valid change', () => {
const result = changePasswordSchema.safeParse({ const result = changePasswordSchema.safeParse({
currentPassword: 'oldpass', currentPassword: 'oldpass',
newPassword: 'newpass123', newPassword: 'NewPass123!',
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
+11 -3
View File
@@ -1,8 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'), password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
}) })
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
export const resetPasswordSchema = z.object({ export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'), newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
}) })
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
export const changePasswordSchema = z.object({ export const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string().min(8, 'Password must be at least 8 characters'), newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
}) })
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
+2 -2
View File
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
it('accepts valid setup', () => { it('accepts valid setup', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
email: 'admin@example.com', email: 'admin@example.com',
password: 'adminpass123', password: 'AdminPass123!',
displayName: 'Admin', displayName: 'Admin',
siteTitle: 'BrainWave', siteTitle: 'BrainWave',
language: 'en', language: 'en',
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
email: 'bad', email: 'bad',
password: 'adminpass123', password: 'AdminPass123!',
displayName: 'Admin', displayName: 'Admin',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
+5 -1
View File
@@ -1,8 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const setupSchema = z.object({ export const setupSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'), password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(), siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(), language: z.enum(['en', 'nl']).optional(),
+65 -20
View File
@@ -129,7 +129,7 @@ function AppInner() {
setExamState(withNotes(state)); setExamState(withNotes(state));
}; };
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null) => { const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null, isRedo = false) => {
if (saveToWorkspace && user && isAuthenticated()) { if (saveToWorkspace && user && isAuthenticated()) {
examApi.createExam(data) examApi.createExam(data)
.then((res) => { .then((res) => {
@@ -156,6 +156,7 @@ function AppInner() {
revealed: {}, revealed: {},
startedAt: Date.now(), startedAt: Date.now(),
feedbackMode: fm, feedbackMode: fm,
isRedo,
}); });
}); });
navigate(`/exam/${getExamUrlId(data)}/q1`); navigate(`/exam/${getExamUrlId(data)}/q1`);
@@ -195,33 +196,60 @@ function AppInner() {
state: { ...examState, completedAt }, state: { ...examState, completedAt },
}; };
if (user && isAuthenticated()) { // Skip saving to history / API when this is a redo session
void resultApi.submitResult({ if (!examState.isRedo) {
exam_id: savedExamIdRef.current, if (user && isAuthenticated()) {
title: entry.title, void resultApi.submitResult({
score_points: entry.scorePoints, exam_id: savedExamIdRef.current,
total_points: entry.totalPoints, title: entry.title,
correct_count: entry.correctCount, score_points: entry.scorePoints,
graded_count: entry.gradedCount, total_points: entry.totalPoints,
passing_score: entry.passingScore, correct_count: entry.correctCount,
elapsed_ms: entry.elapsedMs, graded_count: entry.gradedCount,
state: entry.state as unknown, passing_score: entry.passingScore,
exam: entry.exam as unknown, elapsed_ms: entry.elapsedMs,
}).then((data) => { state: entry.state as unknown,
sessionStorage.setItem('brainwave.pendingResultId', String(data.id)); exam: entry.exam as unknown,
}).catch(() => { }).then((data) => {
sessionStorage.setItem('brainwave.pendingResultId', String(data.id));
}).catch(() => {
appendHistory(entry);
});
} else {
appendHistory(entry); appendHistory(entry);
}); }
} else {
appendHistory(entry); setHistory((prev) => [entry, ...prev]);
} }
setHistory((prev) => [entry, ...prev]);
clearState(); clearState();
setExamState((s) => (s ? { ...s, completedAt } : s)); setExamState((s) => (s ? { ...s, completedAt } : s));
navigate("/results"); navigate("/results");
}, [exam, examState, user, navigate]); }, [exam, examState, user, navigate]);
const redoFailed = useCallback(() => {
if (!exam || !examState) return;
const graded = exam.questions.map((q) =>
gradeQuestion(q, examState.answers[q.id]),
);
const failedQuestions = exam.questions
.map((q, i) => ({ q, result: graded[i] }))
.filter(({ result }) => !result.correct)
.map(({ q }) => q);
if (failedQuestions.length === 0) return;
// Shuffle failed questions using Fisher-Yates
const shuffled = [...failedQuestions];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const redoExam: Exam = { ...exam, questions: shuffled };
loadExam(redoExam, false, savedExamIdRef.current, true);
}, [exam, examState]);
const exit = () => { const exit = () => {
setExam(null); setExam(null);
setExamState(null); setExamState(null);
@@ -261,6 +289,21 @@ function AppInner() {
} }
}; };
const retakeAttempt = (entry: HistoryEntry) => {
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
if (remoteId && user && isAuthenticated()) {
void resultApi.getResult(remoteId)
.then((data) => {
loadExam(data.exam as unknown as Exam, false, null, false);
})
.catch(() => {
loadExam(entry.exam, false, null, false);
});
} else {
loadExam(entry.exam, false, null, false);
}
};
const clearAllHistory = () => { const clearAllHistory = () => {
if (confirm(t('landing.clearHistoryConfirm'))) { if (confirm(t('landing.clearHistoryConfirm'))) {
clearHistory(); clearHistory();
@@ -436,6 +479,7 @@ function AppInner() {
onResume={resume} onResume={resume}
onClearHistory={clearAllHistory} onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt} onReviewAttempt={reviewAttempt}
onRetakeAttempt={retakeAttempt}
/> />
} }
/> />
@@ -461,6 +505,7 @@ function AppInner() {
state={examState} state={examState}
onRestart={restart} onRestart={restart}
onExit={exit} onExit={exit}
onRedoFailed={redoFailed}
reviewOnly={false} reviewOnly={false}
/> />
) : user && isAuthenticated() ? ( ) : user && isAuthenticated() ? (
+4
View File
@@ -6,6 +6,10 @@ export interface User {
displayName: string displayName: string
role: string role: string
emailVerified?: boolean emailVerified?: boolean
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
} }
export interface AuthResponse { export interface AuthResponse {
+5 -1
View File
@@ -11,6 +11,7 @@ interface Props {
onResume: () => void onResume: () => void
onClearHistory: () => void onClearHistory: () => void
onReviewAttempt: (entry: HistoryEntry) => void onReviewAttempt: (entry: HistoryEntry) => void
onRetakeAttempt?: (entry: HistoryEntry) => void
} }
const SAMPLES = [ const SAMPLES = [
@@ -19,7 +20,7 @@ const SAMPLES = [
{ path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' }, { path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
] ]
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) { export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt, onRetakeAttempt }: Readonly<Props>) {
const { t } = useTranslation() const { t } = useTranslation()
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
@@ -483,6 +484,9 @@ Set a 20-minute time limit. Include explanations for each answer."
</div> </div>
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div> <div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div> <div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
{onRetakeAttempt && (
<button className="btn btn-ghost btn-sm" onClick={() => onRetakeAttempt(h)}>{t('landing.retake')}</button>
)}
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button> <button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
</div> </div>
) )
+28 -1
View File
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import * as resultApi from '../api/resultApi' import * as resultApi from '../api/resultApi'
import * as examApi from '../api/examApi'
import { getExamUrlId } from '../utils/slug' import { getExamUrlId } from '../utils/slug'
import type { Exam } from '../types'
interface ResultItem { interface ResultItem {
id: number id: number
@@ -17,7 +19,11 @@ interface ResultItem {
exam_id: number | null exam_id: number | null
} }
export default function MyResults() { interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function MyResults({ onLoadExam }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [results, setResults] = useState<ResultItem[]>([]) const [results, setResults] = useState<ResultItem[]>([])
@@ -25,6 +31,7 @@ export default function MyResults() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
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 [retakingId, setRetakingId] = useState<number | null>(null)
const loadResults = useCallback(async () => { const loadResults = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -54,6 +61,18 @@ export default function MyResults() {
} }
} }
const retakeExam = async (item: ResultItem) => {
if (!item.exam_id) return
setRetakingId(item.id)
try {
const exam = await examApi.getExam(item.exam_id)
onLoadExam(exam, false, item.exam_id)
} catch (e) {
setError((e as Error).message)
setRetakingId(null)
}
}
const reviewResult = async (item: ResultItem) => { const reviewResult = async (item: ResultItem) => {
try { try {
const data = await resultApi.getResult(item.id) const data = await resultApi.getResult(item.id)
@@ -129,6 +148,14 @@ export default function MyResults() {
</div> </div>
<div className="workspace-card-actions" style={{ gap: 8 }}> <div className="workspace-card-actions" style={{ gap: 8 }}>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div> <div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button
className="btn btn-primary btn-sm"
onClick={() => void retakeExam(r)}
disabled={retakingId === r.id || !r.exam_id}
title={r.exam_id ? undefined : t('results.cantRetake')}
>
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
</button>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}> <button className="btn btn-sm" onClick={() => void reviewResult(r)}>
{t('common.edit')} {t('common.edit')}
</button> </button>
+13 -2
View File
@@ -12,10 +12,11 @@ interface Props {
state: ExamState state: ExamState
onRestart: () => void onRestart: () => void
onExit: () => void onExit: () => void
onRedoFailed?: () => void
reviewOnly?: boolean reviewOnly?: boolean
} }
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) { export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { answers, startedAt, completedAt } = state const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt const elapsedMs = (completedAt ?? Date.now()) - startedAt
@@ -30,6 +31,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0) const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
const correctCount = graded.filter((g) => g.result.correct).length const correctCount = graded.filter((g) => g.result.correct).length
const failedCount = gradedCount - correctCount
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0 const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
const passing = exam.passingScore ?? 60 const passing = exam.passingScore ?? 60
const passed = pct >= passing const passed = pct >= passing
@@ -137,7 +139,16 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
})} })}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}> <div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>} {!reviewOnly && (
<>
<button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>
{onRedoFailed && failedCount > 0 && (
<button className="btn" onClick={onRedoFailed}>
{t('results.redoFailed')}
</button>
)}
</>
)}
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button> <button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
</div> </div>
</div> </div>
+57
View File
@@ -25,6 +25,54 @@ function Seg<K extends string>({ value, options, onChange }: {
) )
} }
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function StoragePreview() {
const { t } = useTranslation()
const { user } = useAuth()
const used = user?.usedBytes ?? 0
const quota = user?.storageQuotaBytes ?? 1073741824
const percent = user?.usedPercent ?? 0
let barColor = 'var(--accent)'
let warningText: string | null = null
if (percent >= 95) {
barColor = 'var(--wrong)'
warningText = t('workspace.storageWarningCritical')
} else if (percent >= 80) {
barColor = 'var(--warn)'
warningText = t('workspace.storageWarningHigh')
}
return (
<div className="storage-overview">
<div className="storage-bar-track">
<div
className="storage-bar-fill"
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
/>
</div>
<div className="storage-stats">
<span className="storage-used">{formatBytes(used)}</span>
<span className="storage-of"> {t('workspace.storageOf')} </span>
<span className="storage-total">{formatBytes(quota)}</span>
<span className="storage-percent"> ({percent}%)</span>
</div>
{warningText && (
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
{warningText}
</div>
)}
</div>
)
}
export default function Settings() { export default function Settings() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { user, refreshUser } = useAuth() const { user, refreshUser } = useAuth()
@@ -288,6 +336,15 @@ export default function Settings() {
</div> </div>
</div> </div>
</div> </div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.storage')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<StoragePreview />
</div>
</div>
</div> </div>
) )
} }
+187
View File
@@ -0,0 +1,187 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as examApi from '../api/examApi'
import * as resultApi from '../api/resultApi'
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export default function StorageManager() {
const { t } = useTranslation()
const { user, refreshUser } = useAuth()
const [exams, setExams] = useState<examApi.ExamListItem[]>([])
const [results, setResults] = useState<resultApi.ResultListItem[]>([])
const [showDetails, setShowDetails] = useState(false)
const [deleting, setDeleting] = useState<number | null>(null)
const loadData = useCallback(async () => {
try {
const examList = await examApi.listExams()
setExams(examList)
const resultList = await resultApi.listResults()
setResults(resultList.results)
} catch {
// silently ignore
}
}, [])
useEffect(() => {
void loadData()
}, [loadData])
const handleDeleteExam = useCallback(async (id: number) => {
if (!window.confirm(t('workspace.deleteExamConfirm'))) return
setDeleting(id)
try {
await examApi.deleteExam(id)
await loadData()
await refreshUser()
} catch {
// ignore
} finally {
setDeleting(null)
}
}, [loadData, refreshUser, t])
const handleDeleteResult = useCallback(async (id: number) => {
if (!window.confirm(t('workspace.deleteResultConfirm'))) return
setDeleting(id)
try {
await resultApi.deleteResult(id)
await loadData()
await refreshUser()
} catch {
// ignore
} finally {
setDeleting(null)
}
}, [loadData, refreshUser, t])
const used = user?.usedBytes ?? 0
const quota = user?.storageQuotaBytes ?? 1073741824
const percent = user?.usedPercent ?? 0
let barColor = 'var(--accent)'
let warningText: string | null = null
if (percent >= 95) {
barColor = 'var(--wrong)'
warningText = t('workspace.storageWarningCritical')
} else if (percent >= 80) {
barColor = 'var(--warn)'
warningText = t('workspace.storageWarningHigh')
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.storage')}</h2>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.storageUsage')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="storage-overview">
<div className="storage-bar-track">
<div
className="storage-bar-fill"
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
/>
</div>
<div className="storage-stats">
<span className="storage-used">{formatBytes(used)}</span>
<span className="storage-of"> {t('workspace.storageOf')} </span>
<span className="storage-total">{formatBytes(quota)}</span>
<span className="storage-percent"> ({percent}%)</span>
</div>
{warningText && (
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
{warningText}
</div>
)}
</div>
<div className="storage-breakdown">
<div className="storage-breakdown-row">
<span>{t('workspace.storageExams')}</span>
<span>{exams.length}</span>
</div>
<div className="storage-breakdown-row">
<span>{t('workspace.storageResults')}</span>
<span>{results.length}</span>
</div>
</div>
<div className="workspace-settings-footer">
<button
className="btn btn-ghost"
onClick={() => setShowDetails((v) => !v)}
>
{showDetails ? t('workspace.hideDetails') : t('workspace.showDetails')}
</button>
</div>
</div>
</div>
{showDetails && (
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.storageDetails')}</h3>
</div>
<div className="workspace-panel-body">
{exams.length === 0 && results.length === 0 ? (
<p className="text-muted">{t('workspace.noStorageItems')}</p>
) : (
<div className="storage-detail-list">
{exams.map((exam) => (
<div key={`exam-${exam.id}`} className="storage-detail-item">
<div className="storage-detail-info">
<span className="storage-detail-type">{t('workspace.exam')}</span>
<span className="storage-detail-title">{exam.title}</span>
</div>
<div className="storage-detail-actions">
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteExam(exam.id)}
disabled={deleting === exam.id}
>
{deleting === exam.id ? t('common.loading') : t('common.delete')}
</button>
</div>
</div>
))}
{results.map((result) => (
<div key={`result-${result.id}`} className="storage-detail-item">
<div className="storage-detail-info">
<span className="storage-detail-type">{t('workspace.result')}</span>
<span className="storage-detail-title">{result.title}</span>
</div>
<div className="storage-detail-actions">
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteResult(result.id)}
disabled={deleting === result.id}
>
{deleting === result.id ? t('common.loading') : t('common.delete')}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)
}
+5 -2
View File
@@ -8,12 +8,14 @@ import type { Exam } from '../types'
import MyExams from './MyExams' import MyExams from './MyExams'
import MyResults from './MyResults' import MyResults from './MyResults'
import Settings from './Settings' import Settings from './Settings'
import StorageManager from './StorageManager'
type TabKey = 'exams' | 'results' | 'settings' type TabKey = 'exams' | 'results' | 'storage' | 'settings'
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [ const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' }, { key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
{ key: 'results', labelKey: 'workspace.results', icon: '✦' }, { key: 'results', labelKey: 'workspace.results', icon: '✦' },
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' }, { key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
] ]
@@ -83,7 +85,8 @@ export default function Workspace({ onLoadExam }: Props) {
<main className="workspace-main"> <main className="workspace-main">
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />} {activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
{activeTab === 'results' && <MyResults />} {activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
{activeTab === 'storage' && <StorageManager />}
{activeTab === 'settings' && <Settings />} {activeTab === 'settings' && <Settings />}
</main> </main>
</div> </div>
+18 -2
View File
@@ -9,6 +9,10 @@ export interface User {
email: string email: string
displayName: string displayName: string
role: 'user' | 'admin' role: 'user' | 'admin'
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
} }
interface AuthContextValue { interface AuthContextValue {
@@ -47,7 +51,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return return
} }
const data = await authApi.getMe() const data = await authApi.getMe()
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: (data.role as 'user' | 'admin') ?? 'user' }) setUser({
id: data.id,
email: data.email,
displayName: data.displayName,
role: (data.role as 'user' | 'admin') ?? 'user',
storageQuotaBytes: data.storageQuotaBytes,
usedBytes: data.usedBytes,
usedPercent: data.usedPercent,
plan: data.plan,
})
} catch { } catch {
clearToken() clearToken()
setUser(null) setUser(null)
@@ -63,7 +76,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
const data = await authApi.login({ email, password }) const data = await authApi.login({ email, password })
setToken(data.token) setToken(data.token)
setUser({ ...data.user, role: (data.user.role as 'user' | 'admin') ?? 'user' }) setUser({
...data.user,
role: (data.user.role as 'user' | 'admin') ?? 'user',
})
}, []) }, [])
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => { const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
+19 -1
View File
@@ -39,6 +39,7 @@
"clearHistory": "Clear history", "clearHistory": "Clear history",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct", "dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Review", "review": "Review",
"retake": "Retake",
"loadError": "Could not load: {{message}}", "loadError": "Could not load: {{message}}",
"loadSampleError": "Could not load sample: {{message}}", "loadSampleError": "Could not load sample: {{message}}",
"clearHistoryConfirm": "Clear all past attempts?" "clearHistoryConfirm": "Clear all past attempts?"
@@ -124,6 +125,8 @@
"explanation": "Explanation", "explanation": "Explanation",
"rubric": "Rubric (self-grade)", "rubric": "Rubric (self-grade)",
"retakeExam": "Retake exam", "retakeExam": "Retake exam",
"redoFailed": "Redo failed questions",
"cantRetake": "Exam no longer saved in workspace",
"backToHome": "Back to home" "backToHome": "Back to home"
}, },
"tweaks": { "tweaks": {
@@ -184,7 +187,22 @@
"textSizeDesc": "Adjust the base font size for readability", "textSizeDesc": "Adjust the base font size for readability",
"defaultFeedback": "Default Feedback", "defaultFeedback": "Default Feedback",
"defaultFeedbackLabel": "Feedback mode", "defaultFeedbackLabel": "Feedback mode",
"defaultFeedbackDesc": "Choose when to see answer feedback during exams" "defaultFeedbackDesc": "Choose when to see answer feedback during exams",
"storage": "Storage",
"storageUsage": "Storage Usage",
"storageOf": "of",
"storageExams": "Saved exams",
"storageResults": "Results",
"showDetails": "Show details",
"hideDetails": "Hide details",
"storageDetails": "Storage Details",
"noStorageItems": "No items using storage.",
"exam": "Exam",
"result": "Result",
"deleteExamConfirm": "Delete this exam? This cannot be undone.",
"deleteResultConfirm": "Delete this result? This cannot be undone.",
"storageWarningHigh": "You are using over 80% of your storage.",
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
}, },
"admin": { "admin": {
"administration": "Administration", "administration": "Administration",
+19 -1
View File
@@ -39,6 +39,7 @@
"clearHistory": "Wis geschiedenis", "clearHistory": "Wis geschiedenis",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct", "dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Nakijken", "review": "Nakijken",
"retake": "Opnieuw maken",
"loadError": "Kon niet laden: {{message}}", "loadError": "Kon niet laden: {{message}}",
"loadSampleError": "Kon voorbeeld niet laden: {{message}}", "loadSampleError": "Kon voorbeeld niet laden: {{message}}",
"clearHistoryConfirm": "Alle eerdere pogingen wissen?" "clearHistoryConfirm": "Alle eerdere pogingen wissen?"
@@ -124,6 +125,8 @@
"explanation": "Uitleg", "explanation": "Uitleg",
"rubric": "Rubric (zelfbeoordeling)", "rubric": "Rubric (zelfbeoordeling)",
"retakeExam": "Examen opnieuw maken", "retakeExam": "Examen opnieuw maken",
"redoFailed": "Foute vragen opnieuw maken",
"cantRetake": "Examen niet meer opgeslagen in werkruimte",
"backToHome": "Terug naar home" "backToHome": "Terug naar home"
}, },
"tweaks": { "tweaks": {
@@ -184,7 +187,22 @@
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid", "textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
"defaultFeedback": "Standaard Feedback", "defaultFeedback": "Standaard Feedback",
"defaultFeedbackLabel": "Feedbackmodus", "defaultFeedbackLabel": "Feedbackmodus",
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens" "defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens",
"storage": "Opslag",
"storageUsage": "Opslaggebruik",
"storageOf": "van",
"storageExams": "Opgeslagen examens",
"storageResults": "Resultaten",
"showDetails": "Toon details",
"hideDetails": "Verberg details",
"storageDetails": "Opslagdetails",
"noStorageItems": "Geen items die opslag gebruiken.",
"exam": "Examen",
"result": "Resultaat",
"deleteExamConfirm": "Dit examen verwijderen? Dit kan niet ongedaan worden gemaakt.",
"deleteResultConfirm": "Dit resultaat verwijderen? Dit kan niet ongedaan worden gemaakt.",
"storageWarningHigh": "Je gebruikt meer dan 80% van je opslag.",
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
}, },
"admin": { "admin": {
"administration": "Beheer", "administration": "Beheer",
+125 -1
View File
@@ -424,7 +424,7 @@ select {
} }
.history-row { .history-row {
display: grid; display: grid;
grid-template-columns: 1fr auto auto auto; grid-template-columns: 1fr auto auto auto auto;
gap: 24px; gap: 24px;
padding: 14px 0; padding: 14px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -2664,6 +2664,12 @@ select {
border: 1px solid var(--correct); border: 1px solid var(--correct);
} }
.workspace-alert-warning {
background: var(--warn-weak);
color: var(--warn);
border: 1px solid var(--warn);
}
.workspace-alert-close { .workspace-alert-close {
background: none; background: none;
border: none; border: none;
@@ -2921,6 +2927,113 @@ select {
Workspace / Editor Responsive Workspace / Editor Responsive
============================================================ */ ============================================================ */
/* ---------- Storage ---------- */
.storage-overview {
margin-bottom: 16px;
}
.storage-bar-track {
height: 8px;
background: var(--paper-3);
border-radius: 999px;
overflow: hidden;
margin-bottom: 8px;
}
.storage-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 300ms ease, background-color 300ms ease;
}
.storage-stats {
font-size: 13.5px;
color: var(--ink-2);
margin-bottom: 4px;
}
.storage-used {
font-weight: 600;
color: var(--ink);
}
.storage-of {
color: var(--ink-3);
}
.storage-total {
font-weight: 500;
}
.storage-percent {
color: var(--ink-3);
font-family: var(--font-mono);
font-size: 12px;
}
.storage-breakdown {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.storage-breakdown-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13.5px;
padding: 8px 12px;
background: var(--paper-2);
border-radius: var(--r-md);
color: var(--ink-2);
}
.storage-detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.storage-detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--paper);
}
.storage-detail-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.storage-detail-type {
font-family: var(--font-mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-4);
background: var(--paper-2);
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.storage-detail-title {
font-size: 14px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-detail-actions {
flex-shrink: 0;
}
.btn-danger {
background: var(--wrong-weak);
color: var(--wrong);
border-color: var(--wrong);
}
.btn-danger:hover {
background: var(--wrong);
color: var(--paper);
}
.text-muted {
color: var(--ink-3);
font-size: 13.5px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.workspace-shell { .workspace-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -2992,4 +3105,15 @@ select {
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
} }
.storage-detail-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.storage-detail-actions {
width: 100%;
}
.storage-detail-actions .btn {
width: 100%;
}
} }
+1
View File
@@ -90,6 +90,7 @@ export interface ExamState {
startedAt: number; startedAt: number;
completedAt?: number; completedAt?: number;
feedbackMode: FeedbackMode; feedbackMode: FeedbackMode;
isRedo?: boolean;
} }
export interface HistoryEntry { export interface HistoryEntry {
-6
View File
@@ -1,6 +0,0 @@
{
"name": "brainwave",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -1 +1 @@
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]} {"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.442099999999982,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.80680000000001,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":12.700600000000009,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.035500000000013,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":5.9454000000000065,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.9056999999999675,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":7.014700000000005,"failed":false}],[":backend/dist/config.test.js",{"duration":4.3396999999999935,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.116100000000017,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":11.073699999999974,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":6.721499999999992,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0815000000000055,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.222700000000032,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":166.78979999999999,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.239000000000004,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":118.4222,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.551799999999986,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.303799999999995,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.920199999999994,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":6.263800000000003,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.775499999999994,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.524900000000002,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.508899999999983,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.431699999999978,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.2516999999999996,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":10.521900000000016,"failed":false}]]}
+24 -1
View File
@@ -1,6 +1,29 @@
{ {
"name": "brainwave", "name": "brainwave",
"version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {
"": {
"name": "brainwave",
"version": "0.0.1",
"devDependencies": {
"typescript": "^6.0.3"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
} }
+8
View File
@@ -0,0 +1,8 @@
{
"name": "brainwave",
"private": true,
"version": "0.0.1",
"devDependencies": {
"typescript": "^6.0.3"
}
}