Public Access
feat: enhance setup process with SMTP configuration and token generation
fix: require authentication for certain routes and improve exam data handling chore: update .gitignore to include additional database files
This commit is contained in:
1 parent
ca9c646b65
commit
83bb412b35
8 files changed
+115
-28
No files matched your search
@@ -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
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user