Adds auth pages
This commit is contained in:
@@ -41,3 +41,22 @@ export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDe
|
||||
export interface SetupStatus {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface InvitationValidation {
|
||||
email: string;
|
||||
name: string | null;
|
||||
isValid: boolean;
|
||||
errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null;
|
||||
}
|
||||
|
||||
export interface InviteCompleteRequest {
|
||||
token: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { InvitationValidation, InviteCompleteRequest } from '@/api/types';
|
||||
|
||||
interface ValidateState {
|
||||
data: InvitationValidation | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useValidateInvitation(token: string | undefined): ValidateState {
|
||||
const [state, setState] = useState<ValidateState>({ data: null, isLoading: token !== undefined, error: null });
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token === undefined) {
|
||||
setState({ data: null, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ data: null, isLoading: true, error: null });
|
||||
|
||||
api.get<InvitationValidation>(`/Invitation/validate?token=${encodeURIComponent(token)}`)
|
||||
.then((data) => {
|
||||
if (mounted.current) setState({ data, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (mounted.current)
|
||||
setState({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
interface CompleteSetupState {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseCompleteSetup extends CompleteSetupState {
|
||||
mutate: (data: InviteCompleteRequest) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useCompleteSetup(): UseCompleteSetup {
|
||||
const [state, setState] = useState<CompleteSetupState>({ isLoading: false, error: null });
|
||||
|
||||
const mutate = useCallback(async (data: InviteCompleteRequest): Promise<void> => {
|
||||
setState({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.post('/Invitation/complete', data);
|
||||
setState({ isLoading: false, error: null });
|
||||
} catch (err: unknown) {
|
||||
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
||||
|
||||
return { ...state, mutate, reset };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { SetupRequest, SetupStatus } from '@/api/types';
|
||||
|
||||
// Module-level session cache — one fetch per page load (BR-U2-08).
|
||||
let _setupStatusCache: SetupStatus | null = null;
|
||||
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||
|
||||
export function fetchSetupStatus(): Promise<SetupStatus> {
|
||||
if (_setupStatusCache !== null) return Promise.resolve(_setupStatusCache);
|
||||
if (_setupStatusPromise === null) {
|
||||
_setupStatusPromise = api
|
||||
.get<SetupStatus>('/Setup/status')
|
||||
.then((s) => {
|
||||
_setupStatusCache = s;
|
||||
return s;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
_setupStatusPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return _setupStatusPromise;
|
||||
}
|
||||
|
||||
export function invalidateSetupStatusCache(): void {
|
||||
_setupStatusCache = null;
|
||||
_setupStatusPromise = null;
|
||||
}
|
||||
|
||||
interface SetupStatusState {
|
||||
data: SetupStatus | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useSetupStatus(): SetupStatusState {
|
||||
const [state, setState] = useState<SetupStatusState>(() =>
|
||||
_setupStatusCache !== null
|
||||
? { data: _setupStatusCache, isLoading: false, error: null }
|
||||
: { data: null, isLoading: true, error: null },
|
||||
);
|
||||
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (_setupStatusCache !== null) return;
|
||||
fetchSetupStatus()
|
||||
.then((data) => {
|
||||
if (mounted.current) setState({ data, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (mounted.current)
|
||||
setState({ data: null, isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
interface CreateOwnerState {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseCreateOwner extends CreateOwnerState {
|
||||
mutate: (data: SetupRequest) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useCreateOwner(): UseCreateOwner {
|
||||
const [state, setState] = useState<CreateOwnerState>({ isLoading: false, error: null });
|
||||
|
||||
const mutate = useCallback(async (data: SetupRequest): Promise<void> => {
|
||||
setState({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.post('/Setup', data);
|
||||
_setupStatusCache = { initialized: true };
|
||||
setState({ isLoading: false, error: null });
|
||||
} catch (err: unknown) {
|
||||
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
||||
|
||||
return { ...state, mutate, reset };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UserRole } from '@/api/types';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
|
||||
interface RoleGuardProps {
|
||||
allowedRoles: UserRole[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user !== null && allowedRoles.includes(user.role)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const requiredLabel = allowedRoles.join(' or ');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="access-denied-message"
|
||||
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold">{t('errors.accessDenied')}</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{t('errors.accessDeniedDetail', { roles: requiredLabel })}
|
||||
</p>
|
||||
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface FormBannerErrorProps {
|
||||
error: { message: string } | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function FormBannerError({ error, onDismiss }: FormBannerErrorProps) {
|
||||
if (error === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="form-error-banner"
|
||||
className="flex items-start justify-between gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<span>{error.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss error"
|
||||
className="mt-0.5 shrink-0 hover:opacity-70"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import type { UseFormRegisterReturn } from 'react-hook-form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface PasswordFieldProps extends UseFormRegisterReturn {
|
||||
id: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
export function PasswordField({ id, placeholder, autoComplete = 'new-password', ...register }: PasswordFieldProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...register}
|
||||
id={id}
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
data-testid={`${id}-input`}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((prev) => !prev)}
|
||||
data-testid={`${id}-toggle`}
|
||||
aria-label={show ? 'Hide password' : 'Show password'}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Users",
|
||||
"cms": "CMS"
|
||||
"cms": "CMS",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
@@ -27,6 +29,49 @@
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "System Setup",
|
||||
"subtitle": "Create the first Owner account to get started.",
|
||||
"fields": {
|
||||
"name": "Full name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"locale": "Language"
|
||||
},
|
||||
"localeOptions": {
|
||||
"en": "English",
|
||||
"nl": "Dutch"
|
||||
},
|
||||
"submit": "Create account",
|
||||
"submitting": "Creating account…",
|
||||
"success": "Account created. You can now sign in.",
|
||||
"errors": {
|
||||
"alreadyInitialized": "This system has already been set up.",
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"inviteComplete": {
|
||||
"title": "Complete your account",
|
||||
"subtitle": "You have been invited to {{appName}}.",
|
||||
"loading": "Validating your invitation…",
|
||||
"fields": {
|
||||
"email": "Email",
|
||||
"name": "Full name",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password"
|
||||
},
|
||||
"submit": "Complete setup",
|
||||
"submitting": "Completing setup…",
|
||||
"success": "Account setup complete. You can now sign in.",
|
||||
"errors": {
|
||||
"expired": "This invitation link has expired. Please request a new one.",
|
||||
"used": "This invitation link has already been used.",
|
||||
"notFound": "This invitation link is not valid.",
|
||||
"noToken": "Invalid invitation link.",
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome back, {{name}}",
|
||||
@@ -37,6 +82,9 @@
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"errors": {
|
||||
"network": "Unable to reach the server. Check your connection and try again."
|
||||
"network": "Unable to reach the server. Check your connection and try again.",
|
||||
"accessDenied": "You do not have permission to view this page.",
|
||||
"accessDeniedDetail": "This section requires the {{roles}} role.",
|
||||
"sessionExpired": "Your session has expired. Please sign in again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Gebruikers",
|
||||
"cms": "CMS"
|
||||
"cms": "CMS",
|
||||
"profile": "Profiel",
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
"login": {
|
||||
"title": "Inloggen",
|
||||
@@ -27,6 +29,49 @@
|
||||
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Systeeminstallatie",
|
||||
"subtitle": "Maak het eerste Owner-account aan om te beginnen.",
|
||||
"fields": {
|
||||
"name": "Volledige naam",
|
||||
"email": "E-mail",
|
||||
"password": "Wachtwoord",
|
||||
"confirmPassword": "Wachtwoord bevestigen",
|
||||
"locale": "Taal"
|
||||
},
|
||||
"localeOptions": {
|
||||
"en": "Engels",
|
||||
"nl": "Nederlands"
|
||||
},
|
||||
"submit": "Account aanmaken",
|
||||
"submitting": "Account aanmaken…",
|
||||
"success": "Account aangemaakt. Je kunt nu inloggen.",
|
||||
"errors": {
|
||||
"alreadyInitialized": "Dit systeem is al ingesteld.",
|
||||
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"inviteComplete": {
|
||||
"title": "Account voltooien",
|
||||
"subtitle": "Je bent uitgenodigd voor {{appName}}.",
|
||||
"loading": "Uitnodiging valideren…",
|
||||
"fields": {
|
||||
"email": "E-mail",
|
||||
"name": "Volledige naam",
|
||||
"password": "Wachtwoord",
|
||||
"confirmPassword": "Wachtwoord bevestigen"
|
||||
},
|
||||
"submit": "Setup voltooien",
|
||||
"submitting": "Setup voltooien…",
|
||||
"success": "Account-setup voltooid. Je kunt nu inloggen.",
|
||||
"errors": {
|
||||
"expired": "Deze uitnodigingslink is verlopen. Vraag een nieuwe aan.",
|
||||
"used": "Deze uitnodigingslink is al gebruikt.",
|
||||
"notFound": "Deze uitnodigingslink is niet geldig.",
|
||||
"noToken": "Ongeldige uitnodigingslink.",
|
||||
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welkom terug, {{name}}",
|
||||
@@ -37,6 +82,9 @@
|
||||
"logout": "Uitloggen"
|
||||
},
|
||||
"errors": {
|
||||
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw."
|
||||
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
|
||||
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
||||
"accessDeniedDetail": "Dit gedeelte vereist de rol {{roles}}.",
|
||||
"sessionExpired": "Je sessie is verlopen. Meld je opnieuw aan."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one digit')
|
||||
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const setupSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
locale: z.enum(['en', 'nl']),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const inviteCompleteSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
export type SetupFormValues = z.infer<typeof setupSchema>;
|
||||
export type InviteCompleteFormValues = z.infer<typeof inviteCompleteSchema>;
|
||||
@@ -1,11 +1,13 @@
|
||||
import { authHandlers } from './auth/handlers';
|
||||
import { userHandlers } from './users/handlers';
|
||||
import { setupHandlers } from './setup/handlers';
|
||||
import { invitationHandlers } from './invitation/handlers';
|
||||
|
||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers];
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers];
|
||||
|
||||
export { authHandlers } from './auth/handlers';
|
||||
export { userHandlers } from './users/handlers';
|
||||
export { setupHandlers } from './setup/handlers';
|
||||
export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers } from './setup/handlers';
|
||||
export { invitationHandlers } from './invitation/handlers';
|
||||
export * from './auth/fixtures';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import type { InvitationValidation } from '@/api/types';
|
||||
import { API_BASE } from '../auth/fixtures';
|
||||
|
||||
export const invitationHandlers = [
|
||||
http.get(`${API_BASE}/Invitation/validate`, ({ request }) => {
|
||||
const token = new URL(request.url).searchParams.get('token');
|
||||
|
||||
if (token === 'valid-token') {
|
||||
return HttpResponse.json<InvitationValidation>({
|
||||
isValid: true,
|
||||
email: 'invited@example.com',
|
||||
name: null,
|
||||
errorCode: null,
|
||||
});
|
||||
}
|
||||
if (token === 'used-token') {
|
||||
return HttpResponse.json<InvitationValidation>({
|
||||
isValid: false,
|
||||
email: '',
|
||||
name: null,
|
||||
errorCode: 'USED',
|
||||
});
|
||||
}
|
||||
// expired-token or any unknown token
|
||||
return HttpResponse.json<InvitationValidation>({
|
||||
isValid: false,
|
||||
email: '',
|
||||
name: null,
|
||||
errorCode: 'EXPIRED',
|
||||
});
|
||||
}),
|
||||
|
||||
http.post(`${API_BASE}/Invitation/complete`, () => new HttpResponse(null, { status: 200 })),
|
||||
];
|
||||
@@ -7,4 +7,24 @@ export const setupHandlers = [
|
||||
http.get(`${API_BASE}/Setup/status`, () =>
|
||||
HttpResponse.json<SetupStatus>({ initialized: true }),
|
||||
),
|
||||
|
||||
http.post(`${API_BASE}/Setup`, () => new HttpResponse(null, { status: 201 })),
|
||||
];
|
||||
|
||||
/** Override: returns not-initialized status — use in tests for InitGuard. */
|
||||
export const setupUninitializedHandlers = [
|
||||
http.get(`${API_BASE}/Setup/status`, () =>
|
||||
HttpResponse.json<SetupStatus>({ initialized: false }),
|
||||
),
|
||||
];
|
||||
|
||||
/** Override: POST /Setup returns 409 — system already initialized. */
|
||||
export const setupConflictHandlers = [
|
||||
...setupUninitializedHandlers,
|
||||
http.post(`${API_BASE}/Setup`, () =>
|
||||
HttpResponse.json(
|
||||
{ title: 'Conflict', detail: 'System has already been initialized.', status: 409 },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderApp, mockGuest } from '@/test/utils';
|
||||
import { _resetSetupStatusCache } from '@/router';
|
||||
|
||||
// Reset module-level setup status cache before each test.
|
||||
beforeEach(() => {
|
||||
_resetSetupStatusCache();
|
||||
});
|
||||
|
||||
describe('InviteCompletePage', () => {
|
||||
it('shows a loading spinner on mount while validating the token', async () => {
|
||||
mockGuest();
|
||||
renderApp('/invite/complete?token=valid-token');
|
||||
|
||||
// Loading spinner should appear immediately.
|
||||
expect(await screen.findByTestId('invite-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state for an expired token', async () => {
|
||||
mockGuest();
|
||||
renderApp('/invite/complete?token=expired-token');
|
||||
|
||||
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('invite-name-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state for a used token', async () => {
|
||||
mockGuest();
|
||||
renderApp('/invite/complete?token=used-token');
|
||||
|
||||
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state when no token is provided', async () => {
|
||||
mockGuest();
|
||||
renderApp('/invite/complete');
|
||||
|
||||
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the form with a read-only email for a valid token', async () => {
|
||||
mockGuest();
|
||||
renderApp('/invite/complete?token=valid-token');
|
||||
|
||||
expect(await screen.findByTestId('invite-name-input')).toBeInTheDocument();
|
||||
const emailInput = screen.getByTestId('invite-email-input') as HTMLInputElement;
|
||||
expect(emailInput.value).toBe('invited@example.com');
|
||||
expect(emailInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows validation errors when submitting an empty form', async () => {
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/invite/complete?token=valid-token');
|
||||
|
||||
const submit = await screen.findByTestId('invite-submit-button');
|
||||
await user.click(submit);
|
||||
|
||||
expect(await screen.findByTestId('invite-name-error')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('invite-password-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success message after a valid submission', async () => {
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/invite/complete?token=valid-token');
|
||||
|
||||
await user.type(await screen.findByTestId('invite-name-input'), 'Bob User');
|
||||
await user.type(screen.getByTestId('invite-password-input'), 'ValidPass1!');
|
||||
await user.type(screen.getByTestId('invite-confirmPassword-input'), 'ValidPass1!');
|
||||
await user.click(screen.getByTestId('invite-submit-button'));
|
||||
|
||||
expect(await screen.findByTestId('invite-success')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { PasswordField } from '@/components/ui/PasswordField';
|
||||
import { FormBannerError } from '@/components/ui/FormBannerError';
|
||||
import { useValidateInvitation, useCompleteSetup } from '@/api/useInvitation';
|
||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||
import { inviteCompleteSchema, type InviteCompleteFormValues } from '@/lib/schemas/auth';
|
||||
|
||||
function errorKey(code: string | null | undefined): string {
|
||||
switch (code) {
|
||||
case 'EXPIRED':
|
||||
return 'inviteComplete.errors.expired';
|
||||
case 'USED':
|
||||
return 'inviteComplete.errors.used';
|
||||
case 'NOT_FOUND':
|
||||
return 'inviteComplete.errors.notFound';
|
||||
default:
|
||||
return 'inviteComplete.errors.notFound';
|
||||
}
|
||||
}
|
||||
|
||||
export function InviteCompletePage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ strict: false }) as { token?: string };
|
||||
const token = search.token;
|
||||
|
||||
const { data: invitation, isLoading: validating, error: validateError } = useValidateInvitation(token);
|
||||
const { mutate, isLoading: submitting } = useCompleteSetup();
|
||||
|
||||
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<InviteCompleteFormValues>({
|
||||
resolver: zodResolver(inviteCompleteSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { name: invitation?.name ?? '', password: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
if (token === undefined) return;
|
||||
setBannerError(null);
|
||||
try {
|
||||
await mutate({ token, name: values.name, password: values.password });
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
void navigate({ to: '/login', replace: true });
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkError) {
|
||||
setBannerError({ message: t('errors.network') });
|
||||
} else if (err instanceof ProblemDetailsError) {
|
||||
setBannerError({ message: err.problem.detail ?? t('inviteComplete.errors.generic') });
|
||||
} else {
|
||||
setBannerError({ message: t('inviteComplete.errors.generic') });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Loading state ---
|
||||
if (validating) {
|
||||
return (
|
||||
<div
|
||||
data-testid="invite-loading"
|
||||
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" />
|
||||
<span className="sr-only">{t('inviteComplete.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error / invalid states ---
|
||||
const isInvalid = !token || validateError !== null || (invitation !== null && !invitation.isValid);
|
||||
if (isInvalid) {
|
||||
const code = invitation?.errorCode ?? null;
|
||||
const message = !token
|
||||
? t('inviteComplete.errors.noToken')
|
||||
: validateError !== null
|
||||
? t('errors.network')
|
||||
: t(errorKey(code));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="invite-error"
|
||||
className="flex min-h-svh flex-col items-center justify-center gap-4 p-4 text-center"
|
||||
>
|
||||
<p className="max-w-sm text-sm text-destructive">{message}</p>
|
||||
<Link to="/login" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||
{t('login.title')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Success state ---
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-4">
|
||||
<p
|
||||
role="status"
|
||||
data-testid="invite-success"
|
||||
className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400"
|
||||
>
|
||||
{t('inviteComplete.success')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Form state ---
|
||||
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('inviteComplete.title')}</CardTitle>
|
||||
<CardDescription>{t('inviteComplete.subtitle', { appName: t('common.appName') })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<FormBannerError error={bannerError} onDismiss={() => setBannerError(null)} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">{t('inviteComplete.fields.email')}</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
value={invitation?.email ?? ''}
|
||||
readOnly
|
||||
disabled
|
||||
data-testid="invite-email-input"
|
||||
className="cursor-default opacity-70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">{t('inviteComplete.fields.name')}</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
data-testid="invite-name-input"
|
||||
aria-invalid={errors.name !== undefined}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive" data-testid="invite-name-error">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-password">{t('inviteComplete.fields.password')}</Label>
|
||||
<PasswordField
|
||||
id="invite-password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password !== undefined}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive" data-testid="invite-password-error">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-confirmPassword">
|
||||
{t('inviteComplete.fields.confirmPassword')}
|
||||
</Label>
|
||||
<PasswordField
|
||||
id="invite-confirmPassword"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.confirmPassword !== undefined}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive" data-testid="invite-confirm-error">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={submitting}
|
||||
data-testid="invite-submit-button"
|
||||
>
|
||||
{submitting ? t('inviteComplete.submitting') : t('inviteComplete.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { 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';
|
||||
import { loginSchema, type LoginFormValues } from '@/lib/schemas/auth';
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -18,26 +18,13 @@ export function LoginPage() {
|
||||
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),
|
||||
} = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
@@ -45,7 +32,7 @@ export function LoginPage() {
|
||||
setServerError(null);
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
await navigate({ to: search.redirect ?? '/dashboard' });
|
||||
await navigate({ to: (search.redirect as string | undefined) ?? '/dashboard' });
|
||||
} catch (err) {
|
||||
if (err instanceof ProblemDetailsError && err.status === 401) {
|
||||
setServerError(t('login.errors.invalidCredentials'));
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderApp, mockGuest } from '@/test/utils';
|
||||
import { server } from '@/mocks/server';
|
||||
import { setupUninitializedHandlers, setupConflictHandlers } from '@/mocks/index';
|
||||
import { _resetSetupStatusCache } from '@/router';
|
||||
|
||||
// Each test starts with a fresh setup status cache so the MSW handler controls
|
||||
// what the InitGuard fetches (BR-U2-08).
|
||||
beforeEach(() => {
|
||||
_resetSetupStatusCache();
|
||||
});
|
||||
|
||||
describe('SetupPage', () => {
|
||||
it('renders all five form fields when system is uninitialized', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
renderApp('/setup');
|
||||
|
||||
expect(await screen.findByTestId('setup-name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-password-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-confirmPassword-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-locale-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows inline validation errors when submitting an empty form', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/setup');
|
||||
|
||||
const submit = await screen.findByTestId('setup-submit-button');
|
||||
await user.click(submit);
|
||||
|
||||
expect(await screen.findByTestId('setup-name-error')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-email-error')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('setup-password-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows password error for a weak password (no uppercase)', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/setup');
|
||||
|
||||
const passwordInput = await screen.findByTestId('setup-password-input');
|
||||
await user.type(passwordInput, 'weakpassword1!');
|
||||
await user.tab();
|
||||
|
||||
expect(await screen.findByTestId('setup-password-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirm-password error when passwords do not match', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/setup');
|
||||
|
||||
const passwordInput = await screen.findByTestId('setup-password-input');
|
||||
const confirmInput = screen.getByTestId('setup-confirmPassword-input');
|
||||
await user.type(passwordInput, 'ValidPass1!');
|
||||
await user.type(confirmInput, 'DifferentPass1!');
|
||||
await user.tab();
|
||||
|
||||
expect(await screen.findByTestId('setup-confirm-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success message after a valid submission', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/setup');
|
||||
|
||||
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
|
||||
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
|
||||
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
|
||||
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
|
||||
await user.click(screen.getByTestId('setup-submit-button'));
|
||||
|
||||
expect(await screen.findByTestId('setup-success')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "already initialized" banner on 409 response', async () => {
|
||||
server.use(...setupConflictHandlers);
|
||||
mockGuest();
|
||||
const user = userEvent.setup();
|
||||
renderApp('/setup');
|
||||
|
||||
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
|
||||
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
|
||||
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
|
||||
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
|
||||
await user.click(screen.getByTestId('setup-submit-button'));
|
||||
|
||||
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /login when system is already initialized', async () => {
|
||||
// Default setupHandlers return { initialized: true } — InitGuard redirects.
|
||||
mockGuest();
|
||||
renderApp('/setup');
|
||||
|
||||
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,196 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import i18n from 'i18next';
|
||||
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 { PasswordField } from '@/components/ui/PasswordField';
|
||||
import { FormBannerError } from '@/components/ui/FormBannerError';
|
||||
import { useCreateOwner } from '@/api/useSetup';
|
||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||
import { setupSchema, type SetupFormValues } from '@/lib/schemas/auth';
|
||||
|
||||
const SUPPORTED_LOCALES = ['en', 'nl'] as const;
|
||||
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
function getDefaultLocale(): SupportedLocale {
|
||||
const lang = navigator.language.split('-')[0];
|
||||
return SUPPORTED_LOCALES.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'en';
|
||||
}
|
||||
|
||||
/** Public setup placeholder (initial owner creation / invitation completion). */
|
||||
export function SetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { mutate, isLoading } = useCreateOwner();
|
||||
|
||||
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<SetupFormValues>({
|
||||
resolver: zodResolver(setupSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
locale: getDefaultLocale(),
|
||||
},
|
||||
});
|
||||
|
||||
const currentLocale = watch('locale');
|
||||
|
||||
// Live locale switch (BR-U2-23).
|
||||
useEffect(() => {
|
||||
void i18n.changeLanguage(currentLocale);
|
||||
}, [currentLocale]);
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
setBannerError(null);
|
||||
try {
|
||||
await mutate({ name: values.name, email: values.email, password: values.password });
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
void navigate({ to: '/login', replace: true });
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
if (err instanceof ProblemDetailsError && err.status === 409) {
|
||||
setBannerError({ message: t('setup.errors.alreadyInitialized') });
|
||||
} else if (err instanceof NetworkError) {
|
||||
setBannerError({ message: t('errors.network') });
|
||||
} else {
|
||||
setBannerError({ message: t('setup.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('common.appName')} — Setup</CardTitle>
|
||||
<CardTitle>{t('setup.title')}</CardTitle>
|
||||
<CardDescription>{t('setup.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Coming soon.</p>
|
||||
{success ? (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="setup-success"
|
||||
className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400"
|
||||
>
|
||||
{t('setup.success')}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<FormBannerError
|
||||
error={bannerError}
|
||||
onDismiss={() => setBannerError(null)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-name">{t('setup.fields.name')}</Label>
|
||||
<Input
|
||||
id="setup-name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
data-testid="setup-name-input"
|
||||
aria-invalid={errors.name !== undefined}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive" data-testid="setup-name-error">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-email">{t('setup.fields.email')}</Label>
|
||||
<Input
|
||||
id="setup-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
data-testid="setup-email-input"
|
||||
aria-invalid={errors.email !== undefined}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive" data-testid="setup-email-error">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-password">{t('setup.fields.password')}</Label>
|
||||
<PasswordField
|
||||
id="setup-password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.password !== undefined}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive" data-testid="setup-password-error">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-confirmPassword">
|
||||
{t('setup.fields.confirmPassword')}
|
||||
</Label>
|
||||
<PasswordField
|
||||
id="setup-confirmPassword"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={errors.confirmPassword !== undefined}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive" data-testid="setup-confirm-error">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-locale">{t('setup.fields.locale')}</Label>
|
||||
<select
|
||||
id="setup-locale"
|
||||
data-testid="setup-locale-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
{...register('locale')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as SupportedLocale;
|
||||
setValue('locale', val, { shouldValidate: true });
|
||||
}}
|
||||
value={currentLocale}
|
||||
>
|
||||
<option value="en">{t('setup.localeOptions.en')}</option>
|
||||
<option value="nl">{t('setup.localeOptions.nl')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
data-testid="setup-submit-button"
|
||||
>
|
||||
{isLoading ? t('setup.submitting') : t('setup.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
+85
-10
@@ -7,13 +7,47 @@ import {
|
||||
redirect,
|
||||
} from '@tanstack/react-router';
|
||||
import type { AuthContextValue } from '@/contexts/auth-context';
|
||||
import type { SetupStatus } from '@/api/types';
|
||||
import { api } from '@/lib/api-client';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||
|
||||
export interface RouterContext {
|
||||
auth: AuthContextValue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InitGuard: module-level session cache for setup status (BR-U2-08).
|
||||
// One network request per page load; subsequent beforeLoad calls return cached
|
||||
// data synchronously via Promise.resolve().
|
||||
// ---------------------------------------------------------------------------
|
||||
let _setupStatusCache: SetupStatus | null = null;
|
||||
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||
|
||||
async function fetchSetupStatus(): Promise<SetupStatus> {
|
||||
if (_setupStatusCache !== null) return _setupStatusCache;
|
||||
if (_setupStatusPromise === null) {
|
||||
_setupStatusPromise = api
|
||||
.get<SetupStatus>('/Setup/status')
|
||||
.then((s) => {
|
||||
_setupStatusCache = s;
|
||||
return s;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
_setupStatusPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return _setupStatusPromise;
|
||||
}
|
||||
|
||||
// Exposed for tests so each test starts with a clean cache.
|
||||
export function _resetSetupStatusCache(): void {
|
||||
_setupStatusCache = null;
|
||||
_setupStatusPromise = null;
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
@@ -22,10 +56,14 @@ function RouteFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
function lazyPage<P extends Record<string, never>>(
|
||||
factory: () => Promise<{ [key: string]: ComponentType<P> }>,
|
||||
exportName: string,
|
||||
@@ -40,11 +78,33 @@ function lazyPage<P extends Record<string, never>>(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
pendingComponent: BootstrapSplash,
|
||||
pendingMs: 0,
|
||||
beforeLoad: async ({ location }) => {
|
||||
let status: SetupStatus;
|
||||
try {
|
||||
status = await fetchSetupStatus();
|
||||
} catch {
|
||||
// Network failure — let routes render; API calls will surface errors.
|
||||
return;
|
||||
}
|
||||
|
||||
const onSetup = location.pathname === '/setup';
|
||||
if (!status.initialized && !onSetup) {
|
||||
throw redirect({ to: '/setup' });
|
||||
}
|
||||
if (status.initialized && onSetup) {
|
||||
throw redirect({ to: '/login' });
|
||||
}
|
||||
},
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
||||
// '/' redirects into the protected area; the guard sends guests to /login.
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
@@ -59,7 +119,6 @@ const loginRoute = createRoute({
|
||||
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' });
|
||||
@@ -74,7 +133,15 @@ const setupRoute = createRoute({
|
||||
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
|
||||
});
|
||||
|
||||
// Layout route guarding every protected page (BR-U1-05).
|
||||
const inviteCompleteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/invite/complete',
|
||||
validateSearch: (search: Record<string, unknown>): { token?: string } => ({
|
||||
token: typeof search.token === 'string' ? search.token : undefined,
|
||||
}),
|
||||
component: lazyPage(() => import('@/pages/InviteCompletePage'), 'InviteCompletePage'),
|
||||
});
|
||||
|
||||
const authenticatedRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: '_authenticated',
|
||||
@@ -95,26 +162,34 @@ const dashboardRoute = createRoute({
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/users',
|
||||
component: lazyPage(() => import('@/pages/UsersPage'), 'UsersPage'),
|
||||
component: () => (
|
||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||
{lazyPage(() => import('@/pages/UsersPage'), 'UsersPage')()}
|
||||
</RoleGuard>
|
||||
),
|
||||
});
|
||||
|
||||
const cmsRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/cms',
|
||||
component: lazyPage(() => import('@/pages/CmsPage'), 'CmsPage'),
|
||||
component: () => (
|
||||
<RoleGuard allowedRoles={['Owner']}>
|
||||
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
|
||||
</RoleGuard>
|
||||
),
|
||||
});
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
loginRoute,
|
||||
setupRoute,
|
||||
inviteCompleteRoute,
|
||||
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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
|
||||
import { renderApp, renderWithProviders, mockAuthenticated, mockGuest } from '@/test/utils';
|
||||
import { server } from '@/mocks/server';
|
||||
import { setupUninitializedHandlers } from '@/mocks/index';
|
||||
import { _resetSetupStatusCache } from '@/router';
|
||||
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||
|
||||
describe('Route guards (BR-U1-05, BR-U1-06)', () => {
|
||||
beforeEach(() => {
|
||||
_resetSetupStatusCache();
|
||||
});
|
||||
|
||||
describe('ProtectedRoute (BR-U1-05, BR-U1-06)', () => {
|
||||
it('redirects an unauthenticated user from a protected route to /login', async () => {
|
||||
mockGuest();
|
||||
renderApp('/dashboard');
|
||||
@@ -26,3 +34,54 @@ describe('Route guards (BR-U1-05, BR-U1-06)', () => {
|
||||
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InitGuard (BR-U2-09, BR-U2-10)', () => {
|
||||
it('redirects any route to /setup when system is not initialized', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
renderApp('/login');
|
||||
|
||||
// InitGuard should redirect /login → /setup when not initialized.
|
||||
expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows /setup to render when system is not initialized', async () => {
|
||||
server.use(...setupUninitializedHandlers);
|
||||
mockGuest();
|
||||
renderApp('/setup');
|
||||
|
||||
expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects /setup to /login when system is already initialized', async () => {
|
||||
// Default handlers return { initialized: true }.
|
||||
mockGuest();
|
||||
renderApp('/setup');
|
||||
|
||||
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('setup-submit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleGuard (BR-U2-14–BR-U2-20)', () => {
|
||||
it('renders children when the user has a permitted role', () => {
|
||||
const { getByText } = renderWithProviders(
|
||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||
<p>Protected content</p>
|
||||
</RoleGuard>,
|
||||
);
|
||||
// mockAuthenticated sets role to 'Owner' via makeAuthResponse — see fixtures.
|
||||
expect(getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline Access Denied when role is insufficient', () => {
|
||||
// renderWithProviders uses the guest (no user) state — no role at all.
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<RoleGuard allowedRoles={['Owner']}>
|
||||
<p>Should not appear</p>
|
||||
</RoleGuard>,
|
||||
);
|
||||
expect(getByTestId('access-denied-message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user