Compare commits

...
33 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
LaurensBartelds 99ac7a7fae Merge branch 'main' of https://git.gzmn.nl/LaurensBartelds/brainwave
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m5s
2026-04-27 00:24:42 +02:00
LaurensBartelds 0b62723226 refactor: update Node.js version to 22 and adjust npm install commands for consistency 2026-04-27 00:24:25 +02:00
LaurensBartelds af317058fb Merge pull request 'feat: Implement API modules for admin, auth, exam, result, and setup functionalities' (#1) from refactor into main
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
Reviewed-on: #1
2026-04-27 00:11:28 +02:00
LaurensBartelds 95603f93a0 refactor: update result schemas and tests for improved state management and feedback modes
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Failing after 6s
2026-04-26 23:54:56 +02:00
LaurensBartelds ae8a01bc54 refactor: remove max-width constraint from workspace main layout 2026-04-26 23:31:20 +02:00
LaurensBartelds 2b7939d478 feat: enhance exam loading functionality with examId support and update UI components
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s
2026-04-26 23:25:24 +02:00
LaurensBartelds 50efba6f34 feat: implement tweaks context for user preferences and remove TweaksPanel component
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
2026-04-26 23:16:21 +02:00
LaurensBartelds 92c1de650a feat: add language selection feature to settings and update translations
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 23:04:39 +02:00
LaurensBartelds 83bb412b35 feat: enhance setup process with SMTP configuration and token generation
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
fix: require authentication for certain routes and improve exam data handling
chore: update .gitignore to include additional database files
2026-04-26 22:53:00 +02:00
LaurensBartelds ca9c646b65 chore: remove SQLite database files brainwave.db-shm and brainwave.db-wal
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 22:52:51 +02:00
LaurensBartelds 51e972d994 fix: add brainwave.db-shm to .gitignore to prevent tracking of local database file
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 22:31:04 +02:00
LaurensBartelds 2907197308 fix: update npm ci command to include --legacy-peer-deps for frontend job
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
2026-04-26 22:01:45 +02:00
LaurensBartelds e9c0a2615a feat: add i18next and react-i18next for internationalization support
Test / backend (push) Successful in 12s
Test / frontend (push) Failing after 5s
2026-04-26 21:59:06 +02:00
LaurensBartelds 98bba73192 fix: update comments in latex utility and refactor variable declaration for clarity
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 16s
2026-04-26 21:53:37 +02:00
LaurensBartelds 739133e1f4 feat: update dependencies and add dotenv, express-rate-limit, and zod; enhance adminSchemas validation
Test / backend (push) Successful in 14s
Test / frontend (push) Failing after 13s
2026-04-26 21:49:02 +02:00
LaurensBartelds ef8fe6f938 feat: enhance testing framework with new test cases and configurations
Test / backend (push) Failing after 9s
Test / frontend (push) Failing after 14s
2026-04-26 21:45:02 +02:00
LaurensBartelds a37c093cc4 feat: add CI workflow for backend and frontend tests
Test / backend-tests (push) Failing after 9s
Test / frontend-tests (push) Successful in 9s
2026-04-26 21:23:14 +02:00
LaurensBartelds 6277826472 Add internationalization support with English and Dutch translations
- Integrated i18next for language detection and translation support.
- Added English and Dutch JSON translation files for various app sections.
- Updated TweaksPanel and Workspace components to use translated strings.
- Imported i18n configuration in main entry file.
2026-04-26 21:19:47 +02:00
LaurensBartelds c64cdcd9e3 feat: Implement API modules for admin, auth, exam, result, and setup functionalities
- Created adminApi.ts for admin-related operations including user management and site settings.
- Developed authApi.ts for authentication processes such as registration, login, and profile management.
- Added examApi.ts for handling exam CRUD operations.
- Introduced resultApi.ts for managing results submission and retrieval.
- Established setupApi.ts for initial setup processes and status checks.
- Created ErrorBoundary component to handle rendering errors gracefully in the frontend.
- Re-exported shared API utilities in index.ts for easier access.
2026-04-26 20:10:46 +02:00
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
96 changed files with 10451 additions and 2526 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()
+10 -2
View File
@@ -17,11 +17,11 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "22"
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
working-directory: ./frontend working-directory: ./frontend
run: npm ci run: npm ci --legacy-peer-deps
- name: Run Frontend Tests - name: Run Frontend Tests
working-directory: ./frontend working-directory: ./frontend
@@ -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:
+55
View File
@@ -0,0 +1,55 @@
name: Test
on:
push:
branches:
- '**'
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
env:
JWT_SECRET: ci-test-secret
NODE_ENV: test
DB_PATH: ':memory:'
CORS_ORIGIN: 'http://localhost:5173'
APP_URL: 'http://localhost:5173'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: ./backend/package-lock.json
- run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
- name: Run tests
run: npm test
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: ./frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Type check
run: npx tsc -b --noEmit
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run
+7 -1
View File
@@ -1,4 +1,10 @@
backend/brainwave.db-shm 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
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
brainwave.db-shm
brainwave.db-wal
node_modules/
+4 -4
View File
@@ -1,13 +1,13 @@
# Stage 1: Build the frontend # Stage 1: Build the frontend
FROM node:20-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci --legacy-peer-deps
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# Stage 2: Build the backend # Stage 2: Build the backend
FROM node:20-alpine AS backend-builder FROM node:22-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm ci RUN npm ci
@@ -15,7 +15,7 @@ COPY backend/ ./
RUN npm run build RUN npm run build
# Stage 3: Production # Stage 3: Production
FROM node:20-alpine AS production FROM node:22-alpine AS production
# Install build tools for native modules like better-sqlite3 # Install build tools for native modules like better-sqlite3
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
WORKDIR /app WORKDIR /app
+9 -2
View File
@@ -12,7 +12,7 @@
ansible.builtin.file: ansible.builtin.file:
path: "{{ deploy_dir }}" path: "{{ deploy_dir }}"
state: directory state: directory
mode: '0755' mode: "0755"
- name: Copy ARM tarball - name: Copy ARM tarball
ansible.builtin.copy: ansible.builtin.copy:
@@ -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
@@ -33,4 +39,5 @@
ansible.builtin.command: docker compose up -d ansible.builtin.command: docker compose up -d
args: args:
chdir: "{{ deploy_dir }}" chdir: "{{ deploy_dir }}"
changed_when: true register: compose_up
changed_when: "'Started' in compose_up.stdout or 'Created' in compose_up.stdout or 'Recreating' in compose_up.stdout"
+50 -1
View File
@@ -11,9 +11,13 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.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"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
@@ -1434,6 +1438,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1647,6 +1663,21 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1832,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",
@@ -3374,6 +3414,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }
+5 -1
View File
@@ -13,9 +13,13 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.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"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
describe('env helpers', () => {
it('uses default value when env var is missing', () => {
delete process.env.TEST_VAR_UNIQUE
const env = (key: string, defaultValue?: string): string => {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
return value
}
expect(env('TEST_VAR_UNIQUE', 'fallback')).toBe('fallback')
})
it('returns env value when present', () => {
process.env.TEST_VAR_UNIQUE = 'value'
const env = (key: string, defaultValue?: string): string => {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
return value
}
expect(env('TEST_VAR_UNIQUE')).toBe('value')
delete process.env.TEST_VAR_UNIQUE
})
it('envInt parses integer', () => {
process.env.TEST_INT = '42'
const envInt = (key: string, defaultValue?: number): number => {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
return parsed
}
expect(envInt('TEST_INT')).toBe(42)
delete process.env.TEST_INT
})
it('envInt throws on NaN', () => {
process.env.TEST_INT = 'abc'
const envInt = (key: string, defaultValue?: number): number => {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
return parsed
}
expect(() => envInt('TEST_INT')).toThrow('Invalid int TEST_INT')
delete process.env.TEST_INT
})
})
+48
View File
@@ -0,0 +1,48 @@
import { AppError } from './middleware/error.js'
function env(key: string, defaultValue?: string): string {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new AppError(500, `Missing required environment variable: ${key}`)
}
return value
}
function envInt(key: string, defaultValue?: number): number {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new AppError(500, `Missing required environment variable: ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) {
throw new AppError(500, `Environment variable ${key} must be a valid integer, got: ${raw}`)
}
return parsed
}
export const config = {
env: env('NODE_ENV', 'development'),
port: envInt('PORT', 3001),
jwtSecret: env('JWT_SECRET'),
dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '100kb'),
email: {
smtpHost: env('SMTP_HOST', ''),
smtpPort: envInt('SMTP_PORT', 587),
smtpUser: env('SMTP_USER', ''),
smtpPass: env('SMTP_PASS', ''),
fromAddress: env('EMAIL_FROM', 'BrainWave <noreply@brainwave.local>'),
secure: env('SMTP_SECURE', 'auto'),
},
appUrl: env('APP_URL', 'http://localhost:5173'),
rateLimiting: {
authWindowMs: 15 * 60 * 1000, // 15 minutes
authMaxRequests: 10,
},
} as const
+13 -4
View File
@@ -1,17 +1,26 @@
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 { config } from '../config.js'
import { runMigrations } from './migrations.js' import { runMigrations } from './migrations.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DB_PATH = config.dbPath === ':memory:'
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db') ? ':memory:'
: 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) {
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)
} }
+41 -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,59 @@ 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'`);
}
// ── 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.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);
} }
} }
+59 -5
View File
@@ -1,10 +1,16 @@
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'
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 { errorHandler } from './middleware/error.js'
import { config } from './config.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'
@@ -12,28 +18,76 @@ import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const PORT = Number(process.env['PORT'] ?? '3001') const PORT = config.port
const app = express() const app = express()
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' })) // Security headers
app.use(express.json({ limit: '10mb' })) app.use(helmet())
// CORS
app.use(cors({ origin: config.corsOrigin }))
// Body parsing with reasonable limit
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
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const duration = Date.now() - start
console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`)
})
next()
})
// Initialize database on startup // Initialize database on startup
getDb() 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)
app.use('/api/admin', adminRoutes) app.use('/api/admin', adminRoutes)
// Health check // Health check with subsystem status
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: Date.now() }) let dbStatus: 'connected' | 'error' = 'connected'
try {
getDb().prepare('SELECT 1').get()
} catch {
dbStatus = 'error'
}
const checks = {
database: dbStatus,
email: config.email.smtpHost ? 'configured' : 'not_configured',
}
const overall = dbStatus === 'connected' ? 'healthy' : 'unhealthy'
res.status(overall === 'healthy' ? 200 : 503).json({
status: overall,
timestamp: Date.now(),
checks,
})
}) })
// Error handler (must be after routes)
app.use(errorHandler)
// Serve static frontend files if they exist // Serve static frontend files if they exist
const frontendDistPath = path.join(__dirname, '../../frontend/dist') const frontendDistPath = path.join(__dirname, '../../frontend/dist')
if (fs.existsSync(frontendDistPath)) { if (fs.existsSync(frontendDistPath)) {
+13 -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 {
@@ -9,6 +10,10 @@ declare global {
} }
} }
export interface AuthenticatedRequest extends Request {
user: JwtPayload
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void { export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) { if (!header?.startsWith('Bearer ')) {
@@ -18,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' })
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { AppError } from './error'
describe('AppError', () => {
it('is instance of Error and AppError', () => {
const err = new AppError(404, 'Not found')
expect(err).toBeInstanceOf(AppError)
expect(err).toBeInstanceOf(Error)
})
it('sets statusCode and message', () => {
const err = new AppError(500, 'Server error')
expect(err.statusCode).toBe(500)
expect(err.message).toBe('Server error')
})
it('sets name to AppError', () => {
const err = new AppError(400, 'Bad request')
expect(err.name).toBe('AppError')
})
})
+37
View File
@@ -0,0 +1,37 @@
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public isOperational = true
) {
super(message);
this.name = 'AppError';
Object.setPrototypeOf(this, AppError.prototype);
}
}
export function errorHandler(
err: Error | AppError,
_req: any,
res: any,
_next: any
) {
const statusCode = err instanceof AppError ? err.statusCode : 500;
const isOperational = err instanceof AppError && err.isOperational;
// Log full details server-side
console.error(`[Error] ${statusCode} - ${err.message}`);
if (!isOperational) {
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({
status: 'error',
message: clientMessage,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod'
import type { Request, Response, NextFunction } from 'express'
function getFirstIssueMessage(error: z.ZodError): string {
const issue = error.issues[0]
return issue?.message ?? 'Validation failed'
}
export function validateBody<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
req.body = result.data as z.infer<T>
next()
}
}
export function validateQuery<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
;(req as unknown as Record<string, unknown>).parsedQuery = result.data
next()
}
}
export function validateParams<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.params)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
Object.assign(req.params, result.data)
next()
}
}
+67
View File
@@ -0,0 +1,67 @@
import { getDb } from '../db/database.js'
export interface DbExam {
id: number
user_id: number
title: string
subtitle: string | null
exam_json: string
created_at: number
updated_at: number
}
export interface ExamListItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
}
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
VALUES (?, ?, ?, ?)
`).run(userId, title, subtitle, examJson)
return { id: result.lastInsertRowid as number }
}
export function getExamById(id: number, userId: number): DbExam | undefined {
const db = getDb()
return db.prepare('SELECT * FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as DbExam | undefined
}
export function listExamsByUser(userId: number): ExamListItem[] {
const db = getDb()
return db.prepare(`
SELECT id, title, subtitle, created_at, updated_at,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ?
ORDER BY updated_at DESC
`).all(userId) as ExamListItem[]
}
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(title, subtitle, examJson, id, userId)
return result.changes > 0
}
export function deleteExam(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM saved_exams WHERE id = ? AND user_id = ?').run(id, userId)
return result.changes > 0
}
export function countExams(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
return row.cnt
}
+112
View File
@@ -0,0 +1,112 @@
import { getDb } from '../db/database.js'
export interface DbResult {
id: number
user_id: number
exam_id: number | null
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
state_json: string
exam_json: string
completed_at: number
}
export interface ResultListItem {
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 function createResult(data: Omit<DbResult, 'id'>): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.user_id,
data.exam_id ?? null,
data.title,
data.score_points,
data.total_points,
data.correct_count,
data.graded_count,
data.passing_score ?? 60,
data.elapsed_ms ?? 0,
data.state_json,
data.exam_json
)
return { id: result.lastInsertRowid as number }
}
export function getResultById(id: number, userId: number): DbResult | undefined {
const db = getDb()
return db.prepare('SELECT * FROM exam_results WHERE id = ? AND user_id = ?').get(id, userId) as DbResult | undefined
}
export function listResultsByUser(userId: number, options: { limit?: number; offset?: number } = {}): { results: ResultListItem[]; total: number } {
const db = getDb()
const limit = Math.min(options.limit ?? 30, 100)
const offset = options.offset ?? 0
const results = db.prepare(`
SELECT id, title, score_points, total_points, correct_count, graded_count,
passing_score, elapsed_ms, completed_at, exam_id
FROM exam_results
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT ? OFFSET ?
`).all(userId, limit, offset) as ResultListItem[]
const countRow = db.prepare('SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?').get(userId) as { total: number }
return { results, total: countRow.total }
}
export function deleteResult(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM exam_results WHERE id = ? AND user_id = ?').run(id, userId)
return result.changes > 0
}
export function countResults(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
return row.cnt
}
export function getAverageScore(): number {
const db = getDb()
const row = db.prepare(
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
).get() as { avg: number }
return Math.round(row.avg * 10) / 10
}
export function getRecentResults(limit: number): Array<DbResult & { display_name: string; email: string }> {
const db = getDb()
return db.prepare(`
SELECT er.*, u.display_name, u.email
FROM exam_results er
JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC
LIMIT ?
`).all(limit) as Array<DbResult & { display_name: string; email: string }>
}
export function countResultsByUser(userId: number): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
return row.cnt
}
+56
View File
@@ -0,0 +1,56 @@
import { getDb } from '../db/database.js'
export interface Setting {
key: string
value: string
updated_at: number
}
export function getSetting(key: string): string | undefined {
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 setSetting(key: string, value: string): void {
const db = getDb()
db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`).run(key, value)
}
export function setSettingsBatch(entries: Array<[string, string]>): void {
const db = getDb()
const stmt = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`)
const tx = db.transaction((items: Array<[string, string]>) => {
for (const [key, value] of items) {
stmt.run(key, value)
}
})
tx(entries)
}
export function getAllSettings(): Record<string, string> {
const db = getDb()
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
const settings: Record<string, string> = {}
for (const row of rows) {
settings[row.key] = row.value
}
return settings
}
export function seedDefaultSettings(defaults: Record<string, string>): void {
const db = getDb()
const stmt = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING
`)
for (const [key, value] of Object.entries(defaults)) {
stmt.run(key, value)
}
}
@@ -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
}
+187
View File
@@ -0,0 +1,187 @@
import { getDb } from '../db/database.js'
export interface DbUser {
id: number
email: string
password_hash: string
display_name: string
role: 'user' | 'admin'
email_verified: number
verify_token: string | null
verify_expires: number | null
reset_token: string | null
reset_expires: number | null
storage_quota_bytes: number
plan: string
created_at: number
updated_at: number
}
export interface CreateUserData {
email: string
passwordHash: string
displayName: string
role?: 'user' | 'admin'
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export interface UpdateUserData {
displayName?: string
passwordHash?: string
role?: 'user' | 'admin'
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
resetToken?: string | null
resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export function createUser(data: CreateUserData): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.email.toLowerCase().trim(),
data.passwordHash,
data.displayName.trim(),
data.role ?? 'user',
data.emailVerified ?? 1,
data.verifyToken ?? null,
data.verifyExpires ?? null,
data.storageQuotaBytes ?? 1073741824,
data.plan ?? 'free'
)
return { id: result.lastInsertRowid as number }
}
export function getUserByEmail(email: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim()) as DbUser | undefined
}
export function getUserById(id: number): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
}
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
}
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
}
export function updateUser(id: number, data: UpdateUserData): boolean {
const db = getDb()
const fields: string[] = []
const values: unknown[] = []
if (data.displayName !== undefined) {
fields.push('display_name = ?')
values.push(data.displayName.trim())
}
if (data.passwordHash !== undefined) {
fields.push('password_hash = ?')
values.push(data.passwordHash)
}
if (data.role !== undefined) {
fields.push('role = ?')
values.push(data.role)
}
if (data.emailVerified !== undefined) {
fields.push('email_verified = ?')
values.push(data.emailVerified)
}
if (data.verifyToken !== undefined) {
fields.push('verify_token = ?')
values.push(data.verifyToken)
}
if (data.verifyExpires !== undefined) {
fields.push('verify_expires = ?')
values.push(data.verifyExpires)
}
if (data.resetToken !== undefined) {
fields.push('reset_token = ?')
values.push(data.resetToken)
}
if (data.resetExpires !== undefined) {
fields.push('reset_expires = ?')
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
fields.push('updated_at = unixepoch()')
values.push(id)
const result = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
return result.changes > 0
}
export function deleteUser(id: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
return result.changes > 0
}
export function countUsers(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
return row.cnt
}
export function countAdmins(): number {
const db = getDb()
const row = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'").get() as { cnt: number }
return row.cnt
}
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: DbUser[]; total: number } {
const db = getDb()
const limit = Math.min(options.limit ?? 50, 200)
const offset = options.offset ?? 0
const search = options.search ?? ''
let query = 'SELECT * FROM users'
let countQuery = 'SELECT COUNT(*) AS total FROM users'
const params: unknown[] = []
const countParams: unknown[] = []
if (search) {
query += ' WHERE email LIKE ? OR display_name LIKE ?'
countQuery += ' WHERE email LIKE ? OR display_name LIKE ?'
params.push(`%${search}%`, `%${search}%`)
countParams.push(`%${search}%`, `%${search}%`)
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const users = db.prepare(query).all(...params) as DbUser[]
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
return { users, total: countRow.total }
}
export function promoteFirstUserToAdmin(): void {
const db = getDb()
db.prepare("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)").run()
}
+121 -168
View File
@@ -1,190 +1,143 @@
import { Router } from 'express' import { Router } from 'express'
import { getDb } from '../db/database.js' import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth, requireAdmin } from '../middleware/auth.js' import { requireAuth, requireAdmin } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as userRepo from '../repositories/userRepo.js'
import * as resultRepo from '../repositories/resultRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js'
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
import type { Request } from 'express'
const router = Router() const router = Router()
// All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin) router.use(requireAuth, requireAdmin)
// ─── Dashboard Stats ───────────────────────────────────── // GET /api/admin/users
router.get('/stats', (_req, res) => { router.get(
try { '/users',
const db = getDb() validateQuery(listUsersQuerySchema),
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number } asyncHandler(async (req: Request, res) => {
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number } const parsed = (req as unknown as Record<string, unknown>).parsedQuery as { page: number; search?: string }
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number } const page = parsed.page ?? 1
const avgScore = db.prepare( const limit = 20
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results' const offset = (page - 1) * limit
).get() as { avg: number }
const recentResults = db.prepare(` const { users, total } = userRepo.listUsers({
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at, limit,
u.display_name, u.email offset,
FROM exam_results er search: parsed.search,
JOIN users u ON u.id = er.user_id })
ORDER BY er.completed_at DESC
LIMIT 10 const totalPages = Math.ceil(total / limit)
`).all()
res.json({ res.json({
totalUsers: users.cnt, users,
totalExams: exams.cnt, total,
totalResults: results.cnt, page,
averageScore: Math.round(avgScore.avg * 10) / 10, totalPages,
recentResults, limit,
}) })
} catch (err) { })
console.error('Admin stats error:', err) )
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get All Settings ──────────────────────────────────── // PUT /api/admin/users/:id/role
router.get('/settings', (_req, res) => { router.put(
try { '/users/:id/role',
const db = getDb() validateParams(userIdParamSchema),
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[] validateBody(updateUserRoleSchema),
const settings: Record<string, string> = {} asyncHandler(async (req: Request, res) => {
for (const row of rows) { const id = Number(req.params.id)
settings[row.key] = row.value const { role } = req.body
}
res.json({ settings })
} catch (err) {
console.error('Admin get settings error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Update Settings ───────────────────────────────────── const user = userRepo.getUserById(id)
router.put('/settings', (req, res) => { if (!user) {
try { throw new AppError(404, 'User not found')
const { settings } = req.body as { settings: Record<string, string> }
if (!settings || typeof settings !== 'object') {
res.status(400).json({ error: 'Settings object is required' })
return
} }
const validKeys = [ if (user.id === req.user!.userId) {
'site_name', 'max_upload_mb', 'allow_guest_mode', throw new AppError(400, 'Cannot change your own role')
'registration_enabled', 'default_theme', 'maintenance_mode', }
'maintenance_message',
]
const db = getDb() if (role === 'user' && user.role === 'admin') {
const update = db.prepare(` const adminCount = userRepo.countAdmins()
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ? if (adminCount <= 1) {
`) throw new AppError(400, 'Cannot remove the last admin')
const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue
update.run(String(value), key)
} }
}
userRepo.updateUser(id, { role })
res.json({ success: true })
})
)
// DELETE /api/admin/users/:id
router.delete(
'/users/:id',
validateParams(userIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
if (id === req.user!.userId) {
throw new AppError(400, 'Cannot delete your own account')
}
const user = userRepo.getUserById(id)
if (!user) {
throw new AppError(404, 'User not found')
}
if (user.role === 'admin') {
const adminCount = userRepo.countAdmins()
if (adminCount <= 1) {
throw new AppError(400, 'Cannot delete the last admin')
}
}
userRepo.deleteUser(id)
res.json({ success: true })
})
)
// GET /api/admin/stats
router.get(
'/stats',
asyncHandler(async (_req, res) => {
res.json({
totalUsers: userRepo.countUsers(),
totalExams: resultRepo.countResults(),
totalResults: resultRepo.countResults(),
averageScore: resultRepo.getAverageScore(),
}) })
})
)
updateMany(Object.entries(settings)) // GET /api/admin/recent-results
res.json({ message: 'Settings updated successfully' }) router.get(
} catch (err) { '/recent-results',
console.error('Admin update settings error:', err) asyncHandler(async (_req, res) => {
res.status(500).json({ error: 'Internal server error' }) const results = resultRepo.getRecentResults(10)
} res.json(results)
}) })
)
// ─── List All Users ────────────────────────────────────── // GET /api/admin/settings
router.get('/users', (req, res) => { router.get(
try { '/settings',
const limit = Math.min(Number(req.query['limit']) || 50, 200) asyncHandler(async (_req, res) => {
const offset = Number(req.query['offset']) || 0 res.json({ settings: settingsRepo.getAllSettings() })
const search = (req.query['search'] as string) || '' })
)
const db = getDb() // PUT /api/admin/settings
let query = ` router.put(
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at, '/settings',
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count, validateBody(updateSettingsBatchSchema),
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count asyncHandler(async (req: Request, res) => {
FROM users u const { settings } = req.body
` const entries = Object.entries(settings) as Array<[string, string]>
let countQuery = 'SELECT COUNT(*) AS total FROM users' settingsRepo.setSettingsBatch(entries)
const params: unknown[] = [] res.json({ success: true })
const countParams: unknown[] = [] })
)
if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`
query += where
countQuery += where.replace('u.', '')
params.push(`%${search}%`, `%${search}%`)
countParams.push(`%${search}%`, `%${search}%`)
}
query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const users = db.prepare(query).all(...params)
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
res.json({ users, total: countRow.total })
} catch (err) {
console.error('Admin list users error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Change User Role ────────────────────────────────────
router.patch('/users/:id/role', (req, res) => {
try {
const userId = Number(req.params['id'])
const { role } = req.body as { role: string }
if (!['user', 'admin'].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' })
return
}
// Prevent self-demotion
if (userId === req.user!.userId && role !== 'admin') {
res.status(400).json({ error: 'You cannot remove your own admin role' })
return
}
const db = getDb()
const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId)
if (result.changes === 0) {
res.status(404).json({ error: 'User not found' })
return
}
res.json({ message: `User role updated to ${role}` })
} catch (err) {
console.error('Admin change role error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Delete User ─────────────────────────────────────────
router.delete('/users/:id', (req, res) => {
try {
const userId = Number(req.params['id'])
// Prevent self-deletion
if (userId === req.user!.userId) {
res.status(400).json({ error: 'You cannot delete your own account' })
return
}
const db = getDb()
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId)
if (result.changes === 0) {
res.status(404).json({ error: 'User not found' })
return
}
res.json({ message: 'User deleted' })
} catch (err) {
console.error('Admin delete user error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router export default router
+135 -238
View File
@@ -1,248 +1,145 @@
import { Router } from 'express' import { Router } from 'express'
import bcrypt from 'bcrypt' import rateLimit from 'express-rate-limit'
import { getDb } from '../db/database.js' import { validateBody } from '../middleware/validate.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js'
import * as userRepo from '../repositories/userRepo.js'
import { hashToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import {
registerSchema,
loginSchema,
updateProfileSchema,
changePasswordSchema,
verifyEmailSchema,
resetPasswordSchema,
forgotPasswordSchema,
} from '../validators/authSchemas.js'
import type { Request } from 'express'
const router = Router() const router = Router()
const BCRYPT_ROUNDS = 12
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
// ─── Register ──────────────────────────────────────────── const authLimiter = rateLimit({
router.post('/register', async (req, res) => { windowMs: 15 * 60 * 1000,
try { max: 10,
const { email, password, displayName } = req.body message: { error: 'Too many attempts, please try again later' },
standardHeaders: true,
if (!email || !password || !displayName) { skipSuccessfulRequests: false,
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()
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
if (existing) {
res.status(409).json({ error: 'An account with this email already exists' })
return
}
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const verifyToken = generateVerificationToken()
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires)
VALUES (?, ?, ?, ?, ?)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires)
await sendVerificationEmail(email, verifyToken)
res.status(201).json({
message: 'Account created. Please check your email to verify your account.',
userId: result.lastInsertRowid,
})
} catch (err) {
console.error('Register error:', err)
res.status(500).json({ error: 'Internal server error' })
}
}) })
// ─── Login ─────────────────────────────────────────────── // POST /api/auth/register
router.post('/login', async (req, res) => { router.post(
try { '/register',
const { email, password } = req.body authLimiter,
validateBody(registerSchema),
if (!email || !password) { asyncHandler(async (req, res) => {
res.status(400).json({ error: 'Email and password are required' }) const isFirstUser = userRepo.countUsers() === 0
return const result = await authService.register(req.body, isFirstUser)
} res.json(result)
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(
(email as string).toLowerCase().trim()
) as Record<string, unknown> | undefined
if (!user) {
res.status(401).json({ error: 'Invalid email or password' })
return
}
const valid = await bcrypt.compare(password as string, user['password_hash'] as string)
if (!valid) {
res.status(401).json({ error: 'Invalid email or password' })
return
}
if (!user['email_verified']) {
res.status(403).json({ error: 'Please verify your email before logging in' })
return
}
const token = signToken({
userId: user['id'] as number,
email: user['email'] as string,
displayName: user['display_name'] as string,
role: (user['role'] as 'user' | 'admin') ?? 'user',
})
res.json({
token,
user: {
id: user['id'],
email: user['email'],
displayName: user['display_name'],
role: user['role'] ?? 'user',
},
})
} catch (err) {
console.error('Login error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Verify Email ────────────────────────────────────────
router.get('/verify-email', (req, res) => {
try {
const { token } = req.query
if (!token || typeof token !== 'string') {
res.status(400).json({ error: 'Verification token is required' })
return
}
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as Record<string, unknown> | undefined
if (!user) {
res.status(400).json({ error: 'Invalid verification token' })
return
}
if ((user['verify_expires'] as number) < Date.now()) {
res.status(400).json({ error: 'Verification token has expired. Please register again.' })
return
}
db.prepare(`
UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL, updated_at = unixepoch()
WHERE id = ?
`).run(user['id'])
res.json({ message: 'Email verified successfully. You can now log in.' })
} catch (err) {
console.error('Verify error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Forgot Password ────────────────────────────────────
router.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body
// Always return success to prevent email enumeration
if (!email || typeof email !== 'string') {
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
return
}
const db = getDb()
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(
email.toLowerCase().trim()
) as Record<string, unknown> | undefined
if (user && user['email_verified']) {
const resetToken = generateVerificationToken()
const resetExpires = Date.now() + RESET_EXPIRY_MS
db.prepare(`
UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = unixepoch()
WHERE id = ?
`).run(resetToken, resetExpires, user['id'])
await sendPasswordResetEmail(email, resetToken)
}
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
} catch (err) {
console.error('Forgot password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Reset Password ─────────────────────────────────────
router.post('/reset-password', async (req, res) => {
try {
const { token, newPassword } = req.body
if (!token || typeof token !== 'string') {
res.status(400).json({ error: 'Reset token is required' })
return
}
if (!newPassword || 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 * FROM users WHERE reset_token = ?').get(token) as Record<string, unknown> | undefined
if (!user) {
res.status(400).json({ error: 'Invalid reset token' })
return
}
if ((user['reset_expires'] as number) < Date.now()) {
res.status(400).json({ error: 'Reset token has expired. Please request a new one.' })
return
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
db.prepare(`
UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL, updated_at = unixepoch()
WHERE id = ?
`).run(passwordHash, user['id'])
res.json({ message: 'Password updated successfully. You can now log in with your new password.' })
} catch (err) {
console.error('Reset password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Current User ────────────────────────────────────
router.get('/me', requireAuth, (req, res) => {
const db = getDb()
const user = db.prepare('SELECT id, email, display_name, role, created_at FROM users WHERE id = ?').get(
req.user!.userId
) as Record<string, unknown> | undefined
if (!user) {
res.status(404).json({ error: 'User not found' })
return
}
res.json({
id: user['id'],
email: user['email'],
displayName: user['display_name'],
role: user['role'] ?? 'user',
createdAt: user['created_at'],
}) })
}) )
// POST /api/auth/login
router.post(
'/login',
authLimiter,
validateBody(loginSchema),
asyncHandler(async (req, res) => {
const result = await authService.login(req.body)
res.json(result)
})
)
// GET /api/auth/me
router.get(
'/me',
requireAuth,
asyncHandler(async (req: Request, res) => {
const user = authService.getMe(req.user!.userId)
res.json(user)
})
)
// PUT /api/auth/me
router.put(
'/me',
requireAuth,
validateBody(updateProfileSchema),
asyncHandler(async (req: Request, res) => {
const user = await authService.updateProfile(req.user!.userId, req.body)
res.json(user)
})
)
// POST /api/auth/change-password
router.post(
'/change-password',
requireAuth,
validateBody(changePasswordSchema),
asyncHandler(async (req: Request, res) => {
await authService.changePassword(req.user!.userId, req.body)
res.json({ success: true })
})
)
// GET /api/auth/verify
router.get(
'/verify',
validateBody(verifyEmailSchema),
asyncHandler(async (req, res) => {
await authService.verifyEmail(req.body)
res.json({ message: 'Email verified successfully' })
})
)
// POST /api/auth/forgot-password
router.post(
'/forgot-password',
authLimiter,
validateBody(forgotPasswordSchema),
asyncHandler(async (req, res) => {
await authService.forgotPassword(req.body.email)
res.json({ message: 'If an account exists, a reset email has been sent' })
})
)
// POST /api/auth/reset-password
router.post(
'/reset-password',
authLimiter,
validateBody(resetPasswordSchema),
asyncHandler(async (req, res) => {
await authService.resetPassword(req.body)
res.json({ message: 'Password reset successfully' })
})
)
// 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
router.delete(
'/me',
requireAuth,
asyncHandler(async (req: Request, res) => {
authService.deleteUserAccount(req.user!.userId)
res.json({ success: true })
})
)
export default router export default router
+75 -82
View File
@@ -1,99 +1,92 @@
import { Router } from 'express' import { Router } from 'express'
import { getDb } from '../db/database.js' import { validateBody, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as examRepo from '../repositories/examRepo.js'
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
import { sanitizeExam } from '../utils/sanitizeExam.js'
import type { Request } from 'express'
const router = Router() const router = Router()
// ─── List User's Saved Exams ───────────────────────────── // All exam routes require authentication
router.get('/', requireAuth, (req, res) => { router.use(requireAuth)
try {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, subtitle, created_at, updated_at,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ?
ORDER BY updated_at DESC
`).all(req.user!.userId)
// GET /api/exams
router.get(
'/',
asyncHandler(async (req: Request, res) => {
const exams = examRepo.listExamsByUser(req.user!.userId)
res.json(exams) res.json(exams)
} catch (err) { })
console.error('List exams error:', err) )
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Full Exam ───────────────────────────────────────
router.get('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const exam = db.prepare(`
SELECT * FROM saved_exams WHERE id = ? AND user_id = ?
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
// GET /api/exams/:id
router.get(
'/:id',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = examRepo.getExamById(id, req.user!.userId)
if (!exam) { if (!exam) {
res.status(404).json({ error: 'Exam not found' }) throw new AppError(404, 'Exam not found')
return
} }
const parsedExam = JSON.parse(exam.exam_json)
res.json({ ...parsedExam, id: exam.id })
})
)
res.json({ // POST /api/exams
id: exam['id'], router.post(
title: exam['title'], '/',
subtitle: exam['subtitle'], validateBody(saveExamSchema),
exam: JSON.parse(exam['exam_json'] as string), asyncHandler(async (req: Request, res) => {
createdAt: exam['created_at'], const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
updatedAt: exam['updated_at'], const { id } = examRepo.createExam(
}) req.user!.userId,
} catch (err) { exam.title,
console.error('Get exam error:', err) exam.subtitle ?? null,
res.status(500).json({ error: 'Internal server error' }) JSON.stringify(exam)
} )
}) res.status(201).json({ id, title: exam.title })
})
)
// ─── Save Exam ─────────────────────────────────────────── // PUT /api/exams/:id
router.post('/', requireAuth, (req, res) => { router.put(
try { '/:id',
const { exam } = req.body validateParams(examIdParamSchema),
validateBody(updateExamSchema),
if (!exam || !exam.title || !exam.questions?.length) { asyncHandler(async (req: Request, res) => {
res.status(400).json({ error: 'Valid exam data with title and questions is required' }) const id = Number(req.params.id)
return const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const updated = examRepo.updateExam(
id,
req.user!.userId,
exam.title,
exam.subtitle ?? null,
JSON.stringify(exam)
)
if (!updated) {
throw new AppError(404, 'Exam not found')
} }
res.json({ success: true })
})
)
const db = getDb() // DELETE /api/exams/:id
const result = db.prepare(` router.delete(
INSERT INTO saved_exams (user_id, title, subtitle, exam_json) '/:id',
VALUES (?, ?, ?, ?) validateParams(examIdParamSchema),
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam)) asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
res.status(201).json({ const deleted = examRepo.deleteExam(id, req.user!.userId)
id: result.lastInsertRowid, if (!deleted) {
message: 'Exam saved successfully', throw new AppError(404, 'Exam not found')
})
} catch (err) {
console.error('Save exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Delete Exam ─────────────────────────────────────────
router.delete('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const result = db.prepare(`
DELETE FROM saved_exams WHERE id = ? AND user_id = ?
`).run(Number(req.params['id']), req.user!.userId)
if (result.changes === 0) {
res.status(404).json({ error: 'Exam not found' })
return
} }
res.json({ success: true })
res.json({ message: 'Exam deleted' }) })
} catch (err) { )
console.error('Delete exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router export default router
+64 -122
View File
@@ -1,139 +1,81 @@
import { Router } from 'express' import { Router } from 'express'
import { getDb } from '../db/database.js' import { validateBody, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as resultRepo from '../repositories/resultRepo.js'
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
import type { Request } from 'express'
const router = Router() const router = Router()
// ─── List User's Results ───────────────────────────────── router.use(requireAuth)
router.get('/', requireAuth, (req, res) => {
try {
const limit = Math.min(Number(req.query['limit']) || 30, 100)
const offset = Number(req.query['offset']) || 0
const db = getDb() // GET /api/results
const results = db.prepare(` router.get(
SELECT id, title, score_points, total_points, correct_count, graded_count, '/',
passing_score, elapsed_ms, completed_at, exam_id asyncHandler(async (req: Request, res) => {
FROM exam_results const { results, total } = resultRepo.listResultsByUser(req.user!.userId)
WHERE user_id = ? res.json({ results, total })
ORDER BY completed_at DESC })
LIMIT ? OFFSET ? )
`).all(req.user!.userId, limit, offset)
const countRow = db.prepare(
'SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?'
).get(req.user!.userId) as { total: number }
res.json({ results, total: countRow.total })
} catch (err) {
console.error('List results error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Single Result ───────────────────────────────────
router.get('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const result = db.prepare(`
SELECT * FROM exam_results WHERE id = ? AND user_id = ?
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
// GET /api/results/:id
router.get(
'/:id',
validateParams(resultIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const result = resultRepo.getResultById(id, req.user!.userId)
if (!result) { if (!result) {
res.status(404).json({ error: 'Result not found' }) throw new AppError(404, 'Result not found')
return
} }
res.json({ res.json({
id: result['id'], ...result,
title: result['title'], state: JSON.parse(result.state_json),
scorePoints: result['score_points'], exam: JSON.parse(result.exam_json),
totalPoints: result['total_points'], state_json: undefined,
correctCount: result['correct_count'], exam_json: undefined,
gradedCount: result['graded_count'],
passingScore: result['passing_score'],
elapsedMs: result['elapsed_ms'],
completedAt: result['completed_at'],
examId: result['exam_id'],
state: JSON.parse(result['state_json'] as string),
exam: JSON.parse(result['exam_json'] as string),
}) })
} catch (err) { })
console.error('Get result error:', err) )
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Save Result ───────────────────────────────────────── // POST /api/results
router.post('/', requireAuth, (req, res) => { router.post(
try { '/',
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body validateBody(submitResultSchema),
asyncHandler(async (req: Request, res) => {
if ( const data = req.body
!title || scorePoints == null || totalPoints == null || const { id } = resultRepo.createResult({
correctCount == null || gradedCount == null || !state || !exam user_id: req.user!.userId,
) { exam_id: data.exam_id ?? null,
res.status(400).json({ error: 'Missing required result fields' }) title: data.title,
return score_points: data.score_points,
} total_points: data.total_points,
correct_count: data.correct_count,
let noteSource = '' graded_count: data.graded_count,
if (typeof notes === 'string') { passing_score: data.passing_score ?? 60,
noteSource = notes elapsed_ms: data.elapsed_ms,
} else if (typeof state.notes === 'string') { state_json: JSON.stringify(data.state),
noteSource = state.notes exam_json: JSON.stringify(data.exam),
} completed_at: Date.now(),
const normalizedState = {
...state,
notes: noteSource.slice(0, 1000),
}
const db = getDb()
const result = db.prepare(`
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user!.userId,
examId ?? null,
title,
scorePoints,
totalPoints,
correctCount,
gradedCount,
passingScore ?? 60,
elapsedMs ?? 0,
JSON.stringify(normalizedState),
JSON.stringify(exam),
)
res.status(201).json({
id: result.lastInsertRowid,
message: 'Result saved successfully',
}) })
} catch (err) { res.status(201).json({ id })
console.error('Save result error:', err) })
res.status(500).json({ error: 'Internal server error' }) )
}
})
// ─── Delete Result ─────────────────────────────────────── // DELETE /api/results/:id
router.delete('/:id', requireAuth, (req, res) => { router.delete(
try { '/:id',
const db = getDb() validateParams(resultIdParamSchema),
const result = db.prepare(` asyncHandler(async (req: Request, res) => {
DELETE FROM exam_results WHERE id = ? AND user_id = ? const id = Number(req.params.id)
`).run(Number(req.params['id']), req.user!.userId) const deleted = resultRepo.deleteResult(id, req.user!.userId)
if (!deleted) {
if (result.changes === 0) { throw new AppError(404, 'Result not found')
res.status(404).json({ error: 'Result not found' })
return
} }
res.json({ success: true })
res.json({ message: 'Result deleted' }) })
} catch (err) { )
console.error('Delete result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router export default router
+117
View File
@@ -0,0 +1,117 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js'
import { getDb } from '../db/database.js'
import * as userRepo from '../repositories/userRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js'
import { setupSchema } from '../validators/setupSchemas.js'
import { signToken } from '../utils/tokens.js'
const router = Router()
const SALT_ROUNDS = 10
function getSiteTitle(): string | null {
return settingsRepo.getSetting('site_name') ?? null
}
// GET /api/setup/status
router.get(
'/status',
asyncHandler(async (_req, res) => {
const title = getSiteTitle()
res.json({
completed: !!title,
siteTitle: title ?? undefined,
})
})
)
// POST /api/setup
router.post(
'/',
validateBody(setupSchema),
asyncHandler(async (req, res) => {
const db = getDb()
// Atomic transaction prevents race conditions between concurrent setup requests
const tx = db.transaction(() => {
const title = settingsRepo.getSetting('site_name')
if (title) {
throw new AppError(400, 'Setup already completed')
}
const userCount = userRepo.countUsers()
if (userCount > 0) {
throw new AppError(400, 'Users already exist')
}
const {
email,
password,
displayName,
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
const { id } = userRepo.createUser({
email,
passwordHash,
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 { id, email, displayName } = tx()
const token = signToken({
userId: id,
email,
displayName,
role: 'admin',
})
res.status(201).json({
message: 'Setup completed successfully',
token,
user: {
id,
email,
displayName,
role: 'admin',
},
})
})
)
export default router
+189
View File
@@ -0,0 +1,189 @@
import bcrypt from 'bcrypt'
import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.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'
const SALT_ROUNDS = 10
const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000
export interface AuthResult {
token: string
user: { id: number; email: string; displayName: string; role: string }
}
export async function register(input: RegisterInput, isFirstUser: boolean): Promise<AuthResult> {
const existing = userRepo.getUserByEmail(input.email)
if (existing) {
throw new AppError(400, 'Email already registered')
}
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const role = isFirstUser ? 'admin' : 'user'
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
const { id } = userRepo.createUser({
email: input.email,
passwordHash,
displayName: input.displayName,
role,
emailVerified: 0,
verifyToken: verifyTokenHash,
verifyExpires,
})
const user = userRepo.getUserById(id)!
// Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
}
export async function login(input: LoginInput): Promise<AuthResult> {
const user = userRepo.getUserByEmail(input.email)
if (!user) {
throw new AppError(401, 'Invalid email or password')
}
const valid = await bcrypt.compare(input.password, user.password_hash)
if (!valid) {
throw new AppError(401, 'Invalid email or password')
}
if (!user.email_verified) {
throw new AppError(403, 'Please verify your email before logging in')
}
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
}
export function getMe(userId: number) {
const user = userRepo.getUserById(userId)
if (!user) {
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 {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
emailVerified: !!user.email_verified,
storageQuotaBytes: user.storage_quota_bytes,
usedBytes: usage.usedBytes,
usedPercent,
plan: user.plan,
}
}
export async function updateProfile(userId: number, input: UpdateProfileInput) {
userRepo.updateUser(userId, { displayName: input.displayName })
return getMe(userId)
}
export async function changePassword(userId: number, input: ChangePasswordInput) {
const user = userRepo.getUserById(userId)
if (!user) {
throw new AppError(404, 'User not found')
}
const valid = await bcrypt.compare(input.currentPassword, user.password_hash)
if (!valid) {
throw new AppError(400, 'Current password is incorrect')
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
userRepo.updateUser(userId, { passwordHash })
}
export async function verifyEmail(input: VerifyEmailInput) {
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
throw new AppError(400, 'Invalid or expired verification token')
}
userRepo.updateUser(user.id, {
emailVerified: 1,
verifyToken: null,
verifyExpires: null,
})
}
export async function forgotPassword(email?: string) {
if (!email) {
throw new AppError(400, 'Email is required')
}
const user = userRepo.getUserByEmail(email)
if (!user) {
// Don't reveal whether email exists
return
}
const rawResetToken = generateVerificationToken()
const resetTokenHash = hashToken(rawResetToken)
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
// Fire and forget
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
}
export async function resetPassword(input: ResetPasswordInput) {
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByResetTokenHash(tokenHash)
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
throw new AppError(400, 'Invalid or expired reset token')
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
userRepo.updateUser(user.id, {
passwordHash,
resetToken: null,
resetExpires: null,
})
}
export function deleteUserAccount(userId: number) {
const success = userRepo.deleteUser(userId)
if (!success) {
throw new AppError(404, 'User not found')
}
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { asyncHandler } from './asyncHandler'
import type { Request, Response, NextFunction } from 'express'
const mockReq = {} as Request
const mockRes = {} as Response
let mockNext: NextFunction
beforeEach(() => {
mockNext = vi.fn() as unknown as NextFunction
})
describe('asyncHandler', () => {
it('calls next with error when async handler throws', async () => {
const error = new Error('boom')
const handler = asyncHandler(async () => {
throw error
})
await handler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
it('does not call next when handler resolves', async () => {
const handler = asyncHandler(async () => {
// success
})
await handler(mockReq, mockRes, mockNext)
expect(mockNext).not.toHaveBeenCalled()
})
it('calls next with error when handler rejects', async () => {
const error = new Error('rejected')
const handler = asyncHandler(() => Promise.reject(error))
await handler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
})
+7
View File
@@ -0,0 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express'
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
+146 -41
View File
@@ -1,46 +1,120 @@
import nodemailer from 'nodemailer' import nodemailer from "nodemailer";
import { getDb } from "../db/database.js";
import { config } from "../config.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() || config.appUrl) ?? "http://localhost:5173"
);
} catch {
return config.appUrl;
} }
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 +130,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 +159,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,
);
} }
+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())
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
describe('tokens', () => {
it('round-trips JWT payload', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
const token = signToken(payload)
const decoded = verifyToken(token)
expect(decoded.userId).toBe(1)
expect(decoded.email).toBe('a@b.com')
expect(decoded.displayName).toBe('Test')
expect(decoded.role).toBe('user')
})
it('throws on invalid token', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { verifyToken } = await import('./tokens')
expect(() => verifyToken('invalid.token.here')).toThrow()
})
it('throws on tampered token', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
const token = signToken(payload)
const tampered = token.slice(0, -5) + 'xxxxx'
expect(() => verifyToken(tampered)).toThrow()
})
it('generates hex verification token of expected length', async () => {
const { generateVerificationToken } = await import('./tokens')
const token = generateVerificationToken()
expect(token).toMatch(/^[a-f0-9]+$/)
expect(token.length).toBe(64)
})
it('generates unique verification tokens', async () => {
const { generateVerificationToken } = await import('./tokens')
const t1 = generateVerificationToken()
const t2 = generateVerificationToken()
expect(t1).not.toBe(t2)
})
})
+7 -4
View File
@@ -1,7 +1,6 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import crypto from 'crypto' import crypto from 'crypto'
import { config } from '../config.js'
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
export interface JwtPayload { export interface JwtPayload {
userId: number userId: number
@@ -11,13 +10,17 @@ export interface JwtPayload {
} }
export function signToken(payload: JwtPayload): string { export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' }) return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' })
} }
export function verifyToken(token: string): JwtPayload { export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload return jwt.verify(token, config.jwtSecret) as 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')
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
describe('updateUserRoleSchema', () => {
it('accepts valid role change', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
expect(result.success).toBe(true)
})
it('rejects invalid role', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
expect(result.success).toBe(false)
})
it('rejects zero user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
expect(result.success).toBe(false)
})
})
describe('updateSettingSchema', () => {
it('accepts valid setting', () => {
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' })
expect(result.success).toBe(true)
})
it('rejects empty key', () => {
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
expect(result.success).toBe(false)
})
it('rejects empty 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)
})
})
describe('listUsersQuerySchema', () => {
it('accepts empty query', () => {
const result = listUsersQuerySchema.safeParse({})
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(1)
})
it('accepts page string', () => {
const result = listUsersQuerySchema.safeParse({ page: '3' })
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(3)
})
it('rejects non-numeric page', () => {
const result = listUsersQuerySchema.safeParse({ page: 'abc' })
expect(result.success).toBe(false)
})
it('accepts search', () => {
const result = listUsersQuerySchema.safeParse({ search: 'john' })
expect(result.success).toBe(true)
})
})
describe('userIdParamSchema', () => {
it('accepts numeric id', () => {
const result = userIdParamSchema.safeParse({ id: '7' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = userIdParamSchema.safeParse({ id: 'seven' })
expect(result.success).toBe(false)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod'
export const updateUserRoleSchema = z.object({
user_id: z.number().min(1),
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({
key: z.enum(ALLOWED_SETTING_KEYS),
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({
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
search: z.string().optional(),
})
export const userIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
describe('registerSchema', () => {
it('accepts valid registration', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = registerSchema.safeParse({
email: 'not-an-email',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(false)
})
it('rejects short password', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: '123',
displayName: 'Test User',
})
expect(result.success).toBe(false)
})
it('rejects empty display name', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123!',
displayName: '',
})
expect(result.success).toBe(false)
})
})
describe('loginSchema', () => {
it('accepts valid login', () => {
const result = loginSchema.safeParse({ email: 'test@example.com', password: 'pass' })
expect(result.success).toBe(true)
})
it('rejects missing email', () => {
const result = loginSchema.safeParse({ password: 'pass' })
expect(result.success).toBe(false)
})
})
describe('verifyEmailSchema', () => {
it('accepts valid token', () => {
const result = verifyEmailSchema.safeParse({ token: 'abc123' })
expect(result.success).toBe(true)
})
it('rejects empty token', () => {
const result = verifyEmailSchema.safeParse({ token: '' })
expect(result.success).toBe(false)
})
})
describe('resetPasswordSchema', () => {
it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
expect(result.success).toBe(true)
})
it('rejects short password', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', password: '123' })
expect(result.success).toBe(false)
})
})
describe('updateProfileSchema', () => {
it('accepts valid profile', () => {
const result = updateProfileSchema.safeParse({ displayName: 'New Name' })
expect(result.success).toBe(true)
})
it('rejects empty displayName', () => {
const result = updateProfileSchema.safeParse({ displayName: '' })
expect(result.success).toBe(false)
})
})
describe('changePasswordSchema', () => {
it('accepts valid change', () => {
const result = changePasswordSchema.safeParse({
currentPassword: 'oldpass',
newPassword: 'NewPass123!',
})
expect(result.success).toBe(true)
})
})
describe('forgotPasswordSchema', () => {
it('accepts valid email', () => {
const result = forgotPasswordSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
})
it('accepts empty object', () => {
const result = forgotPasswordSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = forgotPasswordSchema.safeParse({ email: 'bad' })
expect(result.success).toBe(false)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const registerSchema = z.object({
email: z.string().email().max(255),
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),
})
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
})
export const verifyEmailSchema = z.object({
token: z.string().min(1, 'Token is required'),
})
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'),
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({
displayName: z.string().min(1, 'Display name is required').max(100),
})
export const changePasswordSchema = z.object({
currentPassword: z.string(),
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({
email: z.string().email().optional(),
})
export type RegisterInput = z.infer<typeof registerSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
describe('examSchema', () => {
it('accepts minimal valid exam', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
it('rejects missing title', () => {
const result = examSchema.safeParse({
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('rejects empty questions array', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [],
})
expect(result.success).toBe(false)
})
it('rejects question missing id', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [{ type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts optional id', () => {
const result = examSchema.safeParse({
id: 'abc',
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
})
describe('saveExamSchema', () => {
it('accepts valid save payload', () => {
const result = saveExamSchema.safeParse({
exam: {
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
},
})
expect(result.success).toBe(true)
})
it('rejects missing exam', () => {
const result = saveExamSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe('updateExamSchema', () => {
it('accepts valid update payload', () => {
const result = updateExamSchema.safeParse({
exam: {
title: 'Updated',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
},
})
expect(result.success).toBe(true)
})
})
describe('examIdParamSchema', () => {
it('accepts numeric id string', () => {
const result = examIdParamSchema.safeParse({ id: '42' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = examIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
})
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod'
const choiceSchema = z.object({
id: z.string(),
text: z.string(),
})
const matchItemSchema = z.object({
id: z.string(),
text: z.string(),
})
const questionImageSchema = z.object({
src: z.string(),
alt: z.string().optional(),
caption: z.string().optional(),
})
const questionAudioSchema = z.object({
src: z.string(),
})
const questionCodeSchema = z.object({
language: z.string(),
content: z.string(),
})
const questionTableSchema = z.object({
headers: z.array(z.string()),
rows: z.array(z.array(z.string())),
})
const questionSchema = z.object({
id: z.string(),
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
]),
category: z.string().optional(),
points: z.number().optional(),
prompt: z.string(),
explanation: z.string().optional(),
image: questionImageSchema.optional(),
audio: questionAudioSchema.optional(),
code: questionCodeSchema.optional(),
table: questionTableSchema.optional(),
svg: z.string().optional(),
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
choices: z.array(choiceSchema).optional(),
minWords: z.number().optional(),
rubric: z.array(z.string()).optional(),
left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(),
})
export const examSchema = z.object({
id: z.string().optional(),
title: z.string().min(1, 'Title is required'),
subtitle: z.string().optional(),
author: z.string().optional(),
timeLimitMinutes: z.number().nullable().optional(),
feedbackMode: z.enum(['end', 'immediate']).optional(),
passingScore: z.number().optional(),
categories: z.array(z.string()).optional(),
questions: z.array(questionSchema).min(1, 'At least one question is required'),
})
export const saveExamSchema = z.object({
exam: examSchema,
})
export const updateExamSchema = z.object({
exam: examSchema,
})
export const examIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
export type ExamInput = z.infer<typeof examSchema>
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { submitResultSchema, resultIdParamSchema } from './resultSchemas'
describe('submitResultSchema', () => {
it('accepts valid result', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: { q1: 'a' },
flags: { q1: false },
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: {
title: 'My Exam',
questions: [],
},
})
expect(result.success).toBe(true)
})
it('rejects missing title', () => {
const result = submitResultSchema.safeParse({
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(false)
})
it('accepts optional exam_id', () => {
const result = submitResultSchema.safeParse({
exam_id: 1,
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
})
it('accepts negative score_points because schema allows any number', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: -1,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
})
it('accepts immediate feedback mode', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 2,
answers: { q1: 'a', q2: 'b' },
flags: { q1: true },
notes: 'some notes',
revealed: { q1: true },
startedAt: Date.now(),
completedAt: Date.now(),
feedbackMode: 'immediate',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
})
})
describe('resultIdParamSchema', () => {
it('accepts numeric id string', () => {
const result = resultIdParamSchema.safeParse({ id: '42' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = resultIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
})
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod'
export const submitResultSchema = z.object({
exam_id: z.number().nullable().optional(),
title: z.string().min(1),
score_points: z.number(),
total_points: z.number(),
correct_count: z.number(),
graded_count: z.number(),
passing_score: z.number().optional(),
elapsed_ms: z.number(),
state: z.record(z.unknown()),
exam: z.record(z.unknown()),
})
export const resultIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { setupSchema, setupStatusSchema } from './setupSchemas'
describe('setupSchema', () => {
it('accepts valid setup', () => {
const result = setupSchema.safeParse({
email: 'admin@example.com',
password: 'AdminPass123!',
displayName: 'Admin',
siteTitle: 'BrainWave',
language: 'en',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = setupSchema.safeParse({
email: 'bad',
password: 'AdminPass123!',
displayName: 'Admin',
})
expect(result.success).toBe(false)
})
it('rejects short password', () => {
const result = setupSchema.safeParse({
email: 'admin@example.com',
password: '123',
displayName: 'Admin',
})
expect(result.success).toBe(false)
})
})
describe('setupStatusSchema', () => {
it('accepts empty object', () => {
const result = setupStatusSchema.safeParse({})
expect(result.success).toBe(true)
})
it('accepts optional email', () => {
const result = setupStatusSchema.safeParse({ email: 'admin@example.com' })
expect(result.success).toBe(true)
})
it('rejects invalid optional email', () => {
const result = setupStatusSchema.safeParse({ email: 'bad' })
expect(result.success).toBe(false)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const setupSchema = z.object({
email: z.string().email().max(255),
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),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(),
smtpHost: z.string().max(255).optional(),
smtpPort: z.string().max(10).optional(),
smtpUser: z.string().max(255).optional(),
smtpPass: z.string().max(255).optional(),
smtpFrom: z.string().max(255).optional(),
smtpSecure: z.enum(['auto', 'true', 'false']).optional(),
appUrl: z.string().max(255).optional(),
})
export const setupStatusSchema = z.object({
email: z.string().email().max(255).optional(),
password: z.string().min(8).optional(),
})
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
},
})
+7
View File
@@ -0,0 +1,7 @@
import { vi } from 'vitest'
vi.stubEnv('JWT_SECRET', 'test-secret-for-unit-tests-only')
vi.stubEnv('NODE_ENV', 'test')
vi.stubEnv('DB_PATH', ':memory:')
vi.stubEnv('CORS_ORIGIN', 'http://localhost:5173')
vi.stubEnv('APP_URL', 'http://localhost:5173')
BIN
View File
Binary file not shown.
+446
View File
@@ -0,0 +1,446 @@
# BrainWave Implementation Plan — Future Expansion Readiness
> **Status**: Phase 1 & Phase 2 Complete (Foundation + Consolidation). All TypeScript compilation verified.
> **Date**: 2026-04-26
---
## Executive Summary
This plan documents all changes made and remaining work needed to make BrainWave ready for future expansion. The core principle is **layered architecture**: separating data access, business logic, API routes, and frontend concerns into independent, testable layers.
---
## ✅ Completed (Phase 1 — Foundation + Phase 2 — Consolidation)
### 1. Backend: Repository Layer
**Problem**: Routes had direct SQL/DB access mixed with business logic.
**Solution**: Created pure data access modules:
| Repository | File | Responsibility |
| -------------- | ------------------------------------------ | ------------------------------- |
| `userRepo` | `backend/src/repositories/userRepo.ts` | Users CRUD, counting, searching |
| `examRepo` | `backend/src/repositories/examRepo.ts` | Exams CRUD, listing by user |
| `resultRepo` | `backend/src/repositories/resultRepo.ts` | Results CRUD, statistics |
| `settingsRepo` | `backend/src/repositories/settingsRepo.ts` | Key-value settings |
**Why**: Lets you swap SQLite for PostgreSQL later by only touching 4 files. No route or service changes needed.
### 2. Backend: Zod Validation + Middleware
**Problem**: No input validation. Manual type checking in every route.
**Solution**: Created schema-per-domain:
- `authSchemas.ts` — register, login, profile, password, email verification, reset
- `examSchemas.ts` — save, update, id param
- `resultSchemas.ts` — submit, id param
- `adminSchemas.ts` — role update, settings, user list queries
- `setupSchemas.ts` — wizard setup
**Middleware**: `validateBody()`, `validateParams()`, `validateQuery()` — generic wrappers that parse with Zod and attach `req.parsedBody`/`req.parsedQuery`.
**Why**: Defensive coding. Prevents injection, malformed data, and gives typed request bodies.
### 3. Backend: Centralized Error Handling
**Problem**: try/catch in every async route handler, inconsistent error responses.
**Solution**:
- `AppError` class with status codes
- `asyncHandler()` wrapper — catches async errors, forwards to error handler
- `errorHandler` middleware — consistent JSON error format
**Why**: No more try/catch boilerplate. All errors flow through one path.
### 4. Backend: Service Layer (Auth)
**Problem**: Auth business logic (registration, login, email flow) was inline in routes.
**Solution**: `backend/src/services/authService.ts` — pure business logic, no HTTP concerns.
**Why**: Business rules can be unit tested without spinning up Express. Enables sharing logic between REST and future GraphQL/WebSocket APIs.
### 5. Backend: Route Refactoring
All routes now follow the same pattern:
```
middleware → validateInput → asyncHandler(serviceCall) → errorHandler
```
| Route | Status |
| ------------ | -------------------------------------------------------- |
| `auth.ts` | ✅ Full refactor (services + validation + rate limiting) |
| `exams.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `results.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `admin.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `setup.ts` | ✅ Refactored (repo + validation + asyncHandler) |
### 6. Backend: Security Hardening
- **Rate limiting** on auth endpoints (10 req/15min window)
- **JWT secret validation** — app exits with clear error if `JWT_SECRET` missing
- **Request logging** — `METHOD PATH STATUS - DURATIONms`
### 7. Frontend: API Abstraction Layer
**Problem**: Raw `apiFetch()` calls scattered across components. Hard to change endpoints or add caching.
**Solution**: Domain-specific API modules:
| Module | File | Covers |
| ----------- | ------------------------------- | ---------------------------------- |
| `authApi` | `frontend/src/api/authApi.ts` | Login, register, profile, password |
| `examApi` | `frontend/src/api/examApi.ts` | CRUD for exams |
| `resultApi` | `frontend/src/api/resultApi.ts` | List, get, submit, delete results |
| `adminApi` | `frontend/src/api/adminApi.ts` | Users, stats, settings |
| `setupApi` | `frontend/src/api/setupApi.ts` | Setup wizard |
**Updated components**: ALL components now import from API modules:
- `AuthContext` — uses `authApi` + `setupApi`
- `MyExams` — uses `examApi`
- `MyResults` — uses `resultApi`
- `ExamEditor` — uses `examApi`
- `Settings` — uses `authApi`
- `AdminDashboard` — uses `adminApi`
- `SetupWizard` — uses `setupApi`
- `ExamRunner` — uses `resultApi`
- `Workspace` — uses `authApi`
Zero `apiFetch()` calls remain in any `.tsx` file.
**Why**: One place to add request deduplication, caching, or optimistic updates. Also makes mocking for tests trivial.
### 8. Frontend: React Error Boundaries
**Problem**: Any rendering error crashes the entire app (white screen).
**Solution**: `frontend/src/components/ErrorBoundary.tsx` — class component with fallback UI showing error message and reload button.
**Integration**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
**Why**: Isolates crashes to the component that failed, not the whole SPA.
---
## ✅ Completed (Phase 2 — Consolidation)
### Backend
#### A. Database Migration System
**Priority**: High (blocks schema evolution)
**Current**: Schema is created inline in `database.ts` on startup. No version tracking.
**Needed**:
```
backend/src/db/migrations/
001_initial.sql
002_add_exam_tags.sql
003_add_user_preferences.sql
```
With a `schema_migrations` table tracking applied versions. Run on startup, ordered by filename.
**Why**: Required before any schema change. Without it, adding columns = production downtime or manual ALTER TABLE.
#### B. Database Connection Pooling / Migration to PostgreSQL
**Priority**: Medium
**Current**: SQLite with `better-sqlite3`. Single connection, file-based.
**Why SQLite is fine for now**: Single-node deployment, no concurrent writes expected.
**When to migrate**:
- Multi-instance deployments
- Concurrent writes (multiple admins)
- Need for backups, replication, or managed hosting
**Path**: Abstract `getDb()` behind an interface. Swap implementation.
#### C. Email Delivery Reliability
**Priority**: Medium
**Current**: Nodemailer with SMTP. No queue, no retry.
**Needed**:
- Abstract email behind an interface (`IEmailService`)
- Implementations: `SmtpEmailService`, `ConsoleEmailService` (dev), `SendGridEmailService`
- Add retry with exponential backoff
- Consider a job queue (Bull + Redis) for high volume
#### D. Comprehensive API Testing
**Priority**: High
**Current**: Vitest installed but no tests.
**Needed**:
```
backend/src/tests/
auth.test.ts — register, login, me, change password
exams.test.ts — CRUD
results.test.ts — submit, list, delete
admin.test.ts — user management, stats
```
Use `supertest` for HTTP-level tests, in-memory SQLite for isolation.
#### E. Environment Configuration Management ✅
**Created**: `backend/src/config.ts`
Centralized environment variable management with:
- `env()` helper for optional vars with defaults
- `env.required()` helper for mandatory vars (throws with clear error)
- Type-safe exports: `JWT_SECRET`, `PORT`, `DB_PATH`, `SMTP_HOST`, `SMTP_PORT`, `EMAIL_FROM`, `FRONTEND_URL`, `NODE_ENV`
- `tokens.ts` updated to use `config.JWT_SECRET` instead of `process.env`
This provides fail-fast validation at startup and a single source of truth for all environment configuration.
### Frontend
#### F. Remaining Component API Migration ✅
**Priority**: High
All components migrated to API modules. Zero raw `apiFetch()` calls remain in `.tsx` files:
| Component | API Module Used |
| -------------------- | --------------- |
| `Settings.tsx` | `authApi` |
| `AdminDashboard.tsx` | `adminApi` |
| `SetupWizard.tsx` | `setupApi` |
| `ExamRunner.tsx` | `resultApi` |
| `Workspace.tsx` | `authApi` |
#### G. Extract Exam/Result State from App.tsx
**Priority**: High — NOT completed in Phase 2
**Current**: `App.tsx` holds `exam` and `history` in local state, passed via props and `navigate(state)`.
**Needed**: Dedicated contexts:
```
frontend/src/contexts/
ExamContext.tsx — current exam, load, unload
HistoryContext.tsx — result history, review data
```
This decouples state from routing and enables features like:
- Resume interrupted exam
- Background auto-save
- Offline mode (PWA)
#### H. React Error Boundary Integration ✅
**Priority**: Medium
**Created**: `frontend/src/components/ErrorBoundary.tsx`
**Integrated**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
Consider also wrapping specific high-risk sections (exam editor, admin dashboard) individually for finer-grained isolation.
#### I. Frontend Testing
**Priority**: Medium — NOT completed
**Needed**:
```
frontend/src/tests/
api/ — mock API tests
components/ — React Testing Library
utils/ — unit tests for validation, slug, etc.
```
Start with API module tests (mock fetch) and utility function tests.
### DevOps / Infrastructure
#### J. Health Check Endpoint Enhancement
**Priority**: Medium — NOT completed
**Current**: `/api/health` returns `{ status: 'ok' }`
**Needed**:
```json
{
"status": "healthy",
"timestamp": 1234567890,
"version": "1.2.3",
"checks": {
"database": "connected",
"email": "configured",
"disk": "ok"
}
}
```
#### K. Docker Production Optimization
**Priority**: Medium — NOT completed
**Current**: Single Dockerfile, no multi-stage build.
**Needed**: Multi-stage build:
1. `builder` — install deps, build frontend, compile backend
2. `production` — copy only `dist/`, `node_modules` (prod), runtime files
This reduces image size from ~500MB to ~150MB.
#### L. Backup Strategy
**Priority**: High for production — NOT completed
**Current**: SQLite file on disk. No automated backups.
**Needed**:
- Scheduled `sqlite3 .backup` or `VACUUM INTO`
- Upload to S3-compatible storage
- Retention policy (7 daily, 4 weekly, 12 monthly)
- Documented restore procedure
---
## Architecture Decisions
### Why Repository Pattern?
| Without | With |
| ---------------------------- | -------------------------------- |
| SQL in routes | Pure JS functions |
| Can't test routes without DB | Mock repos for unit tests |
| Swapping DB = rewrite routes | Swap 4 files, zero route changes |
### Why Zod over manual validation?
| Manual | Zod |
| --------------------------- | ---------------------------------- |
| `if (!email.includes('@'))` | `email: z.string().email()` |
| Type narrowing manually | `infer<typeof schema>` gives types |
| Easy to miss edge cases | Battle-tested validators |
| No documentation | Schema IS documentation |
### Why API modules on frontend?
| Raw fetch | API modules |
| ------------------------ | -------------------------------- |
| URL strings everywhere | Single source of truth |
| Type `any` for responses | Typed interfaces |
| Can't mock easily | Export functions, mock in tests |
| No caching layer | One place to add SWR/React Query |
---
## Migration Path for New Features
When adding a new feature (e.g., "Exam Tags"):
1. **Schema**: Add `tags` table migration (`003_add_tags.sql`)
2. **Repository**: Add `tagRepo.ts` with `createTag()`, `listTags()`, `attachTagToExam()`
3. **Validation**: Add `tagSchema` in `validators/tagSchemas.ts`
4. **Routes**: Add `routes/tags.ts` using repo + validation + asyncHandler
5. **API Module**: Add `frontend/src/api/tagApi.ts`
6. **UI**: Build components, import from `tagApi`
Each layer is independently testable and replaceable.
---
## Files Created/Modified in Phase 1
### New Files (Backend)
```
backend/src/repositories/userRepo.ts
backend/src/repositories/examRepo.ts
backend/src/repositories/resultRepo.ts
backend/src/repositories/settingsRepo.ts
backend/src/validators/authSchemas.ts
backend/src/validators/examSchemas.ts
backend/src/validators/resultSchemas.ts
backend/src/validators/adminSchemas.ts
backend/src/validators/setupSchemas.ts
backend/src/middleware/validate.ts
backend/src/middleware/error.ts
backend/src/utils/asyncHandler.ts
backend/src/services/authService.ts
```
### Modified Files (Backend)
```
backend/src/index.ts — env validation, request logging, routes
backend/src/routes/auth.ts — services + validation + rate limiting
backend/src/routes/exams.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/results.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/admin.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/setup.ts — repo + validation + asyncHandler + AppError fixes
backend/src/middleware/auth.ts — AuthenticatedRequest interface
backend/src/utils/tokens.ts — uses config.JWT_SECRET
backend/src/services/authService.ts — AppError argument order fixed
```
### New Files (Frontend)
```
frontend/src/api/index.ts
frontend/src/api/authApi.ts
frontend/src/api/examApi.ts
frontend/src/api/resultApi.ts
frontend/src/api/adminApi.ts
frontend/src/api/setupApi.ts
frontend/src/components/ErrorBoundary.tsx
```
### Modified Files (Frontend)
```
frontend/src/contexts/AuthContext.tsx — uses authApi + setupApi
frontend/src/components/MyExams.tsx — uses examApi
frontend/src/components/MyResults.tsx — uses resultApi
frontend/src/components/ExamEditor.tsx — uses examApi
frontend/src/components/Settings.tsx — uses authApi
frontend/src/components/AdminDashboard.tsx — uses adminApi
frontend/src/components/SetupWizard.tsx — uses setupApi
frontend/src/components/ExamRunner.tsx — uses resultApi
frontend/src/components/Workspace.tsx — uses authApi
frontend/src/App.tsx — integrates ErrorBoundary around Routes
```
---
## Conclusion
Phase 1 + Phase 2 establishes a solid, production-ready foundation:
- ✅ Clean separation of concerns
- ✅ Type-safe validation at boundaries
- ✅ Consistent error handling
- ✅ Frontend API abstraction (100% migrated)
- ✅ Error boundaries for resilience
- ✅ Environment configuration management
- ✅ Both frontend and backend compile with zero TypeScript errors
The remaining items (A–D, G, I–L) are well-scoped, incremental improvements that can be tackled one at a time without blocking each other. The architecture now supports horizontal scaling, database migration, and team parallelization.
+22 -9
View File
@@ -1,14 +1,14 @@
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import globals from "globals";
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(["dist"]),
{ {
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
@@ -19,5 +19,18 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
"react-refresh/only-export-components": "warn",
"no-control-regex": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"prefer-const": "warn",
},
}, },
]) ]);
+88
View File
@@ -9,9 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.14.2" "react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
@@ -228,6 +231,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2287,6 +2299,47 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3089,6 +3142,32 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.14.2", "version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
@@ -3612,6 +3691,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -13,9 +13,12 @@
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.14.2" "react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
+219 -133
View File
@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { import {
useNavigate, useNavigate,
useLocation, useLocation,
@@ -6,7 +8,7 @@ import {
Route, Route,
Navigate, Navigate,
} from "react-router-dom"; } from "react-router-dom";
import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types"; import type { Exam, ExamState, HistoryEntry } from "./types";
import { gradeQuestion } from "./utils/grading"; import { gradeQuestion } from "./utils/grading";
import { import {
loadState, loadState,
@@ -16,23 +18,23 @@ import {
appendHistory, appendHistory,
clearHistory, clearHistory,
} from "./utils/storage"; } from "./utils/storage";
import { apiFetch, isAuthenticated } from "./utils/api"; import { isAuthenticated } from "./utils/api";
import { slugify } from "./utils/slug"; import * as resultApi from "./api/resultApi";
import * as examApi from "./api/examApi";
import { getExamUrlId } from "./utils/slug";
import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { TweaksProvider, useTweaks } from "./contexts/TweaksContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Landing from "./components/Landing"; import Landing from "./components/Landing";
import ExamRoute from "./components/ExamRoute"; import ExamRoute from "./components/ExamRoute";
import Results from "./components/Results"; import Results from "./components/Results";
import TweaksPanel from "./components/TweaksPanel";
import AuthModal from "./components/AuthModal"; 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";
const DEFAULT_TWEAKS: Tweaks = { import Workspace from "./components/Workspace";
theme: "light", import ExamEditor from "./components/ExamEditor";
fontSize: "medium",
feedbackMode: "auto",
};
const withNotes = (state: ExamState): ExamState => ({ const withNotes = (state: ExamState): ExamState => ({
...state, ...state,
@@ -40,38 +42,26 @@ const withNotes = (state: ExamState): ExamState => ({
}); });
function AppInner() { function AppInner() {
const { user, loading: authLoading, logout } = useAuth(); const { t } = useTranslation();
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
const { tweaks } = useTweaks();
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);
const [examState, setExamState] = useState<ExamState | null>(null); const [examState, setExamState] = useState<ExamState | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory()); const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS);
const [tweaksOpen, setTweaksOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const savedExamIdRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && user && isAuthenticated()) { if (!authLoading && user && isAuthenticated()) {
apiFetch<{ resultApi.listResults()
results: Array<{
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;
}>;
}>("/api/results")
.then((data) => { .then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({ const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id), id: String(r.id),
title: r.title, title: r.title,
completedAt: r.completed_at * 1000, completedAt: r.completed_at,
elapsedMs: r.elapsed_ms, elapsedMs: r.elapsed_ms,
gradedCount: r.graded_count, gradedCount: r.graded_count,
correctCount: r.correct_count, correctCount: r.correct_count,
@@ -97,65 +87,90 @@ function AppInner() {
} }
}, [user, authLoading]); }, [user, authLoading]);
useEffect(() => {
document.documentElement.dataset["theme"] = tweaks.theme;
const sizeMap: Record<string, string> = {
small: "14px",
medium: "15px",
large: "17px",
};
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px";
}, [tweaks]);
useEffect(() => { useEffect(() => {
if (pathname.startsWith("/exam/") && exam && examState) if (pathname.startsWith("/exam/") && exam && examState)
saveState({ exam, state: examState }); saveState({ exam, state: examState });
}, [exam, examState, pathname]); }, [exam, examState, pathname]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => // Bridge: MyResults stores review data in localStorage; load it on /review/:slug
setTweaks((t) => ({ ...t, [key]: value })); 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 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);
setExamState(withNotes(state)); setExamState(withNotes(state));
}; };
const loadExam = (data: Exam) => { const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null, isRedo = false) => {
if (saveToWorkspace && user && isAuthenticated()) {
examApi.createExam(data)
.then((res) => {
savedExamIdRef.current = res.id;
})
.catch(() => {
// silent fail — exam not saved but user can still take it
});
}
if (examId != null) {
savedExamIdRef.current = examId;
}
const fm = const fm =
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,
isRedo,
});
}); });
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}`,
); );
} }
}; };
@@ -181,37 +196,65 @@ function AppInner() {
state: { ...examState, completedAt }, state: { ...examState, completedAt },
}; };
if (user && isAuthenticated()) { // Skip saving to history / API when this is a redo session
void apiFetch("/api/results", { if (!examState.isRedo) {
method: "POST", if (user && isAuthenticated()) {
body: JSON.stringify({ void resultApi.submitResult({
exam_id: savedExamIdRef.current,
title: entry.title, title: entry.title,
scorePoints: entry.scorePoints, score_points: entry.scorePoints,
totalPoints: entry.totalPoints, total_points: entry.totalPoints,
correctCount: entry.correctCount, correct_count: entry.correctCount,
gradedCount: entry.gradedCount, graded_count: entry.gradedCount,
passingScore: entry.passingScore, passing_score: entry.passingScore,
elapsedMs: entry.elapsedMs, elapsed_ms: entry.elapsedMs,
notes: entry.state.notes, state: entry.state as unknown,
state: entry.state, exam: entry.exam as unknown,
exam: entry.exam, }).then((data) => {
}), sessionStorage.setItem('brainwave.pendingResultId', String(data.id));
}).catch(() => { }).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);
savedExamIdRef.current = null;
sessionStorage.removeItem('brainwave.pendingResultId');
navigate("/"); navigate("/");
}; };
@@ -222,47 +265,86 @@ function AppInner() {
const reviewAttempt = (entry: HistoryEntry) => { const reviewAttempt = (entry: HistoryEntry) => {
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId; const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
if (remoteId && user && isAuthenticated()) { if (remoteId && user && isAuthenticated()) {
void apiFetch<{ void resultApi.getResult(remoteId)
exam: Exam;
state: ExamState;
title: string;
scorePoints: number;
totalPoints: number;
correctCount: number;
gradedCount: number;
passingScore: number;
elapsedMs: number;
completedAt: number;
}>(`/api/results/${remoteId}`)
.then((data) => { .then((data) => {
setExam(data.exam); flushSync(() => {
setExamState(withNotes(data.state)); setExam(data.exam as unknown as Exam);
navigate(`/review/${slugify(entry.title)}`); setExamState(withNotes(data.state as unknown as ExamState));
});
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)}`);
}
};
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("Clear all past attempts?")) { if (confirm(t('landing.clearHistoryConfirm'))) {
clearHistory(); clearHistory();
setHistory([]); setHistory([]);
} }
}; };
const [pendingResultId, setPendingResultId] = useState<number | null>(null);
useEffect(() => {
if (pathname.startsWith('/results') && !exam && !examState && user && isAuthenticated()) {
const id = sessionStorage.getItem('brainwave.pendingResultId');
if (!id) {
navigate('/', { replace: true });
return;
}
if (Number(id) !== pendingResultId) {
setPendingResultId(Number(id));
resultApi.getResult(Number(id))
.then((data) => {
setExam(data.exam as unknown as Exam);
setExamState(withNotes(data.state as unknown as ExamState));
})
.catch(() => {
navigate('/', { replace: true });
});
}
}
}, [pathname, exam, examState, user, navigate, pendingResultId]);
const handleAuthFlowDone = () => { const handleAuthFlowDone = () => {
navigate("/"); navigate("/");
setAuthModalOpen(true); setAuthModalOpen(true);
}; };
if (needsSetup) {
return (
<SetupWizard onComplete={() => void refreshUser()} />
);
}
if (pathname === "/verify") { if (pathname === "/verify") {
return ( return (
<div className="shell"> <div className="shell">
@@ -299,7 +381,7 @@ function AppInner() {
<button <button
className="brand-name" className="brand-name"
onClick={exit} onClick={exit}
aria-label="Home" aria-label={t('common.backToHome')}
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",
@@ -313,7 +395,7 @@ function AppInner() {
BrainWave BrainWave
</button> </button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}> <span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice / {t('common.appTagline')}
</span> </span>
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
@@ -323,25 +405,16 @@ function AppInner() {
{exam.title} {exam.title}
</span> </span>
<button className="btn btn-ghost btn-sm" onClick={exit}> <button className="btn btn-ghost btn-sm" onClick={exit}>
Exit {t('exam.exit')}
</button> </button>
</> </>
)} )}
<button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{!authLoading && !user && ( {!authLoading && !user && (
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)} onClick={() => setAuthModalOpen(true)}
> >
Sign In {t('auth.signIn')}
</button> </button>
)} )}
{!authLoading && user && ( {!authLoading && user && (
@@ -349,7 +422,7 @@ function AppInner() {
<button <button
className="user-avatar" className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)} onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu" aria-label={t('auth.userMenu')}
> >
{user.displayName.charAt(0).toUpperCase()} {user.displayName.charAt(0).toUpperCase()}
</button> </button>
@@ -360,6 +433,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");
}}
>
{t('auth.myWorkspace')}
</button>
{user.role === "admin" && ( {user.role === "admin" && (
<button <button
className="user-menu-item" className="user-menu-item"
@@ -368,7 +450,7 @@ function AppInner() {
navigate("/admin"); navigate("/admin");
}} }}
> >
Admin Dashboard {t('auth.adminDashboard')}
</button> </button>
)} )}
<button <button
@@ -378,7 +460,7 @@ function AppInner() {
logout(); logout();
}} }}
> >
Sign Out {t('auth.signOut')}
</button> </button>
</div> </div>
)} )}
@@ -397,11 +479,12 @@ function AppInner() {
onResume={resume} onResume={resume}
onClearHistory={clearAllHistory} onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt} onReviewAttempt={reviewAttempt}
onRetakeAttempt={retakeAttempt}
/> />
} }
/> />
<Route <Route
path="/exam/:slug/q:n" path="/exam/:slug/:n"
element={ element={
<ExamRoute <ExamRoute
exam={exam} exam={exam}
@@ -422,8 +505,11 @@ function AppInner() {
state={examState} state={examState}
onRestart={restart} onRestart={restart}
onExit={exit} onExit={exit}
onRedoFailed={redoFailed}
reviewOnly={false} reviewOnly={false}
/> />
) : user && isAuthenticated() ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : ( ) : (
<Navigate to="/" replace /> <Navigate to="/" replace />
) )
@@ -455,16 +541,12 @@ function AppInner() {
) )
} }
/> />
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
<Route path="/exam-editor/:id" element={<ExamEditor />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
{tweaksOpen && (
<TweaksPanel
tweaks={tweaks}
update={updateTweak}
onClose={() => setTweaksOpen(false)}
/>
)}
{authModalOpen && ( {authModalOpen && (
<AuthModal <AuthModal
onClose={() => setAuthModalOpen(false)} onClose={() => setAuthModalOpen(false)}
@@ -477,8 +559,12 @@ function AppInner() {
export default function App() { export default function App() {
return ( return (
<AuthProvider> <ErrorBoundary>
<AppInner /> <AuthProvider>
</AuthProvider> <TweaksProvider>
<AppInner />
</TweaksProvider>
</AuthProvider>
</ErrorBoundary>
); );
} }
+92
View File
@@ -0,0 +1,92 @@
import { apiFetch } from '../utils/api.js'
export interface AdminUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
created_at: number
results_count: number
exams_count: number
}
export interface DashboardStats {
totalUsers: number
totalExams: number
totalResults: number
averageScore: number
recentResults: {
id: number
title: string
score_points: number
total_points: number
completed_at: number
display_name: string
email: string
}[]
}
export interface SiteSettings {
site_name: string
max_upload_mb: string
allow_guest_mode: string
registration_enabled: string
default_theme: string
maintenance_mode: string
maintenance_message: string
smtp_host: string
smtp_port: string
smtp_user: string
smtp_pass: string
smtp_from: string
smtp_secure: string
app_url: string
}
export function getStats(): Promise<DashboardStats> {
return apiFetch('/api/admin/stats')
}
export function getSettings(): Promise<{ settings: SiteSettings }> {
return apiFetch('/api/admin/settings')
}
export function updateSettings(settings: SiteSettings): Promise<void> {
return apiFetch('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({ settings }),
})
}
export function listUsers(search?: string): Promise<{ users: AdminUser[]; total: number }> {
const q = search ? `?search=${encodeURIComponent(search)}` : ''
return apiFetch(`/api/admin/users${q}`)
}
export function changeRole(userId: number, role: 'user' | 'admin'): Promise<void> {
return apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
}
export function deleteUser(userId: number): Promise<void> {
return apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
}
export interface SmtpConfig {
host: string
port: string
user: string
pass: string
from: string
secure: string
}
export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise<{ message: string }> {
return apiFetch('/api/admin/test-email', {
method: 'POST',
body: JSON.stringify({ to, smtp }),
})
}
+54
View File
@@ -0,0 +1,54 @@
import { apiFetch } from '../utils/api.js'
export interface User {
id: number
email: string
displayName: string
role: string
emailVerified?: boolean
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
}
export interface AuthResponse {
token: string
user: User
}
export function register(data: { email: string; password: string; displayName: string }): Promise<AuthResponse> {
return apiFetch('/api/auth/register', { method: 'POST', body: JSON.stringify(data) })
}
export function login(data: { email: string; password: string }): Promise<AuthResponse> {
return apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(data) })
}
export function getMe(): Promise<User> {
return apiFetch('/api/auth/me')
}
export function updateProfile(data: { displayName: string }): Promise<User> {
return apiFetch('/api/auth/me', { method: 'PUT', body: JSON.stringify(data) })
}
export function changePassword(data: { currentPassword: string; newPassword: string }): Promise<{ success: boolean }> {
return apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify(data) })
}
export function verifyEmailToken(token: string): Promise<{ message: string }> {
return apiFetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
}
export function forgotPassword(email?: string): Promise<{ message: string }> {
return apiFetch('/api/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) })
}
export function resetPassword(data: { token: string; newPassword: string }): Promise<{ message: string }> {
return apiFetch('/api/auth/reset-password', { method: 'POST', body: JSON.stringify(data) })
}
export function deleteAccount(): Promise<{ success: boolean }> {
return apiFetch('/api/auth/me', { method: 'DELETE' })
}
+31
View File
@@ -0,0 +1,31 @@
import { apiFetch } from '../utils/api.js'
import type { Exam } from '../types'
export interface ExamListItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
}
export function listExams(): Promise<ExamListItem[]> {
return apiFetch('/api/exams')
}
export function getExam(id: number): Promise<Exam> {
return apiFetch(`/api/exams/${id}`)
}
export function createExam(exam: Exam): Promise<{ id: number; title: string }> {
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam }) })
}
export function updateExam(id: number, exam: Exam): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam }) })
}
export function deleteExam(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export shared utilities
export { apiFetch, setToken, clearToken, isAuthenticated, ApiError } from '../utils/api.js'
+43
View File
@@ -0,0 +1,43 @@
import { apiFetch } from '../utils/api.js'
export interface ResultListItem {
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 interface SubmitResultData {
exam_id?: number | null
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score?: number
elapsed_ms: number
state: unknown
exam: unknown
}
export function listResults(): Promise<{ results: ResultListItem[]; total: number }> {
return apiFetch('/api/results')
}
export function getResult(id: number): Promise<ResultListItem & { state: unknown; exam: unknown }> {
return apiFetch(`/api/results/${id}`)
}
export function submitResult(data: SubmitResultData): Promise<{ id: number }> {
return apiFetch('/api/results', { method: 'POST', body: JSON.stringify(data) })
}
export function deleteResult(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/results/${id}`, { method: 'DELETE' })
}
+38
View File
@@ -0,0 +1,38 @@
import { apiFetch } from '../utils/api.js'
export interface SetupStatus {
completed: boolean
siteTitle?: string
}
export function getSetupStatus(): Promise<SetupStatus> {
return apiFetch('/api/setup/status')
}
export interface SetupPayload {
email: string
password: string
displayName: string
smtpHost?: string
smtpPort?: string
smtpUser?: string
smtpPass?: string
smtpFrom?: string
smtpSecure?: string
appUrl?: string
}
export interface SetupResponse {
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}
export function completeSetup(data: SetupPayload): Promise<SetupResponse> {
return apiFetch('/api/setup', { method: 'POST', body: JSON.stringify(data) })
}
+489 -200
View File
@@ -1,236 +1,254 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { apiFetch } from '../utils/api' import { useTranslation } from "react-i18next";
import * as adminApi from "../api/adminApi";
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
interface SiteSettings { type AdminTab = "overview" | "settings" | "users";
site_name: string
max_upload_mb: string
allow_guest_mode: string
registration_enabled: string
default_theme: string
maintenance_mode: string
maintenance_message: string
}
interface DashboardStats {
totalUsers: number
totalExams: number
totalResults: number
averageScore: number
recentResults: {
id: number
title: string
score_points: number
total_points: number
completed_at: number
display_name: string
email: string
}[]
}
interface AdminUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
created_at: number
results_count: number
exams_count: number
}
type AdminTab = 'overview' | 'settings' | 'users'
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 { t } = useTranslation();
const [stats, setStats] = useState<DashboardStats | null>(null) const [tab, setTab] = useState<AdminTab>("overview");
const [settings, setSettings] = useState<SiteSettings | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null);
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null) const [settings, setSettings] = useState<SiteSettings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]) const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
const [usersTotal, setUsersTotal] = useState(0) const [users, setUsers] = useState<AdminUser[]>([]);
const [userSearch, setUserSearch] = useState('') const [usersTotal, setUsersTotal] = useState(0);
const [saving, setSaving] = useState(false) const [userSearch, setUserSearch] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null) const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null) const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailMsg, setTestEmailMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
try { try {
const data = await apiFetch<DashboardStats>('/api/admin/stats') const data = await adminApi.getStats();
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 adminApi.getSettings();
setSettings(data.settings) setSettings(data.settings);
setSettingsDraft(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 data = await adminApi.listUsers(search);
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`) setUsers(data.users);
setUsers(data.users) setUsersTotal(data.total);
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 adminApi.updateSettings(settingsDraft);
method: 'PUT', setSettings(settingsDraft);
body: JSON.stringify({ settings: settingsDraft }), setSaveMsg(t("admin.settingsSaved"));
}) setTimeout(() => setSaveMsg(null), 3000);
setSettings(settingsDraft)
setSaveMsg('Settings saved successfully')
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 handleChangeRole = async (userId: number, newRole: "user" | "admin") => {
try { try {
await apiFetch(`/api/admin/users/${userId}/role`, { await adminApi.changeRole(userId, newRole);
method: 'PATCH', await loadUsers(userSearch);
body: JSON.stringify({ role: newRole }),
})
await loadUsers(userSearch)
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const deleteUser = async (userId: number) => { const handleDeleteUser = async (userId: number) => {
try { try {
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' }) await adminApi.deleteUser(userId);
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 {
await adminApi.sendTestEmail(testEmailTo || undefined, {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
});
setTestEmailMsg({
ok: true,
text: t("admin.testEmailSent"),
});
} 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(undefined, {
} month: "short",
day: "numeric",
year: "numeric",
});
};
const tabLabel = (tName: AdminTab) => {
switch (tName) {
case "overview":
return t("admin.overview");
case "settings":
return t("admin.settings");
case "users":
return t("admin.users");
}
};
return ( return (
<div className="admin"> <div className="admin">
<div className="admin-header"> <div className="admin-header">
<div className="admin-header-left"> <div className="admin-header-left">
<div className="admin-eyebrow">Administration</div> <div className="admin-eyebrow">{t("admin.administration")}</div>
<h1 className="admin-title">Dashboard</h1> <h1 className="admin-title">{t("admin.dashboard")}</h1>
</div> </div>
<button className="btn btn-ghost btn-sm" onClick={onExit}> <button className="btn btn-ghost btn-sm" onClick={onExit}>
← Back to site {t("admin.backToSite")}
</button> </button>
</div> </div>
{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((tName) => (
<button <button
key={t} key={tName}
className={`admin-tab${tab === t ? ' active' : ''}`} className={`admin-tab${tab === tName ? " active" : ""}`}
onClick={() => setTab(t)} onClick={() => setTab(tName)}
> >
<span className="admin-tab-icon"> <span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'} {tName === "overview" ? "◈" : tName === "settings" ? "⚙" : "⊕"}
</span> </span>
{t.charAt(0).toUpperCase() + t.slice(1)} {tabLabel(tName)}
</button> </button>
))} ))}
</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">
<div className="admin-stat-label">Total Users</div> {stats?.totalUsers ?? "—"}
</div>
<div className="admin-stat-label">{t("admin.totalUsers")}</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">
<div className="admin-stat-label">Saved Exams</div> {stats?.totalExams ?? "—"}
</div>
<div className="admin-stat-label">{t("admin.savedExams")}</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">
<div className="admin-stat-label">Exams Taken</div> {stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">{t("admin.examsTaken")}</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<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">{t("admin.avgScore")}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Recent Activity</h3> <h3>{t("admin.recentActivity")}</h3>
</div> </div>
<div className="admin-panel-body"> <div className="admin-panel-body">
{!stats?.recentResults?.length && ( {!stats?.recentResults?.length && (
<div className="admin-empty">No recent activity yet</div> <div className="admin-empty">{t("admin.noRecentActivity")}</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 +256,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 +280,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>
@@ -262,25 +288,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>General</h3> <h3>{t("admin.general")}</h3>
</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
<span className="admin-setting-title">Site Name</span> className="admin-setting-label"
<span className="admin-setting-desc">The name displayed throughout the application</span> htmlFor="setting-site-name"
>
<span className="admin-setting-title">{t("admin.siteName")}</span>
<span className="admin-setting-desc">
{t("admin.siteNameDesc")}
</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">
{t("admin.maxUpload")}
</span>
<span className="admin-setting-desc">
{t("admin.maxUploadDesc")}
</span>
</label> </label>
<input <input
id="setting-max-upload" id="setting-max-upload"
@@ -289,23 +327,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
<span className="admin-setting-title">Default Theme</span> className="admin-setting-label"
<span className="admin-setting-desc">Default theme for new visitors</span> htmlFor="setting-default-theme"
>
<span className="admin-setting-title">{t("admin.defaultTheme")}</span>
<span className="admin-setting-desc">
{t("admin.defaultThemeDesc")}
</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> >
☀ {t("tweaks.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> >
☾ {t("tweaks.dark")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -313,33 +360,53 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Access Control</h3> <h3>{t("admin.accessControl")}</h3>
</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"> <label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span> <span className="admin-setting-title">{t("admin.allowGuestMode")}</span>
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span> <span className="admin-setting-desc">
{t("admin.allowGuestModeDesc")}
</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> {t("admin.registrationEnabled")}
</span>
<span className="admin-setting-desc">
{t("admin.registrationEnabledDesc")}
</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>
@@ -349,34 +416,229 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Maintenance</h3> <h3>{t("admin.emailSmtp")}</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">{t("admin.appUrl")}</span>
<span className="admin-setting-desc">
{t("admin.appUrlDesc")}
</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">{t("admin.smtpHost")}</span>
<span className="admin-setting-desc">
{t("admin.smtpHostDesc")}
</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">{t("admin.smtpPort")}</span>
<span className="admin-setting-desc">
{t("admin.smtpPortDesc")}
</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">{t("admin.username")}</span>
<span className="admin-setting-desc">
{t("admin.usernameDesc")}
</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">{t("admin.password")}</span>
<span className="admin-setting-desc">
{t("admin.passwordDesc")}
</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">{t("admin.fromAddress")}</span>
<span className="admin-setting-desc">
{t("admin.fromAddressDesc")}
</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">{t("admin.tlsMode")}</span>
<span className="admin-setting-desc">
{t("admin.tlsModeDesc")}
</span>
</label>
<div className="admin-setting-toggle-group">
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
{t("admin.auto")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
{t("admin.forceTls")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
{t("admin.off")}
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">{t("admin.sendTestEmail")}</span>
<span className="admin-setting-desc">
{t("admin.sendTestEmailDesc")}
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder={t("admin.sendTestEmailDesc")}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
/>
<button
className="btn btn-sm"
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? t("admin.sending") : t("admin.sendTest")}
</button>
</div>
{testEmailMsg && (
<div
className={`admin-alert ${testEmailMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testEmailMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>{t("admin.maintenance")}</h3>
</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"> <label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span> <span className="admin-setting-title">{t("admin.maintenanceMode")}</span>
<span className="admin-setting-desc">Take the site offline for non-admin users</span> <span className="admin-setting-desc">
{t("admin.maintenanceModeDesc")}
</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">
{t("admin.maintenanceMessage")}
</span>
<span className="admin-setting-desc">
{t("admin.maintenanceMessageDesc")}
</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>
@@ -390,33 +652,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => setSettingsDraft(settings)} onClick={() => setSettingsDraft(settings)}
disabled={!hasChanges} disabled={!hasChanges}
> >
Discard Changes {t("admin.discardChanges")}
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => void saveSettings()} onClick={() => void saveSettings()}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? 'Saving…' : 'Save Settings'} {saving ? t("admin.sending") : t("admin.saveSettings")}
</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">
<input <input
className="input admin-search-input" className="input admin-search-input"
placeholder="Search by name or email…" placeholder={t("admin.searchByNameOrEmail")}
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">
{t("admin.search")}
</button>
</form> </form>
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div> <div className="admin-users-count mono">
{usersTotal} {usersTotal !== 1 ? t("admin.users") : t("admin.user")}
</div>
</div> </div>
<div className="admin-panel"> <div className="admin-panel">
@@ -424,13 +690,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
<th>User</th> <th>{t("admin.user")}</th>
<th>Role</th> <th>{t("admin.role")}</th>
<th>Verified</th> <th>{t("admin.verified")}</th>
<th>Exams</th> <th>{t("admin.exams")}</th>
<th>Results</th> <th>{t("results.points")}</th>
<th>Joined</th> <th>{t("admin.joined")}</th>
<th>Actions</th> <th>{t("admin.actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -442,7 +708,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,31 +721,50 @@ 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 handleChangeRole(
u.id,
e.target.value as "user" | "admin",
)
}
> >
<option value="user">User</option> <option value="user">{t("admin.user")}</option>
<option value="admin">Admin</option> <option value="admin">{t("admin.role")}</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 handleDeleteUser(u.id)}
>
{t("common.confirm")}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeleteConfirm(null)}
>
{t("common.cancel")}
</button>
</div> </div>
) : ( ) : (
<button <button
className="btn btn-ghost btn-sm admin-btn-danger-ghost" className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(u.id)} onClick={() => setDeleteConfirm(u.id)}
title="Delete user" title={t("common.delete")}
> >
✕ ✕
</button> </button>
@@ -488,7 +775,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">
{t("admin.noUsersFound")}
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -498,5 +787,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
)} )}
</div> </div>
) );
} }
+20 -18
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api' import { ApiError } from '../utils/api'
@@ -10,6 +11,7 @@ interface Props {
type Tab = 'login' | 'register' type Tab = 'login' | 'register'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) { export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { t } = useTranslation()
const { login, register } = useAuth() const { login, register } = useAuth()
const [tab, setTab] = useState<Tab>('login') const [tab, setTab] = useState<Tab>('login')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -34,7 +36,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
await login(email, password) await login(email, password)
onClose() onClose()
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed') setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -44,7 +46,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
e.preventDefault() e.preventDefault()
setError(null) setError(null)
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match') setError(t('auth.passwordsDoNotMatch'))
return return
} }
setSubmitting(true) setSubmitting(true)
@@ -52,7 +54,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const message = await register(email, password, displayName) const message = await register(email, password, displayName)
setSuccess(message) setSuccess(message)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Registration failed') setError(err instanceof ApiError ? err.message : t('auth.registrationFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -61,17 +63,17 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
return ( return (
<div className="auth-overlay" onClick={onClose}> <div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}> <div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<button className="auth-close" onClick={onClose} aria-label="Close">×</button> <button className="auth-close" onClick={onClose} aria-label={t('common.close')}>×</button>
<div className="auth-header"> <div className="auth-header">
<div className="brand-mark" /> <div className="brand-mark" />
<h2 className="auth-title"> <h2 className="auth-title">
{tab === 'login' ? 'Welcome back' : 'Create account'} {tab === 'login' ? t('auth.welcomeBack') : t('auth.createAccount')}
</h2> </h2>
<p className="auth-subtitle"> <p className="auth-subtitle">
{tab === 'login' {tab === 'login'
? 'Sign in to access your saved exams and results' ? t('auth.signInSubtitle')
: 'Create an account to save your progress'} : t('auth.registerSubtitle')}
</p> </p>
</div> </div>
@@ -80,13 +82,13 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
className={`auth-tab ${tab === 'login' ? 'active' : ''}`} className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => switchTab('login')} onClick={() => switchTab('login')}
> >
Sign In {t('auth.signIn')}
</button> </button>
<button <button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`} className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => switchTab('register')} onClick={() => switchTab('register')}
> >
Register {t('auth.register')}
</button> </button>
</div> </div>
@@ -96,7 +98,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{!success && tab === 'login' && ( {!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form"> <form onSubmit={(e) => void handleLogin(e)} className="auth-form">
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="login-email">Email</label> <label className="auth-label" htmlFor="login-email">{t('auth.email')}</label>
<input <input
id="login-email" id="login-email"
className="input" className="input"
@@ -109,7 +111,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="login-password">Password</label> <label className="auth-label" htmlFor="login-password">{t('auth.password')}</label>
<input <input
id="login-password" id="login-password"
className="input" className="input"
@@ -122,14 +124,14 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}> <button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign In'} {submitting ? t('auth.signingIn') : t('auth.signIn')}
</button> </button>
<button <button
type="button" type="button"
className="auth-forgot" className="auth-forgot"
onClick={() => { onClose(); onForgotPassword() }} onClick={() => { onClose(); onForgotPassword() }}
> >
Forgot your password? {t('auth.forgotPassword')}
</button> </button>
</form> </form>
)} )}
@@ -137,7 +139,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{!success && tab === 'register' && ( {!success && tab === 'register' && (
<form onSubmit={(e) => void handleRegister(e)} className="auth-form"> <form onSubmit={(e) => void handleRegister(e)} className="auth-form">
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="reg-name">Display Name</label> <label className="auth-label" htmlFor="reg-name">{t('auth.displayName')}</label>
<input <input
id="reg-name" id="reg-name"
className="input" className="input"
@@ -150,7 +152,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="reg-email">Email</label> <label className="auth-label" htmlFor="reg-email">{t('auth.email')}</label>
<input <input
id="reg-email" id="reg-email"
className="input" className="input"
@@ -163,7 +165,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="reg-password">Password</label> <label className="auth-label" htmlFor="reg-password">{t('auth.password')}</label>
<input <input
id="reg-password" id="reg-password"
className="input" className="input"
@@ -177,7 +179,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="reg-confirm">Confirm Password</label> <label className="auth-label" htmlFor="reg-confirm">{t('auth.confirmPassword')}</label>
<input <input
id="reg-confirm" id="reg-confirm"
className="input" className="input"
@@ -191,7 +193,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/> />
</div> </div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}> <button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Creating account…' : 'Create Account'} {submitting ? t('auth.creatingAccount') : t('auth.register')}
</button> </button>
</form> </form>
)} )}
+14 -11
View File
@@ -1,11 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { apiFetch, ApiError } from '../utils/api' import { useTranslation } from 'react-i18next'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props { interface Props {
onDone: () => void onDone: () => void
} }
export default function EmailVerify({ onDone }: Readonly<Props>) { export default function EmailVerify({ onDone }: Readonly<Props>) {
const { t } = useTranslation()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const called = useRef(false) const called = useRef(false)
@@ -19,11 +22,11 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
if (!token) { if (!token) {
setStatus('error') setStatus('error')
setMessage('No verification token found.') setMessage(t('verify.noToken'))
return return
} }
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`) authApi.verifyEmailToken(token)
.then((data) => { .then((data) => {
setStatus('success') setStatus('success')
setMessage(data.message) setMessage(data.message)
@@ -32,9 +35,9 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
}) })
.catch((err) => { .catch((err) => {
setStatus('error') setStatus('error')
setMessage(err instanceof ApiError ? err.message : 'Verification failed') setMessage(err instanceof ApiError ? err.message : t('verify.verificationFailed'))
}) })
}, []) }, [t])
return ( return (
<div className="verify-page"> <div className="verify-page">
@@ -42,27 +45,27 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
{status === 'loading' && ( {status === 'loading' && (
<> <>
<div className="verify-icon">⏳</div> <div className="verify-icon">⏳</div>
<h2 className="verify-title">Verifying your email…</h2> <h2 className="verify-title">{t('verify.verifying')}</h2>
<p className="verify-message">Please wait while we confirm your email address.</p> <p className="verify-message">{t('verify.pleaseWait')}</p>
</> </>
)} )}
{status === 'success' && ( {status === 'success' && (
<> <>
<div className="verify-icon success">✓</div> <div className="verify-icon success">✓</div>
<h2 className="verify-title">Email Verified</h2> <h2 className="verify-title">{t('verify.emailVerified')}</h2>
<p className="verify-message">{message}</p> <p className="verify-message">{message}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}> <button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In → {t('verify.signIn')}
</button> </button>
</> </>
)} )}
{status === 'error' && ( {status === 'error' && (
<> <>
<div className="verify-icon error">✗</div> <div className="verify-icon error">✗</div>
<h2 className="verify-title">Verification Failed</h2> <h2 className="verify-title">{t('verify.verificationFailed')}</h2>
<p className="verify-message">{message}</p> <p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}> <button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home {t('verify.backToHome')}
</button> </button>
</> </>
)} )}
+56
View File
@@ -0,0 +1,56 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
override componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, info)
}
override render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div style={{ padding: 40, textAlign: 'center' }}>
<h2>Something went wrong</h2>
<p style={{ color: '#666' }}>
{this.state.error?.message ?? 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: 16,
padding: '10px 20px',
background: '#1a1a2e',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Reload page
</button>
</div>
)
)
}
return this.props.children
}
}
+767
View File
@@ -0,0 +1,767 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import type { Exam, Question, QuestionType, Choice } from '../types'
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
{ value: 'multiple-choice', labelKey: 'editor.multipleChoice' },
{ value: 'multiple-select', labelKey: 'editor.multipleSelect' },
{ value: 'short-answer', labelKey: 'editor.shortAnswer' },
{ value: 'translation', labelKey: 'editor.translation' },
{ value: 'math-input', labelKey: 'editor.mathInput' },
{ value: 'essay', labelKey: 'editor.essay' },
{ value: 'match', labelKey: 'editor.match' },
{ value: 'order', labelKey: 'editor.order' },
]
const BLANK_EXAM: Exam = {
title: '',
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 { t } = useTranslation()
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)
examApi.getExam(examId!)
.then((data) => {
setExam(data)
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(t('editor.examTitleRequired'))
return
}
if (exam.questions.length === 0) {
setError(t('editor.atLeastOneQuestion'))
return
}
setSaving(true)
setError(null)
try {
if (isNew) {
const data = await examApi.createExam(exam)
setSaveMsg(t('editor.examCreated'))
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await examApi.updateExam(examId!, exam)
setSaveMsg(t('editor.examSaved'))
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')}>
{t('editor.back')}
</button>
<input
className="exam-editor-title-input"
value={exam.title}
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
placeholder={t('editor.examTitlePlaceholder')}
/>
</div>
<div className="exam-editor-header-right">
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
{hasChanges && <span className="exam-editor-unsaved">{t('editor.unsavedChanges')}</span>}
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
{saving ? t('editor.saving') : isNew ? t('editor.createExam') : t('editor.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">{t('common.loading')}</div>
) : (
<div className="exam-editor-body">
{/* Sidebar */}
<aside className="exam-editor-sidebar">
<div className="sidebar-section-label">{t('editor.questionsLabel')}</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={t('common.delete')}
>
✕
</span>
</button>
))}
</div>
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((tType) => (
<button
key={tType.value}
className="exam-editor-type-btn"
onClick={() => addQuestion(tType.value)}
>
{t(tType.labelKey)}
</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">{t('editor.noQuestions')}</div>
<div className="workspace-empty-desc">{t('editor.noQuestionsDesc')}</div>
</div>
)}
</main>
</div>
)}
</div>
)
}
/* ─── Question Editor ─── */
interface QEProps {
question: Question
onChange: (fn: (q: Question) => void) => void
}
function QuestionEditor({ question, onChange }: QEProps) {
const { t } = useTranslation()
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 }}>{t('editor.questionEditor')}</span>
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
</div>
<div className="question-form-row">
<label className="question-form-label">{t('editor.prompt')}</label>
<textarea
className="textarea"
value={question.prompt}
onChange={(e) => update('prompt', e.target.value)}
rows={3}
placeholder={t('editor.promptPlaceholder')}
/>
</div>
<div className="question-form-row-inline">
<div>
<label className="question-form-label">{t('editor.categoryOptional')}</label>
<input
className="input"
value={question.category ?? ''}
onChange={(e) => update('category', e.target.value || undefined)}
placeholder={t('editor.categoryPlaceholder')}
/>
</div>
<div style={{ width: 100 }}>
<label className="question-form-label">{t('editor.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">{t('editor.explanationOptional')}</label>
<textarea
className="textarea"
value={question.explanation ?? ''}
onChange={(e) => update('explanation', e.target.value || undefined)}
rows={2}
placeholder={t('editor.explanationPlaceholder')}
/>
</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">{t('editor.sourceLanguage')}</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder={t('editor.sourceLanguagePlaceholder')} />
</div>
<div>
<label className="question-form-label">{t('editor.targetLanguage')}</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder={t('editor.targetLanguagePlaceholder')} />
</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 { t } = useTranslation()
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">{t('editor.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={t('editor.markAsCorrect')}
/>
<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={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
{t('editor.addChoice')}
</button>
</div>
)
}
/* ─── Multiple Select ─── */
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
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">{t('editor.choicesCheckCorrect')}</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={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
{t('editor.addChoice')}
</button>
</div>
)
}
/* ─── Text Answer (short, translation, math) ─── */
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const accepted = q.acceptedForms ?? []
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">{t('editor.correctAnswer')}</label>
<input
className="input"
value={(q.answer as string) ?? ''}
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
placeholder={t('editor.correctAnswerPlaceholder')}
/>
</div>
<div className="question-form-row">
<label className="question-form-label">{t('editor.acceptedForms')}</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={t('editor.alternativeAnswer')}
/>
<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 }}
>
{t('editor.addAlternative')}
</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 })}
/>
{t('editor.caseSensitive')}
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignorePunctuation ?? false}
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
/>
{t('editor.ignorePunctuation')}
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignoreAccents ?? false}
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
/>
{t('editor.ignoreAccents')}
</label>
</div>
</div>
)
}
/* ─── Essay ─── */
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
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">{t('editor.minWords')}</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">{t('editor.rubricCriteria')}</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={t('editor.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 }}
>
{t('editor.addCriterion')}
</button>
</div>
</div>
)
}
/* ─── Match ─── */
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
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">{t('editor.leftColumn')}</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={t('editor.leftItemPlaceholder', { index: i + 1 })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>{t('editor.addLeft')}</button>
</div>
<div>
<label className="question-form-label">{t('editor.rightColumn')}</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={t('editor.rightItemPlaceholder', { index: i + 1 })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>{t('editor.addRight')}</button>
</div>
</div>
{left.length > 0 && right.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">{t('editor.correctPairings')}</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="">{t('editor.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 { t } = useTranslation()
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">{t('editor.items')}</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={t('editor.itemPlaceholder', { index: 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 }}>{t('editor.addItem')}</button>
{items.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">{t('editor.correctOrder')}</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 }}>
{t('editor.correctOrderHint')}
</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) })}
>
{t('editor.setCurrentOrder')}
</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}
/> />
) )
} }
+57 -29
View File
@@ -1,4 +1,6 @@
"use no memo";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState, Question } from "../types"; import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading"; import { gradeQuestion } from "../utils/grading";
import { formatTime } from "../utils/formatting"; import { formatTime } from "../utils/formatting";
@@ -13,6 +15,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 +25,12 @@ export default function ExamRunner({
onSubmit, onSubmit,
onExit, onExit,
onNavigate, onNavigate,
n,
}: Readonly<Props>) { }: Readonly<Props>) {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state; const { t } = useTranslation()
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 ?? "";
@@ -146,7 +153,7 @@ export default function ExamRunner({
<h2 className="sidebar-title">{exam.title}</h2> <h2 className="sidebar-title">{exam.title}</h2>
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>} {exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
<div className="sidebar-section-label">Questions</div> <div className="sidebar-section-label">{t('exam.questionsLabel')}</div>
<div className="nav-list"> <div className="nav-list">
{exam.questions.map((qq, i) => { {exam.questions.map((qq, i) => {
const st = navStatus(qq); const st = navStatus(qq);
@@ -169,9 +176,9 @@ export default function ExamRunner({
})} })}
</div> </div>
<div className="sidebar-section-label">Progress</div> <div className="sidebar-section-label">{t('exam.progressLabel')}</div>
<div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}> <div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
{answeredCount} of {total} answered {t('exam.progressText', { answered: answeredCount, total })}
</div> </div>
<div className="progress-bar"> <div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} /> <div style={{ width: `${(answeredCount / total) * 100}%` }} />
@@ -186,10 +193,10 @@ export default function ExamRunner({
}} }}
> >
<button className="btn btn-primary" onClick={onSubmit}> <button className="btn btn-primary" onClick={onSubmit}>
Submit exam {t('exam.submitExam')}
</button> </button>
<button className="btn btn-ghost btn-sm" onClick={onExit}> <button className="btn btn-ghost btn-sm" onClick={onExit}>
Exit to home {t('exam.exitToHome')}
</button> </button>
</div> </div>
</aside> </aside>
@@ -197,12 +204,11 @@ export default function ExamRunner({
<main className="main"> <main className="main">
<header className="question-header"> <header className="question-header">
<span className="q-index"> <span className="q-index">
Question {String(currentIdx + 1).padStart(2, "0")} /{" "} {t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
{String(total).padStart(2, "0")}
</span> </span>
{q.category && <span className="q-category">{q.category}</span>} {q.category && <span className="q-category">{q.category}</span>}
<span className="q-points"> <span className="q-points">
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"} {q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
</span> </span>
</header> </header>
@@ -222,7 +228,7 @@ export default function ExamRunner({
{graded && result && q.type !== "essay" && ( {graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}> <div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label"> <div className="feedback-label">
{result.correct ? "Correct" : "Not quite"} {result.correct ? t('exam.correct') : t('exam.notQuite')}
</div> </div>
<div className="feedback-body"> <div className="feedback-body">
{!result.correct && {!result.correct &&
@@ -230,7 +236,7 @@ export default function ExamRunner({
q.type !== "match" && q.type !== "match" &&
q.type !== "order" && ( q.type !== "order" && (
<p> <p>
<strong>Answer:</strong> <Tex>{answerText}</Tex> <strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
</p> </p>
)} )}
{q.explanation && ( {q.explanation && (
@@ -248,10 +254,10 @@ export default function ExamRunner({
onClick={() => goTo(currentIdx - 1)} onClick={() => goTo(currentIdx - 1)}
disabled={currentIdx === 0} disabled={currentIdx === 0}
> >
← Previous {t('exam.previous')}
</button> </button>
<button className="btn btn-ghost" onClick={toggleFlag}> <button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"} {flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
</button> </button>
<span className="spacer" /> <span className="spacer" />
{feedbackMode === "immediate" && {feedbackMode === "immediate" &&
@@ -259,7 +265,7 @@ export default function ExamRunner({
q.type !== "essay" && q.type !== "essay" &&
answered && ( answered && (
<button className="btn btn-primary" onClick={checkAnswer}> <button className="btn btn-primary" onClick={checkAnswer}>
Check answer {t('exam.checkAnswer')}
</button> </button>
)} )}
{currentIdx < total - 1 ? ( {currentIdx < total - 1 ? (
@@ -267,11 +273,11 @@ export default function ExamRunner({
className="btn btn-primary" className="btn btn-primary"
onClick={() => goTo(currentIdx + 1)} onClick={() => goTo(currentIdx + 1)}
> >
Next → {t('exam.next')}
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={onSubmit}> <button className="btn btn-primary" onClick={onSubmit}>
Submit exam {t('exam.submitExam')}
</button> </button>
)} )}
</footer> </footer>
@@ -288,15 +294,15 @@ export default function ExamRunner({
> >
<span> <span>
<span className="kbd">←</span> <span className="kbd">→</span>{" "} <span className="kbd">←</span> <span className="kbd">→</span>{" "}
navigate {t('exam.navigateHint')}
</span> </span>
<span> <span>
<span className="kbd">F</span> flag <span className="kbd">F</span> {t('exam.flagHint')}
</span> </span>
{q.type === "multiple-choice" && q.choices && ( {q.type === "multiple-choice" && q.choices && (
<span> <span>
<span className="kbd">1</span>– <span className="kbd">1</span>–
<span className="kbd">{q.choices.length}</span> select <span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
</span> </span>
)} )}
</div> </div>
@@ -304,12 +310,12 @@ export default function ExamRunner({
<aside className="meta-rail"> <aside className="meta-rail">
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Elapsed</div> <div className="meta-label">{t('exam.elapsed')}</div>
<div className="meta-value mono">{formatTime(elapsedMs)}</div> <div className="meta-value mono">{formatTime(elapsedMs)}</div>
</div> </div>
{remainingMs != null && ( {remainingMs != null && (
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Remaining</div> <div className="meta-label">{t('exam.remaining')}</div>
<div <div
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`} className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
> >
@@ -318,35 +324,57 @@ export default function ExamRunner({
</div> </div>
)} )}
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Points possible</div> <div className="meta-label">{t('exam.pointsPossible')}</div>
<div className="meta-value mono">{totalPossible}</div> <div className="meta-value mono">{totalPossible}</div>
</div> </div>
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Mode</div> <div className="meta-label">{t('exam.mode')}</div>
<div className="meta-value mono" style={{ fontSize: 13 }}> <div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === "immediate" {feedbackMode === "immediate"
? "Immediate feedback" ? t('exam.immediateFeedback')
: "Grade at end"} : t('exam.gradeAtEnd')}
</div> </div>
</div> </div>
<div className="meta-block notes-block"> <div className="meta-block notes-block">
<div className="meta-label">Notes</div> <div className="meta-label">{t('exam.notes')}</div>
<textarea <textarea
className="textarea notes-pad" className="textarea notes-pad"
value={notes} value={notes}
onChange={(e) => updateNotes(e.target.value)} onChange={(e) => updateNotes(e.target.value)}
placeholder="Add short notes while you work..." placeholder={t('exam.notesPlaceholder')}
maxLength={1000} maxLength={1000}
aria-label="Exam notes" aria-label={t('exam.notes')}
/> />
<div className="notes-count mono">{notes.length}/1000</div> <div className="notes-count mono">{notes.length}/1000</div>
</div> </div>
{exam.author && ( {exam.author && (
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Author</div> <div className="meta-label">{t('exam.author')}</div>
<div style={{ fontSize: 14 }}>{exam.author}</div> <div style={{ fontSize: 14 }}>{exam.author}</div>
</div> </div>
)} )}
<div className="meta-block" style={{ marginTop: 'auto' }}>
<div className="meta-label">{t('tweaks.feedback')}</div>
<div className="tweak-control">
<button
className={`tweak-seg${feedbackMode === 'immediate' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'immediate' }))
}
>
{t('tweaks.immediate')}
</button>
<button
className={`tweak-seg${feedbackMode === 'end' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'end' }))
}
>
{t('tweaks.end')}
</button>
</div>
</div>
</aside> </aside>
</div> </div>
); );
+33 -23
View File
@@ -1,15 +1,17 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, HistoryEntry } from '../types' import type { Exam, HistoryEntry } from '../types'
import { validateExam, parseExam } from '../utils/validation' import { validateExam, parseExam } from '../utils/validation'
import { loadState } from '../utils/storage' import { loadState } from '../utils/storage'
import { formatDate, formatTime } from '../utils/formatting' import { formatDate, formatTime } from '../utils/formatting'
interface Props { interface Props {
onLoadExam: (exam: Exam) => void onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
history: HistoryEntry[] history: HistoryEntry[]
onResume: () => void onResume: () => void
onClearHistory: () => void onClearHistory: () => void
onReviewAttempt: (entry: HistoryEntry) => void onReviewAttempt: (entry: HistoryEntry) => void
onRetakeAttempt?: (entry: HistoryEntry) => void
} }
const SAMPLES = [ const SAMPLES = [
@@ -18,7 +20,8 @@ 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 [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
@@ -30,9 +33,9 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
const data = JSON.parse(text) as unknown const data = JSON.parse(text) as unknown
const msg = validateExam(data) const msg = validateExam(data)
if (msg) throw new Error(msg) if (msg) throw new Error(msg)
onLoadExam(parseExam(data)) onLoadExam(parseExam(data), true)
} catch (e) { } catch (e) {
setErr(`Could not load: ${(e as Error).message}`) setErr(t('landing.loadError', { message: (e as Error).message }))
} }
} }
@@ -46,7 +49,7 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
if (msg) throw new Error(msg) if (msg) throw new Error(msg)
onLoadExam(parseExam(data)) onLoadExam(parseExam(data))
} catch (e) { } catch (e) {
setErr(`Could not load sample: ${(e as Error).message}`) setErr(t('landing.loadSampleError', { message: (e as Error).message }))
} }
} }
@@ -375,10 +378,10 @@ Set a 20-minute time limit. Include explanations for each answer."
return ( return (
<div className="landing"> <div className="landing">
<div className="landing-eyebrow">BrainWave · practice exam platform</div> <div className="landing-eyebrow">{t('landing.eyebrow')}</div>
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1> <h1 className="landing-title">{t('landing.title')}<em>{t('landing.titleEmphasis')}</em></h1>
<p className="landing-sub"> <p className="landing-sub">
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically. {t('landing.subtitle')}
</p> </p>
<div className="landing-grid"> <div className="landing-grid">
@@ -395,9 +398,13 @@ 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">
<h3>Drop your exam JSON here</h3> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<p>or click to browse · max 5 MB</p> <path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<h3>{t('landing.dropzoneTitle')}</h3>
<p>{t('landing.dropzoneSub')}</p>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
@@ -413,8 +420,8 @@ Set a 20-minute time limit. Include explanations for each answer."
onClick={downloadInstructions} onClick={downloadInstructions}
id="download-instructions" id="download-instructions"
> >
<span className="download-instructions-icon">↓</span> <span className="download-instructions-icon">{t('landing.downloadInstructionsIcon')}</span>
Download Instructions — How to create your own exam JSON {t('landing.downloadInstructions')}
</button> </button>
{err && ( {err && (
@@ -426,23 +433,23 @@ Set a 20-minute time limit. Include explanations for each answer."
{resumable && ( {resumable && (
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div> <div style={{ fontWeight: 500, fontSize: 14 }}>{t('landing.resumeTitle')}</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}> <div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
{resumable.exam.title} · {Object.keys(resumable.state.answers ?? {}).length}/{resumable.exam.questions.length} answered {resumable.exam.title} · {t('landing.resumeProgress', { answered: Object.keys(resumable.state.answers ?? {}).length, total: resumable.exam.questions.length })}
</div> </div>
</div> </div>
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button> <button className="btn btn-primary btn-sm" onClick={onResume}>{t('landing.resumeButton')}</button>
</div> </div>
)} )}
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}> <div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div> <div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>{t('landing.supportedTypes')}</div>
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order {t('landing.supportedTypesList')}
</div> </div>
</div> </div>
<div className="samples"> <div className="samples">
<h4>Try a sample</h4> <h4>{t('landing.trySample')}</h4>
{SAMPLES.map((s) => ( {SAMPLES.map((s) => (
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}> <button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div> <div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
@@ -460,9 +467,9 @@ Set a 20-minute time limit. Include explanations for each answer."
<div className="history"> <div className="history">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}> <h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>
Past attempts {t('landing.pastAttempts')}
</h4> </h4>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button> <button className="btn btn-ghost btn-sm" onClick={onClearHistory}>{t('landing.clearHistory')}</button>
</div> </div>
{history.map((h) => { {history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0 const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0
@@ -472,12 +479,15 @@ Set a 20-minute time limit. Include explanations for each answer."
<div> <div>
<div className="history-title">{h.title}</div> <div className="history-title">{h.title}</div>
<div className="muted" style={{ fontSize: 12 }}> <div className="muted" style={{ fontSize: 12 }}>
{formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct {t('landing.dateTimeFormat', { date: formatDate(h.completedAt), correct: h.correctCount, graded: h.gradedCount })}
</div> </div>
</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>
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>Review</button> {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>
</div> </div>
) )
})} })}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import type { Exam } from '../types'
interface SavedExam {
id: number
title: string
subtitle: string | null
question_count: number
created_at: number
updated_at: number
}
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function MyExams({ onLoadExam }: Props) {
const { t } = useTranslation()
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 [startingId, setStartingId] = useState<number | null>(null)
const loadExams = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await examApi.listExams()
setExams(data)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadExams()
}, [loadExams])
const deleteExam = async (id: number) => {
try {
await examApi.deleteExam(id)
setDeleteConfirm(null)
void loadExams()
} catch (e) {
setError((e as Error).message)
}
}
const createNewExam = () => {
navigate('/exam-editor/new')
}
const startExam = async (id: number) => {
setStartingId(id)
try {
const exam = await examApi.getExam(id)
onLoadExam(exam, false, id)
} catch (e) {
setError((e as Error).message)
setStartingId(null)
}
}
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">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ {t('workspace.newExam')}
</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">{t('common.loading')}</div>
) : exams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
<div className="workspace-empty-desc">
{t('workspace.noSavedExamsDesc')}
</div>
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
{t('workspace.createFirstExam')}
</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} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
<span>·</span>
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
</div>
</div>
<div className="workspace-card-actions">
<button
className="btn btn-primary btn-sm"
onClick={() => void startExam(exam.id)}
disabled={startingId === exam.id}
>
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
</button>
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
disabled={startingId === exam.id}
>
{t('common.edit')}
</button>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title={t('common.delete')}
disabled={startingId === exam.id}
>
✕
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as resultApi from '../api/resultApi'
import * as examApi from '../api/examApi'
import { getExamUrlId } from '../utils/slug'
import type { Exam } 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
}
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function MyResults({ onLoadExam }: Props) {
const { t } = useTranslation()
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 [retakingId, setRetakingId] = useState<number | null>(null)
const loadResults = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await resultApi.listResults()
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 resultApi.deleteResult(id)
setDeleteConfirm(null)
void loadResults()
} catch (e) {
setError((e as Error).message)
}
}
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) => {
try {
const data = await resultApi.getResult(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))
const completedAt = (data as unknown as Record<string, unknown>).completed_at
const state = data.state as Record<string, unknown> | undefined
localStorage.setItem('brainwave.review.state', JSON.stringify({
...(state || {}),
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
}))
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
} 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">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.results')}</h2>
</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} {total === 1 ? t('workspace.attempts') : t('workspace.attemptsPlural')}</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">{t('common.loading')}</div>
) : results.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">✦</div>
<div className="workspace-empty-title">{t('workspace.noAttempts')}</div>
<div className="workspace-empty-desc">
{t('workspace.noAttemptsDesc')}
</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} {t('results.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-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)}>
{t('common.edit')}
</button>
{deleteConfirm === r.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(r.id)}
title={t('common.delete')}
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+27 -30
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { apiFetch, ApiError } from '../utils/api' import { useTranslation } from 'react-i18next'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props { interface Props {
onDone: () => void onDone: () => void
@@ -8,6 +10,7 @@ interface Props {
type Step = 'request' | 'reset' | 'success' type Step = 'request' | 'reset' | 'success'
export default function ResetPassword({ onDone }: Readonly<Props>) { export default function ResetPassword({ onDone }: Readonly<Props>) {
const { t } = useTranslation()
const [step, setStep] = useState<Step>('request') const [step, setStep] = useState<Step>('request')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -19,9 +22,9 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const t = params.get('token') const tk = params.get('token')
if (t) { if (tk) {
setToken(t) setToken(tk)
setStep('reset') setStep('reset')
window.history.replaceState({}, '', '/reset-password') window.history.replaceState({}, '', '/reset-password')
} }
@@ -32,13 +35,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
setError(null) setError(null)
setSubmitting(true) setSubmitting(true)
try { try {
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', { const data = await authApi.forgotPassword(email)
method: 'POST',
body: JSON.stringify({ email }),
})
setMessage(data.message) setMessage(data.message)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Request failed') setError(err instanceof ApiError ? err.message : t('resetPassword.requestFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -48,18 +48,15 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError('Passwords do not match') setError(t('resetPassword.passwordsDoNotMatch'))
return return
} }
setSubmitting(true) setSubmitting(true)
try { try {
await apiFetch('/api/auth/reset-password', { await authApi.resetPassword({ token: token!, newPassword })
method: 'POST',
body: JSON.stringify({ token, newPassword }),
})
setStep('success') setStep('success')
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Password reset failed') setError(err instanceof ApiError ? err.message : t('resetPassword.passwordResetFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -71,12 +68,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'request' && !message && ( {step === 'request' && !message && (
<> <>
<div className="verify-icon">🔑</div> <div className="verify-icon">🔑</div>
<h2 className="verify-title">Reset Password</h2> <h2 className="verify-title">{t('auth.resetPassword')}</h2>
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p> <p className="verify-message">{t('auth.resetPasswordDesc')}</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>} {error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}> <form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="reset-email">Email</label> <label className="auth-label" htmlFor="reset-email">{t('auth.emailLabel')}</label>
<input <input
id="reset-email" id="reset-email"
className="input" className="input"
@@ -88,10 +85,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/> />
</div> </div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}> <button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send Reset Link'} {submitting ? t('auth.sending') : t('auth.sendResetLink')}
</button> </button>
<button type="button" className="auth-forgot" onClick={onDone}> <button type="button" className="auth-forgot" onClick={onDone}>
← Back to sign in ← {t('auth.backToSignIn')}
</button> </button>
</form> </form>
</> </>
@@ -100,10 +97,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'request' && message && ( {step === 'request' && message && (
<> <>
<div className="verify-icon success">📧</div> <div className="verify-icon success">📧</div>
<h2 className="verify-title">Check Your Email</h2> <h2 className="verify-title">{t('auth.checkYourEmail')}</h2>
<p className="verify-message">{message}</p> <p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}> <button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home {t('auth.backToHome')}
</button> </button>
</> </>
)} )}
@@ -111,12 +108,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'reset' && ( {step === 'reset' && (
<> <>
<div className="verify-icon">🔐</div> <div className="verify-icon">🔐</div>
<h2 className="verify-title">Set New Password</h2> <h2 className="verify-title">{t('auth.setNewPassword')}</h2>
<p className="verify-message">Enter your new password below.</p> <p className="verify-message">{t('auth.enterNewPassword')}</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>} {error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}> <form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="new-password">New Password</label> <label className="auth-label" htmlFor="new-password">{t('auth.newPassword')}</label>
<input <input
id="new-password" id="new-password"
className="input" className="input"
@@ -129,7 +126,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/> />
</div> </div>
<div className="auth-field"> <div className="auth-field">
<label className="auth-label" htmlFor="confirm-new-password">Confirm Password</label> <label className="auth-label" htmlFor="confirm-new-password">{t('auth.confirmPassword')}</label>
<input <input
id="confirm-new-password" id="confirm-new-password"
className="input" className="input"
@@ -142,7 +139,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/> />
</div> </div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}> <button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Resetting…' : 'Reset Password'} {submitting ? t('auth.resetting') : t('auth.resetPassword')}
</button> </button>
</form> </form>
</> </>
@@ -151,10 +148,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'success' && ( {step === 'success' && (
<> <>
<div className="verify-icon success">✓</div> <div className="verify-icon success">✓</div>
<h2 className="verify-title">Password Updated</h2> <h2 className="verify-title">{t('auth.passwordUpdated')}</h2>
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p> <p className="verify-message">{t('auth.passwordUpdatedDesc')}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}> <button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In → {t('auth.signIn')} →
</button> </button>
</> </>
)} )}
+27 -14
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState } from '../types' import type { Exam, ExamState } from '../types'
import { gradeQuestion } from '../utils/grading' import { gradeQuestion } from '../utils/grading'
import { formatTime } from '../utils/formatting' import { formatTime } from '../utils/formatting'
@@ -11,10 +12,12 @@ 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 { answers, startedAt, completedAt } = state const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt const elapsedMs = (completedAt ?? Date.now()) - startedAt
@@ -28,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
@@ -47,28 +51,28 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
return ( return (
<div className="results"> <div className="results">
<div className="results-eyebrow"> <div className="results-eyebrow">
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title} {reviewOnly ? t('results.reviewing') : t('results.examComplete')} · {exam.title}
</div> </div>
<h1 className="results-title"> <h1 className="results-title">
You scored <em>{pct}%</em><br /> {t('results.scored')} <em>{pct}%</em><br />
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}> <span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
{passed {passed
? `Above the ${passing}% passing mark.` ? t('results.abovePassing', { passing })
: `Below the ${passing}% passing mark. Keep practicing.`} : t('results.belowPassing', { passing })}
</span> </span>
</h1> </h1>
<div className="score-grid"> <div className="score-grid">
<div className="score-cell"> <div className="score-cell">
<div className="score-cell-label">Points</div> <div className="score-cell-label">{t('results.points')}</div>
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div> <div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
</div> </div>
<div className="score-cell" style={{ paddingLeft: 24 }}> <div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Correct</div> <div className="score-cell-label">{t('results.correct')}</div>
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div> <div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
</div> </div>
<div className="score-cell" style={{ paddingLeft: 24 }}> <div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Time</div> <div className="score-cell-label">{t('results.time')}</div>
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div> <div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div>
</div> </div>
</div> </div>
@@ -76,7 +80,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
{Object.keys(byCat).length > 1 && ( {Object.keys(byCat).length > 1 && (
<div className="category-breakdown"> <div className="category-breakdown">
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}> <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>
By category {t('results.byCategory')}
</div> </div>
{Object.entries(byCat).map(([cat, c]) => { {Object.entries(byCat).map(([cat, c]) => {
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0 const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0
@@ -91,7 +95,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
</div> </div>
)} )}
<h2 className="review-heading">Review</h2> <h2 className="review-heading">{t('results.review')}</h2>
{graded.map((g, i) => { {graded.map((g, i) => {
const promptText = g.q.prompt const promptText = g.q.prompt
.replace(/\$\$([^$]+)\$\$/g, '[math]') .replace(/\$\$([^$]+)\$\$/g, '[math]')
@@ -116,13 +120,13 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded /> <QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && ( {g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}> <div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">Explanation</div> <div className="feedback-label">{t('results.explanation')}</div>
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div> <div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
</div> </div>
)} )}
{g.q.rubric && ( {g.q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}> <div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div> <div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}> <ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)} {g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
</ul> </ul>
@@ -135,8 +139,17 @@ 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}>Retake exam</button>} {!reviewOnly && (
<button className="btn" onClick={onExit}>Back to home</button> <>
<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>
</div> </div>
</div> </div>
) )
+350
View File
@@ -0,0 +1,350 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { useTweaks } from '../contexts/TweaksContext'
import * as authApi from '../api/authApi'
import type { ThemeMode, FontSize, FeedbackTweak } from '../types'
function Seg<K extends string>({ value, options, onChange }: {
value: K
options: { value: K; label: string }[]
onChange: (v: K) => void
}) {
return (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg${value === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
)
}
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() {
const { t, i18n } = useTranslation()
const { user, refreshUser } = useAuth()
const { tweaks, updateTweak } = useTweaks()
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: t('workspace.displayNameRequired') })
return
}
setSavingName(true)
setNameMsg(null)
try {
await authApi.updateProfile({ displayName: trimmed })
setNameMsg({ ok: true, text: t('workspace.displayNameUpdated') })
await refreshUser()
} catch (e) {
setNameMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingName(false)
}
}, [displayName, refreshUser, t])
const changePassword = useCallback(async () => {
if (newPassword.length < 8) {
setPasswordMsg({ ok: false, text: t('workspace.passwordMinLength') })
return
}
if (newPassword !== confirmPassword) {
setPasswordMsg({ ok: false, text: t('workspace.passwordsDoNotMatch') })
return
}
setSavingPassword(true)
setPasswordMsg(null)
try {
await authApi.changePassword({ currentPassword, newPassword })
setPasswordMsg({ ok: true, text: t('workspace.passwordChanged') })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (e) {
setPasswordMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingPassword(false)
}
}, [currentPassword, newPassword, confirmPassword, t])
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.settings')}</h2>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.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">{t('workspace.displayNameLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.displayNameDesc')}</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">{t('workspace.emailLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.emailDesc')}</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 ? t('workspace.saving') : t('workspace.saveProfile')}
</button>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.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">{t('workspace.currentPassword')}</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">{t('workspace.newPassword')}</span>
<span className="workspace-setting-desc">{t('workspace.newPasswordDesc')}</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">{t('workspace.confirmNewPassword')}</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 ? t('workspace.changing') : t('workspace.changePassword')}
</button>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.language')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.languageLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.languageDesc')}</span>
</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => void i18n.changeLanguage('en')}
style={{ fontWeight: i18n.language === 'en' ? 700 : 400, fontSize: 13 }}
>
English
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => void i18n.changeLanguage('nl')}
style={{ fontWeight: i18n.language === 'nl' ? 700 : 400, fontSize: 13 }}
>
Nederlands
</button>
</div>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.appearance')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.themeLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.themeDesc')}</span>
</label>
<Seg
value={tweaks.theme}
options={[
{ value: 'light' as ThemeMode, label: t('tweaks.light') },
{ value: 'dark' as ThemeMode, label: t('tweaks.dark') },
]}
onChange={(v) => updateTweak('theme', v)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.textSizeLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.textSizeDesc')}</span>
</label>
<Seg
value={tweaks.fontSize}
options={[
{ value: 'small' as FontSize, label: t('tweaks.small') },
{ value: 'medium' as FontSize, label: t('tweaks.medium') },
{ value: 'large' as FontSize, label: t('tweaks.large') },
]}
onChange={(v) => updateTweak('fontSize', v)}
/>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.defaultFeedback')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.defaultFeedbackLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.defaultFeedbackDesc')}</span>
</label>
<Seg
value={tweaks.feedbackMode}
options={[
{ value: 'auto' as FeedbackTweak, label: t('tweaks.auto') },
{ value: 'immediate' as FeedbackTweak, label: t('tweaks.immediate') },
{ value: 'end' as FeedbackTweak, label: t('tweaks.end') },
]}
onChange={(v) => updateTweak('feedbackMode', v)}
/>
</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>
)
}
+301
View File
@@ -0,0 +1,301 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { setToken } from '../utils/api'
import * as setupApi from '../api/setupApi'
interface Props {
onComplete: () => void
}
export default function SetupWizard({ onComplete }: Readonly<Props>) {
const { t } = useTranslation()
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(t('setup.validEmailRequired'))
return false
}
if (!displayName.trim()) {
setError(t('setup.displayNameRequired'))
return false
}
if (password.length < 8) {
setError(t('setup.passwordMinLength'))
return false
}
if (password !== confirmPassword) {
setError(t('setup.passwordsDoNotMatch'))
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 setupApi.completeSetup({
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 : t('setup.setupFailed'),
)
} 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">{t('setup.welcome')}</h1>
<p className="setup-subtitle">
{t('setup.subtitle')}
</p>
<div className="setup-steps">
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
<span className="setup-step-num">1</span>
<span className="setup-step-label">{t('setup.adminAccount')}</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">{t('setup.emailSettings')}</span>
</div>
</div>
{error && <div className="auth-error">{error}</div>}
{step === 1 && (
<>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-email">
{t('setup.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">
{t('setup.displayName')}
</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">
{t('setup.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">
{t('setup.confirmPassword')}
</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 }}
>
{t('setup.continue')}
</button>
</>
)}
{step === 2 && (
<form onSubmit={(e) => void handleSubmit(e)}>
<p className="setup-hint">
{t('setup.setupHint1')}
</p>
<p className="setup-hint">
{t('setup.setupHint2')}
</p>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-host">
{t('setup.smtpHost')}
</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">
{t('setup.smtpPort')}
</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">
{t('setup.smtpUsername')}
</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">
{t('setup.smtpPassword')}
</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">
{t('setup.fromAddress')}
</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">
{t('setup.security')}
</label>
<select
id="smtp-secure"
className="input"
value={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.value)}
>
<option value="auto">{t('setup.autoSsl')}</option>
<option value="true">{t('setup.sslTls')}</option>
<option value="false">{t('setup.noneStarttls')}</option>
</select>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="app-url">
{t('setup.appUrl')}
</label>
<input
id="app-url"
className="input"
type="url"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://brainwave.example.com"
/>
</div>
<div className="setup-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => setStep(1)}
>
{t('setup.back')}
</button>
<button
className="btn btn-primary auth-submit"
type="submit"
disabled={submitting}
>
{submitting ? t('setup.settingUp') : t('setup.completeSetup')}
</button>
</div>
</form>
)}
</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>
)
}
-57
View File
@@ -1,57 +0,0 @@
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
interface Props {
tweaks: Tweaks
update: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
onClose: () => void
}
function Seg({ tkey, options, tweaks, update }: {
tkey: keyof Tweaks
options: { value: string; label: string }[]
tweaks: Tweaks
update: Props['update']
}) {
return (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg${tweaks[tkey] === opt.value ? ' active' : ''}`}
onClick={() => update(tkey, opt.value as never)}
>
{opt.label}
</button>
))}
</div>
)
}
export default function TweaksPanel({ tweaks, update, onClose }: Props) {
return (
<div className="tweaks-panel">
<div className="tweaks-header">
<span>Tweaks</span>
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label">Theme</div>
<Seg tkey="theme" options={[{ value: 'light' as ThemeMode, label: 'Light' }, { value: 'dark' as ThemeMode, label: 'Dark' }]} tweaks={tweaks} update={update} />
</div>
<div className="tweak-row">
<div className="tweak-label">Text size</div>
<Seg tkey="fontSize" options={[{ value: 'small' as FontSize, label: 'S' }, { value: 'medium' as FontSize, label: 'M' }, { value: 'large' as FontSize, label: 'L' }]} tweaks={tweaks} update={update} />
</div>
<div className="tweak-row">
<div className="tweak-label">Feedback</div>
<Seg tkey="feedbackMode" options={[
{ value: 'auto' as FeedbackTweak, label: 'Auto' },
{ value: 'immediate' as FeedbackTweak, label: 'Immediate' },
{ value: 'end' as FeedbackTweak, label: 'End' },
]} tweaks={tweaks} update={update} />
</div>
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as examApi from '../api/examApi'
import * as resultApi from '../api/resultApi'
import type { Exam } from '../types'
import MyExams from './MyExams'
import MyResults from './MyResults'
import Settings from './Settings'
import StorageManager from './StorageManager'
type TabKey = 'exams' | 'results' | 'storage' | 'settings'
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
]
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function Workspace({ onLoadExam }: Props) {
const { t } = useTranslation()
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 examApi.listExams()
setExamCount(exams.length)
const results = await resultApi.listResults()
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">
{TAB_KEYS.map((tabItem) => (
<button
key={tabItem.key}
className={`workspace-nav-item${activeTab === tabItem.key ? ' active' : ''}`}
onClick={() => setTab(tabItem.key)}
>
<span className="workspace-nav-icon">{tabItem.icon}</span>
<span className="workspace-nav-label">{t(tabItem.labelKey)}</span>
{tabItem.key === 'exams' && examCount > 0 && (
<span className="workspace-nav-badge">{examCount}</span>
)}
{tabItem.key === 'results' && resultCount > 0 && (
<span className="workspace-nav-badge">{resultCount}</span>
)}
</button>
))}
</nav>
</aside>
<main className="workspace-main">
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
{activeTab === 'storage' && <StorageManager />}
{activeTab === 'settings' && <Settings />}
</main>
</div>
)
}
+44 -19
View File
@@ -1,17 +1,25 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react' import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { apiFetch, setToken, clearToken, isAuthenticated } from '../utils/api' import { setToken, clearToken, isAuthenticated } from '../utils/api'
import * as authApi from '../api/authApi'
import * as setupApi from '../api/setupApi'
export interface User { export interface User {
id: number id: number
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 {
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,16 +31,36 @@ 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 {
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me') // Always check setup status first
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' }) const setupStatus = await setupApi.getSetupStatus()
if (!setupStatus.completed) {
setNeedsSetup(true)
setUser(null)
setLoading(false)
return
}
setNeedsSetup(false)
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
const data = await authApi.getMe()
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)
@@ -46,20 +74,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [refreshUser]) }, [refreshUser])
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
const data = await apiFetch<{ token: string; user: User }>('/api/auth/login', { const data = await authApi.login({ email, password })
method: 'POST',
body: JSON.stringify({ email, password }),
})
setToken(data.token) setToken(data.token)
setUser({ ...data.user, role: data.user.role ?? '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> => {
const data = await apiFetch<{ message: string }>('/api/auth/register', { await authApi.register({ email, password, displayName })
method: 'POST', return 'Registration successful! Please check your email to verify your account.'
body: JSON.stringify({ email, password, displayName }),
})
return data.message
}, []) }, [])
const logout = useCallback(() => { const logout = useCallback(() => {
@@ -68,7 +93,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>
) )
+77
View File
@@ -0,0 +1,77 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
const TWEAKS_KEY = 'brainwave.tweaks'
const DEFAULT_TWEAKS: Tweaks = {
theme: 'light',
fontSize: 'medium',
feedbackMode: 'auto',
}
const getSystemTheme = (): ThemeMode =>
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
const loadTweaks = (): Tweaks => {
try {
const raw = localStorage.getItem(TWEAKS_KEY)
if (!raw) {
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
}
const parsed = JSON.parse(raw) as Partial<Tweaks>
return {
theme: (parsed.theme as ThemeMode) ?? getSystemTheme(),
fontSize: (parsed.fontSize as FontSize) ?? DEFAULT_TWEAKS.fontSize,
feedbackMode: (parsed.feedbackMode as FeedbackTweak) ?? DEFAULT_TWEAKS.feedbackMode,
}
} catch {
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
}
}
const saveTweaks = (tweaks: Tweaks): void => {
try { localStorage.setItem(TWEAKS_KEY, JSON.stringify(tweaks)) } catch { /* quota */ }
}
interface TweaksContextValue {
tweaks: Tweaks
updateTweak: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
}
const TweaksContext = createContext<TweaksContextValue | null>(null)
export function TweaksProvider({ children }: { children: React.ReactNode }) {
const [tweaks, setTweaks] = useState<Tweaks>(loadTweaks)
const updateTweak = useCallback(<K extends keyof Tweaks>(key: K, value: Tweaks[K]) => {
setTweaks((prev) => {
const next = { ...prev, [key]: value }
saveTweaks(next)
return next
})
}, [])
useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme
const sizeMap: Record<string, string> = {
small: '14px',
medium: '15px',
large: '17px',
}
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
}, [tweaks.theme, tweaks.fontSize])
return (
<TweaksContext.Provider value={{ tweaks, updateTweak }}>
{children}
</TweaksContext.Provider>
)
}
export function useTweaks(): TweaksContextValue {
const ctx = useContext(TweaksContext)
if (!ctx) throw new Error('useTweaks must be used within TweaksProvider')
return ctx
}
+399
View File
@@ -0,0 +1,399 @@
{
"common": {
"appName": "BrainWave",
"appTagline": "practice exam platform",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"back": "Back",
"backToHome": "Back to home",
"loading": "Loading…",
"error": "Error",
"success": "Success",
"yes": "Yes",
"no": "No",
"pts": "pts",
"pt": "pt",
"questions": "questions",
"question": "question"
},
"landing": {
"eyebrow": "BrainWave · practice exam platform",
"title": "Upload an exam. ",
"titleEmphasis": "Practice anything.",
"subtitle": "Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.",
"dropzoneTitle": "Drop your exam JSON here",
"dropzoneSub": "or click to browse · max 5 MB",
"downloadInstructions": "Download Instructions — How to create your own exam JSON",
"downloadInstructionsIcon": "↓",
"supportedTypes": "Supported types",
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order",
"trySample": "Try a sample",
"resumeTitle": "Resume in-progress exam",
"resumeProgress": "{{answered}}/{{total}} answered",
"resumeButton": "Resume →",
"pastAttempts": "Past attempts",
"clearHistory": "Clear history",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Review",
"retake": "Retake",
"loadError": "Could not load: {{message}}",
"loadSampleError": "Could not load sample: {{message}}",
"clearHistoryConfirm": "Clear all past attempts?"
},
"auth": {
"signIn": "Sign In",
"signOut": "Sign Out",
"register": "Register",
"welcomeBack": "Welcome back",
"createAccount": "Create account",
"signInSubtitle": "Sign in to access your saved exams and results",
"registerSubtitle": "Create an account to save your progress",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"displayName": "Display Name",
"forgotPassword": "Forgot your password?",
"signingIn": "Signing in…",
"creatingAccount": "Creating account…",
"loginFailed": "Login failed",
"registrationFailed": "Registration failed",
"passwordsDoNotMatch": "Passwords do not match",
"myWorkspace": "My Workspace",
"adminDashboard": "Admin Dashboard",
"userMenu": "User menu",
"resetPassword": "Reset Password",
"resetPasswordDesc": "Enter your email address and we'll send you a link to reset your password.",
"checkYourEmail": "Check Your Email",
"setNewPassword": "Set New Password",
"enterNewPassword": "Enter your new password below.",
"newPassword": "New Password",
"resetting": "Resetting…",
"passwordUpdated": "Password Updated",
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
"requestFailed": "Request failed",
"passwordResetFailed": "Password reset failed",
"sendResetLink": "Send Reset Link",
"sending": "Sending…",
"backToSignIn": "Back to sign in"
},
"exam": {
"submitExam": "Submit exam",
"exitToHome": "Exit to home",
"questionsLabel": "Questions",
"progressLabel": "Progress",
"progressText": "{{answered}} of {{total}} answered",
"questionHeader": "Question {{current}} / {{total}}",
"correct": "Correct",
"notQuite": "Not quite",
"answerLabel": "Answer:",
"previous": "← Previous",
"flagged": "⚑ Flagged",
"flagForReview": "⚐ Flag for review",
"checkAnswer": "Check answer",
"next": "Next →",
"elapsed": "Elapsed",
"remaining": "Remaining",
"pointsPossible": "Points possible",
"mode": "Mode",
"immediateFeedback": "Immediate feedback",
"gradeAtEnd": "Grade at end",
"notes": "Notes",
"notesPlaceholder": "Add short notes while you work…",
"author": "Author",
"navigateHint": "navigate",
"flagHint": "flag",
"selectHint": "select",
"exit": "Exit",
"untimed": "Untimed"
},
"results": {
"reviewing": "Reviewing past attempt",
"examComplete": "Exam complete",
"scored": "You scored ",
"abovePassing": "Above the {{passing}}% passing mark.",
"belowPassing": "Below the {{passing}}% passing mark. Keep practicing.",
"points": "Points",
"correct": "Correct",
"time": "Time",
"byCategory": "By category",
"uncategorized": "Uncategorized",
"review": "Review",
"explanation": "Explanation",
"rubric": "Rubric (self-grade)",
"retakeExam": "Retake exam",
"redoFailed": "Redo failed questions",
"cantRetake": "Exam no longer saved in workspace",
"backToHome": "Back to home"
},
"tweaks": {
"title": "Tweaks",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"textSize": "Text size",
"small": "S",
"medium": "M",
"large": "L",
"feedback": "Feedback",
"auto": "Auto",
"immediate": "Immediate",
"end": "End"
},
"workspace": {
"myWorkspace": "My Workspace",
"myExams": "My Exams",
"results": "Results",
"settings": "Settings",
"newExam": "New Exam",
"noSavedExams": "No saved exams yet",
"noSavedExamsDesc": "Upload an exam from the home page, or create a new one to get started.",
"createFirstExam": "Create Your First Exam",
"takeExam": "Take exam",
"noAttempts": "No attempts yet",
"noAttemptsDesc": "Take an exam to see your results here.",
"attempts": "attempt",
"attemptsPlural": "attempts",
"updated": "Updated",
"profile": "Profile",
"security": "Security",
"displayNameLabel": "Display Name",
"displayNameDesc": "Your name shown throughout the app",
"emailLabel": "Email",
"emailDesc": "Your email address",
"saveProfile": "Save Profile",
"saving": "Saving…",
"displayNameUpdated": "Display name updated",
"displayNameRequired": "Display name is required (max 100 characters)",
"currentPassword": "Current Password",
"newPassword": "New Password",
"newPasswordDesc": "Minimum 8 characters",
"confirmNewPassword": "Confirm New Password",
"changePassword": "Change Password",
"changing": "Changing…",
"passwordChanged": "Password changed successfully",
"passwordMinLength": "New password must be at least 8 characters",
"passwordsDoNotMatch": "Passwords do not match",
"language": "Language",
"languageLabel": "Language",
"languageDesc": "Select your preferred display language",
"appearance": "Appearance",
"themeLabel": "Theme",
"themeDesc": "Choose between light and dark mode",
"textSizeLabel": "Text size",
"textSizeDesc": "Adjust the base font size for readability",
"defaultFeedback": "Default Feedback",
"defaultFeedbackLabel": "Feedback mode",
"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": {
"administration": "Administration",
"dashboard": "Dashboard",
"backToSite": "← Back to site",
"overview": "Overview",
"settings": "Settings",
"users": "Users",
"totalUsers": "Total Users",
"savedExams": "Saved Exams",
"examsTaken": "Exams Taken",
"avgScore": "Avg. Score",
"recentActivity": "Recent Activity",
"noRecentActivity": "No recent activity yet",
"general": "General",
"siteName": "Site Name",
"siteNameDesc": "The name displayed throughout the application",
"maxUpload": "Max Upload Size (MB)",
"maxUploadDesc": "Maximum file size for exam JSON uploads",
"defaultTheme": "Default Theme",
"defaultThemeDesc": "Default theme for new visitors",
"accessControl": "Access Control",
"allowGuestMode": "Allow Guest Mode",
"allowGuestModeDesc": "Let unauthenticated users take exams without signing in",
"registrationEnabled": "Registration Enabled",
"registrationEnabledDesc": "Allow new users to create accounts",
"emailSmtp": "Email / SMTP",
"appUrl": "App URL",
"appUrlDesc": "Base URL used in email links (e.g. https://brainwave.example.com)",
"smtpHost": "SMTP Host",
"smtpHostDesc": "Hostname of your SMTP server",
"smtpPort": "SMTP Port",
"smtpPortDesc": "Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)",
"username": "Username",
"usernameDesc": "SMTP authentication username",
"password": "Password",
"passwordDesc": "SMTP authentication password",
"fromAddress": "From Address",
"fromAddressDesc": "The sender name and address shown in emails",
"tlsMode": "TLS Mode",
"tlsModeDesc": "Auto detects from port (465→SSL). Use explicit override if needed.",
"auto": "Auto",
"forceTls": "Force TLS",
"off": "Off",
"sendTestEmail": "Send Test Email",
"sendTestEmailDesc": "Sends a test email using the settings above (uses draft values, no need to save first)",
"sendTest": "Send Test",
"sending": "Sending…",
"maintenance": "Maintenance",
"maintenanceMode": "Maintenance Mode",
"maintenanceModeDesc": "Take the site offline for non-admin users",
"maintenanceMessage": "Maintenance Message",
"maintenanceMessageDesc": "Message displayed to visitors during maintenance",
"discardChanges": "Discard Changes",
"saveSettings": "Save Settings",
"settingsSaved": "Settings saved successfully",
"searchByNameOrEmail": "Search by name or email…",
"search": "Search",
"user": "User",
"role": "Role",
"verified": "Verified",
"exams": "Exams",
"joined": "Joined",
"actions": "Actions",
"noUsersFound": "No users found",
"testEmailSent": "Test email sent successfully! Check your inbox."
},
"editor": {
"back": "← Back",
"examTitlePlaceholder": "Exam title",
"unsavedChanges": "Unsaved changes",
"saving": "Saving…",
"createExam": "Create Exam",
"save": "Save",
"examCreated": "Exam created successfully",
"examSaved": "Exam saved",
"questionsLabel": "Questions",
"addQuestion": "Add Question",
"noQuestions": "No questions yet",
"noQuestionsDesc": "Select a question type from the sidebar to add your first question.",
"questionEditor": "Question Editor",
"prompt": "Prompt",
"promptPlaceholder": "Enter the question prompt…",
"categoryOptional": "Category (optional)",
"categoryPlaceholder": "e.g. Algebra",
"points": "Points",
"explanationOptional": "Explanation (optional)",
"explanationPlaceholder": "Explanation shown after grading…",
"choices": "Choices",
"markAsCorrect": "Mark as correct",
"addChoice": "+ Add Choice",
"remove": "Remove",
"multipleChoice": "Multiple Choice",
"multipleSelect": "Multiple Select",
"shortAnswer": "Short Answer",
"translation": "Translation",
"mathInput": "Math Input",
"essay": "Essay",
"match": "Match",
"order": "Order",
"correctAnswer": "Correct Answer",
"correctAnswerPlaceholder": "Enter the correct answer",
"acceptedForms": "Accepted Forms (optional alternatives)",
"alternativeAnswer": "Alternative answer",
"addAlternative": "+ Add Alternative",
"caseSensitive": "Case sensitive",
"ignorePunctuation": "Ignore punctuation",
"ignoreAccents": "Ignore accents",
"minWords": "Min Words",
"rubricCriteria": "Rubric Criteria",
"criterion": "Criterion",
"addCriterion": "+ Add Criterion",
"leftColumn": "Left Column",
"rightColumn": "Right Column",
"addLeft": "+ Add Left",
"addRight": "+ Add Right",
"correctPairings": "Correct Pairings",
"select": "— Select —",
"items": "Items (drag not implemented, use arrows to reorder)",
"itemPlaceholder": "Item {{index}}",
"correctOrder": "Correct Order",
"correctOrderHint": "The correct order is the order shown above. Reorder items to set the correct sequence.",
"setCurrentOrder": "Set Current Order as Correct",
"examTitleRequired": "Exam title is required",
"atLeastOneQuestion": "Add at least one question",
"sourceLanguage": "Source Language",
"sourceLanguagePlaceholder": "e.g. English",
"targetLanguage": "Target Language",
"targetLanguagePlaceholder": "e.g. Spanish",
"choicesCheckCorrect": "Choices (check correct answers)",
"choicePlaceholder": "Choice {{id}}",
"addItem": "+ Add Item",
"leftItemPlaceholder": "Left item {{index}}",
"rightItemPlaceholder": "Right item {{index}}"
},
"setup": {
"welcome": "Welcome to BrainWave",
"subtitle": "Let's get your instance set up. This only takes a minute.",
"adminAccount": "Admin Account",
"emailSettings": "Email Settings",
"email": "Email",
"displayName": "Display Name",
"password": "Password",
"confirmPassword": "Confirm Password",
"continue": "Continue →",
"smtpHost": "SMTP Host",
"smtpPort": "SMTP Port",
"smtpUsername": "SMTP Username",
"smtpPassword": "SMTP Password",
"fromAddress": "From Address",
"security": "Security",
"appUrl": "App URL",
"back": "← Back",
"completeSetup": "Complete Setup",
"settingUp": "Setting up…",
"setupHint1": "Configure your outgoing email server so BrainWave can send verification and password-reset emails.",
"setupHint2": "You can leave these blank and configure them later in the admin dashboard.",
"autoSsl": "Auto (use SSL on port 465)",
"sslTls": "SSL/TLS",
"noneStarttls": "None / STARTTLS",
"validEmailRequired": "Please enter a valid email address",
"displayNameRequired": "Display name is required",
"passwordMinLength": "Password must be at least 8 characters",
"passwordsDoNotMatch": "Passwords do not match",
"setupFailed": "Setup failed. Please try again."
},
"verify": {
"verifying": "Verifying your email…",
"pleaseWait": "Please wait while we confirm your email address.",
"emailVerified": "Email Verified",
"signIn": "Sign In →",
"verificationFailed": "Verification Failed",
"backToHome": "Back to Home",
"noToken": "No verification token found."
},
"resetPassword": {
"resetPassword": "Reset Password",
"enterEmail": "Enter your email address and we'll send you a link to reset your password.",
"sendResetLink": "Send Reset Link",
"sending": "Sending…",
"backToSignIn": "← Back to sign in",
"checkEmail": "Check Your Email",
"setNewPassword": "Set New Password",
"enterNewPassword": "Enter your new password below.",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"resetting": "Resetting…",
"resetPasswordBtn": "Reset Password",
"passwordUpdated": "Password Updated",
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
"requestFailed": "Request failed",
"passwordResetFailed": "Password reset failed"
}
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './en.json'
import nl from './nl.json'
const resources = {
en: { translation: en },
nl: { translation: nl },
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
})
export default i18n
+398
View File
@@ -0,0 +1,398 @@
{
"common": {
"appName": "BrainWave",
"appTagline": "oefenexamenplatform",
"cancel": "Annuleren",
"confirm": "Bevestigen",
"close": "Sluiten",
"save": "Opslaan",
"delete": "Verwijderen",
"edit": "Bewerken",
"back": "Terug",
"backToHome": "Terug naar home",
"loading": "Laden…",
"error": "Fout",
"success": "Succes",
"yes": "Ja",
"no": "Nee",
"pts": "pnt",
"pt": "pnt",
"questions": "vragen",
"question": "vraag"
},
"landing": {
"eyebrow": "BrainWave · oefenexamenplatform",
"title": "Upload een examen. ",
"titleEmphasis": "Oefen alles.",
"subtitle": "Sleep een JSON-bestand naar binnen om een oefenexamen te starten — wiskunde met volledige LaTeX, taalvertaling, meerkeuze, koppelen, rangschikken, opstellen, en meer. Voortgang wordt automatisch opgeslagen.",
"dropzoneTitle": "Sleep je examen-JSON hierheen",
"dropzoneSub": "of klik om te bladeren · max 5 MB",
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
"downloadInstructionsIcon": "↓",
"supportedTypes": "Ondersteunde types",
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken",
"trySample": "Probeer een voorbeeld",
"resumeTitle": "Hervat lopend examen",
"resumeProgress": "{{answered}}/{{total}} beantwoord",
"resumeButton": "Hervat →",
"pastAttempts": "Eerdere pogingen",
"clearHistory": "Wis geschiedenis",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Nakijken",
"retake": "Opnieuw maken",
"loadError": "Kon niet laden: {{message}}",
"loadSampleError": "Kon voorbeeld niet laden: {{message}}",
"clearHistoryConfirm": "Alle eerdere pogingen wissen?"
},
"auth": {
"signIn": "Inloggen",
"signOut": "Uitloggen",
"register": "Registreren",
"welcomeBack": "Welkom terug",
"createAccount": "Account aanmaken",
"signInSubtitle": "Log in om toegang te krijgen tot je opgeslagen examens en resultaten",
"registerSubtitle": "Maak een account aan om je voortgang op te slaan",
"email": "E-mail",
"password": "Wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"displayName": "Weergavenaam",
"forgotPassword": "Wachtwoord vergeten?",
"signingIn": "Inloggen…",
"creatingAccount": "Account aanmaken…",
"loginFailed": "Inloggen mislukt",
"registrationFailed": "Registratie mislukt",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"myWorkspace": "Mijn Werkruimte",
"adminDashboard": "Admin Dashboard",
"userMenu": "Gebruikersmenu",
"resetPassword": "Wachtwoord resetten",
"resetPasswordDesc": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
"checkYourEmail": "Controleer je e-mail",
"setNewPassword": "Nieuw wachtwoord instellen",
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
"newPassword": "Nieuw wachtwoord",
"resetting": "Resetten…",
"passwordUpdated": "Wachtwoord bijgewerkt",
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
"requestFailed": "Verzoek mislukt",
"passwordResetFailed": "Wachtwoordreset mislukt",
"sendResetLink": "Resetlink versturen",
"sending": "Versturen…",
"backToSignIn": "Terug naar inloggen"
},
"exam": {
"submitExam": "Examen inleveren",
"exitToHome": "Terug naar home",
"questionsLabel": "Vragen",
"progressLabel": "Voortgang",
"progressText": "{{answered}} van {{total}} beantwoord",
"questionHeader": "Vraag {{current}} / {{total}}",
"correct": "Correct",
"notQuite": "Niet helemaal",
"answerLabel": "Antwoord:",
"previous": "← Vorige",
"flagged": "⚑ Gemarkeerd",
"flagForReview": "⚐ Markeer voor controle",
"checkAnswer": "Controleer antwoord",
"next": "Volgende →",
"elapsed": "Verstreken",
"remaining": "Resterend",
"pointsPossible": "Mogelijke punten",
"mode": "Modus",
"immediateFeedback": "Directe feedback",
"gradeAtEnd": "Beoordeling aan het eind",
"notes": "Notities",
"notesPlaceholder": "Voeg korte notities toe terwijl je werkt…",
"author": "Auteur",
"navigateHint": "navigeren",
"flagHint": "markeren",
"selectHint": "selecteren",
"exit": "Afsluiten",
"untimed": "Ongetimed"
},
"results": {
"reviewing": "Eerdere poging bekijken",
"examComplete": "Examen voltooid",
"scored": "Je score: ",
"abovePassing": "Boven de {{passing}}% slaaggrens.",
"belowPassing": "Onder de {{passing}}% slaaggrens. Blijf oefenen.",
"points": "Punten",
"correct": "Correct",
"time": "Tijd",
"byCategory": "Per categorie",
"uncategorized": "Geen categorie",
"review": "Nakijken",
"explanation": "Uitleg",
"rubric": "Rubric (zelfbeoordeling)",
"retakeExam": "Examen opnieuw maken",
"redoFailed": "Foute vragen opnieuw maken",
"cantRetake": "Examen niet meer opgeslagen in werkruimte",
"backToHome": "Terug naar home"
},
"tweaks": {
"title": "Instellingen",
"theme": "Thema",
"light": "Licht",
"dark": "Donker",
"textSize": "Tekstgrootte",
"small": "K",
"medium": "M",
"large": "G",
"feedback": "Feedback",
"auto": "Auto",
"immediate": "Direct",
"end": "Einde"
},
"workspace": {
"myWorkspace": "Mijn Werkruimte",
"myExams": "Mijn Examens",
"results": "Resultaten",
"settings": "Instellingen",
"newExam": "Nieuw Examen",
"noSavedExams": "Nog geen opgeslagen examens",
"noSavedExamsDesc": "Upload een examen vanaf de homepagina, of maak een nieuw examen aan om te beginnen.",
"createFirstExam": "Maak je eerste examen",
"takeExam": "Examen maken",
"noAttempts": "Nog geen pogingen",
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
"attempts": "poging",
"attemptsPlural": "pogingen",
"updated": "Bijgewerkt",
"profile": "Profiel",
"security": "Beveiliging",
"displayNameLabel": "Weergavenaam",
"displayNameDesc": "Je naam die door de app wordt getoond",
"emailLabel": "E-mail",
"emailDesc": "Je e-mailadres",
"saveProfile": "Profiel opslaan",
"saving": "Opslaan…",
"displayNameUpdated": "Weergavenaam bijgewerkt",
"displayNameRequired": "Weergavenaam is verplicht (max 100 tekens)",
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"newPasswordDesc": "Minimaal 8 tekens",
"confirmNewPassword": "Bevestig nieuw wachtwoord",
"changePassword": "Wachtwoord wijzigen",
"changing": "Wijzigen…",
"passwordChanged": "Wachtwoord succesvol gewijzigd",
"passwordMinLength": "Nieuw wachtwoord moet minimaal 8 tekens bevatten",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"language": "Taal",
"languageLabel": "Taal",
"languageDesc": "Selecteer je voorkeurstaal voor de interface",
"appearance": "Weergave",
"themeLabel": "Thema",
"themeDesc": "Kies tussen licht en donker",
"textSizeLabel": "Tekstgrootte",
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
"defaultFeedback": "Standaard Feedback",
"defaultFeedbackLabel": "Feedbackmodus",
"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": {
"administration": "Beheer",
"dashboard": "Dashboard",
"backToSite": "← Terug naar site",
"overview": "Overzicht",
"settings": "Instellingen",
"users": "Gebruikers",
"totalUsers": "Totaal gebruikers",
"savedExams": "Opgeslagen examens",
"examsTaken": "Gemaakte examens",
"avgScore": "Gem. score",
"recentActivity": "Recente activiteit",
"noRecentActivity": "Nog geen recente activiteit",
"general": "Algemeen",
"siteName": "Sitenaam",
"siteNameDesc": "De naam die door de applicatie wordt getoond",
"maxUpload": "Max uploadgrootte (MB)",
"maxUploadDesc": "Maximale bestandsgrootte voor examen-JSON uploads",
"defaultTheme": "Standaardthema",
"defaultThemeDesc": "Standaardthema voor nieuwe bezoekers",
"accessControl": "Toegangsbeheer",
"allowGuestMode": "Gastmodus toestaan",
"allowGuestModeDesc": "Laat niet-ingelogde gebruikers examens maken zonder in te loggen",
"registrationEnabled": "Registratie ingeschakeld",
"registrationEnabledDesc": "Sta nieuwe gebruikers toe om accounts aan te maken",
"emailSmtp": "E-mail / SMTP",
"appUrl": "App-URL",
"appUrlDesc": "Basis-URL die in e-maillinks wordt gebruikt (bijv. https://brainwave.example.com)",
"smtpHost": "SMTP-host",
"smtpHostDesc": "Hostnaam van je SMTP-server",
"smtpPort": "SMTP-poort",
"smtpPortDesc": "Gebruikelijke poorten: 587 (STARTTLS), 465 (SSL), 25 (platte tekst)",
"username": "Gebruikersnaam",
"usernameDesc": "SMTP-authenticatie gebruikersnaam",
"password": "Wachtwoord",
"passwordDesc": "SMTP-authenticatie wachtwoord",
"fromAddress": "Afzenderadres",
"fromAddressDesc": "De afzendernaam en -adres die in e-mails worden getoond",
"tlsMode": "TLS-modus",
"tlsModeDesc": "Auto detecteert vanaf poort (465→SSL). Gebruik expliciete overschrijving indien nodig.",
"auto": "Auto",
"forceTls": "Forceer TLS",
"off": "Uit",
"sendTestEmail": "Test-e-mail versturen",
"sendTestEmailDesc": "Verstuurt een test-e-mail met bovenstaande instellingen (gebruikt conceptwaarden, opslaan niet nodig)",
"sendTest": "Verstuur test",
"sending": "Versturen…",
"maintenance": "Onderhoud",
"maintenanceMode": "Onderhoudsmodus",
"maintenanceModeDesc": "Zet de site offline voor niet-admin gebruikers",
"maintenanceMessage": "Onderhoudsbericht",
"maintenanceMessageDesc": "Bericht dat aan bezoekers wordt getoond tijdens onderhoud",
"discardChanges": "Wijzigingen negeren",
"saveSettings": "Instellingen opslaan",
"settingsSaved": "Instellingen succesvol opgeslagen",
"searchByNameOrEmail": "Zoek op naam of e-mail…",
"search": "Zoeken",
"role": "Rol",
"verified": "Geverifieerd",
"exams": "Examens",
"joined": "Lid sinds",
"actions": "Acties",
"noUsersFound": "Geen gebruikers gevonden",
"testEmailSent": "Test-e-mail succesvol verstuurd! Controleer je inbox."
},
"editor": {
"back": "← Terug",
"examTitlePlaceholder": "Examennaam",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"saving": "Opslaan…",
"createExam": "Examen aanmaken",
"save": "Opslaan",
"examCreated": "Examen succesvol aangemaakt",
"examSaved": "Examen opgeslagen",
"questionsLabel": "Vragen",
"addQuestion": "Vraag toevoegen",
"noQuestions": "Nog geen vragen",
"noQuestionsDesc": "Selecteer een vraagtype in de zijbalk om je eerste vraag toe te voegen.",
"questionEditor": "Vraagbewerker",
"prompt": "Vraagtekst",
"promptPlaceholder": "Voer de vraagtekst in…",
"categoryOptional": "Categorie (optioneel)",
"categoryPlaceholder": "bijv. Algebra",
"points": "Punten",
"explanationOptional": "Uitleg (optioneel)",
"explanationPlaceholder": "Uitleg die na beoordeling wordt getoond…",
"choices": "Keuzes",
"markAsCorrect": "Markeer als correct",
"addChoice": "+ Keuze toevoegen",
"remove": "Verwijderen",
"multipleChoice": "Meerkeuze",
"multipleSelect": "Meervoudige selectie",
"shortAnswer": "Kort antwoord",
"translation": "Vertaling",
"mathInput": "Wiskunde-invoer",
"essay": "Opstel",
"match": "Koppelen",
"order": "Rangschikken",
"correctAnswer": "Correct antwoord",
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
"alternativeAnswer": "Alternatief antwoord",
"addAlternative": "+ Alternatief toevoegen",
"caseSensitive": "Hoofdlettergevoelig",
"ignorePunctuation": "Negeer leestekens",
"ignoreAccents": "Negeer accenten",
"minWords": "Min. woorden",
"rubricCriteria": "Rubric-criteria",
"criterion": "Criterium",
"addCriterion": "+ Criterium toevoegen",
"leftColumn": "Linkerkolom",
"rightColumn": "Rechterkolom",
"addLeft": "+ Links toevoegen",
"addRight": "+ Rechts toevoegen",
"correctPairings": "Correcte koppelingen",
"select": "— Selecteer —",
"items": "Items (slepen niet geïmplementeerd, gebruik pijlen om te rangschikken)",
"itemPlaceholder": "Item {{index}}",
"correctOrder": "Correcte volgorde",
"correctOrderHint": "De correcte volgorde is de volgorde hierboven. Rangschik de items om de juiste volgorde in te stellen.",
"setCurrentOrder": "Huidige volgorde instellen als correct",
"examTitleRequired": "Examennaam is verplicht",
"atLeastOneQuestion": "Voeg minimaal één vraag toe",
"sourceLanguage": "Brontaal",
"sourceLanguagePlaceholder": "bijv. Engels",
"targetLanguage": "Doeltaal",
"targetLanguagePlaceholder": "bijv. Spaans",
"choicesCheckCorrect": "Keuzes (vink correcte antwoorden aan)",
"choicePlaceholder": "Keuze {{id}}",
"addItem": "+ Item toevoegen",
"leftItemPlaceholder": "Link item {{index}}",
"rightItemPlaceholder": "Recht item {{index}}"
},
"setup": {
"welcome": "Welkom bij BrainWave",
"subtitle": "Laten we je instantie instellen. Dit duurt maar een minuut.",
"adminAccount": "Admin-account",
"emailSettings": "E-mailinstellingen",
"email": "E-mail",
"displayName": "Weergavenaam",
"password": "Wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"continue": "Doorgaan →",
"smtpHost": "SMTP-host",
"smtpPort": "SMTP-poort",
"smtpUsername": "SMTP-gebruikersnaam",
"smtpPassword": "SMTP-wachtwoord",
"fromAddress": "Afzenderadres",
"security": "Beveiliging",
"appUrl": "App-URL",
"back": "← Terug",
"completeSetup": "Installatie voltooien",
"settingUp": "Bezig met instellen…",
"setupHint1": "Configureer je uitgaande e-mailserver zodat BrainWave verificatie- en wachtwoordreset-e-mails kan versturen.",
"setupHint2": "Je kunt deze velden leeglaten en later configureren in het admin dashboard.",
"autoSsl": "Auto (gebruik SSL op poort 465)",
"sslTls": "SSL/TLS",
"noneStarttls": "Geen / STARTTLS",
"validEmailRequired": "Voer een geldig e-mailadres in",
"displayNameRequired": "Weergavenaam is verplicht",
"passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"setupFailed": "Installatie mislukt. Probeer het opnieuw."
},
"verify": {
"verifying": "E-mail verifiëren…",
"pleaseWait": "Een moment geduld terwijl we je e-mailadres bevestigen.",
"emailVerified": "E-mail geverifieerd",
"signIn": "Inloggen →",
"verificationFailed": "Verificatie mislukt",
"backToHome": "Terug naar home",
"noToken": "Geen verificatietoken gevonden."
},
"resetPassword": {
"resetPassword": "Wachtwoord resetten",
"enterEmail": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
"sendResetLink": "Resetlink versturen",
"sending": "Versturen…",
"backToSignIn": "← Terug naar inloggen",
"checkEmail": "Controleer je e-mail",
"setNewPassword": "Nieuw wachtwoord instellen",
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
"newPassword": "Nieuw wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"resetting": "Resetten…",
"resetPasswordBtn": "Wachtwoord resetten",
"passwordUpdated": "Wachtwoord bijgewerkt",
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
"requestFailed": "Verzoek mislukt",
"passwordResetFailed": "Wachtwoordreset mislukt"
}
}
+2598 -1276
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./index.css"; import "./index.css";
import "./i18n";
import App from "./App.tsx"; import App from "./App.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
+2
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;
@@ -89,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 {
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { ApiError } from './api'
describe('ApiError', () => {
it('extends Error', () => {
const err = new ApiError(404, 'Not found')
expect(err).toBeInstanceOf(Error)
})
it('sets status and message', () => {
const err = new ApiError(500, 'Server error')
expect(err.status).toBe(500)
expect(err.message).toBe('Server error')
})
it('sets name to ApiError', () => {
const err = new ApiError(401, 'Unauthorized')
expect(err.name).toBe('ApiError')
})
})
+2 -2
View File
@@ -47,9 +47,9 @@ export const highlightCode = (code: string, _lang: string): string => {
// Extract comments and strings into placeholders before applying keyword/number/fn regexes // Extract comments and strings into placeholders before applying keyword/number/fn regexes
// so later passes don't corrupt previously-injected span tag attributes. // so later passes don't corrupt previously-injected span tag attributes.
// is a Unicode private-use-area char that never appears in source code. // \uE000 is a Unicode private-use-area char that never appears in source code.
// Word chars around the index (e.g. _HL0_) prevent \b(\d+)\b from matching the index digits. // Word chars around the index (e.g. _HL0_) prevent \b(\d+)\b from matching the index digits.
let result = escaped const result = escaped
.replaceAll(/(\/\/[^\n]*|#[^\n]*)/g, (m) => { .replaceAll(/(\/\/[^\n]*|#[^\n]*)/g, (m) => {
const i = spans.length const i = spans.length
spans.push(`<span class="cm-comment">${m}</span>`) spans.push(`<span class="cm-comment">${m}</span>`)
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { slugify } from './slug' import { slugify, getExamUrlId, extractExamSlug } from './slug'
describe('slugify', () => { describe('slugify', () => {
it('lowercases', () => { it('lowercases', () => {
@@ -24,3 +24,31 @@ describe('slugify', () => {
expect(slugify('')).toBe('') expect(slugify('')).toBe('')
}) })
}) })
describe('getExamUrlId', () => {
it('returns slug with id when id exists', () => {
expect(getExamUrlId({ id: 'abc12345', title: 'My Exam' })).toBe('my-exam-abc12345')
})
it('returns slug only when id is missing', () => {
expect(getExamUrlId({ title: 'My Exam' })).toBe('my-exam')
})
})
describe('extractExamSlug', () => {
it('extracts slug from slug-id format', () => {
expect(extractExamSlug('my-exam-abc12345')).toBe('my-exam')
})
it('returns full string when no dash', () => {
expect(extractExamSlug('myexam')).toBe('myexam')
})
it('returns full string when suffix is too short to be an id', () => {
expect(extractExamSlug('my-exam-abc')).toBe('my-exam-abc')
})
it('handles slug with multiple dashes', () => {
expect(extractExamSlug('my-cool-exam-abc12345678')).toBe('my-cool-exam')
})
})
+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
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './storage'
const storage: Record<string, string> = {}
beforeEach(() => {
Object.keys(storage).forEach((k) => delete storage[k])
globalThis.localStorage = {
getItem: (key: string) => storage[key] ?? null,
setItem: (key: string, value: string) => { storage[key] = value },
removeItem: (key: string) => { delete storage[key] },
length: 0,
key: () => null,
clear: () => { Object.keys(storage).forEach((k) => delete storage[k]) },
} as Storage
})
describe('loadState / saveState / clearState', () => {
it('returns null when nothing saved', () => {
expect(loadState()).toBeNull()
})
it('returns saved state', () => {
const data = { exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } }
saveState(data)
expect(loadState()).toEqual(data)
})
it('clears saved state', () => {
saveState({ exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
clearState()
expect(loadState()).toBeNull()
})
it('returns null for corrupted json', () => {
localStorage.setItem('brainwave.exam.state', 'not-json')
expect(loadState()).toBeNull()
})
})
describe('loadHistory / appendHistory / clearHistory', () => {
it('returns empty array when nothing saved', () => {
expect(loadHistory()).toEqual([])
})
it('appends entries and limits to 30', () => {
for (let i = 0; i < 35; i++) {
appendHistory({ id: String(i), title: `Exam ${i}`, completedAt: Date.now(), elapsedMs: 0, gradedCount: 10, correctCount: i, totalPoints: 10, scorePoints: i, passingScore: 5, exam: { title: `Exam ${i}`, questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
}
const hist = loadHistory()
expect(hist.length).toBe(30)
expect(hist[0].id).toBe('34')
})
it('clears history', () => {
appendHistory({ id: '1', title: 'E', completedAt: Date.now(), elapsedMs: 0, gradedCount: 1, correctCount: 1, totalPoints: 1, scorePoints: 1, passingScore: 1, exam: { title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
clearHistory()
expect(loadHistory()).toEqual([])
})
it('returns empty array for corrupted json', () => {
localStorage.setItem('brainwave.exam.history', 'not-json')
expect(loadHistory()).toEqual([])
})
})
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { validateExam, parseExam } from './validation'
describe('validateExam', () => {
it('returns error for non-object', () => {
expect(validateExam(null)).toBe('Not a JSON object')
expect(validateExam('string')).toBe('Not a JSON object')
expect(validateExam(123)).toBe('Not a JSON object')
})
it('returns error for missing title', () => {
expect(validateExam({ questions: [] })).toBe('Missing "title"')
})
it('returns error for missing questions array', () => {
expect(validateExam({ title: 'Exam' })).toBe('Missing "questions" array')
})
it('returns error for empty questions array', () => {
expect(validateExam({ title: 'Exam', questions: [] })).toBe('Missing "questions" array')
})
it('returns error for question missing id', () => {
expect(validateExam({ title: 'Exam', questions: [{ type: 'essay', prompt: 'Q' }] })).toBe('Question missing id')
})
it('returns error for question missing type', () => {
expect(validateExam({ title: 'Exam', questions: [{ id: '1', prompt: 'Q' }] })).toBe('Question "1" missing type')
})
it('returns error for question missing prompt', () => {
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay' }] })).toBe('Question "1" missing prompt')
})
it('returns null for valid exam', () => {
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay', prompt: 'Q' }] })).toBe(null)
})
})
describe('parseExam', () => {
it('generates id when missing', () => {
const exam = parseExam({ title: 'T', questions: [] })
expect(exam.id).toBeTruthy()
expect(typeof exam.id).toBe('string')
})
it('preserves existing id', () => {
const exam = parseExam({ id: 'abc123', title: 'T', questions: [] })
expect(exam.id).toBe('abc123')
})
})
+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": {
@@ -0,0 +1 @@
{"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}]]}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "brainwave",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"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"
}
}