Adds front-end set-up

This commit is contained in:
2026-06-20 17:04:17 +02:00
parent efd1569c26
commit 7dfc3a9692
62 changed files with 6875 additions and 3 deletions
+43
View File
@@ -0,0 +1,43 @@
// Domain types shared across the frontend. Names align with backend payloads
// (the user property is `name`, never `naam`) — see BR-U1-10.
// Roles aligned with backend authorization.
export type UserRole = 'Owner' | 'Administrator' | 'User';
export interface User {
id: string; // UUID
email: string;
name: string;
role: UserRole;
isActive: boolean;
}
export interface AuthResponse {
accessToken: string; // JWT access token
expiresAt: string; // ISO timestamp
user: User;
}
export interface LoginRequest {
email: string;
password: string;
}
// RFC 9457 ProblemDetails for standardized error handling.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
// Extension members (e.g. traceId, errors).
[extension: string]: unknown;
}
// Convenience wrapper for API results (optional usage).
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDetails };
// Setup status (used by guards during bootstrap).
export interface SetupStatus {
initialized: boolean;
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { Sidebar } from '@/components/layout/Sidebar';
import { Topbar } from '@/components/layout/Topbar';
/**
* Shell for authenticated routes. Renders the persistent chrome and reacts to
* runtime auth loss (e.g. a failed refresh during an API call) by redirecting
* to /login (BR-U1-14).
*/
export function AppLayout() {
const { isAuthenticated, status } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (status === 'guest') {
void navigate({ to: '/login' });
}
}, [status, navigate]);
if (!isAuthenticated) {
return null;
}
return (
<div className="flex min-h-svh">
<Sidebar />
<div className="flex flex-1 flex-col">
<Topbar />
<main className="flex-1 p-6" data-testid="app-main">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavItem {
to: string;
labelKey: string;
icon: LucideIcon;
testId: string;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' },
];
export function Sidebar() {
const { t } = useTranslation();
return (
<aside
className="hidden w-64 shrink-0 border-r border-border bg-card md:flex md:flex-col"
data-testid="app-sidebar"
>
<div className="flex h-16 items-center gap-2 border-b border-border px-6">
<span className="h-3 w-3 rounded-full bg-primary" aria-hidden="true" />
<span className="text-lg font-semibold">{t('common.appName')}</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
data-testid={item.testId}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
activeProps={{
className: cn('bg-accent text-accent-foreground'),
}}
>
<Icon className="size-4" />
{t(item.labelKey)}
</Link>
);
})}
</nav>
</aside>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { UserMenu } from '@/components/layout/UserMenu';
export function Topbar() {
return (
<header
className="flex h-16 items-center justify-end gap-1 border-b border-border bg-background px-6"
data-testid="app-topbar"
>
<LanguageSwitcher />
<UserMenu />
</header>
);
}
@@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LogOut, User as UserIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
export function UserMenu() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
await navigate({ to: '/login' });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={user?.name ?? 'User menu'}
data-testid="user-menu-button"
>
<UserIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="font-medium" data-testid="user-menu-name">
{user?.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{user?.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void handleLogout();
}}
data-testid="user-menu-logout"
>
<LogOut />
{t('userMenu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components
export { buttonVariants };
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return (
<h2
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
export function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return <p className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />;
}
@@ -0,0 +1,70 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
/** A checkable indicator for selected items (e.g. the active language). */
export function DropdownMenuCheck({ checked }: { checked: boolean }) {
return <Check className={cn('ml-auto', checked ? 'opacity-100' : 'opacity-0')} />;
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
export const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
+6
View File
@@ -0,0 +1,6 @@
import { Toaster as SonnerToaster } from 'sonner';
/** App toast surface (NFR-U1-03 / Q5-B). */
export function Toaster() {
return <SonnerToaster position="top-right" richColors closeButton />;
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { api } from '@/lib/api-client';
import { server } from '@/mocks/server';
import { API_BASE, mockUser } from '@/mocks/auth/fixtures';
import { mockGuest } from '@/test/utils';
import i18n from '@/i18n/config';
function wrapper({ children }: { children: ReactNode }) {
return (
<I18nextProvider i18n={i18n}>
<AuthProvider>{children}</AuthProvider>
</I18nextProvider>
);
}
describe('AuthContext', () => {
it('hydrates the session via silent refresh on mount (BR-U1-01)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe(mockUser.email);
expect(result.current.accessToken).toBe('mock-access-token');
});
it('falls back to guest when silent refresh fails', async () => {
mockGuest();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('guest'));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('refreshes and retries once on a 401 (BR-U1-04)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
let calls = 0;
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () => {
calls += 1;
if (calls === 1) {
return HttpResponse.json(
{ status: 401, title: 'Unauthorized' },
{ status: 401 },
);
}
return HttpResponse.json({ value: 'ok' });
}),
);
let data: unknown;
await act(async () => {
data = await api.get('/api/v1/widgets');
});
expect(calls).toBe(2);
expect(data).toEqual({ value: 'ok' });
});
it('clears the session when the 401 refresh also fails', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
// Both the protected call and the refresh now fail.
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
);
await act(async () => {
await expect(api.get('/api/v1/widgets')).rejects.toThrow();
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(false));
});
});
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse } from '@/api/types';
import { api } from '@/lib/api-client';
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/contexts/auth-context';
/**
* Owns the in-memory authentication session. On mount it attempts a silent
* refresh using the httpOnly cookie (BR-U1-01) and wires the ApiClient's
* 401 interceptor to this provider's refresh/clear logic (BR-U1-04).
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContextValue['user']>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const applySession = useCallback((data: AuthResponse) => {
setUser(data.user);
setAccessToken(data.accessToken);
setExpiresAt(data.expiresAt);
api.setAccessToken(data.accessToken);
setStatus('authenticated');
}, []);
const clearSession = useCallback(() => {
setUser(null);
setAccessToken(null);
setExpiresAt(null);
api.setAccessToken(null);
setStatus('guest');
}, []);
const refresh = useCallback(async (): Promise<string | null> => {
try {
const data = await api.post<AuthResponse>('/api/v1/auth/refresh', undefined, {
skipAuthRefresh: true,
});
applySession(data);
return data.accessToken;
} catch {
clearSession();
return null;
}
}, [applySession, clearSession]);
const login = useCallback(
async (email: string, password: string) => {
const data = await api.post<AuthResponse>(
'/api/v1/auth/login',
{ email, password },
{ skipAuthRefresh: true },
);
applySession(data);
},
[applySession],
);
const logout = useCallback(async () => {
try {
await api.post('/api/v1/auth/revoke', undefined, { skipAuthRefresh: true });
} catch {
// Revoke is best-effort; clear local state regardless.
} finally {
clearSession();
}
}, [clearSession]);
// Wire the ApiClient interceptor hooks to this provider.
useEffect(() => {
api.setRefreshHandler(refresh);
api.setAuthFailureHandler(clearSession);
return () => {
api.setRefreshHandler(null);
api.setAuthFailureHandler(null);
};
}, [refresh, clearSession]);
// Silent refresh on app mount (BR-U1-01). This intentionally synchronizes
// React state with the external session (httpOnly cookie) on startup.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
}, [refresh]);
const value = useMemo<AuthContextValue>(
() => ({
status,
user,
accessToken,
expiresAt,
isAuthenticated: status === 'authenticated',
login,
logout,
refresh,
}),
[status, user, accessToken, expiresAt, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+27
View File
@@ -0,0 +1,27 @@
import { createContext, useContext } from 'react';
import type { User } from '@/api/types';
export type AuthStatus = 'loading' | 'authenticated' | 'guest';
export interface AuthContextValue {
status: AuthStatus;
user: User | null;
/** Access token, kept only in memory (BR-U1-02). */
accessToken: string | null;
expiresAt: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Performs a cookie-based silent refresh; resolves to the new token or null. */
refresh: () => Promise<string | null>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheck,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
changeLanguage,
LANGUAGE_LABELS,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from '@/i18n/config';
/** Language selector for the topbar; lazy-loads the chosen locale (Q4-B). */
export function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const current = (i18n.resolvedLanguage ?? 'en') as SupportedLanguage;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('userMenu.language')}
data-testid="language-switcher-button"
>
<Languages />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('userMenu.language')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{SUPPORTED_LANGUAGES.map((lng) => (
<DropdownMenuItem
key={lng}
onSelect={() => {
void changeLanguage(lng);
}}
data-testid={`language-option-${lng}`}
>
{LANGUAGE_LABELS[lng]}
<DropdownMenuCheck checked={current === lng} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+54
View File
@@ -0,0 +1,54 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en/translation.json';
export const SUPPORTED_LANGUAGES = ['en', 'nl'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
en: 'English',
nl: 'Nederlands',
};
// English (the fallback) is bundled eagerly so the UI never flashes raw keys.
// Other locales are lazy-loaded on demand (Q4-B / NFR-U1-05).
const loaded = new Set<string>(['en']);
async function loadLocale(lng: string): Promise<void> {
if (loaded.has(lng) || !SUPPORTED_LANGUAGES.includes(lng as SupportedLanguage)) {
return;
}
const module = await import(`./locales/${lng}/translation.json`);
i18n.addResourceBundle(lng, 'translation', module.default, true, true);
loaded.add(lng);
}
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES as unknown as string[],
nonExplicitSupportedLngs: true,
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
react: { useSuspense: false },
});
// Ensure the detected language is loaded after init.
void loadLocale(i18n.resolvedLanguage ?? 'en');
/** Lazy-load the target locale, then switch to it (persisted by the detector). */
export async function changeLanguage(lng: SupportedLanguage): Promise<void> {
await loadLocale(lng);
await i18n.changeLanguage(lng);
}
export default i18n;
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Loading…",
"cancel": "Cancel",
"save": "Save",
"retry": "Retry"
},
"nav": {
"dashboard": "Dashboard",
"users": "Users",
"cms": "CMS"
},
"login": {
"title": "Sign in",
"subtitle": "Sign in to your SlpModularCms account",
"email": "Email",
"emailPlaceholder": "you@example.com",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"errors": {
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"passwordRequired": "Password is required",
"invalidCredentials": "Invalid email or password",
"generic": "Something went wrong. Please try again."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {{name}}",
"placeholder": "Your dashboard widgets will appear here."
},
"userMenu": {
"language": "Language",
"logout": "Sign out"
},
"errors": {
"network": "Unable to reach the server. Check your connection and try again."
}
}
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Laden…",
"cancel": "Annuleren",
"save": "Opslaan",
"retry": "Opnieuw"
},
"nav": {
"dashboard": "Dashboard",
"users": "Gebruikers",
"cms": "CMS"
},
"login": {
"title": "Inloggen",
"subtitle": "Log in op je SlpModularCms-account",
"email": "E-mail",
"emailPlaceholder": "jij@voorbeeld.nl",
"password": "Wachtwoord",
"submit": "Inloggen",
"submitting": "Bezig met inloggen…",
"errors": {
"emailRequired": "E-mail is verplicht",
"emailInvalid": "Voer een geldig e-mailadres in",
"passwordRequired": "Wachtwoord is verplicht",
"invalidCredentials": "Ongeldige e-mail of wachtwoord",
"generic": "Er is iets misgegaan. Probeer het opnieuw."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welkom terug, {{name}}",
"placeholder": "Je dashboard-widgets verschijnen hier."
},
"userMenu": {
"language": "Taal",
"logout": "Uitloggen"
},
"errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw."
}
}
+118
View File
@@ -0,0 +1,118 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
/*
* Design tokens. Primary brand color is #ac0000 per BR-U1-07.
* shadcn/ui-style semantic variables consumed by component primitives.
*/
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #ac0000;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f5e6e6;
--accent-foreground: #ac0000;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #ac0000;
--radius: 0.5rem;
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #18181b;
--card-foreground: #fafafa;
--popover: #18181b;
--popover-foreground: #fafafa;
--primary: #e11d1d;
--primary-foreground: #fafafa;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #3a1212;
--accent-foreground: #fafafa;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #e11d1d;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
}
@layer base {
* {
border-color: var(--color-border);
}
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Visible focus ring for keyboard navigation (NFR-U1-02 / Q2-A). */
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
}
+155
View File
@@ -0,0 +1,155 @@
import type { ProblemDetails } from '@/api/types';
import { getAppConfig } from '@/lib/config';
/**
* Typed error thrown for any non-2xx API response. Wraps an RFC 9457
* ProblemDetails body so callers can present context-aware messages
* (BR-U1-08, NFR-U1-05 / Q5-B) instead of raw stack traces.
*/
export class ProblemDetailsError extends Error {
readonly status: number;
readonly problem: ProblemDetails;
constructor(status: number, problem: ProblemDetails) {
super(problem.title ?? problem.detail ?? `Request failed with status ${status}`);
this.name = 'ProblemDetailsError';
this.status = status;
this.problem = problem;
}
}
/** Thrown when the network request itself fails (offline, timeout, DNS). */
export class NetworkError extends Error {
constructor(message = 'Network request failed') {
super(message);
this.name = 'NetworkError';
}
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
/** Parsed and JSON-serialized automatically when present. */
body?: unknown;
/** Skip the 401 refresh+retry interceptor (used by the refresh call itself). */
skipAuthRefresh?: boolean;
}
type RefreshHandler = () => Promise<string | null>;
type AuthFailureHandler = () => void;
/**
* Thin fetch wrapper. All calls send credentials so the httpOnly refresh
* cookie travels with the request (BR-U1-03). The access token is held only
* in memory and injected per request (BR-U1-02).
*/
export class ApiClient {
private readonly baseUrl: string;
private accessToken: string | null = null;
private refreshHandler: RefreshHandler | null = null;
private authFailureHandler: AuthFailureHandler | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
setAccessToken(token: string | null): void {
this.accessToken = token;
}
/** Registered by AuthContext: performs a cookie-based refresh, returns the new token or null. */
setRefreshHandler(handler: RefreshHandler | null): void {
this.refreshHandler = handler;
}
/** Registered by AuthContext: invoked when refresh fails and auth must be cleared. */
setAuthFailureHandler(handler: AuthFailureHandler | null): void {
this.authFailureHandler = handler;
}
get<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'GET' });
}
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'POST', body });
}
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'PUT', body });
}
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'DELETE' });
}
private async request<T>(path: string, options: RequestOptions): Promise<T> {
let response = await this.rawFetch(path, options);
// 401 intercept: try a single cookie-based refresh, then retry once (BR-U1-04).
if (response.status === 401 && !options.skipAuthRefresh && this.refreshHandler !== null) {
const newToken = await this.refreshHandler();
if (newToken !== null) {
response = await this.rawFetch(path, options);
} else {
this.authFailureHandler?.();
}
}
return this.parse<T>(response);
}
private async rawFetch(path: string, options: RequestOptions): Promise<Response> {
const { body, skipAuthRefresh: _skip, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
if (body !== undefined && body !== null) {
finalHeaders.set('Content-Type', 'application/json');
}
if (this.accessToken !== null) {
finalHeaders.set('Authorization', `Bearer ${this.accessToken}`);
}
try {
return await fetch(`${this.baseUrl}${path}`, {
...rest,
headers: finalHeaders,
credentials: 'include',
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
});
} catch (cause) {
throw new NetworkError(cause instanceof Error ? cause.message : undefined);
}
}
private async parse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const data = text.length > 0 ? safeJsonParse(text) : undefined;
if (!response.ok) {
const problem: ProblemDetails = isProblemDetails(data)
? data
: { status: response.status, title: response.statusText };
throw new ProblemDetailsError(response.status, problem);
}
return data as T;
}
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isProblemDetails(value: unknown): value is ProblemDetails {
return typeof value === 'object' && value !== null;
}
/** Shared singleton API client configured from the typed environment. */
export const api = new ApiClient(getAppConfig().apiBaseUrl);
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/**
* Application configuration sourced from Vite env (Q6-A).
* Strong typing comes from vite-env.d.ts; the optional Zod parse below runs
* once and only warns in development — production trusts the build-time env.
*/
const configSchema = z.object({
apiBaseUrl: z.string().url(),
});
export type AppConfig = z.infer<typeof configSchema>;
let cached: AppConfig | null = null;
export function getAppConfig(): AppConfig {
if (cached !== null) {
return cached;
}
const raw: AppConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
};
if (import.meta.env.DEV) {
const result = configSchema.safeParse(raw);
if (!result.success) {
// Development-only warning; never throws so the app still boots.
console.warn('[config] Invalid environment configuration:', result.error.format());
}
}
cached = raw;
return cached;
}
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge conditional class names and resolve Tailwind conflicts.
* Standard shadcn/ui helper.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+52
View File
@@ -0,0 +1,52 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import './index.css';
import './i18n/config';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
function BootstrapSplash() {
return (
<div className="flex min-h-svh items-center justify-center text-muted-foreground">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/**
* Renders the router only once the initial silent refresh has settled, so the
* route guards see a definitive auth state rather than the transient loading one.
*/
function InnerApp() {
const auth = useAuth();
if (auth.status === 'loading') {
return <BootstrapSplash />;
}
return <RouterProvider router={router} context={{ auth }} />;
}
async function enableMocking(): Promise<void> {
if (import.meta.env.VITE_ENABLE_MSW !== 'true') {
return;
}
const { worker } = await import('@/mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
void enableMocking().then(() => {
const rootElement = document.getElementById('root');
if (rootElement === null) {
throw new Error('Root element #root not found');
}
createRoot(rootElement).render(
<StrictMode>
<AuthProvider>
<InnerApp />
<Toaster />
</AuthProvider>
</StrictMode>,
);
});
+27
View File
@@ -0,0 +1,27 @@
import type { AuthResponse, User } from '@/api/types';
/** Base URL the ApiClient targets; mocks must match the absolute URL. */
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000';
export const TEST_CREDENTIALS = {
email: 'owner@example.com',
password: 'Password123!',
};
export const mockUser: User = {
id: '11111111-1111-1111-1111-111111111111',
email: TEST_CREDENTIALS.email,
name: 'Test Owner',
role: 'Owner',
isActive: true,
};
export function makeAuthResponse(overrides: Partial<AuthResponse> = {}): AuthResponse {
return {
accessToken: 'mock-access-token',
// Fixed timestamp keeps fixtures deterministic.
expiresAt: '2099-01-01T00:00:00.000Z',
user: mockUser,
...overrides,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { http, HttpResponse } from 'msw';
import type { LoginRequest, ProblemDetails } from '@/api/types';
import { API_BASE, makeAuthResponse, TEST_CREDENTIALS } from './fixtures';
function problem(status: number, title: string, detail?: string): ProblemDetails {
return {
type: 'about:blank',
title,
status,
detail,
traceId: '00-mock-trace-00',
};
}
/**
* Default auth mocks aligned with the real backend contract:
* POST /api/v1/auth/login, /refresh, /revoke. Tests override these per case
* via `server.use(...)` to simulate failures and 401 flows.
*/
export const authHandlers = [
http.post(`${API_BASE}/api/v1/auth/login`, async ({ request }) => {
const body = (await request.json()) as LoginRequest;
if (body.email === TEST_CREDENTIALS.email && body.password === TEST_CREDENTIALS.password) {
return HttpResponse.json(makeAuthResponse());
}
return HttpResponse.json(problem(401, 'Invalid email or password'), { status: 401 });
}),
// By default refresh succeeds (simulates a valid httpOnly cookie present).
http.post(`${API_BASE}/api/v1/auth/refresh`, () => {
return HttpResponse.json(makeAuthResponse());
}),
http.post(`${API_BASE}/api/v1/auth/revoke`, () => {
return new HttpResponse(null, { status: 204 });
}),
];
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './index';
/** MSW worker for development in the browser. */
export const worker = setupWorker(...handlers);
+11
View File
@@ -0,0 +1,11 @@
import { authHandlers } from './auth/handlers';
import { userHandlers } from './users/handlers';
import { setupHandlers } from './setup/handlers';
/** All default MSW handlers, composed from feature folders (Q3-B). */
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers];
export { authHandlers } from './auth/handlers';
export { userHandlers } from './users/handlers';
export { setupHandlers } from './setup/handlers';
export * from './auth/fixtures';
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from 'msw/node';
import { handlers } from './index';
/** MSW server for the Node/jsdom test environment. */
export const server = setupServer(...handlers);
+10
View File
@@ -0,0 +1,10 @@
import { http, HttpResponse } from 'msw';
import type { SetupStatus } from '@/api/types';
import { API_BASE } from '../auth/fixtures';
/** Setup status mock (used by public bootstrap guards). */
export const setupHandlers = [
http.get(`${API_BASE}/Setup/status`, () =>
HttpResponse.json<SetupStatus>({ initialized: true }),
),
];
+17
View File
@@ -0,0 +1,17 @@
import { http, HttpResponse } from 'msw';
import type { User } from '@/api/types';
import { API_BASE, mockUser } from '../auth/fixtures';
const mockUsers: User[] = [
mockUser,
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
},
];
/** Placeholder user-management mocks; expanded in Unit 5. */
export const userHandlers = [http.get(`${API_BASE}/Users`, () => HttpResponse.json(mockUsers))];
+14
View File
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next';
/** Placeholder; CMS management (Owner-only, US-18/US-20) arrives in a later unit. */
export function CmsPage() {
const { t } = useTranslation();
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold" data-testid="cms-title">
{t('nav.cms')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/contexts/auth-context';
export function DashboardPage() {
const { t } = useTranslation();
const { user } = useAuth();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold" data-testid="dashboard-title">
{t('dashboard.title')}
</h1>
<p className="text-muted-foreground" data-testid="dashboard-welcome">
{t('dashboard.welcome', { name: user?.name ?? '' })}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t('dashboard.title')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('dashboard.placeholder')}</p>
</CardContent>
</Card>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { TEST_CREDENTIALS } from '@/mocks/auth/fixtures';
describe('LoginPage', () => {
it('shows validation errors when submitting an empty form', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
const submit = await screen.findByTestId('login-form-submit-button');
await user.click(submit);
expect(await screen.findByTestId('login-email-error')).toBeInTheDocument();
expect(await screen.findByTestId('login-password-error')).toBeInTheDocument();
});
it('logs in with valid credentials and lands on the dashboard', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), TEST_CREDENTIALS.email);
await user.type(screen.getByTestId('login-password-input'), TEST_CREDENTIALS.password);
await user.click(screen.getByTestId('login-form-submit-button'));
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
});
it('shows an error banner on invalid credentials', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), 'wrong@example.com');
await user.type(screen.getByTestId('login-password-input'), 'wrongpassword');
await user.click(screen.getByTestId('login-form-submit-button'));
const banner = await screen.findByTestId('login-error');
expect(banner).toBeInTheDocument();
// Still on the login page.
await waitFor(() =>
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument(),
);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
export function LoginPage() {
const { t } = useTranslation();
const { login } = useAuth();
const navigate = useNavigate();
const search = useSearch({ strict: false }) as { redirect?: string };
const [serverError, setServerError] = useState<string | null>(null);
const schema = useMemo(
() =>
z.object({
email: z
.string()
.min(1, t('login.errors.emailRequired'))
.email(t('login.errors.emailInvalid')),
password: z.string().min(1, t('login.errors.passwordRequired')),
}),
[t],
);
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
await login(values.email, values.password);
await navigate({ to: search.redirect ?? '/dashboard' });
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 401) {
setServerError(t('login.errors.invalidCredentials'));
} else if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('login.errors.generic'));
}
}
});
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('login.title')}</CardTitle>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} noValidate className="space-y-4">
{serverError !== null && (
<div
role="alert"
data-testid="login-error"
className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder')}
data-testid="login-email-input"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && (
<p
className="text-sm text-destructive"
data-testid="login-email-error"
>
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password')}</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
data-testid="login-password-input"
aria-invalid={errors.password !== undefined}
{...register('password')}
/>
{errors.password && (
<p
className="text-sm text-destructive"
data-testid="login-password-error"
>
{errors.password.message}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
data-testid="login-form-submit-button"
>
{isSubmitting ? t('login.submitting') : t('login.submit')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
/** Public setup placeholder (initial owner creation / invitation completion). */
export function SetupPage() {
const { t } = useTranslation();
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('common.appName')} Setup</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Coming soon.</p>
</CardContent>
</Card>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next';
/** Placeholder; full user management arrives in Unit 5. */
export function UsersPage() {
const { t } = useTranslation();
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold" data-testid="users-title">
{t('nav.users')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { lazy, Suspense, type ComponentType } from 'react';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
} from '@tanstack/react-router';
import type { AuthContextValue } from '@/contexts/auth-context';
import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage';
export interface RouterContext {
auth: AuthContextValue;
}
function RouteFallback() {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/**
* Wrap a per-feature page in a lazy boundary so Vite emits a separate chunk
* (NFR-U1-01 / Q1-A). The auth/root shell and login stay eager for fast paint.
*/
function lazyPage<P extends Record<string, never>>(
factory: () => Promise<{ [key: string]: ComponentType<P> }>,
exportName: string,
) {
const Loaded = lazy(() => factory().then((module) => ({ default: module[exportName] })));
return function LazyRouteComponent() {
return (
<Suspense fallback={<RouteFallback />}>
<Loaded {...({} as P)} />
</Suspense>
);
};
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
});
// '/' redirects into the protected area; the guard sends guests to /login.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/dashboard' });
},
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
}),
// Authenticated users never see /login (BR-U1-06).
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/dashboard' });
}
},
component: LoginPage,
});
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup',
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
});
// Layout route guarding every protected page (BR-U1-05).
const authenticatedRoute = createRoute({
getParentRoute: () => rootRoute,
id: '_authenticated',
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.href } });
}
},
component: AppLayout,
});
const dashboardRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/dashboard',
component: lazyPage(() => import('@/pages/DashboardPage'), 'DashboardPage'),
});
const usersRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/users',
component: lazyPage(() => import('@/pages/UsersPage'), 'UsersPage'),
});
const cmsRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/cms',
component: lazyPage(() => import('@/pages/CmsPage'), 'CmsPage'),
});
export const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
setupRoute,
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute]),
]);
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
// Real auth is injected per render via RouterProvider's `context` prop.
context: { auth: undefined as unknown as AuthContextValue },
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
describe('Route guards (BR-U1-05, BR-U1-06)', () => {
it('redirects an unauthenticated user from a protected route to /login', async () => {
mockGuest();
renderApp('/dashboard');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument();
});
it('allows an authenticated user to reach a protected route', async () => {
mockAuthenticated();
renderApp('/dashboard');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
});
it('redirects an authenticated user away from /login to the dashboard', async () => {
mockAuthenticated();
renderApp('/login');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
});
});
+45
View File
@@ -0,0 +1,45 @@
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from '@/mocks/server';
import { api } from '@/lib/api-client';
import '@/i18n/config';
// jsdom is missing a few browser APIs that Radix/sonner touch.
const globalAny = globalThis as unknown as {
matchMedia?: unknown;
ResizeObserver?: unknown;
};
if (typeof globalAny.matchMedia === 'undefined') {
globalAny.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
if (typeof globalAny.ResizeObserver === 'undefined') {
globalAny.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
// Reset shared client state between tests.
api.setAccessToken(null);
localStorage.clear();
});
afterAll(() => server.close());
+64
View File
@@ -0,0 +1,64 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, type ReactElement } from 'react';
import { render } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { I18nextProvider } from 'react-i18next';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { routeTree } from '@/router';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
import i18n from '@/i18n/config';
/** Force the silent-refresh-on-mount to fail, leaving the app in guest state. */
export function mockGuest(): void {
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json({ status: 401, title: 'Unauthorized' }, { status: 401 }),
),
);
}
/** Force the silent-refresh-on-mount to succeed, leaving the app authenticated. */
export function mockAuthenticated(): void {
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () => HttpResponse.json(makeAuthResponse())),
);
}
/** Render an arbitrary element wrapped in the i18n + auth providers. */
export function renderWithProviders(ui: ReactElement) {
return render(
<I18nextProvider i18n={i18n}>
<AuthProvider>{ui}</AuthProvider>
</I18nextProvider>,
);
}
function AppHarness({ initialPath }: { initialPath: string }) {
const auth = useAuth();
const [router] = useState(() =>
createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [initialPath] }),
context: { auth },
}),
);
if (auth.status === 'loading') {
return null;
}
return <RouterProvider router={router} context={{ auth }} />;
}
/** Render the full application router at a given path, inside all providers. */
export function renderApp(initialPath = '/') {
return render(
<I18nextProvider i18n={i18n}>
<AuthProvider>
<AppHarness initialPath={initialPath} />
</AuthProvider>
</I18nextProvider>,
);
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the SlpModularCms .NET API (e.g. http://localhost:5000). */
readonly VITE_API_BASE_URL: string;
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
readonly VITE_ENABLE_MSW?: string;
// Add future typed env flags here.
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}