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:
@@ -26,12 +26,14 @@ test('admin invites user; user accepts and logs in', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /Account aanmaken/ }).click();
|
||||
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible();
|
||||
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.getByLabel(/E-mailadres/).fill(adminEmail);
|
||||
await page.getByLabel(/Wachtwoord/).fill(adminPw);
|
||||
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');
|
||||
const inviteeEmail = `invitee+${Date.now()}@example.com`;
|
||||
|
||||
@@ -27,16 +27,14 @@ test('register → verify → login → create lesson → add card → practice
|
||||
|
||||
const link = await fetchVerifyLink(email);
|
||||
await page.goto(link);
|
||||
// Verify endpoint is called on mount; React StrictMode in dev triggers it twice
|
||||
// (second call fails because token is already consumed). The DB is updated by
|
||||
// the first call, so we can safely proceed regardless of UI state.
|
||||
await expect(page.getByRole('heading', { name: 'E-mailverificatie' })).toBeVisible();
|
||||
// Wait for the verify POST to finish before logging in.
|
||||
await expect(page.getByRole('link', { name: 'Naar inloggen' })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/E-mailadres/).fill(email);
|
||||
await page.getByLabel(/Wachtwoord/).fill(password);
|
||||
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.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les');
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createApp(db: Db): Express {
|
||||
app.use('/api/lessons', requireAuth, verifyCsrf, lessonsRouter(db));
|
||||
app.use('/api', requireAuth, verifyCsrf, cardsRouter(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));
|
||||
|
||||
// Static frontend in production
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user