Adds unit 2 functional design and code generation plan and gap 4 report

This commit is contained in:
2026-06-21 23:28:45 +02:00
parent ab93a5c7d1
commit f9689d2091
9 changed files with 1567 additions and 1003 deletions
@@ -4,321 +4,443 @@
```mermaid
graph TD
Root["__root · InitGuard"] --> AuthLayout["_authenticated · ProtectedRoute"]
Root --> LoginPage["LoginPage"]
Root --> SetupPage["SetupPage"]
Root --> InviteCompletePage["InviteCompletePage"]
AuthLayout --> Dashboard["DashboardPage"]
AuthLayout --> ProfilePage["ProfilePage"]
AuthLayout --> RoleGuardUsers["RoleGuard Owner|Admin<br/>UsersPage"]
AuthLayout --> RoleGuardSettings["RoleGuard Owner<br/>SettingsPage"]
AuthLayout --> RoleGuardCms["RoleGuard Owner<br/>CmsPage"]
SetupPage --> useSetup["useSetup<br/>useSetupStatus · useCreateOwner"]
InviteCompletePage --> useInvitation["useInvitation stub<br/>useValidateInvitation · useCompleteSetup"]
SetupPage -.-> FormBanner["FormBannerError"]
SetupPage -.-> PasswordField["PasswordField"]
InviteCompletePage -.-> FormBanner
InviteCompletePage -.-> PasswordField
LoginPage -.-> FormBanner
classDef route fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
classDef page fill:#dcfce7,stroke:#15803d,stroke-width:1px,color:#1a1a1a
classDef guard fill:#fef3c7,stroke:#b45309,stroke-width:2px,color:#1a1a1a
classDef shared fill:#f3e8ff,stroke:#7c3aed,stroke-width:1px,color:#1a1a1a
classDef hook fill:#e0e7ff,stroke:#4338ca,stroke-width:1px,color:#1a1a1a
class Root,AuthLayout route
class LoginPage,SetupPage,InviteCompletePage,Dashboard,ProfilePage page
class RoleGuardUsers,RoleGuardSettings,RoleGuardCms guard
class FormBanner,PasswordField shared
class useSetup,useInvitation hook
root["__root.tsx<br/>(Root Layout)"]
initguard["InitGuard<br/>(wrapper/hook)"]
errorboundary["ErrorBoundary<br/>(from Unit 1)"]
auth["_authenticated.tsx<br/>(Protected Layout)"]
protroute["ProtectedRoute<br/>(from Unit 1)"]
roleguard["RoleGuard<br/>(NEW)"]
setup["SetupPage<br/>NEW /setup"]
invite["InviteCompletePage<br/>NEW /invite/complete"]
login["LoginPage<br/>from Unit 1 /login"]
dashboard["DashboardPage<br/>/dashboard<br/>(all roles)"]
profile["ProfilePage<br/>/profile<br/>(all roles)"]
users["UsersPage<br/>/users<br/>(Owner/Admin)"]
settings["SettingsPage<br/>/settings<br/>(Owner)"]
cms["CmsPage<br/>/cms<br/>(Owner)"]
root --> initguard
root --> errorboundary
errorboundary --> auth
auth --> protroute
auth --> roleguard
root --> setup
root --> invite
root --> login
protroute --> dashboard
protroute --> profile
roleguard --> users
roleguard --> settings
roleguard --> cms
classDef rootComponent fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
classDef guardComponent fill:#FF9800,stroke:#E65100,color:#fff,stroke-width:2px
classDef publicPage fill:#FFC107,stroke:#F57F17,color:#000,stroke-width:2px
classDef protectedPage fill:#2196F3,stroke:#0D47A1,color:#fff,stroke-width:2px
classDef restrictedPage fill:#E91E63,stroke:#880E4F,color:#fff,stroke-width:2px
class root rootComponent
class initguard,errorboundary,protroute,roleguard guardComponent
class setup,invite,login publicPage
class dashboard,profile protectedPage
class users,settings,cms restrictedPage
```
Text alternative: Root route contains InitGuard logic; _authenticated layout wraps protected pages (ProtectedRoute in beforeLoad); public pages (Login, Setup, InviteComplete) are siblings at root level; RoleGuard wraps role-restricted pages as a rendering wrapper.
---
## Shared Schema — `src/lib/schemas/auth.ts`
**Purpose**: Single source of truth for password and auth form validation. Imported by all form pages.
```
Exports:
- passwordSchema Zod schema for a single password field (BR-U2-0105)
- confirmPasswordSchema Zod object extension with .refine() for password match (BR-U2-06)
- loginSchema email + password (used by LoginPage)
- setupSchema name + email + password + confirmPassword + locale
- inviteCompleteSchema name + password + confirmPassword (email from API, not validated as input)
```
**File location**: `src/lib/schemas/auth.ts`
Text alternative: Component hierarchy showing root layout at top, with InitGuard and ErrorBoundary branching down to the _authenticated layout containing ProtectedRoute and RoleGuard. Unprotected routes (Setup, Invite, Login) branch directly from root; protected routes branch from ProtectedRoute (Dashboard, Profile) or RoleGuard (Users, Settings, CMS), with color indicating access level (green=root, orange=guard, yellow=public, blue=protected, pink=owner-only).
---
## Component Specifications
### 1. InitGuard (embedded in `__root` route)
### SetupPage
Not a standalone component — implemented as logic within the `__root.tsx` route using TanStack Router's `beforeLoad` or as a React effect on mount.
**Path**: `src/pages/SetupPage.tsx`
| Aspect | Specification |
|---|---|
| **Trigger** | Runs on every navigation while app is mounted |
| **State** | `setupStatus: SetupStatus \| null`, `isLoadingStatus: boolean` |
| **Fetch** | Calls `useSetupStatus()` on mount; result cached in query cache with `staleTime: Infinity` (session-level caching, BR-U2-08) |
| **Loading state** | While `isLoadingStatus = true`, renders a full-screen loading spinner — no route content shown |
| **Redirect logic** | See business-logic-model.md Flow 1 |
| **API** | `GET /Setup/status``{ initialized: boolean }` |
**Purpose**: Collect first Owner account credentials and system initialization
---
### 2. ProtectedRoute (embedded in `_authenticated` layout route)
Implemented as a `beforeLoad` guard in the `_authenticated` TanStack Router layout route.
| Aspect | Specification |
|---|---|
| **Check** | `AuthContext.user !== null` |
| **Loading** | AuthProvider sets `isRestoring: boolean` while attempting silent refresh on mount. Guard waits for `isRestoring = false` before evaluating. |
| **Redirect** | On no user: redirect to `/login?redirect=<currentPath>` (BR-U2-12) |
| **No flash** | Guard blocks rendering of child routes until check resolves (BR-U2-13) |
---
### 3. RoleGuard
A React wrapper component that renders the page or an inline "Access Denied" state.
**Props**:
| Prop | Type | Description |
|---|---|---|
| `allowedRoles` | `UserRole[]` | Roles permitted to see the content |
| `children` | `ReactNode` | The page component to render if role matches |
**State**: None (reads `user.role` from `AuthContext`)
**Render logic**:
- If `user.role` is in `allowedRoles` → render `children`
- Otherwise → render inline `AccessDeniedMessage` (see below)
**Usage**:
```
// In the route component:
<RoleGuard allowedRoles={['Owner']}>
<SettingsPage />
</RoleGuard>
```
**AccessDeniedMessage** (inline component, no separate route):
- Heading: "Access Denied"
- Body: "You do not have permission to view this page. This section requires the [Role] role."
- Link: Back to Dashboard
---
### 4. SetupPage (`src/pages/SetupPage.tsx`)
**Purpose**: Collects first Owner account details and submits `POST /Setup`.
**Form fields**:
| Field | Input type | Validation | Notes |
|---|---|---|---|
| `name` | `text` | Required, min 1 char | Full name |
| `email` | `email` | Required, valid email (BR-U2-07) | |
| `password` | `password` | BR-U2-0105 | PasswordField component (show/hide toggle) |
| `confirmPassword` | `password` | Must match `password` (BR-U2-06) | PasswordField component |
| `locale` | `select` | Required, one of `en \| nl` | Defaults to browser language; changes trigger `i18n.changeLanguage()` immediately |
**Props**: None (route component)
**State**:
- `formData`: { name, email, password, confirmPassword, language }
- `isLoading`: boolean (during submit)
- `error`: FormError | null (banner error)
| State | Type | Description |
|---|---|---|
| `bannerError` | `FormBannerError \| null` | API/network error shown above form |
| `isSuccess` | `boolean` | True after successful submission (shows success state) |
**Form Fields**:
- Text input: **Name** (required, 1255 chars)
- Email input: **Email** (required, valid email)
- Password input: **Password** (required, 8+ chars, upper, digit, special)
- Password input: **Confirm Password** (required, must match password)
- Dropdown select: **Language Preference** (options: "English", "Nederlands")
**User interaction flow**:
1. Page renders with locale pre-selected based on browser language
2. User fills in fields; inline errors appear on blur (BR-U2-32)
3. User changes locale → immediate language switch (BR-U2-23)
4. On submit: Zod validates all fields; inline errors shown if invalid
5. If valid: submit button disabled + loading spinner; `POST /Setup` called
6. On success: success message displayed; redirect to `/login` after ~1.5s
7. On API error: banner shown; form re-enabled (BR-U2-34)
**Validation**:
- Real-time: Zod schema on blur/change
- Submit: Full form validation, show errors inline + banner
**API integration**: `useCreateOwner()` mutation from `src/api/useSetup.ts`
**Submission**:
- POST `/Setup` with form data
- Success: Show success message, redirect to `/login` after delay (23 seconds)
- Error: Show banner with backend error message
- Network error: Show "Network error. Please try again."
**i18n keys** (in `translation.json` under `setup`):
- `setup.title`, `setup.subtitle`
- `setup.fields.name`, `setup.fields.email`, `setup.fields.password`, `setup.fields.confirmPassword`, `setup.fields.locale`
- `setup.submit`, `setup.success`, `setup.errors.*`
**Data Attributes**:
- `data-testid="setup-form"` — form element
- `data-testid="setup-name-input"` — name field
- `data-testid="setup-email-input"` — email field
- `data-testid="setup-password-input"` — password field
- `data-testid="setup-confirm-password-input"` — confirm password field
- `data-testid="setup-language-select"` — language dropdown
- `data-testid="setup-submit-button"` — submit button
**Layout**:
- Card container (from Unit 1 shadcn primitives)
- Heading: "Initialize System" (translated: `t('setup.title')`)
- Form fields in vertical stack
- Submit button: "Create Owner Account" (translated: `t('setup.submitButton')`)
- Error banner (if error exists): dismissible, red background
- Success message (after submit): inline, success styling
**API Hooks**:
- Custom hook `useSetup()` — POST /Setup
- Returns: `{ mutate, isLoading, error }`
- Error format: backend ProblemDetails
**i18n Keys**:
- `setup.title`, `setup.nameLabel`, `setup.emailLabel`, `setup.passwordLabel`, `setup.confirmPasswordLabel`, `setup.languageLabel`, `setup.submitButton`, `setup.successMessage`
---
### 5. InviteCompletePage (`src/pages/InviteCompletePage.tsx`)
### InviteCompletePage
**Purpose**: Completes account setup for an invited user via a tokenized URL.
**Path**: `src/pages/InviteCompletePage.tsx`
**States / lifecycle**:
**Purpose**: Complete user invitation and create non-Owner user account
| State | UI shown |
|---|---|
| `loading` (token validation in progress) | Full-page loading spinner |
| `valid` (token validated successfully) | Form with email (read-only), name, password, confirmPassword |
| `invalid` (token expired/used/not found) | Error state with reason + link to /login |
| `no-token` (no `?token` in URL) | Error state: "Invalid invitation link" |
| `success` (form submitted successfully) | Success banner; redirect to /login |
**Form fields** (shown only when `state = valid`):
| Field | Input type | Validation | Notes |
|---|---|---|---|
| `email` | `text` | Read-only | Pre-filled from `InvitationValidation.email` |
| `name` | `text` | Required, min 1 char | |
| `password` | `password` | BR-U2-0105 | PasswordField component |
| `confirmPassword` | `password` | Must match `password` (BR-U2-06) | PasswordField component |
**Props**: None (route component; token from URL query param)
**State**:
- `token`: string (from URL query param)
- `invitationEmail`: string | null (from validation)
- `loadingState`: "loading" | "ready" | "error" | "submitting" | "success"
- `formData`: { name, password, confirmPassword }
- `error`: FormError | null (banner error)
| State | Type | Description |
|---|---|---|
| `tokenValidationState` | `TokenValidationState` | `loading \| valid \| invalid` |
| `invitationValidation` | `InvitationValidation \| null` | Set when token is valid |
| `bannerError` | `FormBannerError \| null` | API/network error after form submit |
**Lifecycle**:
1. Mount: Extract token from URL
2. Validation: GET `/Invitation/validate?token=xxx`
- Show loading state
- If valid: show form with email read-only
- If invalid: show error state with "Request new invitation" link
**On mount logic**:
1. Extract `token` from `useSearch()` (TanStack Router search params)
2. If no `token` → set state to `invalid` immediately (no API call)
3. If `token` present → call `useValidateInvitation(token)`, set state to `loading`
4. On validation success → set state to `valid`, store `InvitationValidation`
5. On validation failure → set state to `invalid` with `errorCode`
**Form Fields** (shown only if token valid):
- Text input: **Email** (read-only, pre-filled from validation)
- Text input: **Name** (required, 1255 chars)
- Password input: **Password** (required, 8+ chars, upper, digit, special)
- Password input: **Confirm Password** (required, must match password)
**API integration**:
- `useValidateInvitation(token)``GET /Invitation/validate?token=xxx` (stub in Unit 2)
- `useCompleteSetup()``POST /Invitation/complete` (stub in Unit 2)
**Validation**:
- Real-time: Zod schema on blur/change
- Submit: Full form validation, show errors inline + banner
**i18n keys** (in `translation.json` under `inviteComplete`):
- `inviteComplete.title`, `inviteComplete.loading`
- `inviteComplete.fields.*`
- `inviteComplete.errors.expired`, `inviteComplete.errors.used`, `inviteComplete.errors.notFound`, `inviteComplete.errors.noToken`
- `inviteComplete.submit`, `inviteComplete.success`
**Submission**:
- POST `/Invitation/complete` with { token, name, password }
- Success: Show success message, redirect to `/login` after delay (23 seconds)
- Error: Show banner with backend error message
- Token expired during form fill: Show "Token expired. Request a new invitation."
**Data Attributes**:
- `data-testid="invite-complete-form"` — form element
- `data-testid="invite-email-input"` — email field (read-only)
- `data-testid="invite-name-input"` — name field
- `data-testid="invite-password-input"` — password field
- `data-testid="invite-confirm-password-input"` — confirm password field
- `data-testid="invite-submit-button"` — submit button
- `data-testid="invite-loading-spinner"` — loading indicator
**States & Layout**:
- **Loading**: Spinner + "Validating invitation..." (translated: `t('inviteComplete.loadingMessage')`)
- **Ready** (token valid): Form with all fields visible
- **Error** (token invalid): Full-page error message + link to request new invitation
- **Success**: Success message + redirect message
**Headings & Labels**:
- Heading: "Complete Your Account" (translated: `t('inviteComplete.title')`)
- Email label: "Email" (read-only)
- Form fields, error messages, button text all translated
**API Hooks**:
- Custom hook `useValidateInvitation(token)` — GET /Invitation/validate
- Returns: `{ data: { valid, email }, isLoading, error }`
- Custom hook `useCompleteInvitation()` — POST /Invitation/complete
- Returns: `{ mutate, isLoading, error }`
**i18n Keys**:
- `inviteComplete.title`, `inviteComplete.emailLabel`, `inviteComplete.nameLabel`, `inviteComplete.passwordLabel`, `inviteComplete.confirmPasswordLabel`, `inviteComplete.submitButton`, `inviteComplete.loadingMessage`, `inviteComplete.invalidTokenMessage`, `inviteComplete.requestNewInvitationLink`, `inviteComplete.successMessage`
---
### 6. LoginPage (update — `src/pages/LoginPage.tsx`)
### InitGuard Hook
**Change from Unit 1**: Add inline field validation on blur (BR-U2-32/33). The API error banner is already in place.
**Path**: `src/auth/InitGuard.tsx` or `src/contexts/useInitGuard.ts`
**Specific changes**:
- Enable react-hook-form's `mode: 'onBlur'` (or `mode: 'onTouched'`) instead of submit-only validation
- After first blur, switch to `reValidateMode: 'onChange'` so errors clear immediately when corrected
- No structural changes to the component
**Purpose**: Ensure system initialization before accessing protected routes
**Hook Signature**:
```typescript
function useInitGuard(): {
initialized: boolean | null;
isLoading: boolean;
error: Error | null;
}
```
**Implementation**:
- Runs in `__root.tsx` route `beforeLoad`
- Calls `GET /Setup/status` on app mount
- Caches result for entire session (`staleTime: Infinity`)
- Returns `{ initialized, isLoading, error }`
**Router Integration** (in `src/router.tsx`):
```typescript
export const rootRoute = createRootRoute({
component: () => {
const { initialized, isLoading } = useInitGuard();
if (isLoading) return <LoadingSpinner />;
if (!initialized) {
return <Navigate to="/setup" />;
}
return <RootLayout />;
}
});
```
**Logic**:
- If `isLoading`: show spinner
- If not initialized: redirect to `/setup` (blocks all other routes)
- If initialized: proceed normally
**Bypass Routes**:
- `/setup` — always accessible (InitGuard not checked)
- `/login` — always accessible (public route)
- `/invite/complete` — always accessible (token-authenticated, public)
**Cache Duration**: Infinite (session-scoped)
---
### 7. Shared Component — PasswordField
### RoleGuard Hook/Component
A reusable wrapper around shadcn `Input` that adds a show/hide toggle.
**Path**: `src/auth/RoleGuard.tsx` or inline in route `beforeLoad`
**Purpose**: Enforce role-based access to protected routes
**Implementation**: TanStack Router `beforeLoad` hook in each protected route
**Logic**:
```typescript
const authenticatedRoute = createRoute({
getParentRoute: () => _authenticatedRoute,
path: '/users',
beforeLoad: ({ context }) => {
const { user } = context; // from AuthContext
if (!user || !['Owner', 'Admin'].includes(user.role)) {
// Denied — return error or redirect with flag
throw new Error('Access denied'); // or set flag in context
}
},
component: UsersPage
});
```
**Alternative: Page-Level Guard**:
- Route allows access, component checks role
- If denied: show inline error message instead of redirect
- **User's preference (Q10-C)**: Inline message (not separate /403 route)
**Routes & Requirements**:
| Route | Required Role(s) | If Denied |
|-------|------------------|-----------|
| `/users` | Owner or Admin | Inline message |
| `/settings` | Owner | Inline message |
| `/cms` | Owner | Inline message |
**Page-Level Implementation** (if using page component):
```typescript
function UsersPage() {
const { user } = useAuth();
const hasAccess = user?.role === 'Owner' || user?.role === 'Admin';
if (!hasAccess) {
return (
<div className="p-6 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-yellow-800">{t('errors.accessDenied')}</p>
</div>
);
}
return <UsersPageContent />;
}
```
**Data Attributes**:
- `data-testid="access-denied-message"` — error message container
**i18n Keys**:
- `errors.accessDenied`
---
## Shared Components & Hooks (Unit 2 Creates or Extends)
### useSetup Hook
**Path**: `src/api/useSetup.ts`
**Purpose**: Handle POST /Setup API call
**Signature**:
```typescript
function useSetup() {
return {
mutate: (data: SetupFormData) => Promise<SetupResponse>,
isLoading: boolean,
error: ProblemDetails | null
};
}
```
**Implementation**:
- Uses `api.post('/Setup', data)`
- Returns ProblemDetails error if backend rejects
---
### useValidateInvitation Hook
**Path**: `src/api/useInvitation.ts`
**Purpose**: Validate invitation token
**Signature**:
```typescript
function useValidateInvitation(token: string) {
return {
data: { valid: boolean, email?: string } | null,
isLoading: boolean,
error: ProblemDetails | null
};
}
```
**Implementation**:
- Calls `api.get('/Invitation/validate', { params: { token } })`
- Auto-runs on mount if token provided
---
### useCompleteInvitation Hook
**Path**: `src/api/useInvitation.ts`
**Purpose**: Complete invitation and create user
**Signature**:
```typescript
function useCompleteInvitation() {
return {
mutate: (data: InvitationCompletionData) => Promise<InvitationCompletionResponse>,
isLoading: boolean,
error: ProblemDetails | null
};
}
```
**Implementation**:
- Uses `api.post('/Invitation/complete', data)`
- Returns ProblemDetails error if backend rejects
---
## Error Handling Components
### FormErrorBanner
**Path**: `src/components/ui/FormErrorBanner.tsx`
**Purpose**: Display dismissible form-level error message
**Props**:
```typescript
interface FormErrorBannerProps {
error: FormError | null;
onDismiss: () => void;
}
```
| Prop | Type | Description |
|---|---|---|
| `id` | `string` | HTML id for label association |
| `placeholder` | `string` | Input placeholder text |
| `...register` | `UseFormRegisterReturn` | react-hook-form register props spread |
**Layout**:
- Red/warning background
- Error message text
- Close (×) button
- Fade animation on dismiss
**Behaviour**:
- Internal `showPassword: boolean` state
- Renders `<Input type={showPassword ? 'text' : 'password'}>`
- Toggle button uses an eye / eye-off icon (lucide-react)
- `autocomplete` attribute set to `'new-password'` for setup/invite, `'current-password'` for login
**File location**: `src/components/ui/PasswordField.tsx`
**Used by**: SetupPage, InviteCompletePage
---
### 8. Shared Component — FormBannerError
### FieldError
A dismissible alert banner rendered above form fields when an API or network error occurs.
**Path**: `src/components/ui/FieldError.tsx`
**Purpose**: Display inline field-level error
**Props**:
```typescript
interface FieldErrorProps {
message?: string;
}
```
| Prop | Type | Description |
|---|---|---|
| `error` | `FormBannerError \| null` | The error to display; `null` means hidden |
| `onDismiss` | `() => void` | Called when user dismisses the banner |
**Layout**:
- Small red text below input
- Only shown if message exists
**Behaviour**:
- Renders nothing when `error = null`
- Uses shadcn `Alert` component with destructive variant
- Dismiss button (×) calls `onDismiss`
- Accessible: `role="alert"` attribute
**File location**: `src/components/ui/FormBannerError.tsx`
**Used by**: All form fields (SetupPage, InviteCompletePage, LoginPage)
---
## API Hooks
## Consistency with Unit 1
### `src/api/useSetup.ts`
### Reused from Unit 1:
- shadcn Input, Button, Label, Card, DropdownMenu (from Tailwind v4 + primitives)
- LoginPage form pattern (react-hook-form + zod, error handling)
- AuthContext and useAuth hook
- ApiClient and error handling (ProblemDetails)
- MSW mock setup and handlers
- i18n (react-i18next with lazy locale loading)
- TanStack Router and beforeLoad guards
- Test setup and testing utilities
| Hook | Type | Description |
|---|---|---|
| `useSetupStatus()` | Query | `GET /Setup/status``SetupStatus`. `staleTime: Infinity` (session cache). |
| `useCreateOwner()` | Mutation | `POST /Setup` with `{ name, email, password }`. |
### New in Unit 2:
- SetupPage (form + setup flow)
- InviteCompletePage (form + invitation flow)
- InitGuard (route guard hook)
- RoleGuard (route guard hook)
- useSetup, useValidateInvitation, useCompleteInvitation (API hooks)
- FormErrorBanner component
- FieldError component
- New i18n keys (setup, inviteComplete, errors sections)
- New MSW handlers (setup, invitation)
### `src/api/useInvitation.ts` (stub for Unit 2)
| Hook | Type | Description |
|---|---|---|
| `useValidateInvitation(token)` | Query | `GET /Invitation/validate?token=xxx``InvitationValidation`. Disabled when `token` is undefined. |
| `useCompleteSetup()` | Mutation | `POST /Invitation/complete` with `{ token, name, password }`. |
These hooks use MSW stubs in Unit 2. Full dynamic backend integration is deferred to Unit 5.
---
## MSW Handlers
### `src/mocks/setup/handlers.ts` (extend existing)
| Handler | Scenario |
|---|---|
| `POST /Setup` — success | Returns `201 Created` |
| `POST /Setup` — already initialized | Returns `409 Conflict` with `ProblemDetails` |
### `src/mocks/invitation/handlers.ts` (new file)
| Handler | Scenario |
|---|---|
| `GET /Invitation/validate?token=valid-token` | Returns `{ isValid: true, email: "test@example.com", name: null }` |
| `GET /Invitation/validate?token=expired-token` | Returns `{ isValid: false, errorCode: "EXPIRED" }` |
| `GET /Invitation/validate?token=used-token` | Returns `{ isValid: false, errorCode: "USED" }` |
| `POST /Invitation/complete` — success | Returns `200 OK` |
| `POST /Invitation/complete` — error | Returns `400 Bad Request` with `ProblemDetails` |
---
## File Location Summary
| File | Location | Status |
|---|---|---|
| Zod schemas | `src/lib/schemas/auth.ts` | New |
| InitGuard logic | `src/__root.tsx` (route) | New |
| ProtectedRoute logic | `src/routes/_authenticated.tsx` (route beforeLoad) | Extends Unit 1 |
| RoleGuard component | `src/components/auth/RoleGuard.tsx` | New |
| SetupPage | `src/pages/SetupPage.tsx` | Replaces Unit 1 stub |
| InviteCompletePage | `src/pages/InviteCompletePage.tsx` | New |
| LoginPage | `src/pages/LoginPage.tsx` | Update (inline validation) |
| PasswordField | `src/components/ui/PasswordField.tsx` | New |
| FormBannerError | `src/components/ui/FormBannerError.tsx` | New |
| useSetup hooks | `src/api/useSetup.ts` | New |
| useInvitation hooks | `src/api/useInvitation.ts` | New (stub) |
| Setup MSW handlers | `src/mocks/setup/handlers.ts` | Extend |
| Invitation MSW handlers | `src/mocks/invitation/handlers.ts` | New |
| EN translations | `src/i18n/locales/en/translation.json` | Extend |
| NL translations | `src/i18n/locales/nl/translation.json` | Extend |
> **Deviation note**: Unit-of-work.md specified `src/routes/` for page files. Per the established deviation from Unit 1, pages live in `src/pages/` and routing is in `src/router.tsx`. Route files (\_\_root.tsx, \_authenticated.tsx) follow TanStack Router conventions in `src/` root or `src/routes/` as needed by the router configuration.
### Pattern Consistency:
- All forms use react-hook-form + zod
- All API calls use api client
- All errors follow ProblemDetails format
- All components use shadcn primitives
- All routes defined in src/router.tsx
- All pages in src/pages/
- All tests use Vitest + RTL + MSW