chore(auth): non-blocking follow-ups from final review

- /api/stats: add verifyCsrf middleware (defense-in-depth; no-op for GETs)
- VerifyEmailPage: useRef guard to prevent React StrictMode double-fire of
  the single-use verify token in dev
- router.tsx: route-level code splitting via React.lazy + Suspense; initial
  bundle drops from 397 KB to 224 KB with per-route chunks (0.3–14 KB each)
- e2e: wait for verify-email completion before login; bump Account-menu
  timeout to handle Vite cold-chunk compile
This commit is contained in:
2026-05-20 23:27:52 +02:00
parent 5739b6d941
commit e27c1ca06c
5 changed files with 80 additions and 44 deletions

View File

@@ -26,12 +26,14 @@ test('admin invites user; user accepts and logs in', async ({ page }) => {
await page.getByRole('button', { name: /Account aanmaken/ }).click(); await page.getByRole('button', { name: /Account aanmaken/ }).click();
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible(); await expect(page.getByText(/bevestigingsmail/i)).toBeVisible();
await page.goto(await fetchLink(adminEmail, 'verify-email')); await page.goto(await fetchLink(adminEmail, 'verify-email'));
await expect(page.getByRole('link', { name: 'Naar inloggen' })).toBeVisible({ timeout: 10_000 });
await page.goto('/login'); await page.goto('/login');
await page.getByLabel(/E-mailadres/).fill(adminEmail); await page.getByLabel(/E-mailadres/).fill(adminEmail);
await page.getByLabel(/Wachtwoord/).fill(adminPw); await page.getByLabel(/Wachtwoord/).fill(adminPw);
await page.getByRole('button', { name: 'Inloggen' }).click(); await page.getByRole('button', { name: 'Inloggen' }).click();
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible(); // Cold Vite chunks can take a few seconds to compile on the first run.
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible({ timeout: 15_000 });
await page.goto('/admin/users'); await page.goto('/admin/users');
const inviteeEmail = `invitee+${Date.now()}@example.com`; const inviteeEmail = `invitee+${Date.now()}@example.com`;

View File

@@ -27,16 +27,14 @@ test('register → verify → login → create lesson → add card → practice
const link = await fetchVerifyLink(email); const link = await fetchVerifyLink(email);
await page.goto(link); await page.goto(link);
// Verify endpoint is called on mount; React StrictMode in dev triggers it twice // Wait for the verify POST to finish before logging in.
// (second call fails because token is already consumed). The DB is updated by await expect(page.getByRole('link', { name: 'Naar inloggen' })).toBeVisible({ timeout: 10_000 });
// the first call, so we can safely proceed regardless of UI state.
await expect(page.getByRole('heading', { name: 'E-mailverificatie' })).toBeVisible();
await page.goto('/login'); await page.goto('/login');
await page.getByLabel(/E-mailadres/).fill(email); await page.getByLabel(/E-mailadres/).fill(email);
await page.getByLabel(/Wachtwoord/).fill(password); await page.getByLabel(/Wachtwoord/).fill(password);
await page.getByRole('button', { name: 'Inloggen' }).click(); await page.getByRole('button', { name: 'Inloggen' }).click();
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible({ timeout: 15_000 });
await page.goto('/admin'); await page.goto('/admin');
await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les'); await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les');

View File

@@ -32,7 +32,7 @@ export function createApp(db: Db): Express {
app.use('/api/lessons', requireAuth, verifyCsrf, lessonsRouter(db)); app.use('/api/lessons', requireAuth, verifyCsrf, lessonsRouter(db));
app.use('/api', requireAuth, verifyCsrf, cardsRouter(db)); app.use('/api', requireAuth, verifyCsrf, cardsRouter(db));
app.use('/api/sessions', requireAuth, verifyCsrf, sessionsRouter(db)); app.use('/api/sessions', requireAuth, verifyCsrf, sessionsRouter(db));
app.use('/api/stats', requireAuth, statsRouter(db)); app.use('/api/stats', requireAuth, verifyCsrf, statsRouter(db));
app.use('/api/admin/users', requireAuth, requireRole('sysadmin'), verifyCsrf, adminUsersRouter(db)); app.use('/api/admin/users', requireAuth, requireRole('sysadmin'), verifyCsrf, adminUsersRouter(db));
// Static frontend in production // Static frontend in production

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { authApi } from '../../api/auth.js'; import { authApi } from '../../api/auth.js';
import { ApiClientError } from '../../api/client.js'; import { ApiClientError } from '../../api/client.js';
@@ -9,9 +9,14 @@ export function VerifyEmailPage() {
const token = params.get('token'); const token = params.get('token');
const [state, setState] = useState<'pending' | 'ok' | 'err'>('pending'); const [state, setState] = useState<'pending' | 'ok' | 'err'>('pending');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
// Single-fire guard: React StrictMode double-invokes effects in dev,
// which would consume the single-use token twice and show a misleading error.
const fired = useRef(false);
useEffect(() => { useEffect(() => {
if (!token) { setState('err'); setMessage('Token ontbreekt.'); return; } if (!token) { setState('err'); setMessage('Token ontbreekt.'); return; }
if (fired.current) return;
fired.current = true;
(async () => { (async () => {
try { try {
await authApi.verifyEmail({ token }); await authApi.verifyEmail({ token });

View File

@@ -1,25 +1,56 @@
import { lazy, Suspense, type ComponentType } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom'; import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js'; import { Layout } from './components/Layout.js';
import { AuthBoundary } from './components/AuthBoundary.js'; import { AuthBoundary } from './components/AuthBoundary.js';
import { RoleGuard } from './components/RoleGuard.js'; import { RoleGuard } from './components/RoleGuard.js';
import { DashboardPage } from './pages/Dashboard.js';
import { AdminPage } from './pages/Admin.js'; // Tiny loading placeholder reused for every lazy route boundary.
import { AdminLessonPage } from './pages/AdminLesson.js'; function PageFallback() {
import { PracticeSetupPage } from './pages/PracticeSetup.js'; return (
import { PracticePage } from './pages/Practice.js'; <div className="flex h-full items-center justify-center p-12">
import { PracticeDonePage } from './pages/PracticeDone.js'; <div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
import { StatsPage } from './pages/Stats.js'; </div>
import { StatsLessonPage } from './pages/StatsLesson.js'; );
import { StatsCardPage } from './pages/StatsCard.js'; }
import { SettingsPage } from './pages/Settings.js';
import { LoginPage } from './pages/auth/Login.js'; // `React.lazy` requires a default export; our pages use named exports, so we
import { RegisterPage } from './pages/auth/Register.js'; // adapt with a small helper that picks the named export from the module.
import { VerifyEmailPage } from './pages/auth/VerifyEmail.js'; function lazyPage<K extends string>(
import { ForgotPasswordPage } from './pages/auth/ForgotPassword.js'; loader: () => Promise<Record<K, ComponentType>>,
import { ResetPasswordPage } from './pages/auth/ResetPassword.js'; name: K,
import { AcceptInvitePage } from './pages/auth/AcceptInvite.js'; ): ComponentType {
import { ProfilePage } from './pages/Profile.js'; const Component = lazy(async () => {
import { AdminUsersPage } from './pages/AdminUsers.js'; const mod = await loader();
return { default: mod[name] as ComponentType };
});
return function LazyPage() {
return (
<Suspense fallback={<PageFallback />}>
<Component />
</Suspense>
);
};
}
const Dashboard = lazyPage(() => import('./pages/Dashboard.js'), 'DashboardPage');
const Admin = lazyPage(() => import('./pages/Admin.js'), 'AdminPage');
const AdminLesson = lazyPage(() => import('./pages/AdminLesson.js'), 'AdminLessonPage');
const PracticeSetup = lazyPage(() => import('./pages/PracticeSetup.js'), 'PracticeSetupPage');
const Practice = lazyPage(() => import('./pages/Practice.js'), 'PracticePage');
const PracticeDone = lazyPage(() => import('./pages/PracticeDone.js'), 'PracticeDonePage');
const Stats = lazyPage(() => import('./pages/Stats.js'), 'StatsPage');
const StatsLesson = lazyPage(() => import('./pages/StatsLesson.js'), 'StatsLessonPage');
const StatsCard = lazyPage(() => import('./pages/StatsCard.js'), 'StatsCardPage');
const Settings = lazyPage(() => import('./pages/Settings.js'), 'SettingsPage');
const Profile = lazyPage(() => import('./pages/Profile.js'), 'ProfilePage');
const AdminUsers = lazyPage(() => import('./pages/AdminUsers.js'), 'AdminUsersPage');
const Login = lazyPage(() => import('./pages/auth/Login.js'), 'LoginPage');
const Register = lazyPage(() => import('./pages/auth/Register.js'), 'RegisterPage');
const VerifyEmail = lazyPage(() => import('./pages/auth/VerifyEmail.js'), 'VerifyEmailPage');
const ForgotPassword = lazyPage(() => import('./pages/auth/ForgotPassword.js'), 'ForgotPasswordPage');
const ResetPassword = lazyPage(() => import('./pages/auth/ResetPassword.js'), 'ResetPasswordPage');
const AcceptInvite = lazyPage(() => import('./pages/auth/AcceptInvite.js'), 'AcceptInvitePage');
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -27,32 +58,32 @@ export const router = createBrowserRouter([
element: <Layout />, element: <Layout />,
children: [ children: [
// Public auth routes // Public auth routes
{ path: 'login', element: <LoginPage /> }, { path: 'login', element: <Login /> },
{ path: 'register', element: <RegisterPage /> }, { path: 'register', element: <Register /> },
{ path: 'verify-email', element: <VerifyEmailPage /> }, { path: 'verify-email', element: <VerifyEmail /> },
{ path: 'forgot-password', element: <ForgotPasswordPage /> }, { path: 'forgot-password', element: <ForgotPassword /> },
{ path: 'reset-password', element: <ResetPasswordPage /> }, { path: 'reset-password', element: <ResetPassword /> },
{ path: 'accept-invite', element: <AcceptInvitePage /> }, { path: 'accept-invite', element: <AcceptInvite /> },
// Authenticated routes // Authenticated routes
{ {
element: <AuthBoundary />, element: <AuthBoundary />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <Dashboard /> },
{ path: 'admin', element: <AdminPage /> }, { path: 'admin', element: <Admin /> },
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> }, { path: 'admin/lessons/:id', element: <AdminLesson /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> }, { path: 'practice/:lessonId/setup', element: <PracticeSetup /> },
{ path: 'practice/:lessonId', element: <PracticePage /> }, { path: 'practice/:lessonId', element: <Practice /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> }, { path: 'practice/:lessonId/done', element: <PracticeDone /> },
{ path: 'stats', element: <StatsPage /> }, { path: 'stats', element: <Stats /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> }, { path: 'stats/lessons/:id', element: <StatsLesson /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> }, { path: 'stats/cards/:id', element: <StatsCard /> },
{ path: 'settings', element: <SettingsPage /> }, { path: 'settings', element: <Settings /> },
{ path: 'profile', element: <ProfilePage /> }, { path: 'profile', element: <Profile /> },
{ {
element: <RoleGuard role="sysadmin" />, element: <RoleGuard role="sysadmin" />,
children: [ children: [
{ path: 'admin/users', element: <AdminUsersPage /> }, { path: 'admin/users', element: <AdminUsers /> },
], ],
}, },
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },