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
This commit is contained in:
LaurensBartelds committed 2026-04-26 22:53:00 +02:00
1 parent ca9c646b65
commit 83bb412b35
8 files changed
+115 -28

No files matched your search

+1
View File
@@ -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
+5
View File
@@ -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 })
+2 -1
View File
@@ -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 })
})
)
+46 -10
View File
@@ -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',
},
})
})
)
+10 -2
View File
@@ -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(),
})
+49 -12
View File
@@ -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 (
<SetupWizard
onComplete={() => {
void refreshUser()
}}
/>
)
}
const navigate = useNavigate();
const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(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<number | null>(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<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 = () => {
navigate("/");
setAuthModalOpen(true);
};
if (needsSetup) {
return (
<SetupWizard onComplete={() => void refreshUser()} />
);
}
if (pathname === "/verify") {
return (
<div className="shell">
@@ -471,6 +506,8 @@ function AppInner() {
onExit={exit}
reviewOnly={false}
/>
) : user && isAuthenticated() ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : (
<Navigate to="/" replace />
)
+2 -2
View File
@@ -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 }))
}
-1
View File
@@ -274,7 +274,6 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://brainwave.example.com"
required
/>
</div>