diff --git a/.gitignore b/.gitignore index e0711bb..704ef12 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ backend/db/brainwave.db-shm backend/db/brainwave.db-wal node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json brainwave.db-shm +brainwave.db-wal diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 30a555b..785d919 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import rateLimit from 'express-rate-limit' import { validateBody } from '../middleware/validate.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' @@ -51,6 +52,7 @@ router.post( // GET /api/auth/me router.get( '/me', + requireAuth, asyncHandler(async (req: Request, res) => { const user = authService.getMe(req.user!.userId) res.json(user) @@ -60,6 +62,7 @@ router.get( // 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) @@ -70,6 +73,7 @@ router.put( // 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) @@ -112,6 +116,7 @@ router.post( // DELETE /api/auth/me router.delete( '/me', + requireAuth, asyncHandler(async (req: Request, res) => { authService.deleteUserAccount(req.user!.userId) res.json({ success: true }) diff --git a/backend/src/routes/exams.ts b/backend/src/routes/exams.ts index 89d59a8..1a5d439 100644 --- a/backend/src/routes/exams.ts +++ b/backend/src/routes/exams.ts @@ -31,7 +31,8 @@ router.get( if (!exam) { throw new AppError(404, 'Exam not found') } - res.json(exam) + const parsedExam = JSON.parse(exam.exam_json) + res.json({ ...parsedExam, id: exam.id }) }) ) diff --git a/backend/src/routes/setup.ts b/backend/src/routes/setup.ts index c91989f..1f04afb 100644 --- a/backend/src/routes/setup.ts +++ b/backend/src/routes/setup.ts @@ -6,7 +6,7 @@ import { AppError } from '../middleware/error.js' import * as userRepo from '../repositories/userRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js' import { setupSchema } from '../validators/setupSchemas.js' -import { sendTestEmail } from '../utils/email.js' +import { signToken } from '../utils/tokens.js' const router = Router() @@ -41,11 +41,24 @@ router.post( throw new AppError(400, 'Users already exist') } - const { email, password, displayName, siteTitle, language } = req.body + const { + email, + password, + displayName, + siteTitle, + language, + smtpHost, + smtpPort, + smtpUser, + smtpPass, + smtpFrom, + smtpSecure, + appUrl, + } = req.body const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) - userRepo.createUser({ + const { id } = userRepo.createUser({ email, passwordHash, displayName, @@ -53,16 +66,39 @@ router.post( emailVerified: 1, }) - settingsRepo.setSettingsBatch([ - ['site_title', siteTitle.trim()], - ['site_language', language], + const settings: Array<[string, string]> = [ + ['site_title', siteTitle?.trim() || 'BrainWave'], + ['site_language', language || 'en'], ['maintenance_mode', 'false'], - ]) + ] - // Send a test email to the new admin - sendTestEmail(email).catch(() => {}) + 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]) - res.status(201).json({ success: true }) + settingsRepo.setSettingsBatch(settings) + + const token = signToken({ + userId: id, + email, + displayName, + role: 'admin', + }) + + res.status(201).json({ + message: 'Setup completed successfully', + token, + user: { + id, + email, + displayName, + role: 'admin', + }, + }) }) ) diff --git a/backend/src/validators/setupSchemas.ts b/backend/src/validators/setupSchemas.ts index 085bf6e..2fd770f 100644 --- a/backend/src/validators/setupSchemas.ts +++ b/backend/src/validators/setupSchemas.ts @@ -4,11 +4,19 @@ export const setupSchema = z.object({ email: z.string().email().max(255), password: z.string().min(8, 'Password must be at least 8 characters'), displayName: z.string().min(1, 'Display name is required').max(100), - siteTitle: z.string().min(1, 'Site title is required').max(255), - language: z.enum(['en', 'nl']), + 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(), }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccda1c1..f629d69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -20,6 +20,7 @@ import { } from "./utils/storage"; import { isAuthenticated } from "./utils/api"; import * as resultApi from "./api/resultApi"; +import * as examApi from "./api/examApi"; import { getExamUrlId } from "./utils/slug"; import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; @@ -49,16 +50,6 @@ const withNotes = (state: ExamState): ExamState => ({ function AppInner() { const { t, i18n } = useTranslation(); const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth(); - - if (needsSetup) { - return ( - { - void refreshUser() - }} - /> - ) - } const navigate = useNavigate(); const { pathname } = useLocation(); const [exam, setExam] = useState(null); @@ -68,6 +59,7 @@ function AppInner() { const [tweaksOpen, setTweaksOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false); + const savedExamIdRef = useRef(null); useEffect(() => { if (!authLoading && user && isAuthenticated()) { @@ -157,7 +149,16 @@ function AppInner() { setExamState(withNotes(state)); }; - const loadExam = (data: Exam) => { + const loadExam = (data: Exam, saveToWorkspace = 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 + }); + } const fm = tweaks.feedbackMode === "auto" ? (data.feedbackMode ?? "end") @@ -213,6 +214,7 @@ function AppInner() { if (user && isAuthenticated()) { void resultApi.submitResult({ + exam_id: savedExamIdRef.current, title: entry.title, score_points: entry.scorePoints, total_points: entry.totalPoints, @@ -222,6 +224,8 @@ function AppInner() { elapsed_ms: entry.elapsedMs, state: entry.state as unknown, exam: entry.exam as unknown, + }).then((data) => { + sessionStorage.setItem('brainwave.pendingResultId', String(data.id)); }).catch(() => { appendHistory(entry); }); @@ -238,6 +242,8 @@ function AppInner() { const exit = () => { setExam(null); setExamState(null); + savedExamIdRef.current = null; + sessionStorage.removeItem('brainwave.pendingResultId'); navigate("/"); }; @@ -279,11 +285,40 @@ function AppInner() { } }; + const [pendingResultId, setPendingResultId] = useState(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 = () => { navigate("/"); setAuthModalOpen(true); }; + if (needsSetup) { + return ( + void refreshUser()} /> + ); + } + if (pathname === "/verify") { return (
@@ -471,6 +506,8 @@ function AppInner() { onExit={exit} reviewOnly={false} /> + ) : user && isAuthenticated() ? ( +
{t('common.loading')}
) : ( ) diff --git a/frontend/src/components/Landing.tsx b/frontend/src/components/Landing.tsx index 1d44a61..4fa0074 100644 --- a/frontend/src/components/Landing.tsx +++ b/frontend/src/components/Landing.tsx @@ -6,7 +6,7 @@ import { loadState } from '../utils/storage' import { formatDate, formatTime } from '../utils/formatting' interface Props { - onLoadExam: (exam: Exam) => void + onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void history: HistoryEntry[] onResume: () => void onClearHistory: () => void @@ -32,7 +32,7 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory, const data = JSON.parse(text) as unknown const msg = validateExam(data) if (msg) throw new Error(msg) - onLoadExam(parseExam(data)) + onLoadExam(parseExam(data), true) } catch (e) { setErr(t('landing.loadError', { message: (e as Error).message })) } diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 11f85c7..0563225 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -274,7 +274,6 @@ export default function SetupWizard({ onComplete }: Readonly) { value={appUrl} onChange={(e) => setAppUrl(e.target.value)} placeholder="https://brainwave.example.com" - required />