Adds front-end set-up
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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')} />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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 }),
|
||||
),
|
||||
];
|
||||
@@ -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))];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
Vendored
+13
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user