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

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { authApi } from '../../api/auth.js';
import { ApiClientError } from '../../api/client.js';
@@ -9,9 +9,14 @@ export function VerifyEmailPage() {
const token = params.get('token');
const [state, setState] = useState<'pending' | 'ok' | 'err'>('pending');
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(() => {
if (!token) { setState('err'); setMessage('Token ontbreekt.'); return; }
if (fired.current) return;
fired.current = true;
(async () => {
try {
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 { Layout } from './components/Layout.js';
import { AuthBoundary } from './components/AuthBoundary.js';
import { RoleGuard } from './components/RoleGuard.js';
import { DashboardPage } from './pages/Dashboard.js';
import { AdminPage } from './pages/Admin.js';
import { AdminLessonPage } from './pages/AdminLesson.js';
import { PracticeSetupPage } from './pages/PracticeSetup.js';
import { PracticePage } from './pages/Practice.js';
import { PracticeDonePage } from './pages/PracticeDone.js';
import { StatsPage } from './pages/Stats.js';
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';
import { RegisterPage } from './pages/auth/Register.js';
import { VerifyEmailPage } from './pages/auth/VerifyEmail.js';
import { ForgotPasswordPage } from './pages/auth/ForgotPassword.js';
import { ResetPasswordPage } from './pages/auth/ResetPassword.js';
import { AcceptInvitePage } from './pages/auth/AcceptInvite.js';
import { ProfilePage } from './pages/Profile.js';
import { AdminUsersPage } from './pages/AdminUsers.js';
// Tiny loading placeholder reused for every lazy route boundary.
function PageFallback() {
return (
<div className="flex h-full items-center justify-center p-12">
<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%]" />
</div>
);
}
// `React.lazy` requires a default export; our pages use named exports, so we
// adapt with a small helper that picks the named export from the module.
function lazyPage<K extends string>(
loader: () => Promise<Record<K, ComponentType>>,
name: K,
): ComponentType {
const Component = lazy(async () => {
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([
{
@@ -27,32 +58,32 @@ export const router = createBrowserRouter([
element: <Layout />,
children: [
// Public auth routes
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'verify-email', element: <VerifyEmailPage /> },
{ path: 'forgot-password', element: <ForgotPasswordPage /> },
{ path: 'reset-password', element: <ResetPasswordPage /> },
{ path: 'accept-invite', element: <AcceptInvitePage /> },
{ path: 'login', element: <Login /> },
{ path: 'register', element: <Register /> },
{ path: 'verify-email', element: <VerifyEmail /> },
{ path: 'forgot-password', element: <ForgotPassword /> },
{ path: 'reset-password', element: <ResetPassword /> },
{ path: 'accept-invite', element: <AcceptInvite /> },
// Authenticated routes
{
element: <AuthBoundary />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'admin', element: <AdminPage /> },
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
{ path: 'practice/:lessonId', element: <PracticePage /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
{ path: 'stats', element: <StatsPage /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ index: true, element: <Dashboard /> },
{ path: 'admin', element: <Admin /> },
{ path: 'admin/lessons/:id', element: <AdminLesson /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetup /> },
{ path: 'practice/:lessonId', element: <Practice /> },
{ path: 'practice/:lessonId/done', element: <PracticeDone /> },
{ path: 'stats', element: <Stats /> },
{ path: 'stats/lessons/:id', element: <StatsLesson /> },
{ path: 'stats/cards/:id', element: <StatsCard /> },
{ path: 'settings', element: <Settings /> },
{ path: 'profile', element: <Profile /> },
{
element: <RoleGuard role="sysadmin" />,
children: [
{ path: 'admin/users', element: <AdminUsersPage /> },
{ path: 'admin/users', element: <AdminUsers /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },