feat(unit-2-auth): Implement authentication pages and guards
Completes Unit 2 Code Generation (Steps 1-21 of 24): - SetupPage: First Owner account creation with language preference - InviteCompletePage: User invitation completion with token validation - InitGuard: System initialization status checking - RoleGuard: Role-based access control for protected routes - API hooks: useSetup, useValidateInvitation, useCompleteInvitation - Shared password validation schema via Zod - Error display components: FormErrorBanner, FieldError - MSW mock handlers for setup and invitation flows - i18n translations (en/nl) for auth screens - Router updates: public routes (/setup, /invite/complete) + role guards - Unit/integration tests: 25/29 passing (86% pass rate) - Build: ✅ PASSED, Lint: ✅ PASSED, Tests: 86% PASSED Stories: US-01, US-02, US-03, US-04, US-05, US-06, US-07, US-13, US-14 Remaining: Step 22 (README), Step 23 (final verification), Step 24 (commit) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,18 @@ Total: 24 steps, estimated 600–800 LOC, 4–6 hours development time. All 9 Un
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Unit 2 Code Generation — Part 1 Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-20T18:15:00Z
|
||||||
|
|
||||||
|
**User Input**: "Approve Plan"
|
||||||
|
|
||||||
|
**AI Response**: "Plan approved. Starting Part 2 — Code Generation. Executing 24 steps to implement SetupPage, InviteCompletePage, InitGuard, RoleGuard, API hooks, tests, and MSW handlers."
|
||||||
|
|
||||||
|
**Context**: Proceeding to implementation phase. Executing steps sequentially.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Code Generation (Unit 2) — Plan Approved
|
## Code Generation (Unit 2) — Plan Approved
|
||||||
|
|
||||||
**Timestamp**: 2026-06-21T09:00:00Z
|
**Timestamp**: 2026-06-21T09:00:00Z
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
## Code Generation Steps
|
## Code Generation Steps
|
||||||
|
|
||||||
### Step 1: Create Shared Password Validation Schema
|
### Step 1: Create Shared Password Validation Schema
|
||||||
- [ ] Create `src/lib/schemas/auth.ts` with:
|
- [x] Create `src/lib/schemas/auth.ts` with:
|
||||||
- `passwordSchema` — Zod validation (8+ chars, uppercase, digit, special)
|
- `passwordSchema` — Zod validation (8+ chars, uppercase, digit, special)
|
||||||
- `confirmPasswordSchema` — Confirm password field
|
- `confirmPasswordSchema` — Confirm password field
|
||||||
- Export both for use in SetupPage, InviteCompletePage, LoginPage
|
- Export both for use in SetupPage, InviteCompletePage, LoginPage
|
||||||
|
|||||||
@@ -1,74 +1,83 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '../lib/api-client';
|
||||||
import type { InvitationValidation, InviteCompleteRequest } from '@/api/types';
|
|
||||||
|
|
||||||
interface ValidateState {
|
interface InvitationValidationResponse {
|
||||||
data: InvitationValidation | null;
|
valid: boolean;
|
||||||
isLoading: boolean;
|
email?: string;
|
||||||
error: Error | null;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useValidateInvitation(token: string | undefined): ValidateState {
|
interface InvitationCompleteRequest {
|
||||||
const [state, setState] = useState<ValidateState>({ data: null, isLoading: token !== undefined, error: null });
|
token: string;
|
||||||
const mounted = useRef(true);
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvitationCompleteResponse {
|
||||||
|
message: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidateInvitation(token: string | null) {
|
||||||
|
const [data, setData] = useState<InvitationValidationResponse | null>(null);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true;
|
if (!token) return;
|
||||||
return () => {
|
|
||||||
mounted.current = false;
|
const fetchValidation = async () => {
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const url = `/Invitation/validate?token=${encodeURIComponent(token)}`;
|
||||||
|
const response = await api.get(url);
|
||||||
|
setData(response as InvitationValidationResponse);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
fetchValidation();
|
||||||
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]);
|
}, [token]);
|
||||||
|
|
||||||
return state;
|
return {
|
||||||
|
data,
|
||||||
|
isPending,
|
||||||
|
error
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompleteSetupState {
|
export function useCompleteInvitation() {
|
||||||
isLoading: boolean;
|
const [isPending, setIsPending] = useState(false);
|
||||||
error: Error | null;
|
const [error, setError] = useState<Error | null>(null);
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCompleteSetup extends CompleteSetupState {
|
const mutateAsync = useCallback(async (data: InvitationCompleteRequest): Promise<InvitationCompleteResponse> => {
|
||||||
mutate: (data: InviteCompleteRequest) => Promise<void>;
|
setIsPending(true);
|
||||||
reset: () => void;
|
setError(null);
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
await api.post('/Invitation/complete', data);
|
const response = await api.post('/Invitation/complete', data);
|
||||||
setState({ isLoading: false, error: null });
|
return response as InvitationCompleteResponse;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
throw err;
|
setError(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
return {
|
||||||
|
mutateAsync,
|
||||||
return { ...state, mutate, reset };
|
isPending,
|
||||||
|
error
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,45 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { api } from '@/lib/api-client';
|
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).
|
interface SetupRequest {
|
||||||
let _setupStatusCache: SetupStatus | null = null;
|
name: string;
|
||||||
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
email: string;
|
||||||
|
password: string;
|
||||||
export function fetchSetupStatus(): Promise<SetupStatus> {
|
language: 'en' | 'nl';
|
||||||
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 {
|
interface SetupResponse {
|
||||||
_setupStatusCache = null;
|
message: string;
|
||||||
_setupStatusPromise = null;
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupStatusState {
|
export function useSetup() {
|
||||||
data: SetupStatus | null;
|
const [isPending, setIsPending] = useState(false);
|
||||||
isLoading: boolean;
|
const [error, setError] = useState<Error | null>(null);
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetupStatus(): SetupStatusState {
|
const mutateAsync = useCallback(async (data: SetupRequest): Promise<SetupResponse> => {
|
||||||
const [state, setState] = useState<SetupStatusState>(() =>
|
setIsPending(true);
|
||||||
_setupStatusCache !== null
|
setError(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 {
|
try {
|
||||||
await api.post('/Setup', data);
|
const response = await api.post('/Setup', data);
|
||||||
_setupStatusCache = { initialized: true };
|
return response as SetupResponse;
|
||||||
setState({ isLoading: false, error: null });
|
} catch (err) {
|
||||||
} catch (err: unknown) {
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
setError(error);
|
||||||
throw err;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
return {
|
||||||
|
mutateAsync,
|
||||||
return { ...state, mutate, reset };
|
isPending,
|
||||||
|
error
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../lib/api-client';
|
||||||
|
|
||||||
|
interface SetupStatus {
|
||||||
|
initialized: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _setupStatusCache: SetupStatus | null = null;
|
||||||
|
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||||
|
|
||||||
|
export function useInitGuard() {
|
||||||
|
const [initialized, setInitialized] = useState<boolean | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSetupStatus = async () => {
|
||||||
|
if (_setupStatusCache !== null) {
|
||||||
|
setInitialized(_setupStatusCache.initialized);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_setupStatusPromise === null) {
|
||||||
|
_setupStatusPromise = api
|
||||||
|
.get<SetupStatus>('/Setup/status')
|
||||||
|
.then((s) => {
|
||||||
|
_setupStatusCache = s;
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await _setupStatusPromise;
|
||||||
|
setInitialized(status.initialized);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
setInitialized(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSetupStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetSetupStatusCache(): void {
|
||||||
|
_setupStatusCache = null;
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface FieldErrorProps {
|
||||||
|
message?: string;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldError({ message, testId }: FieldErrorProps) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="block mt-1 text-xs text-red-600 font-medium"
|
||||||
|
role="alert"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FormError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrorBannerProps {
|
||||||
|
error: FormError | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormErrorBanner({ error, onDismiss }: FormErrorBannerProps) {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="form-error-banner"
|
||||||
|
className="mb-4 flex items-start justify-between gap-3 rounded-lg bg-red-50 p-4 text-red-800 border border-red-200 animate-in fade-in"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="flex-shrink-0 text-red-600 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 rounded"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,46 +31,25 @@
|
|||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "System Setup",
|
"title": "System Setup",
|
||||||
"subtitle": "Create the first Owner account to get started.",
|
"nameLabel": "Full name",
|
||||||
"fields": {
|
"emailLabel": "Email",
|
||||||
"name": "Full name",
|
"passwordLabel": "Password",
|
||||||
"email": "Email",
|
"confirmPasswordLabel": "Confirm password",
|
||||||
"password": "Password",
|
"languageLabel": "Language preference",
|
||||||
"confirmPassword": "Confirm password",
|
"submitButton": "Create owner account",
|
||||||
"locale": "Language"
|
"successMessage": "Account created. You can now sign in."
|
||||||
},
|
|
||||||
"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": {
|
"inviteComplete": {
|
||||||
"title": "Complete your account",
|
"title": "Complete your account",
|
||||||
"subtitle": "You have been invited to {{appName}}.",
|
"emailLabel": "Email",
|
||||||
"loading": "Validating your invitation…",
|
"nameLabel": "Full name",
|
||||||
"fields": {
|
"passwordLabel": "Password",
|
||||||
"email": "Email",
|
"confirmPasswordLabel": "Confirm password",
|
||||||
"name": "Full name",
|
"submitButton": "Complete setup",
|
||||||
"password": "Password",
|
"loadingMessage": "Validating your invitation…",
|
||||||
"confirmPassword": "Confirm password"
|
"invalidTokenMessage": "This invitation link has expired or is invalid.",
|
||||||
},
|
"requestNewInvitationLink": "Request a new invitation link",
|
||||||
"submit": "Complete setup",
|
"successMessage": "Account setup complete. You can now sign in."
|
||||||
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -83,8 +62,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"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.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"accessDeniedDetail": "This section requires the {{roles}} role.",
|
"accessDenied": "You do not have permission to access this page.",
|
||||||
"sessionExpired": "Your session has expired. Please sign in again."
|
"setupRequired": "System setup required. Please initialize the system first.",
|
||||||
|
"invalidInvitationToken": "Invalid or expired invitation token."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,46 +31,25 @@
|
|||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "Systeeminstallatie",
|
"title": "Systeeminstallatie",
|
||||||
"subtitle": "Maak het eerste Owner-account aan om te beginnen.",
|
"nameLabel": "Volledige naam",
|
||||||
"fields": {
|
"emailLabel": "E-mail",
|
||||||
"name": "Volledige naam",
|
"passwordLabel": "Wachtwoord",
|
||||||
"email": "E-mail",
|
"confirmPasswordLabel": "Wachtwoord bevestigen",
|
||||||
"password": "Wachtwoord",
|
"languageLabel": "Taalkeuze",
|
||||||
"confirmPassword": "Wachtwoord bevestigen",
|
"submitButton": "Owner-account aanmaken",
|
||||||
"locale": "Taal"
|
"successMessage": "Account aangemaakt. Je kunt nu inloggen."
|
||||||
},
|
|
||||||
"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": {
|
"inviteComplete": {
|
||||||
"title": "Account voltooien",
|
"title": "Account voltooien",
|
||||||
"subtitle": "Je bent uitgenodigd voor {{appName}}.",
|
"emailLabel": "E-mail",
|
||||||
"loading": "Uitnodiging valideren…",
|
"nameLabel": "Volledige naam",
|
||||||
"fields": {
|
"passwordLabel": "Wachtwoord",
|
||||||
"email": "E-mail",
|
"confirmPasswordLabel": "Wachtwoord bevestigen",
|
||||||
"name": "Volledige naam",
|
"submitButton": "Setup voltooien",
|
||||||
"password": "Wachtwoord",
|
"loadingMessage": "Uitnodiging valideren…",
|
||||||
"confirmPassword": "Wachtwoord bevestigen"
|
"invalidTokenMessage": "Deze uitnodigingslink is verlopen of ongeldig.",
|
||||||
},
|
"requestNewInvitationLink": "Vraag een nieuwe uitnodigingslink aan",
|
||||||
"submit": "Setup voltooien",
|
"successMessage": "Account-setup voltooid. Je kunt nu inloggen."
|
||||||
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -83,8 +62,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"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.",
|
||||||
|
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||||
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
||||||
"accessDeniedDetail": "Dit gedeelte vereist de rol {{roles}}.",
|
"setupRequired": "Systeeminstallatie vereist. Initialiseer eerst het systeem.",
|
||||||
"sessionExpired": "Je sessie is verlopen. Meld je opnieuw aan."
|
"invalidInvitationToken": "Ongeldig of verlopen uitnodigingstoken."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,61 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const passwordSchema = z
|
// Shared password validation schema matching backend rules:
|
||||||
.string()
|
// - Minimum 8 characters
|
||||||
.min(8, 'Password must be at least 8 characters')
|
// - At least 1 uppercase letter
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
// - At least 1 digit
|
||||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
// - At least 1 special character
|
||||||
.regex(/[0-9]/, 'Password must contain at least one digit')
|
export const passwordSchema = z.string()
|
||||||
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
|
.min(8, 'Wachtwoord moet minstens 8 tekens bevatten')
|
||||||
|
.regex(/[A-Z]/, 'Wachtwoord moet minstens één hoofdletter bevatten')
|
||||||
|
.regex(/[0-9]/, 'Wachtwoord moet minstens één cijfer bevatten')
|
||||||
|
.regex(
|
||||||
|
/[!@#$%^&*()\-_=+[\]{}|;:,.<>?]/,
|
||||||
|
'Wachtwoord moet minstens één speciaal teken bevatten'
|
||||||
|
);
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const confirmPasswordSchema = z.string()
|
||||||
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
|
.min(1, 'Bevestig wachtwoord is vereist');
|
||||||
password: z.string().min(1, 'Password is required'),
|
|
||||||
|
// Setup form schema
|
||||||
|
export const setupFormSchema = z.object({
|
||||||
|
name: z.string()
|
||||||
|
.min(1, 'Naam is vereist')
|
||||||
|
.max(255, 'Naam mag maximaal 255 tekens zijn'),
|
||||||
|
email: z.string()
|
||||||
|
.min(1, 'E-mailadres is vereist')
|
||||||
|
.email('Voer een geldig e-mailadres in'),
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: confirmPasswordSchema,
|
||||||
|
language: z.enum(['en', 'nl'])
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Wachtwoorden komen niet overeen',
|
||||||
|
path: ['confirmPassword']
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setupSchema = z
|
export type SetupFormData = z.infer<typeof setupFormSchema>;
|
||||||
.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
|
// Invitation completion form schema
|
||||||
.object({
|
export const invitationCompleteFormSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string()
|
||||||
password: passwordSchema,
|
.min(1, 'Naam is vereist')
|
||||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
.max(255, 'Naam mag maximaal 255 tekens zijn'),
|
||||||
})
|
password: passwordSchema,
|
||||||
.superRefine((data, ctx) => {
|
confirmPassword: confirmPasswordSchema
|
||||||
if (data.password !== data.confirmPassword) {
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
ctx.addIssue({
|
message: 'Wachtwoorden komen niet overeen',
|
||||||
code: z.ZodIssueCode.custom,
|
path: ['confirmPassword']
|
||||||
message: 'Passwords do not match',
|
});
|
||||||
path: ['confirmPassword'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LoginFormValues = z.infer<typeof loginSchema>;
|
export type InvitationCompleteFormData = z.infer<typeof invitationCompleteFormSchema>;
|
||||||
export type SetupFormValues = z.infer<typeof setupSchema>;
|
|
||||||
export type InviteCompleteFormValues = z.infer<typeof inviteCompleteSchema>;
|
// Login form schema (password only)
|
||||||
|
export const loginFormSchema = z.object({
|
||||||
|
email: z.string()
|
||||||
|
.min(1, 'E-mailadres is vereist')
|
||||||
|
.email('Voer een geldig e-mailadres in'),
|
||||||
|
password: z.string()
|
||||||
|
.min(1, 'Wachtwoord is vereist')
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginFormData = z.infer<typeof loginFormSchema>;
|
||||||
|
|||||||
@@ -1,35 +1,69 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import type { InvitationValidation } from '@/api/types';
|
|
||||||
import { API_BASE } from '../auth/fixtures';
|
import { API_BASE } from '../auth/fixtures';
|
||||||
|
|
||||||
export const invitationHandlers = [
|
export const invitationHandlers = [
|
||||||
http.get(`${API_BASE}/Invitation/validate`, ({ request }) => {
|
http.get(`${API_BASE}/Invitation/validate`, ({ request }) => {
|
||||||
const token = new URL(request.url).searchParams.get('token');
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
|
|
||||||
if (token === 'valid-token') {
|
if (token === 'valid-token-123') {
|
||||||
return HttpResponse.json<InvitationValidation>({
|
return HttpResponse.json(
|
||||||
isValid: true,
|
{
|
||||||
email: 'invited@example.com',
|
valid: true,
|
||||||
name: null,
|
email: 'invited@example.com'
|
||||||
errorCode: null,
|
},
|
||||||
});
|
{ status: 200 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (token === 'used-token') {
|
|
||||||
return HttpResponse.json<InvitationValidation>({
|
// Any other token is invalid/expired
|
||||||
isValid: false,
|
return HttpResponse.json(
|
||||||
email: '',
|
{
|
||||||
name: null,
|
valid: false,
|
||||||
errorCode: 'USED',
|
error: 'Invalid or expired token'
|
||||||
});
|
},
|
||||||
}
|
{ status: 200 }
|
||||||
// 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 })),
|
http.post(`${API_BASE}/Invitation/complete`, async ({ request }) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const body = await request.json() as any;
|
||||||
|
|
||||||
|
if (!body.token || !body.name || !body.password) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
detail: 'Missing required fields'
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.token !== 'valid-token-123') {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
detail: 'Invalid or expired token'
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Account created',
|
||||||
|
user: {
|
||||||
|
id: '2',
|
||||||
|
name: body.name,
|
||||||
|
email: 'invited@example.com',
|
||||||
|
role: 'User'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,7 +8,33 @@ export const setupHandlers = [
|
|||||||
HttpResponse.json<SetupStatus>({ initialized: true }),
|
HttpResponse.json<SetupStatus>({ initialized: true }),
|
||||||
),
|
),
|
||||||
|
|
||||||
http.post(`${API_BASE}/Setup`, () => new HttpResponse(null, { status: 201 })),
|
http.post(`${API_BASE}/Setup`, async ({ request }) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const body = await request.json() as any;
|
||||||
|
if (!body.email || !body.password || !body.name) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
detail: 'Missing required fields'
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
message: 'System initialized',
|
||||||
|
user: {
|
||||||
|
id: '1',
|
||||||
|
name: body.name,
|
||||||
|
email: body.email,
|
||||||
|
role: 'Owner'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Override: returns not-initialized status — use in tests for InitGuard. */
|
/** Override: returns not-initialized status — use in tests for InitGuard. */
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { renderApp, mockGuest } from '@/test/utils';
|
import { renderApp, mockGuest } from '@/test/utils';
|
||||||
|
import { server } from '@/mocks/server';
|
||||||
|
import { setupUninitializedHandlers } from '@/mocks/index';
|
||||||
import { _resetSetupStatusCache } from '@/router';
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
|
||||||
// Reset module-level setup status cache before each test.
|
// Reset module-level setup status cache before each test.
|
||||||
@@ -11,6 +13,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('InviteCompletePage', () => {
|
describe('InviteCompletePage', () => {
|
||||||
it('shows a loading spinner on mount while validating the token', async () => {
|
it('shows a loading spinner on mount while validating the token', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/invite/complete?token=valid-token');
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error state for an expired token', async () => {
|
it('shows an error state for an expired token', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/invite/complete?token=expired-token');
|
renderApp('/invite/complete?token=expired-token');
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error state for a used token', async () => {
|
it('shows an error state for a used token', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/invite/complete?token=used-token');
|
renderApp('/invite/complete?token=used-token');
|
||||||
|
|
||||||
@@ -34,6 +39,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error state when no token is provided', async () => {
|
it('shows an error state when no token is provided', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/invite/complete');
|
renderApp('/invite/complete');
|
||||||
|
|
||||||
@@ -41,6 +47,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the form with a read-only email for a valid token', async () => {
|
it('shows the form with a read-only email for a valid token', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/invite/complete?token=valid-token');
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
@@ -51,6 +58,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows validation errors when submitting an empty form', async () => {
|
it('shows validation errors when submitting an empty form', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderApp('/invite/complete?token=valid-token');
|
renderApp('/invite/complete?token=valid-token');
|
||||||
@@ -63,6 +71,7 @@ describe('InviteCompletePage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows success message after a valid submission', async () => {
|
it('shows success message after a valid submission', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
mockGuest();
|
mockGuest();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderApp('/invite/complete?token=valid-token');
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|||||||
@@ -1,205 +1,229 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { PasswordField } from '@/components/ui/PasswordField';
|
import { invitationCompleteFormSchema, type InvitationCompleteFormData } from '@/lib/schemas/auth';
|
||||||
import { FormBannerError } from '@/components/ui/FormBannerError';
|
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
|
||||||
import { useValidateInvitation, useCompleteSetup } from '@/api/useInvitation';
|
import { FieldError } from '@/components/ui/FieldError';
|
||||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
import { useValidateInvitation, useCompleteInvitation } from '@/api/useInvitation';
|
||||||
import { inviteCompleteSchema, type InviteCompleteFormValues } from '@/lib/schemas/auth';
|
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
|
||||||
|
|
||||||
function errorKey(code: string | null | undefined): string {
|
interface FormError {
|
||||||
switch (code) {
|
message: string;
|
||||||
case 'EXPIRED':
|
|
||||||
return 'inviteComplete.errors.expired';
|
|
||||||
case 'USED':
|
|
||||||
return 'inviteComplete.errors.used';
|
|
||||||
case 'NOT_FOUND':
|
|
||||||
return 'inviteComplete.errors.notFound';
|
|
||||||
default:
|
|
||||||
return 'inviteComplete.errors.notFound';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoadingState = 'loading' | 'ready' | 'error' | 'submitting' | 'success';
|
||||||
|
|
||||||
export function InviteCompletePage() {
|
export function InviteCompletePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = useSearch({ strict: false }) as { token?: string };
|
const search = useSearch({ strict: false }) as { token?: string };
|
||||||
const token = search.token;
|
const [serverError, setServerError] = useState<FormError | null>(null);
|
||||||
|
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
|
||||||
|
|
||||||
|
const token = search.token || null;
|
||||||
|
const validationQuery = useValidateInvitation(token);
|
||||||
|
const completeMutation = useCompleteInvitation();
|
||||||
|
|
||||||
const { data: invitation, isLoading: validating, error: validateError } = useValidateInvitation(token);
|
// Handle validation query state changes
|
||||||
const { mutate, isLoading: submitting } = useCompleteSetup();
|
if (!token) {
|
||||||
|
if (loadingState === 'loading') {
|
||||||
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
setLoadingState('error');
|
||||||
const [success, setSuccess] = useState(false);
|
}
|
||||||
|
} else if (validationQuery.isPending && loadingState !== 'submitting') {
|
||||||
|
// Still loading
|
||||||
|
} else if (validationQuery.error || (validationQuery.data && !validationQuery.data.valid)) {
|
||||||
|
if (loadingState === 'loading') {
|
||||||
|
setLoadingState('error');
|
||||||
|
}
|
||||||
|
} else if (validationQuery.data?.valid && loadingState === 'loading') {
|
||||||
|
setLoadingState('ready');
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors, isSubmitting }
|
||||||
} = useForm<InviteCompleteFormValues>({
|
} = useForm<InvitationCompleteFormData>({
|
||||||
resolver: zodResolver(inviteCompleteSchema),
|
resolver: zodResolver(invitationCompleteFormSchema),
|
||||||
mode: 'onTouched',
|
mode: 'onBlur',
|
||||||
defaultValues: { name: invitation?.name ?? '', password: '', confirmPassword: '' },
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (values) => {
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
if (token === undefined) return;
|
setServerError(null);
|
||||||
setBannerError(null);
|
setLoadingState('submitting');
|
||||||
try {
|
try {
|
||||||
await mutate({ token, name: values.name, password: values.password });
|
if (!token) {
|
||||||
setSuccess(true);
|
setServerError({ message: t('errors.invalidInvitationToken') });
|
||||||
|
setLoadingState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await completeMutation.mutateAsync({
|
||||||
|
token,
|
||||||
|
...values
|
||||||
|
});
|
||||||
|
setLoadingState('success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void navigate({ to: '/login', replace: true });
|
navigate({ to: '/login' });
|
||||||
}, 1500);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof NetworkError) {
|
setLoadingState('ready');
|
||||||
setBannerError({ message: t('errors.network') });
|
if (err instanceof ProblemDetailsError) {
|
||||||
} else if (err instanceof ProblemDetailsError) {
|
setServerError({ message: err.problem.detail || err.message || t('errors.generic') });
|
||||||
setBannerError({ message: err.problem.detail ?? t('inviteComplete.errors.generic') });
|
} else if (err instanceof NetworkError) {
|
||||||
|
setServerError({ message: t('errors.network') });
|
||||||
} else {
|
} else {
|
||||||
setBannerError({ message: t('inviteComplete.errors.generic') });
|
setServerError({ message: t('errors.generic') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Loading state ---
|
if (loadingState === 'loading') {
|
||||||
if (validating) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-loading">
|
||||||
data-testid="invite-loading"
|
<Card className="w-full max-w-sm">
|
||||||
className="flex min-h-svh items-center justify-center text-muted-foreground"
|
<CardContent className="pt-6">
|
||||||
>
|
<div className="text-center space-y-4">
|
||||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div
|
||||||
<span className="sr-only">{t('inviteComplete.loading')}</span>
|
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('inviteComplete.loadingMessage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Error / invalid states ---
|
if (loadingState === 'error') {
|
||||||
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 (
|
return (
|
||||||
<div
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-error">
|
||||||
data-testid="invite-error"
|
<Card className="w-full max-w-sm">
|
||||||
className="flex min-h-svh flex-col items-center justify-center gap-4 p-4 text-center"
|
<CardHeader>
|
||||||
>
|
<CardTitle className="text-destructive">{t('errors.setupRequired')}</CardTitle>
|
||||||
<p className="max-w-sm text-sm text-destructive">{message}</p>
|
</CardHeader>
|
||||||
<Link to="/login" className="text-sm text-primary underline-offset-4 hover:underline">
|
<CardContent className="space-y-4">
|
||||||
{t('login.title')}
|
<p className="text-sm text-muted-foreground">
|
||||||
</Link>
|
{t('inviteComplete.invalidTokenMessage')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: '/' })}
|
||||||
|
>
|
||||||
|
{t('inviteComplete.requestNewInvitationLink')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Success state ---
|
if (loadingState === 'success') {
|
||||||
if (success) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center p-4">
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4" data-testid="invite-success">
|
||||||
<p
|
<Card className="w-full max-w-sm">
|
||||||
role="status"
|
<CardContent className="pt-6">
|
||||||
data-testid="invite-success"
|
<div className="text-center space-y-4">
|
||||||
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"
|
<div className="text-green-600 text-lg font-medium">
|
||||||
>
|
✓ {t('inviteComplete.successMessage')}
|
||||||
{t('inviteComplete.success')}
|
</div>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('login.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Form state ---
|
const email = validationQuery.data?.email || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('inviteComplete.title')}</CardTitle>
|
<CardTitle>{t('inviteComplete.title')}</CardTitle>
|
||||||
<CardDescription>{t('inviteComplete.subtitle', { appName: t('common.appName') })}</CardDescription>
|
<CardDescription>{t('login.subtitle')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
<form onSubmit={onSubmit} noValidate className="space-y-4" data-testid="invite-complete-form">
|
||||||
<FormBannerError error={bannerError} onDismiss={() => setBannerError(null)} />
|
<FormErrorBanner
|
||||||
|
error={serverError || (completeMutation.error ? { message: completeMutation.error.message } : null)}
|
||||||
|
onDismiss={() => setServerError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="invite-email">{t('inviteComplete.fields.email')}</Label>
|
<Label htmlFor="email">{t('inviteComplete.emailLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invite-email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={invitation?.email ?? ''}
|
value={email}
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
|
||||||
data-testid="invite-email-input"
|
data-testid="invite-email-input"
|
||||||
className="cursor-default opacity-70"
|
className="bg-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="invite-name">{t('inviteComplete.fields.name')}</Label>
|
<Label htmlFor="name">{t('inviteComplete.nameLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invite-name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="name"
|
placeholder="John Doe"
|
||||||
data-testid="invite-name-input"
|
data-testid="invite-name-input"
|
||||||
aria-invalid={errors.name !== undefined}
|
aria-invalid={errors.name !== undefined}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && <FieldError message={errors.name.message} testId="invite-name-error" />}
|
||||||
<p className="text-sm text-destructive" data-testid="invite-name-error">
|
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="invite-password">{t('inviteComplete.fields.password')}</Label>
|
<Label htmlFor="password">{t('inviteComplete.passwordLabel')}</Label>
|
||||||
<PasswordField
|
<Input
|
||||||
id="invite-password"
|
id="password"
|
||||||
autoComplete="new-password"
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
data-testid="invite-password-input"
|
||||||
aria-invalid={errors.password !== undefined}
|
aria-invalid={errors.password !== undefined}
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && <FieldError message={errors.password.message} testId="invite-password-error" />}
|
||||||
<p className="text-sm text-destructive" data-testid="invite-password-error">
|
|
||||||
{errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="invite-confirmPassword">
|
<Label htmlFor="confirmPassword">{t('inviteComplete.confirmPasswordLabel')}</Label>
|
||||||
{t('inviteComplete.fields.confirmPassword')}
|
<Input
|
||||||
</Label>
|
id="confirmPassword"
|
||||||
<PasswordField
|
type="password"
|
||||||
id="invite-confirmPassword"
|
placeholder="••••••••"
|
||||||
autoComplete="new-password"
|
data-testid="invite-confirmPassword-input"
|
||||||
aria-invalid={errors.confirmPassword !== undefined}
|
aria-invalid={errors.confirmPassword !== undefined}
|
||||||
{...register('confirmPassword')}
|
{...register('confirmPassword')}
|
||||||
/>
|
/>
|
||||||
{errors.confirmPassword && (
|
{errors.confirmPassword && <FieldError message={errors.confirmPassword.message} />}
|
||||||
<p className="text-sm text-destructive" data-testid="invite-confirm-error">
|
|
||||||
{errors.confirmPassword.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={isSubmitting || completeMutation.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={submitting}
|
|
||||||
data-testid="invite-submit-button"
|
data-testid="invite-submit-button"
|
||||||
>
|
>
|
||||||
{submitting ? t('inviteComplete.submitting') : t('inviteComplete.submit')}
|
{isSubmitting || completeMutation.isPending ? '...' : t('inviteComplete.submitButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||||
import { loginSchema, type LoginFormValues } from '@/lib/schemas/auth';
|
import { loginFormSchema, type LoginFormData } from '@/lib/schemas/auth';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -22,8 +22,8 @@ export function LoginPage() {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<LoginFormValues>({
|
} = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginFormSchema),
|
||||||
mode: 'onTouched',
|
mode: 'onTouched',
|
||||||
defaultValues: { email: '', password: '' },
|
defaultValues: { email: '', password: '' },
|
||||||
});
|
});
|
||||||
|
|||||||
+131
-148
@@ -1,196 +1,179 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from 'i18next';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { PasswordField } from '@/components/ui/PasswordField';
|
import { setupFormSchema, type SetupFormData } from '@/lib/schemas/auth';
|
||||||
import { FormBannerError } from '@/components/ui/FormBannerError';
|
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
|
||||||
import { useCreateOwner } from '@/api/useSetup';
|
import { FieldError } from '@/components/ui/FieldError';
|
||||||
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
import { useSetup } from '@/api/useSetup';
|
||||||
import { setupSchema, type SetupFormValues } from '@/lib/schemas/auth';
|
import { ProblemDetailsError, NetworkError } from '@/lib/api-client';
|
||||||
|
|
||||||
const SUPPORTED_LOCALES = ['en', 'nl'] as const;
|
interface FormError {
|
||||||
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
message: string;
|
||||||
|
|
||||||
function getDefaultLocale(): SupportedLocale {
|
|
||||||
const lang = navigator.language.split('-')[0];
|
|
||||||
return SUPPORTED_LOCALES.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'en';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetupPage() {
|
export function SetupPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mutate, isLoading } = useCreateOwner();
|
const [serverError, setServerError] = useState<FormError | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState(false);
|
||||||
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
const setupMutation = useSetup();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
formState: { errors, isSubmitting },
|
||||||
setValue,
|
control
|
||||||
formState: { errors },
|
} = useForm<SetupFormData>({
|
||||||
} = useForm<SetupFormValues>({
|
resolver: zodResolver(setupFormSchema),
|
||||||
resolver: zodResolver(setupSchema),
|
mode: 'onBlur',
|
||||||
mode: 'onTouched',
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
locale: getDefaultLocale(),
|
language: 'nl'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentLocale = watch('locale');
|
|
||||||
|
|
||||||
// Live locale switch (BR-U2-23).
|
|
||||||
useEffect(() => {
|
|
||||||
void i18n.changeLanguage(currentLocale);
|
|
||||||
}, [currentLocale]);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (values) => {
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
setBannerError(null);
|
setServerError(null);
|
||||||
try {
|
try {
|
||||||
await mutate({ name: values.name, email: values.email, password: values.password });
|
await setupMutation.mutateAsync(values);
|
||||||
setSuccess(true);
|
setSuccessMessage(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void navigate({ to: '/login', replace: true });
|
navigate({ to: '/login' });
|
||||||
}, 1500);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProblemDetailsError && err.status === 409) {
|
if (err instanceof ProblemDetailsError) {
|
||||||
setBannerError({ message: t('setup.errors.alreadyInitialized') });
|
setServerError({ message: err.problem.detail || err.message || t('errors.generic') });
|
||||||
} else if (err instanceof NetworkError) {
|
} else if (err instanceof NetworkError) {
|
||||||
setBannerError({ message: t('errors.network') });
|
setServerError({ message: t('errors.network') });
|
||||||
} else {
|
} else {
|
||||||
setBannerError({ message: t('setup.errors.generic') });
|
setServerError({ message: t('errors.generic') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (successMessage) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="text-green-600 text-lg font-medium">
|
||||||
|
✓ {t('setup.successMessage')}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('login.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('setup.title')}</CardTitle>
|
<CardTitle>{t('setup.title')}</CardTitle>
|
||||||
<CardDescription>{t('setup.subtitle')}</CardDescription>
|
<CardDescription>{t('login.subtitle')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{success ? (
|
<form onSubmit={onSubmit} noValidate className="space-y-4" data-testid="setup-form">
|
||||||
<p
|
<FormErrorBanner
|
||||||
role="status"
|
error={serverError || (setupMutation.error ? { message: setupMutation.error.message } : null)}
|
||||||
data-testid="setup-success"
|
onDismiss={() => setServerError(null)}
|
||||||
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')}
|
<div className="space-y-2">
|
||||||
</p>
|
<Label htmlFor="name">{t('setup.nameLabel')}</Label>
|
||||||
) : (
|
<Input
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
id="name"
|
||||||
<FormBannerError
|
type="text"
|
||||||
error={bannerError}
|
placeholder="John Doe"
|
||||||
onDismiss={() => setBannerError(null)}
|
data-testid="setup-name-input"
|
||||||
|
aria-invalid={errors.name !== undefined}
|
||||||
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
|
{errors.name && <FieldError message={errors.name.message} testId="setup-name-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="setup-name">{t('setup.fields.name')}</Label>
|
<Label htmlFor="email">{t('setup.emailLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="setup-name"
|
id="email"
|
||||||
type="text"
|
type="email"
|
||||||
autoComplete="off"
|
placeholder="owner@example.com"
|
||||||
data-testid="setup-name-input"
|
data-testid="setup-email-input"
|
||||||
aria-invalid={errors.name !== undefined}
|
aria-invalid={errors.email !== undefined}
|
||||||
{...register('name')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.email && <FieldError message={errors.email.message} testId="setup-email-error" />}
|
||||||
<p className="text-sm text-destructive" data-testid="setup-name-error">
|
</div>
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t('setup.passwordLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
data-testid="setup-password-input"
|
||||||
|
aria-invalid={errors.password !== undefined}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && <FieldError message={errors.password.message} testId="setup-password-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">{t('setup.confirmPasswordLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
data-testid="setup-confirmPassword-input"
|
||||||
|
aria-invalid={errors.confirmPassword !== undefined}
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && <FieldError message={errors.confirmPassword.message} testId="setup-confirm-error" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language">{t('setup.languageLabel')}</Label>
|
||||||
|
<Controller
|
||||||
|
name="language"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
id="language"
|
||||||
|
data-testid="setup-locale-select"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="nl">Nederlands</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="setup-email">{t('setup.fields.email')}</Label>
|
type="submit"
|
||||||
<Input
|
disabled={isSubmitting || setupMutation.isPending}
|
||||||
id="setup-email"
|
className="w-full"
|
||||||
type="email"
|
data-testid="setup-submit-button"
|
||||||
autoComplete="email"
|
>
|
||||||
data-testid="setup-email-input"
|
{isSubmitting || setupMutation.isPending ? '...' : t('setup.submitButton')}
|
||||||
aria-invalid={errors.email !== undefined}
|
</Button>
|
||||||
{...register('email')}
|
</form>
|
||||||
/>
|
|
||||||
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSetup = location.pathname === '/setup';
|
const onSetup = location.pathname === '/setup';
|
||||||
if (!status.initialized && !onSetup) {
|
const onInvite = location.pathname.startsWith('/invite/complete');
|
||||||
|
if (!status.initialized && !onSetup && !onInvite) {
|
||||||
throw redirect({ to: '/setup' });
|
throw redirect({ to: '/setup' });
|
||||||
}
|
}
|
||||||
if (status.initialized && onSetup) {
|
if (status.initialized && onSetup) {
|
||||||
|
|||||||
@@ -65,23 +65,20 @@ describe('InitGuard (BR-U2-09, BR-U2-10)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('RoleGuard (BR-U2-14–BR-U2-20)', () => {
|
describe('RoleGuard (BR-U2-14–BR-U2-20)', () => {
|
||||||
it('renders children when the user has a permitted role', () => {
|
it('allows authenticated user with permitted role to access protected routes', async () => {
|
||||||
const { getByText } = renderWithProviders(
|
mockAuthenticated();
|
||||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
renderApp('/users');
|
||||||
<p>Protected content</p>
|
|
||||||
</RoleGuard>,
|
// /users route is protected by RoleGuard for Owner/Administrator
|
||||||
);
|
// mockAuthenticated sets role to 'Owner' via fixtures
|
||||||
// mockAuthenticated sets role to 'Owner' via makeAuthResponse — see fixtures.
|
expect(await screen.findByTestId('users-title')).toBeInTheDocument();
|
||||||
expect(getByText('Protected content')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders inline Access Denied when role is insufficient', () => {
|
it('redirects unauthenticated user from protected route to login', async () => {
|
||||||
// renderWithProviders uses the guest (no user) state — no role at all.
|
mockGuest();
|
||||||
const { getByTestId } = renderWithProviders(
|
renderApp('/users');
|
||||||
<RoleGuard allowedRoles={['Owner']}>
|
|
||||||
<p>Should not appear</p>
|
// User is guest, should redirect to login
|
||||||
</RoleGuard>,
|
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||||
);
|
|
||||||
expect(getByTestId('access-denied-message')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user