Adds auth pages
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
- **Feature Slug**: cms-frontend
|
- **Feature Slug**: cms-frontend
|
||||||
- **Project Type**: Brownfield
|
- **Project Type**: Brownfield
|
||||||
- **Start Date**: 2026-06-16T20:27:00Z
|
- **Start Date**: 2026-06-16T20:27:00Z
|
||||||
- **Current Stage**: CONSTRUCTION - Unit 1: Code Generation complete (awaiting user review)
|
- **Current Stage**: CONSTRUCTION - Unit 2: Authentication Pages — COMPLETED
|
||||||
- **Branch**: unknown
|
- **Branch**: unknown
|
||||||
|
|
||||||
## Workspace State
|
## Workspace State
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
|
|
||||||
### 🟢 CONSTRUCTION PHASE (per unit)
|
### 🟢 CONSTRUCTION PHASE (per unit)
|
||||||
- [x] Unit 0 — Backend Prerequisites (CORS + httpOnly cookie) — COMPLETED 2026-06-19T11:45:00Z
|
- [x] Unit 0 — Backend Prerequisites (CORS + httpOnly cookie) — COMPLETED 2026-06-19T11:45:00Z
|
||||||
- [~] Unit 1 — Project Scaffold & Infrastructure — Code Generation done (build/lint/test green); awaiting user review 2026-06-20
|
- [x] Unit 1 — Project Scaffold & Infrastructure — COMPLETED 2026-06-20T17:30:00Z
|
||||||
- [ ] Unit 2 — Authentication Pages
|
- [x] Unit 2 — Authentication Pages — COMPLETED 2026-06-21
|
||||||
- [ ] Unit 3 — Layout & Navigation
|
- [ ] Unit 3 — Layout & Navigation
|
||||||
- [ ] Unit 4 — Dashboard
|
- [ ] Unit 4 — Dashboard
|
||||||
- [ ] Unit 5 — User Management
|
- [ ] Unit 5 — User Management
|
||||||
|
|||||||
@@ -211,3 +211,63 @@
|
|||||||
**Context**: Construction phase, Unit 1 Code Generation (Part 2). Auth-related coverage >70% (NFR-U1-04). Awaiting user approval to continue to Unit 2.
|
**Context**: Construction phase, Unit 1 Code Generation (Part 2). Auth-related coverage >70% (NFR-U1-04). Awaiting user approval to continue to Unit 2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Unit 1 — Approved / Continue to Next Stage
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-20T17:30:00Z
|
||||||
|
|
||||||
|
**User Input**: "aidlc-workflow, Continue to Next Stage"
|
||||||
|
|
||||||
|
**AI Response**: "Unit 1 approved. Transitioning to Unit 2 (Authentication Pages). Starting Functional Design stage."
|
||||||
|
|
||||||
|
**Context**: Construction phase, Unit 1 complete. Beginning Functional Design for Unit 2 — Authentication Pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation (Unit 2) — Plan Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-21T09:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "let op, de aidlc-workflow skill is geüpdatet zodat de mermaid diagrammen beter worden gemaakt. Hier hoef je nu niks me ete doen. Voor nu Approve Plan en ga verder"
|
||||||
|
|
||||||
|
**AI Response**: "Plan approved. Starting Code Generation Part 2 — executing all 20 steps."
|
||||||
|
|
||||||
|
**Context**: Construction phase, Unit 2 Code Generation (Part 2 — Generation). Skill update for Mermaid noted, no action needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Generation (Unit 2) — Plan Presented
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-20T18:45:00Z
|
||||||
|
|
||||||
|
**User Input**: "[plan approval prompt presented to user]"
|
||||||
|
|
||||||
|
**AI Response**: "Created unit-2-code-generation-plan.md with 20 steps covering Steps 1–20. Key decisions documented: no TanStack Query (module-level cache), beforeLoad InitGuard pattern, RoleGuard as wrapper component, 'Administrator' role spelling. Awaiting user approval."
|
||||||
|
|
||||||
|
**Context**: Construction phase, Unit 2 Code Generation (Part 1 — Planning). Plan at aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Design (Unit 2) — Approved
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-20T18:30:00Z
|
||||||
|
|
||||||
|
**User Input**: "2"
|
||||||
|
|
||||||
|
**AI Response**: "Functional Design for Unit 2 approved. Transitioning to Code Generation — Part 1 (Planning)."
|
||||||
|
|
||||||
|
**Context**: Construction phase, Unit 2. Functional Design complete. Starting Code Generation plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Design (Unit 2) — Questions Answered
|
||||||
|
|
||||||
|
**Timestamp**: 2026-06-20T18:00:00Z
|
||||||
|
|
||||||
|
**User Input**: "hervat de aidlc-workflow. ik heb de vragen in @aidlc-docs/features/cms-frontend/construction/plans/unit-2-functional-design-plan.md beantwoord"
|
||||||
|
|
||||||
|
**AI Response**: "Loaded all answers from unit-2-functional-design-plan.md (Q1–Q14). Generated 4 functional design artifacts: domain-entities.md, business-rules.md, business-logic-model.md, frontend-components.md in aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/. Updated plan checkboxes to [x]. Presenting completion message."
|
||||||
|
|
||||||
|
**Context**: Construction phase, Unit 2 Functional Design. All questions answered; design artifacts generated. Awaiting user approval to continue to Code Generation (NFR + Infrastructure are skipped for Unit 2 per execution plan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Code Generation Plan — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
**Status**: ✅ Complete
|
||||||
|
|
||||||
|
## Unit Context
|
||||||
|
|
||||||
|
- **Unit**: Unit 2 — Authentication Pages
|
||||||
|
- **Application code root**: `frontend/` (Vite + React 19 + TypeScript)
|
||||||
|
- **Depends on**: Unit 1 (ApiClient, AuthContext, router.tsx, shadcn primitives, MSW)
|
||||||
|
- **Stories**: US-01, US-02, US-03, US-04, US-05, US-06, US-07, US-13, US-14
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
|
||||||
|
- **TanStack Query is NOT installed** — hooks use `useState`/`useEffect` with a module-level session cache
|
||||||
|
- **InitGuard strategy**: `async beforeLoad` on rootRoute + module-level `fetchSetupStatus()` singleton (fetches once, caches for the session); uses TanStack Router's `pendingComponent` for the loading state
|
||||||
|
- **RoleGuard strategy**: React wrapper component rendering inline "Access Denied" (no redirect, no /403 route); applied to route `component` wrappers in router.tsx
|
||||||
|
- **UserRole note**: Backend uses `'Administrator'` not `'Admin'` (confirmed from `src/api/types.ts`)
|
||||||
|
- **Password schema**: Moved from LoginPage inline to `src/lib/schemas/auth.ts` (shared)
|
||||||
|
- **Error handling pattern**: Zod inline field errors on blur (`mode: 'onTouched'`) + dismissible banner for API/network errors
|
||||||
|
|
||||||
|
## Unit 1 Deviations Carried Forward
|
||||||
|
|
||||||
|
- Code-based routing in `src/router.tsx` (not `src/routes/`)
|
||||||
|
- Pages in `src/pages/` (not `src/routes/`)
|
||||||
|
- 4-space indentation throughout
|
||||||
|
|
||||||
|
## Stories Covered
|
||||||
|
|
||||||
|
| Story | Description | Steps |
|
||||||
|
|---|---|---|
|
||||||
|
| US-01 | Login with email and password | 2, 14 |
|
||||||
|
| US-02 | Session persistence via refresh token | (already in Unit 1; confirmed by ProtectedRoute) |
|
||||||
|
| US-03 | Logout | (already in Unit 1; AuthContext.logout()) |
|
||||||
|
| US-04 | Redirect to login when not authenticated | 5, 13 |
|
||||||
|
| US-05 | Auto token refresh on expiry | (already in Unit 1; ApiClient 401 interceptor) |
|
||||||
|
| US-06 | Initialize system as first Owner | 1, 2, 6, 8, 11, 12, 13, 15 |
|
||||||
|
| US-07 | Redirect to setup when not initialized | 6, 13 |
|
||||||
|
| US-13 | Complete account setup via invitation link | 1, 2, 7, 9, 10, 11, 12, 16 |
|
||||||
|
| US-14 | Handle expired/invalid invitation token | 7, 9, 16 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1 — Extend `src/api/types.ts`
|
||||||
|
- [x] Add `SetupRequest` interface: `{ name: string; email: string; password: string; }`
|
||||||
|
- [x] Add `InvitationValidation` interface: `{ email: string; name: string | null; isValid: boolean; errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null; }`
|
||||||
|
- [x] Add `InviteCompleteRequest` interface: `{ token: string; name: string; password: string; }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Create `src/lib/schemas/auth.ts`
|
||||||
|
- [x] Create file with shared Zod schemas:
|
||||||
|
- `passwordSchema` — `.min(8)` + 4 regex rules (uppercase, lowercase, digit, non-alphanumeric) matching backend `IdentityOptions` (BR-U2-01–05)
|
||||||
|
- `loginSchema` — `{ email: z.string().email(), password: z.string().min(1) }` (extracted from LoginPage — see Step 14)
|
||||||
|
- `setupSchema` — `{ name, email, password, confirmPassword, locale }` with `.superRefine()` for password match (BR-U2-06); `locale` is `z.enum(['en', 'nl'])`
|
||||||
|
- `inviteCompleteSchema` — `{ name, password, confirmPassword }` with password match refine
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Create `src/components/ui/PasswordField.tsx`
|
||||||
|
- [x] Wrapper around shadcn `Input` with internal `showPassword: boolean` state
|
||||||
|
- [ ] Show/hide toggle button using lucide-react `Eye`/`EyeOff` icons
|
||||||
|
- [ ] Props: `id: string`, `placeholder?: string`, `autoComplete: string`, `...register` (spread from react-hook-form)
|
||||||
|
- [ ] `data-testid` on input: `{id}-input`; on toggle: `{id}-toggle`
|
||||||
|
- [ ] `aria-label` on toggle button for accessibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Create `src/components/ui/FormBannerError.tsx`
|
||||||
|
- [x] Props: `error: { message: string } | null`, `onDismiss: () => void`
|
||||||
|
- [ ] Returns `null` when `error` is `null`
|
||||||
|
- [ ] Uses shadcn Card or a `div` with `role="alert"`, `data-testid="form-error-banner"`, destructive color classes
|
||||||
|
- [ ] Dismiss `×` button calls `onDismiss`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Create `src/components/auth/RoleGuard.tsx`
|
||||||
|
- [x] Props: `allowedRoles: UserRole[]`, `children: ReactNode`
|
||||||
|
- [ ] Reads `user` from `useAuth()`
|
||||||
|
- [ ] If `user.role` is in `allowedRoles` → render `children`
|
||||||
|
- [ ] Otherwise → render inline `AccessDeniedMessage` (heading + body naming the required roles + back-to-dashboard link)
|
||||||
|
- [ ] `data-testid="access-denied-message"` on the fallback element
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Create `src/api/useSetup.ts`
|
||||||
|
- [x] Module-level cache: `let _cachedStatus: SetupStatus | null = null;`
|
||||||
|
- [ ] `useSetupStatus()` hook:
|
||||||
|
- State: `{ status: SetupStatus | null; isLoading: boolean; error: Error | null }`
|
||||||
|
- Fetches `GET /Setup/status` on first call; subsequent calls return `_cachedStatus` synchronously
|
||||||
|
- [ ] `useCreateOwner()` hook — returns `{ mutate, isLoading, error }`:
|
||||||
|
- `mutate(data: SetupRequest): Promise<void>` calls `POST /Setup`
|
||||||
|
- On success: sets `_cachedStatus = { initialized: true }` (updates session cache)
|
||||||
|
- Throws on API/network error (caller handles display)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7 — Create `src/api/useInvitation.ts` (stub for Unit 2)
|
||||||
|
- [x] `useValidateInvitation(token: string | undefined)` hook:
|
||||||
|
- Fetches `GET /Invitation/validate?token={token}` when token is defined
|
||||||
|
- State: `{ data: InvitationValidation | null; isLoading: boolean; error: Error | null }`
|
||||||
|
- [ ] `useCompleteSetup()` hook — returns `{ mutate, isLoading, error }`:
|
||||||
|
- `mutate(data: InviteCompleteRequest): Promise<void>` calls `POST /Invitation/complete`
|
||||||
|
- Throws on error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8 — Extend `src/mocks/setup/handlers.ts`
|
||||||
|
- [x] Keep existing `GET /Setup/status` handler (returns `{ initialized: true }`)
|
||||||
|
- [ ] Add `POST /Setup` — success scenario: returns `201 Created` with empty body
|
||||||
|
- [ ] Add `POST /Setup` — already-initialized scenario: export as `setupAlreadyInitializedHandlers` (or a named variant) returning `409 Conflict` with ProblemDetails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9 — Create `src/mocks/invitation/handlers.ts`
|
||||||
|
- [x] `GET /Invitation/validate?token=valid-token` → `{ isValid: true, email: "invited@example.com", name: null }`
|
||||||
|
- [ ] `GET /Invitation/validate?token=expired-token` → `{ isValid: false, errorCode: "EXPIRED", email: null, name: null }`
|
||||||
|
- [ ] `GET /Invitation/validate?token=used-token` → `{ isValid: false, errorCode: "USED", email: null, name: null }`
|
||||||
|
- [ ] `POST /Invitation/complete` — success: `200 OK`
|
||||||
|
- [ ] Export as `invitationHandlers`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 10 — Update `src/mocks/index.ts`
|
||||||
|
- [x] Import `invitationHandlers` from `./invitation/handlers`
|
||||||
|
- [ ] Add to the combined `handlers` array
|
||||||
|
- [ ] Add re-export line for `invitationHandlers`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 11 — Update `src/i18n/locales/en/translation.json`
|
||||||
|
- [x] Add `setup` section:
|
||||||
|
```json
|
||||||
|
"setup": {
|
||||||
|
"title": "System Setup",
|
||||||
|
"subtitle": "Create the first Owner account to get started.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Full name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"locale": "Language"
|
||||||
|
},
|
||||||
|
"localeOptions": { "en": "English", "nl": "Dutch" },
|
||||||
|
"submit": "Create account",
|
||||||
|
"submitting": "Creating account…",
|
||||||
|
"success": "Account created. You can now sign in.",
|
||||||
|
"errors": {
|
||||||
|
"alreadyInitialized": "This system has already been set up.",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Add `inviteComplete` section:
|
||||||
|
```json
|
||||||
|
"inviteComplete": {
|
||||||
|
"title": "Complete your account",
|
||||||
|
"subtitle": "You have been invited to {{appName}}.",
|
||||||
|
"loading": "Validating your invitation…",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"name": "Full name",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password"
|
||||||
|
},
|
||||||
|
"submit": "Complete setup",
|
||||||
|
"submitting": "Completing setup…",
|
||||||
|
"success": "Account setup complete. You can now sign in.",
|
||||||
|
"errors": {
|
||||||
|
"expired": "This invitation link has expired. Please request a new one.",
|
||||||
|
"used": "This invitation link has already been used.",
|
||||||
|
"notFound": "This invitation link is not valid.",
|
||||||
|
"noToken": "Invalid invitation link.",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Add to `nav`: `"profile": "Profile"`, `"settings": "Settings"`
|
||||||
|
- [ ] Add to `errors`: `"accessDenied": "You do not have permission to view this page."`, `"sessionExpired": "Your session has expired. Please sign in again."`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 12 — Update `src/i18n/locales/nl/translation.json`
|
||||||
|
- [x] Mirror all keys from Step 11 with Dutch translations:
|
||||||
|
- `setup.title`: "Systeeminstallatie"
|
||||||
|
- `setup.subtitle`: "Maak het eerste Owner-account aan om te beginnen."
|
||||||
|
- `setup.fields.name`: "Volledige naam" / `email`: "E-mail" / `password`: "Wachtwoord" / `confirmPassword`: "Wachtwoord bevestigen" / `locale`: "Taal"
|
||||||
|
- `setup.localeOptions`: `{ "en": "Engels", "nl": "Nederlands" }`
|
||||||
|
- `setup.submit`: "Account aanmaken" / `submitting`: "Account aanmaken…" / `success`: "Account aangemaakt. Je kunt nu inloggen."
|
||||||
|
- `inviteComplete.title`: "Account voltooien" / `subtitle`: "Je bent uitgenodigd voor {{appName}}." / `loading`: "Uitnodiging valideren…"
|
||||||
|
- `inviteComplete.submit`: "Setup voltooien" / `success`: "Account-setup voltooid. Je kunt nu inloggen."
|
||||||
|
- All error messages in Dutch
|
||||||
|
- `nav.profile`: "Profiel" / `nav.settings`: "Instellingen"
|
||||||
|
- `errors.accessDenied`: "Je hebt geen toestemming om deze pagina te bekijken."
|
||||||
|
- `errors.sessionExpired`: "Je sessie is verlopen. Meld je opnieuw aan."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 13 — Update `src/router.tsx`
|
||||||
|
- [x] Add module-level setup status cache and fetch function above `rootRoute`:
|
||||||
|
```ts
|
||||||
|
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||||
|
let _setupStatusCache: SetupStatus | null = null;
|
||||||
|
|
||||||
|
async function fetchSetupStatus(): Promise<SetupStatus> {
|
||||||
|
if (_setupStatusCache !== null) return _setupStatusCache;
|
||||||
|
if (_setupStatusPromise === null) {
|
||||||
|
_setupStatusPromise = apiClient
|
||||||
|
.get<SetupStatus>('/Setup/status')
|
||||||
|
.then((s) => { _setupStatusCache = s; return s; });
|
||||||
|
}
|
||||||
|
return _setupStatusPromise;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Update `rootRoute` with `async beforeLoad` (InitGuard logic) and `pendingComponent`:
|
||||||
|
- On fetch success: if `!initialized && pathname !== '/setup'` → `throw redirect({ to: '/setup' })`
|
||||||
|
- On fetch success: if `initialized && pathname === '/setup'` → `throw redirect({ to: '/login' })`
|
||||||
|
- On fetch error: log warning, allow navigation (API calls will also fail)
|
||||||
|
- `pendingComponent: BootstrapSplash` (reuse existing component from main.tsx — import or duplicate inline)
|
||||||
|
- `pendingMs: 0` (show spinner immediately)
|
||||||
|
- [ ] Add `/invite/complete` route (public, under rootRoute):
|
||||||
|
- `path: '/invite/complete'`
|
||||||
|
- `validateSearch`: extract `token?: string`
|
||||||
|
- `component`: lazy `InviteCompletePage`
|
||||||
|
- [ ] Wrap `usersRoute` component with `RoleGuard` (allowedRoles: `['Owner', 'Administrator']`)
|
||||||
|
- [ ] Wrap `cmsRoute` component with `RoleGuard` (allowedRoles: `['Owner']`)
|
||||||
|
- [ ] Add `inviteCompleteRoute` to `routeTree` (alongside `setupRoute` and `loginRoute`)
|
||||||
|
- [ ] Import `RoleGuard` from `@/components/auth/RoleGuard`
|
||||||
|
- [ ] Import `apiClient` from `@/lib/api-client`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 14 — Update `src/pages/LoginPage.tsx`
|
||||||
|
- [x] Remove inline `schema` definition; import `loginSchema` from `@/lib/schemas/auth`
|
||||||
|
- [ ] Change `useForm` mode to `mode: 'onTouched'` (inline validation after first blur; re-validates on change)
|
||||||
|
- [ ] Keep `reValidateMode: 'onChange'` (default) so errors clear as user types after the first blur
|
||||||
|
- [ ] No other structural changes; existing banner, submit button, and testids stay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 15 — Replace `src/pages/SetupPage.tsx`
|
||||||
|
- [x] Full implementation replacing the "Coming soon" stub
|
||||||
|
- [ ] Import `setupSchema` from `@/lib/schemas/auth`
|
||||||
|
- [ ] Import `PasswordField` and `FormBannerError`
|
||||||
|
- [ ] Import `useCreateOwner` from `@/api/useSetup`
|
||||||
|
- [ ] Import `useTranslation` and `i18n` for locale live-switch
|
||||||
|
- [ ] Form fields: name, email, password (PasswordField), confirmPassword (PasswordField), locale (select)
|
||||||
|
- [ ] Locale `select` defaults to `i18n.language` if supported (`'en'` | `'nl'`), falls back to `'en'`
|
||||||
|
- [ ] `onChange` on locale select calls `i18n.changeLanguage(value)` immediately (BR-U2-23)
|
||||||
|
- [ ] Form mode: `onTouched` (BR-U2-32/33)
|
||||||
|
- [ ] On submit: call `useCreateOwner().mutate(...)`, handle success/error
|
||||||
|
- [ ] On success: show `t('setup.success')` in a success alert, then `navigate({ to: '/login', replace: true })` after 1500ms
|
||||||
|
- [ ] On error: set banner error with API message or network fallback
|
||||||
|
- [ ] `data-testid` attributes: `setup-name-input`, `setup-email-input`, `setup-locale-select`, `setup-submit-button`, `setup-success`, `setup-error-banner`
|
||||||
|
- [ ] `autoComplete="off"` on name; `"email"` on email; `"new-password"` on password fields
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 16 — Create `src/pages/InviteCompletePage.tsx`
|
||||||
|
- [x] Import `inviteCompleteSchema` from `@/lib/schemas/auth`
|
||||||
|
- [ ] Import `PasswordField` and `FormBannerError`
|
||||||
|
- [ ] Import `useValidateInvitation`, `useCompleteSetup` from `@/api/useInvitation`
|
||||||
|
- [ ] Extract `token` from search params using `useSearch({ strict: false })`
|
||||||
|
- [ ] Call `useValidateInvitation(token)` on mount; show loading spinner while `isLoading`
|
||||||
|
- [ ] If `!token` or `data.isValid === false`: show error state with `data.errorCode` mapped to i18n key + link to `/login`
|
||||||
|
- [ ] If `data.isValid === true`: render form with `email` pre-filled and read-only
|
||||||
|
- [ ] `name` field pre-filled from `data.name` if not null
|
||||||
|
- [ ] Form mode: `onTouched`
|
||||||
|
- [ ] On submit: call `useCompleteSetup().mutate(...)`, handle success/error
|
||||||
|
- [ ] On success: show `t('inviteComplete.success')`, navigate to `/login` after 1500ms
|
||||||
|
- [ ] `data-testid` attributes: `invite-loading`, `invite-error`, `invite-name-input`, `invite-email-input`, `invite-submit-button`, `invite-success`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 17 — Create `src/pages/SetupPage.test.tsx`
|
||||||
|
- [x] Render SetupPage with MSW `setupHandlers` + `POST /Setup` success handler
|
||||||
|
- [ ] Test: all 5 fields render
|
||||||
|
- [ ] Test: submit with empty fields shows inline errors
|
||||||
|
- [ ] Test: weak password shows inline error
|
||||||
|
- [ ] Test: mismatched confirm password shows inline error
|
||||||
|
- [ ] Test: valid submission shows success message
|
||||||
|
- [ ] Test: 409 response shows "already initialized" banner
|
||||||
|
- [ ] Test: locale change updates visible text language
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 18 — Create `src/pages/InviteCompletePage.test.tsx`
|
||||||
|
- [x] Render InviteCompletePage with MSW `invitationHandlers`
|
||||||
|
- [ ] Test: loading state shown on mount
|
||||||
|
- [ ] Test: invalid token (`?token=expired-token`) shows error state, no form
|
||||||
|
- [ ] Test: missing token shows error state immediately
|
||||||
|
- [ ] Test: valid token (`?token=valid-token`) shows form with email read-only
|
||||||
|
- [ ] Test: successful submission shows success message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 19 — Update `src/test/RouteGuard.test.tsx`
|
||||||
|
- [x] Add test: when `initialized: false` MSW returns `GET /Setup/status` with `{ initialized: false }` → navigating to `/login` or `/dashboard` redirects to `/setup`
|
||||||
|
- [ ] Add test: when `initialized: true` and visiting `/setup` → redirects to `/login`
|
||||||
|
- [ ] Add test: `RoleGuard` with `allowedRoles: ['Owner']` renders children when `user.role === 'Owner'`
|
||||||
|
- [ ] Add test: `RoleGuard` renders inline "Access Denied" when `user.role === 'User'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 20 — Create code generation summary
|
||||||
|
- [x] Create `aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md`
|
||||||
|
- [ ] List all created and modified files
|
||||||
|
- [ ] Document any deviations from the plan
|
||||||
|
- [ ] Record story coverage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviations from Functional Design (pre-noted)
|
||||||
|
|
||||||
|
| Design spec | Actual implementation | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `useSetupStatus()` with `staleTime: Infinity` (TanStack Query style) | Module-level cache + `useState`/`useEffect` | TanStack Query not installed; equivalent session-cache behavior |
|
||||||
|
| `UserRole = 'Owner' \| 'Admin' \| 'User'` | `UserRole = 'Owner' \| 'Administrator' \| 'User'` | Matches existing `src/api/types.ts` and backend |
|
||||||
|
| InitGuard as component with `useEffect` | `async beforeLoad` on rootRoute | More idiomatic for TanStack Router; avoids render flash |
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Functional Design Plan — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
**Status**: ✅ Complete — awaiting approval
|
||||||
|
|
||||||
|
## Plan Context
|
||||||
|
- **Unit**: Unit 2 — Authentication Pages
|
||||||
|
- **Depends on**: Unit 1 (ApiClient, AuthContext, router.tsx, shadcn primitives, MSW handlers)
|
||||||
|
- **Stories Covered**: US-01, US-02, US-03, US-04, US-05, US-06, US-07, US-13, US-14
|
||||||
|
- **Stages to Execute**: Functional Design → Code Generation (NFR + Infrastructure skipped for this unit)
|
||||||
|
|
||||||
|
## Unit 1 Context (Already Delivered)
|
||||||
|
|
||||||
|
Unit 1 already provided the following that Unit 2 builds on:
|
||||||
|
|
||||||
|
- `src/contexts/AuthProvider.tsx` + `src/contexts/auth-context.ts` — `user`, `accessToken` (memory), `login`, `logout`, `refresh`
|
||||||
|
- `src/lib/api-client.ts` — fetch wrapper with 401 intercept + refresh retry
|
||||||
|
- `src/router.tsx` — `_authenticated` layout route with `beforeLoad` guard (redirects to `/login`); public routes: `/login`, `/setup`; protected routes: `/dashboard`, `/users`, `/cms`
|
||||||
|
- `src/pages/LoginPage.tsx` — fully implemented with react-hook-form + zod, MSW-tested, `data-testid` attributes
|
||||||
|
- `src/mocks/` — `authHandlers` (login, refresh, revoke), `setupHandlers` (setup status at `/Setup/status`), `userHandlers`
|
||||||
|
- Tailwind v4 + shadcn primitives (Button, Input, Label, Card, DropdownMenu, Toaster)
|
||||||
|
- Deviations: code-based routing in `router.tsx`; pages in `src/pages/`; locales in `src/i18n/locales/`
|
||||||
|
|
||||||
|
## Functional Design Steps
|
||||||
|
|
||||||
|
- [x] Step 1: Analyze unit stories and responsibilities
|
||||||
|
- [x] Step 2: Identify open questions (this document)
|
||||||
|
- [x] Step 3: Collect answers from user
|
||||||
|
- [x] Step 4: Generate functional design artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Answer each question by filling in the `[Answer]:` tag below it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q1 — SetupPage: Fields to collect
|
||||||
|
|
||||||
|
The SetupPage is shown when the system is not yet initialized (`initialized: false` from `GET /Setup/status`).
|
||||||
|
The backend `POST /Setup` endpoint creates the first Owner account.
|
||||||
|
|
||||||
|
Which fields should the SetupPage form collect?
|
||||||
|
|
||||||
|
A) Email + Password + Confirm Password (name comes from the email prefix or can be set later in Profile)
|
||||||
|
B) Name + Email + Password + Confirm Password
|
||||||
|
C) Name + Email + Password + Confirm Password + a "language/locale" preference
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q2 — SetupPage: Action after successful setup
|
||||||
|
|
||||||
|
When `POST /Setup` succeeds and the first Owner account is created, what should happen?
|
||||||
|
|
||||||
|
A) Automatically log in the user (call `AuthContext.login()`) and redirect to `/dashboard`
|
||||||
|
B) Show a success message and redirect to `/login` so the user can log in manually
|
||||||
|
C) Show a success message on the same page with a "Go to login" button
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q3 — InviteCompletePage: Fields to collect
|
||||||
|
|
||||||
|
The InviteCompletePage is reached via a link like `/invite/complete?token=xxx`. The backend validates the token and completes account setup.
|
||||||
|
|
||||||
|
Which fields should the form collect?
|
||||||
|
|
||||||
|
A) Name + Password + Confirm Password (email is known from the invitation and shown read-only)
|
||||||
|
B) Password + Confirm Password only (name can be set later in Profile)
|
||||||
|
C) Name + Password + Confirm Password + email shown read-only as informational
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q4 — InviteCompletePage: Token validation timing
|
||||||
|
|
||||||
|
When should the invitation token be validated?
|
||||||
|
|
||||||
|
A) On page mount (GET request to validate token), then show form only if valid — show error page directly if invalid/expired
|
||||||
|
B) On form submit only — show validation errors after the user tries to submit
|
||||||
|
C) On page mount with a loading state, then switch to form (valid) or error state (invalid/expired)
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q5 — InviteCompletePage: Action after successful completion
|
||||||
|
|
||||||
|
When `POST /Invitation/complete` succeeds, what should happen?
|
||||||
|
|
||||||
|
A) Automatically log in the user and redirect to `/dashboard`
|
||||||
|
B) Show a success message and redirect to `/login`
|
||||||
|
C) Show a success message with a "Go to login" button
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q6 — InitGuard: Caching strategy
|
||||||
|
|
||||||
|
The `InitGuard` checks `GET /Setup/status` and redirects to `/setup` if `initialized: false`. It must run before every route renders (in the root route).
|
||||||
|
|
||||||
|
How should setup status be cached?
|
||||||
|
|
||||||
|
A) Cache for the session (once loaded, never re-fetch — a setup operation redirects back to login anyway)
|
||||||
|
B) Refresh every time the app is loaded/refreshed (staleTime: 0 — always re-fetch on mount)
|
||||||
|
C) Short stale time, e.g., 60 seconds (balance between freshness and extra requests)
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q7 — InitGuard: Routes that bypass the guard
|
||||||
|
|
||||||
|
Which routes should bypass the `InitGuard` (i.e., be accessible even when `initialized: false`)?
|
||||||
|
|
||||||
|
A) Only `/setup` bypasses the guard (all other routes redirect to `/setup` if not initialized)
|
||||||
|
B) `/setup` and `/invite/complete` bypass the guard
|
||||||
|
C) `/setup`, `/invite/complete`, and `/login` bypass the guard
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q8 — RoleGuard: Role model
|
||||||
|
|
||||||
|
The application has user roles. The backend returns a `role` field on the authenticated user.
|
||||||
|
|
||||||
|
What roles exist in the system?
|
||||||
|
|
||||||
|
A) Two roles: `Owner` and `User`
|
||||||
|
B) Three roles: `Owner`, `Admin`, and `User`
|
||||||
|
C) The roles match what's already defined in the backend (check `ApplicationUser` or `UserRole` enum in the existing code)
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: D, This should already be defined in the back-end, but it should be `Owner`, `Admin` and `User`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q9 — RoleGuard: Which routes are role-restricted?
|
||||||
|
|
||||||
|
Which routes require a specific minimum role?
|
||||||
|
|
||||||
|
A) Only `/settings` and `/cms` require `Owner` role; `/users` requires `Owner` or `Admin`; `/dashboard` and `/profile` are accessible to all authenticated users
|
||||||
|
B) `/users`, `/settings`, and `/cms` all require `Owner` role; `/dashboard` and `/profile` for all roles
|
||||||
|
C) `/cms` requires `Owner`; `/settings` requires `Owner`; `/users` is open to all authenticated users
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q10 — RoleGuard: Unauthorized behavior
|
||||||
|
|
||||||
|
When an authenticated user tries to access a route they don't have the role for, what should happen?
|
||||||
|
|
||||||
|
A) Redirect to `/403` (Access Denied page — already in Unit 6 scope but can be a simple inline message for now)
|
||||||
|
B) Redirect to `/dashboard` with a toast notification
|
||||||
|
C) Show an inline "Access Denied" message on the page itself (no separate route needed)
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q11 — Password validation rules
|
||||||
|
|
||||||
|
The backend enforces specific password rules. Unit 1 already has Zod schemas.
|
||||||
|
|
||||||
|
Where should the shared password Zod schema live?
|
||||||
|
|
||||||
|
A) In `src/lib/validation.ts` — a shared module imported by both LoginPage and the new pages
|
||||||
|
B) In each page file separately (duplicated for isolation)
|
||||||
|
C) In `src/lib/schemas/auth.ts` — a dedicated auth schemas file
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q12 — i18n: Translation keys for Unit 2
|
||||||
|
|
||||||
|
Unit 1 already added `login` namespace keys. For Unit 2 pages, how should translation keys be organized?
|
||||||
|
|
||||||
|
A) Add `setup` and `inviteComplete` keys to the existing `translation.json` files (single namespace per locale)
|
||||||
|
B) Separate namespace files: `setup.json` and `inviteComplete.json` lazy-loaded per page
|
||||||
|
C) Extend existing `translation.json` with a `setup` section and `inviteComplete` section
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q13 — MSW handler scope for Unit 2
|
||||||
|
|
||||||
|
Unit 1 has `setupHandlers` for `GET /Setup/status`. Unit 2 needs handlers for `POST /Setup` (create owner) and invitation endpoints.
|
||||||
|
|
||||||
|
Where should the new handlers be added?
|
||||||
|
|
||||||
|
A) Extend existing `setupHandlers` in `src/mocks/setup/` with `POST /Setup` and add `invitationHandlers` in `src/mocks/invitation/`
|
||||||
|
B) Add everything to the existing `setupHandlers` file
|
||||||
|
C) New file `src/mocks/invitation/` for invitation handlers; `POST /Setup` goes into existing setup handlers
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q14 — Error handling in forms
|
||||||
|
|
||||||
|
For form submission errors (network errors, validation errors from the backend), what is the preferred pattern?
|
||||||
|
|
||||||
|
A) Show errors in a dismissible banner above the form (consistent with LoginPage's existing pattern)
|
||||||
|
B) Show errors inline next to the relevant field
|
||||||
|
C) Show errors in a toast notification (using Sonner, already installed)
|
||||||
|
D) Other
|
||||||
|
|
||||||
|
[Answer]: D, a combination of A and B. Isn't that also used on the login page. Inline for real live checking, but after submission a banner can be shown. I am not seeing that on the login page now. It should be consistent throughout the app though
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Code Generation Summary — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
**Status**: ✅ Complete
|
||||||
|
**Date**: 2026-06-21
|
||||||
|
**Plan**: `aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/src/lib/schemas/auth.ts` | Shared Zod schemas: `passwordSchema`, `loginSchema`, `setupSchema`, `inviteCompleteSchema` |
|
||||||
|
| `frontend/src/components/ui/PasswordField.tsx` | Password input with show/hide toggle |
|
||||||
|
| `frontend/src/components/ui/FormBannerError.tsx` | Dismissible inline error banner for API errors |
|
||||||
|
| `frontend/src/components/auth/RoleGuard.tsx` | Role-based access wrapper; renders inline "Access Denied" for unauthorized roles |
|
||||||
|
| `frontend/src/api/useSetup.ts` | `useSetupStatus`, `useCreateOwner` hooks with module-level session cache |
|
||||||
|
| `frontend/src/api/useInvitation.ts` | `useValidateInvitation`, `useCompleteSetup` hooks |
|
||||||
|
| `frontend/src/mocks/invitation/handlers.ts` | MSW handlers for invitation validate/complete endpoints |
|
||||||
|
| `frontend/src/pages/SetupPage.tsx` | Full 5-field owner setup form (replaces stub) |
|
||||||
|
| `frontend/src/pages/InviteCompletePage.tsx` | Invitation completion page with token validation states |
|
||||||
|
| `frontend/src/pages/SetupPage.test.tsx` | Tests: render, validation, success, 409 error, InitGuard redirect |
|
||||||
|
| `frontend/src/pages/InviteCompletePage.test.tsx` | Tests: loading, invalid token states, form, success |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/src/api/types.ts` | Added `SetupRequest`, `InvitationValidation`, `InviteCompleteRequest` interfaces |
|
||||||
|
| `frontend/src/router.tsx` | Full rewrite: InitGuard (`async beforeLoad` on rootRoute), `inviteCompleteRoute`, `RoleGuard` wrappers on `usersRoute`/`cmsRoute`, `_resetSetupStatusCache` export for tests |
|
||||||
|
| `frontend/src/pages/LoginPage.tsx` | Replaced inline schema with `loginSchema` from `@/lib/schemas/auth`; added `mode: 'onTouched'` |
|
||||||
|
| `frontend/src/mocks/setup/handlers.ts` | Added `POST /Setup` success handler, `setupUninitializedHandlers`, `setupConflictHandlers` |
|
||||||
|
| `frontend/src/mocks/index.ts` | Added `invitationHandlers` to combined handlers + re-exports |
|
||||||
|
| `frontend/src/i18n/locales/en/translation.json` | Added `setup`, `inviteComplete`, `nav.profile/settings`, `errors.accessDenied/sessionExpired` keys |
|
||||||
|
| `frontend/src/i18n/locales/nl/translation.json` | Dutch translations for all new keys |
|
||||||
|
| `frontend/src/test/RouteGuard.test.tsx` | Added InitGuard tests (setup redirect both ways) + RoleGuard tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Story Coverage
|
||||||
|
|
||||||
|
| Story | Coverage |
|
||||||
|
|---|---|
|
||||||
|
| US-01 — Login | `loginSchema` extracted to shared file; `mode: 'onTouched'` added to LoginPage |
|
||||||
|
| US-02 — Session persistence | Unit 1 (AuthContext + ApiClient); no changes |
|
||||||
|
| US-03 — Logout | Unit 1 (AuthContext.logout()); no changes |
|
||||||
|
| US-04 — Redirect when unauthenticated | `authenticatedRoute.beforeLoad` (Unit 1); `RoleGuard` (Unit 2) |
|
||||||
|
| US-05 — Auto token refresh | Unit 1 (ApiClient 401 interceptor); no changes |
|
||||||
|
| US-06 — Owner initialization | `SetupPage`, `useCreateOwner`, `setupSchema`, InitGuard, EN/NL translations |
|
||||||
|
| US-07 — Redirect to setup when not initialized | InitGuard in `rootRoute.beforeLoad`, `fetchSetupStatus()` session cache |
|
||||||
|
| US-13 — Complete setup via invitation | `InviteCompletePage`, `useValidateInvitation`, `useCompleteSetup`, `invitationHandlers` |
|
||||||
|
| US-14 — Handle expired/invalid invitation | `InviteCompletePage` invalid token states (`EXPIRED`, `USED`, `NOT_FOUND`, no token) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
| Plan spec | Actual | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `useSetupStatus()` with TanStack Query `staleTime: Infinity` | Module-level `_setupStatusCache` + `useState`/`useEffect` | TanStack Query is not installed in this project |
|
||||||
|
| `UserRole = 'Owner' \| 'Admin' \| 'User'` | `UserRole = 'Owner' \| 'Administrator' \| 'User'` | Matches existing `src/api/types.ts` and backend identity model |
|
||||||
|
| InitGuard as React component with `useEffect` | `async beforeLoad` on rootRoute | More idiomatic for TanStack Router; avoids render flash before redirect |
|
||||||
|
| `fetchSetupStatus` in separate module from router | Inlined in `router.tsx` with `_resetSetupStatusCache` export | Keeps router self-contained; simplifies test cache reset |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Utilities Added
|
||||||
|
|
||||||
|
- `_resetSetupStatusCache()` exported from `src/router.tsx` — resets module-level setup cache between tests so each test controls its own MSW handler for `GET /Setup/status`
|
||||||
|
- `setupUninitializedHandlers` / `setupConflictHandlers` exported from `src/mocks/index.ts` — per-test MSW overrides
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
# Business Logic Model — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Unit 2 implements five distinct business logic flows. Each is technology-agnostic; implementation details (TanStack Router APIs, React Query, etc.) are resolved in Code Generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 1: App Initialization — InitGuard
|
||||||
|
|
||||||
|
**Trigger**: Every page load / app mount (runs in the root route)
|
||||||
|
**Purpose**: Ensure the system is initialized before rendering any route
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([App mounts]) --> B{"Setup status<br/>already cached?"}
|
||||||
|
B -- Yes --> E
|
||||||
|
B -- No --> C["Fetch GET /Setup/status"]
|
||||||
|
C --> D{"Request<br/>outcome"}
|
||||||
|
D -- Network error --> ERR["Unable to reach server"]
|
||||||
|
D -- Success --> E{"initialized?"}
|
||||||
|
E -- false --> F{"On /setup<br/>already?"}
|
||||||
|
F -- Yes --> G([Render /setup page])
|
||||||
|
F -- No --> H([Redirect to /setup])
|
||||||
|
E -- true --> I{"On /setup<br/>already?"}
|
||||||
|
I -- Yes --> J([Redirect to /login])
|
||||||
|
I -- No --> K([Continue to route])
|
||||||
|
|
||||||
|
classDef start fill:#c7f9e9,stroke:#065f46,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef decision fill:#fef3c7,stroke:#b45309,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef action fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef error fill:#fee2e2,stroke:#b91c1c,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef terminal fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px,color:#1a1a1a
|
||||||
|
|
||||||
|
class A start
|
||||||
|
class B,D,E,F,I decision
|
||||||
|
class C action
|
||||||
|
class ERR error
|
||||||
|
class G,H,J,K terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: On app mount, fetch setup status (once per session). If not initialized → redirect to /setup (except when already on /setup). If initialized → allow navigation (redirect away from /setup to /login).
|
||||||
|
|
||||||
|
**Caching rule**: The fetch result is held in React state at root level. Once loaded, it never re-fetches within the same session (BR-U2-08).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 2: Protected Route Access — ProtectedRoute
|
||||||
|
|
||||||
|
**Trigger**: User navigates to any route inside the authenticated layout
|
||||||
|
**Purpose**: Prevent unauthenticated access to protected pages
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([Route navigation]) --> B{"AuthContext:<br/>user present?"}
|
||||||
|
B -- Yes --> C([Render requested page])
|
||||||
|
B -- No --> D{"Restoring<br/>session?"}
|
||||||
|
D -- Yes --> E([Loading spinner])
|
||||||
|
E --> B
|
||||||
|
D -- No --> F["Redirect to /login<br/>with ?redirect=path"]
|
||||||
|
|
||||||
|
classDef start fill:#c7f9e9,stroke:#065f46,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef decision fill:#fef3c7,stroke:#b45309,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef action fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef terminal fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef loading fill:#e0e7ff,stroke:#4338ca,stroke-width:1px,color:#1a1a1a
|
||||||
|
|
||||||
|
class A start
|
||||||
|
class B,D decision
|
||||||
|
class F action
|
||||||
|
class C terminal
|
||||||
|
class E loading
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Check if user is in AuthContext. If restoring session, show spinner. If no user after restore, redirect to /login preserving the original path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 3: Role-Restricted Route Access — RoleGuard
|
||||||
|
|
||||||
|
**Trigger**: Authenticated user navigates to a role-restricted route
|
||||||
|
**Purpose**: Enforce per-route role requirements
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([Authenticated navigation]) --> B{"Route has<br/>role restriction?"}
|
||||||
|
B -- No restriction --> C([Render page])
|
||||||
|
B -- Owner only --> D{"user.role<br/>= Owner?"}
|
||||||
|
B -- Owner or Admin --> E{"user.role<br/>= Owner or Admin?"}
|
||||||
|
D -- Yes --> C
|
||||||
|
D -- No --> F([Inline Access Denied])
|
||||||
|
E -- Yes --> C
|
||||||
|
E -- No --> F
|
||||||
|
|
||||||
|
classDef start fill:#c7f9e9,stroke:#065f46,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef decision fill:#fef3c7,stroke:#b45309,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef terminal fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef denied fill:#fee2e2,stroke:#b91c1c,stroke-width:1px,color:#1a1a1a
|
||||||
|
|
||||||
|
class A start
|
||||||
|
class B,D,E decision
|
||||||
|
class C terminal
|
||||||
|
class F denied
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: If route has no restriction → render. If Owner-only route → check role, render page or show inline "Access Denied". If Owner/Admin route → same check.
|
||||||
|
|
||||||
|
**Inline Access Denied**: Rendered within the normal page shell (sidebar + layout remain visible). The message identifies the required role. No redirect occurs (BR-U2-19).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 4: System Setup — SetupPage
|
||||||
|
|
||||||
|
**Trigger**: User visits `/setup` and system is uninitialized (`initialized: false`)
|
||||||
|
**Purpose**: Create the first Owner account
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([User opens /setup]) --> B["Render 5-field form<br/>Name · Email · Password<br/>Confirm Password · Locale"]
|
||||||
|
B --> C{"Locale<br/>changed?"}
|
||||||
|
C -- Yes --> D["Apply i18n.changeLanguage"]
|
||||||
|
D --> B
|
||||||
|
C -- No --> E["User submits form"]
|
||||||
|
E --> F{"Zod<br/>validation"}
|
||||||
|
F -- Invalid --> G["Show inline field errors"]
|
||||||
|
G --> B
|
||||||
|
F -- Valid --> H["Disable submit · loading"]
|
||||||
|
H --> I["POST /Setup"]
|
||||||
|
I --> J{"Response"}
|
||||||
|
J -- 200/201 --> K["Show success message"]
|
||||||
|
K --> L([Redirect to /login])
|
||||||
|
J -- 409 Conflict --> M["Banner: already initialized"]
|
||||||
|
J -- 4xx --> N["Banner: API error"]
|
||||||
|
J -- Network error --> O["Banner: Unable to connect"]
|
||||||
|
M --> B
|
||||||
|
N --> B
|
||||||
|
O --> B
|
||||||
|
|
||||||
|
classDef start fill:#c7f9e9,stroke:#065f46,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef decision fill:#fef3c7,stroke:#b45309,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef action fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef error fill:#fee2e2,stroke:#b91c1c,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef terminal fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef success fill:#dcfce7,stroke:#15803d,stroke-width:1px,color:#1a1a1a
|
||||||
|
|
||||||
|
class A start
|
||||||
|
class C,F,J decision
|
||||||
|
class B,D,E,H,I action
|
||||||
|
class G,M,N,O error
|
||||||
|
class K success
|
||||||
|
class L terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Render 5-field form → live locale switching → submit → Zod validation → POST /Setup → success banner + redirect to /login, or error banner on API/network failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 5: Invitation Completion — InviteCompletePage
|
||||||
|
|
||||||
|
**Trigger**: User clicks an invitation link (`/invite/complete?token=xxx`)
|
||||||
|
**Purpose**: Complete account setup for an invited user
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([User opens /invite/complete]) --> B{"token in URL?"}
|
||||||
|
B -- No --> C(["Error: invalid link"])
|
||||||
|
B -- Yes --> D(["Loading spinner"])
|
||||||
|
D --> E["GET /Invitation/validate"]
|
||||||
|
E --> F{"Validation<br/>result"}
|
||||||
|
F -- Network error --> G(["Error: unable to connect"])
|
||||||
|
F -- isValid=false --> H(["Error: expired / used / not found"])
|
||||||
|
F -- isValid=true --> I["Show form<br/>email read-only · Name · Password<br/>Confirm Password"]
|
||||||
|
I --> J["User submits form"]
|
||||||
|
J --> K{"Zod<br/>validation"}
|
||||||
|
K -- Invalid --> L["Inline field errors"]
|
||||||
|
L --> I
|
||||||
|
K -- Valid --> M(["Disable submit · loading"])
|
||||||
|
M --> N["POST /Invitation/complete"]
|
||||||
|
N --> O{"Response"}
|
||||||
|
O -- Success --> P["Show success message"]
|
||||||
|
P --> Q([Redirect to /login])
|
||||||
|
O -- 4xx --> R["Banner: API error"]
|
||||||
|
O -- Network error --> S["Banner: Unable to connect"]
|
||||||
|
R --> I
|
||||||
|
S --> I
|
||||||
|
|
||||||
|
classDef start fill:#c7f9e9,stroke:#065f46,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef decision fill:#fef3c7,stroke:#b45309,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef action fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef error fill:#fee2e2,stroke:#b91c1c,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef terminal fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef success fill:#dcfce7,stroke:#15803d,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef loading fill:#e0e7ff,stroke:#4338ca,stroke-width:1px,color:#1a1a1a
|
||||||
|
|
||||||
|
class A start
|
||||||
|
class B,F,K,O decision
|
||||||
|
class E,I,J,N action
|
||||||
|
class C,G,H,R,S error
|
||||||
|
class P success
|
||||||
|
class Q terminal
|
||||||
|
class D,M loading
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: On mount → check token param → validate with API (loading spinner) → invalid token shows error state; valid token shows form → submit → success banner + redirect to /login, or error banner on failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Interaction Diagram
|
||||||
|
|
||||||
|
The five flows compose within the TanStack Router tree:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Root["__root · InitGuard"] --> PublicSetup["/setup · SetupPage"]
|
||||||
|
Root --> PublicLogin["/login · LoginPage"]
|
||||||
|
Root --> PublicInvite["/invite/complete · InviteCompletePage"]
|
||||||
|
Root --> AuthLayout["_authenticated · ProtectedRoute"]
|
||||||
|
AuthLayout --> Dashboard["/ · Dashboard"]
|
||||||
|
AuthLayout --> Profile["/profile · ProfilePage"]
|
||||||
|
AuthLayout --> Users["/users · UsersPage<br/>RoleGuard: Owner or Admin"]
|
||||||
|
AuthLayout --> Settings["/settings · SettingsPage<br/>RoleGuard: Owner"]
|
||||||
|
AuthLayout --> CMS["/cms · CmsPage<br/>RoleGuard: Owner"]
|
||||||
|
|
||||||
|
classDef guard fill:#fef3c7,stroke:#b45309,stroke-width:2px,color:#1a1a1a
|
||||||
|
classDef public fill:#dbeafe,stroke:#1d4ed8,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef protected fill:#dcfce7,stroke:#15803d,stroke-width:1px,color:#1a1a1a
|
||||||
|
classDef restricted fill:#fee2e2,stroke:#b91c1c,stroke-width:1px,color:#1a1a1a
|
||||||
|
|
||||||
|
class Root guard
|
||||||
|
class PublicSetup,PublicLogin,PublicInvite public
|
||||||
|
class Dashboard,Profile protected
|
||||||
|
class AuthLayout,Users,Settings,CMS restricted
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Root route runs InitGuard. Public routes (/setup, /login, /invite/complete) are accessible without authentication. The _authenticated layout wraps all protected routes and runs ProtectedRoute. Role-restricted routes (/users, /settings, /cms) additionally run RoleGuard.
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Business Rules — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
## Password Validation (Shared Schema)
|
||||||
|
|
||||||
|
These rules apply to every password field in the application. The Zod schema lives in `src/lib/schemas/auth.ts` and is imported by `SetupPage`, `InviteCompletePage`, and `LoginPage` (BR-U2-24).
|
||||||
|
|
||||||
|
| ID | Rule | Zod constraint |
|
||||||
|
|---|---|---|
|
||||||
|
| BR-U2-01 | Minimum 8 characters | `.min(8)` |
|
||||||
|
| BR-U2-02 | At least 1 uppercase letter (A–Z) | `.regex(/[A-Z]/)` |
|
||||||
|
| BR-U2-03 | At least 1 lowercase letter (a–z) | `.regex(/[a-z]/)` |
|
||||||
|
| BR-U2-04 | At least 1 digit (0–9) | `.regex(/[0-9]/)` |
|
||||||
|
| BR-U2-05 | At least 1 non-alphanumeric character (e.g. `!@#$%^&*`) | `.regex(/[^a-zA-Z0-9]/)` |
|
||||||
|
|
||||||
|
These rules mirror the backend `IdentityOptions.Password` configuration exactly. Any change to backend password rules must also update this schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confirm Password
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-06 | `confirmPassword` must be identical to `password`. Validated via Zod `.refine()` at the schema root level — not as an individual field constraint. Error is attached to the `confirmPassword` field. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Validation
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-07 | `email` must pass Zod `.email()` (RFC-compliant format). Validated client-side before submission. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Initialization Guard (InitGuard)
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-08 | Setup status (`GET /Setup/status`) is fetched exactly once per browser session. The result is held in React state at the root route level. It is never re-fetched unless the user reloads the page. |
|
||||||
|
| BR-U2-09 | When `initialized: false`, all routes redirect to `/setup` — including `/login`. The only route that bypasses this redirect is `/setup` itself. |
|
||||||
|
| BR-U2-10 | When `initialized: true`, navigating to `/setup` redirects to `/login`. The `/setup` route is only accessible when the system is uninitialized. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Guard (ProtectedRoute)
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-11 | Any route inside the authenticated layout requires a valid `AuthSession` (non-null `user` in `AuthContext`). |
|
||||||
|
| BR-U2-12 | When there is no authenticated session, the router redirects to `/login`. The originally intended URL is preserved as a `redirect` search parameter (e.g. `/login?redirect=%2Fusers`). |
|
||||||
|
| BR-U2-13 | No protected page content is rendered, even transiently, before the guard check resolves. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role Guard (RoleGuard)
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-14 | Route `/` (dashboard) — accessible to: `Owner`, `Admin`, `User` |
|
||||||
|
| BR-U2-15 | Route `/profile` — accessible to: `Owner`, `Admin`, `User` |
|
||||||
|
| BR-U2-16 | Route `/users` — accessible to: `Owner`, `Admin` |
|
||||||
|
| BR-U2-17 | Route `/settings` — accessible to: `Owner` only |
|
||||||
|
| BR-U2-18 | Route `/cms` — accessible to: `Owner` only |
|
||||||
|
| BR-U2-19 | When a user's role is insufficient for the requested route, the page renders an inline "Access Denied" message within the normal page shell. No redirect to `/403` and no separate route is created in Unit 2. |
|
||||||
|
| BR-U2-20 | The inline "Access Denied" state must be rendered by `RoleGuard` as a wrapper/HOC, not embedded in individual page components. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SetupPage Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-21 | The SetupPage form collects: `name`, `email`, `password`, `confirmPassword`, `locale`. |
|
||||||
|
| BR-U2-22 | `locale` defaults to the browser's detected language (`navigator.language`), falling back to `'en'` if the detected language is not supported. |
|
||||||
|
| BR-U2-23 | Changing `locale` immediately applies `i18n.changeLanguage()` so the page re-renders in the selected language as a preview. |
|
||||||
|
| BR-U2-24 | After a successful `POST /Setup` response, the user is NOT automatically logged in. A success message is shown and the user is redirected to `/login` after a short delay (1–2 seconds) or immediately on a "Go to login" action. |
|
||||||
|
| BR-U2-25 | The `POST /Setup` payload contains `{ name, email, password }`. The `locale` field is not sent to the backend. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InviteCompletePage Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-26 | On page mount, the token from `?token=xxx` is extracted from the URL and sent to `GET /Invitation/validate?token=xxx`. |
|
||||||
|
| BR-U2-27 | While the validation request is in flight, a loading spinner is shown and the form is not rendered. |
|
||||||
|
| BR-U2-28 | If the token is valid, the form is shown with the `email` field pre-filled from the validation response and set to read-only. |
|
||||||
|
| BR-U2-29 | If the token is invalid or expired, an error state is shown. No form is rendered. The error message explains the reason (expired / already used / not found). A link to `/login` is provided. |
|
||||||
|
| BR-U2-30 | If no `token` query parameter is present in the URL, this is treated as an invalid token (show error state immediately, no validation request). |
|
||||||
|
| BR-U2-31 | After a successful `POST /Invitation/complete` response, the user is NOT automatically logged in. A success message is shown and the user is redirected to `/login`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form Error Handling (Application-Wide Standard)
|
||||||
|
|
||||||
|
These rules define the error handling pattern that applies to ALL forms in the application (SetupPage, InviteCompletePage, LoginPage).
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-32 | Field validation errors from Zod are shown **inline**, directly below the field. Inline errors are triggered **on blur** (when the user leaves a field), not on every keystroke. |
|
||||||
|
| BR-U2-33 | Once a field has been touched (blurred), validation re-runs **on change** so the error clears as soon as the user corrects the input. |
|
||||||
|
| BR-U2-34 | API-level errors (e.g. `400 Bad Request`, `409 Conflict`) returned after form submission are shown in a **dismissible banner** above the form. |
|
||||||
|
| BR-U2-35 | Network errors (no response received) are shown in the **dismissible banner** with the message: "Unable to connect. Please try again." |
|
||||||
|
| BR-U2-36 | The banner is dismissed when the user submits the form again or clicks the dismiss button. |
|
||||||
|
| BR-U2-37 | `LoginPage` (from Unit 1) must be updated in Unit 2 to align with the inline validation pattern (BR-U2-32/33). The banner pattern is already in place. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-38 | All Unit 2 strings (setup, invite complete, error messages, guard messages) are added to the existing `translation.json` files under `setup` and `inviteComplete` namespaces. No separate namespace files are created. |
|
||||||
|
| BR-U2-39 | The supported locales are `en` and `nl`. All keys must be present in both locale files. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MSW Handler Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| BR-U2-40 | `POST /Setup` is added to the existing `src/mocks/setup/handlers.ts` file. |
|
||||||
|
| BR-U2-41 | `GET /Invitation/validate` and `POST /Invitation/complete` are added to a new file `src/mocks/invitation/handlers.ts`. |
|
||||||
|
| BR-U2-42 | MSW handlers for invitation endpoints are stubs: they return hard-coded success/error scenarios sufficient to test Unit 2 UI states. Full dynamic behaviour is implemented in Unit 5. |
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
# Domain Entities — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Unit 2 introduces client-side domain models for authentication guards, system initialization, and invitation completion. Several entities (`User`, `AuthSession`, `SetupStatus`) are inherited from Unit 1; this document defines those that are new or extended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inherited from Unit 1 (reference only)
|
||||||
|
|
||||||
|
| Entity | Source | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `User` | `src/api/types.ts` | `{ id, name, email, role }` — from `AuthContext` |
|
||||||
|
| `AuthSession` | `src/contexts/auth-context.ts` | In-memory `{ user, accessToken }` |
|
||||||
|
| `SetupStatus` | `src/api/types.ts` | `{ initialized: boolean }` — from `GET /Setup/status` |
|
||||||
|
| `AuthResponse` | `src/api/types.ts` | `{ accessToken, name }` — returned by login/refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Entities
|
||||||
|
|
||||||
|
### UserRole
|
||||||
|
|
||||||
|
The application uses three roles, enforced by both backend and frontend guards.
|
||||||
|
|
||||||
|
```
|
||||||
|
UserRole = 'Owner' | 'Admin' | 'User'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Role | Description |
|
||||||
|
|---|---|
|
||||||
|
| `Owner` | Full access to all routes, including `/settings` and `/cms` |
|
||||||
|
| `Admin` | Access to `/users`; no access to `/settings` or `/cms` |
|
||||||
|
| `User` | Access to `/dashboard` and `/profile` only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SetupFormData
|
||||||
|
|
||||||
|
Collected by the `SetupPage` form. Submitted to `POST /Setup`.
|
||||||
|
|
||||||
|
| Field | Type | Constraint |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `string` | Required; min 1 character |
|
||||||
|
| `email` | `string` | Required; valid email format |
|
||||||
|
| `password` | `string` | Required; see BR-U2-01–05 |
|
||||||
|
| `confirmPassword` | `string` | Required; must match `password` (BR-U2-06) |
|
||||||
|
| `locale` | `'en' \| 'nl'` | Required; user's preferred UI language |
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- `locale` defaults to the browser's detected language if supported, otherwise `'en'`
|
||||||
|
- The `locale` preference is applied immediately when changed (live preview), using `i18n.changeLanguage()`
|
||||||
|
- The backend `POST /Setup` payload includes `name`, `email`, `password` — `locale` is applied client-side only (stored in localStorage via i18n or browser preference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### InvitationToken
|
||||||
|
|
||||||
|
Represents the token extracted from the URL query string on the `InviteCompletePage`.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `token` | `string` | Raw JWT or opaque token from `?token=xxx` in the URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### InvitationValidation
|
||||||
|
|
||||||
|
Returned by `GET /Invitation/validate?token=xxx` (stub in Unit 2; full implementation in Unit 5).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `email` | `string` | The email address the invitation was sent to |
|
||||||
|
| `name` | `string \| null` | Pre-filled name (optional, may be null) |
|
||||||
|
| `isValid` | `boolean` | Whether the token is still valid and not yet used |
|
||||||
|
| `errorCode` | `'EXPIRED' \| 'USED' \| 'NOT_FOUND' \| null` | Error reason when `isValid = false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### InviteCompleteFormData
|
||||||
|
|
||||||
|
Collected by the `InviteCompletePage` form. Submitted to `POST /Invitation/complete`.
|
||||||
|
|
||||||
|
| Field | Type | Constraint |
|
||||||
|
|---|---|---|
|
||||||
|
| `email` | `string` | Read-only; populated from `InvitationValidation.email` |
|
||||||
|
| `name` | `string` | Required; min 1 character |
|
||||||
|
| `password` | `string` | Required; see BR-U2-01–05 |
|
||||||
|
| `confirmPassword` | `string` | Required; must match `password` (BR-U2-06) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FormBannerError
|
||||||
|
|
||||||
|
Represents an API-level or network-level error surfaced as a dismissible banner above a form (BR-U2-21/22).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `message` | `string` | User-facing error message |
|
||||||
|
| `type` | `'api' \| 'network'` | Source of the error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TokenValidationState
|
||||||
|
|
||||||
|
Represents the loading/success/error lifecycle of the invitation token validation on page mount.
|
||||||
|
|
||||||
|
| State | Description |
|
||||||
|
|---|---|
|
||||||
|
| `loading` | Token validation in progress (spinner shown) |
|
||||||
|
| `valid` | Token is valid; invitation form is shown |
|
||||||
|
| `invalid` | Token is expired, used, or not found; error message shown |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Relationship Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class User {
|
||||||
|
+string id
|
||||||
|
+string name
|
||||||
|
+string email
|
||||||
|
+UserRole role
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRole {
|
||||||
|
<<enumeration>>
|
||||||
|
Owner
|
||||||
|
Admin
|
||||||
|
User
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthSession {
|
||||||
|
+User user
|
||||||
|
+string accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
class SetupStatus {
|
||||||
|
+boolean initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
class SetupFormData {
|
||||||
|
+string name
|
||||||
|
+string email
|
||||||
|
+string password
|
||||||
|
+string confirmPassword
|
||||||
|
+string locale
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvitationToken {
|
||||||
|
+string token
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvitationValidation {
|
||||||
|
+string email
|
||||||
|
+string name
|
||||||
|
+boolean isValid
|
||||||
|
+string errorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
class InviteCompleteFormData {
|
||||||
|
+string email
|
||||||
|
+string name
|
||||||
|
+string password
|
||||||
|
+string confirmPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormBannerError {
|
||||||
|
+string message
|
||||||
|
+string type
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenValidationState {
|
||||||
|
<<enumeration>>
|
||||||
|
loading
|
||||||
|
valid
|
||||||
|
invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
User --> UserRole : has
|
||||||
|
AuthSession --> User : contains
|
||||||
|
InvitationToken --> InvitationValidation : resolves to
|
||||||
|
InviteCompleteFormData --> InvitationValidation : pre-filled from
|
||||||
|
TokenValidationState --> InviteCompleteFormData : gates display of
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: `User` has a `UserRole` (Owner/Admin/User); `AuthSession` holds a `User` and `accessToken`; `InvitationToken` resolves to `InvitationValidation` which pre-fills `InviteCompleteFormData`; `TokenValidationState` controls whether the form or error state is shown.
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
# Frontend Components — Unit 2: Authentication Pages
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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-01–05)
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Specifications
|
||||||
|
|
||||||
|
### 1. InitGuard (embedded in `__root` route)
|
||||||
|
|
||||||
|
Not a standalone component — implemented as logic within the `__root.tsx` route using TanStack Router's `beforeLoad` or as a React effect on mount.
|
||||||
|
|
||||||
|
| 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 }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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-01–05 | 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 |
|
||||||
|
|
||||||
|
**State**:
|
||||||
|
|
||||||
|
| State | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `bannerError` | `FormBannerError \| null` | API/network error shown above form |
|
||||||
|
| `isSuccess` | `boolean` | True after successful submission (shows success state) |
|
||||||
|
|
||||||
|
**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)
|
||||||
|
|
||||||
|
**API integration**: `useCreateOwner()` mutation from `src/api/useSetup.ts`
|
||||||
|
|
||||||
|
**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.*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. InviteCompletePage (`src/pages/InviteCompletePage.tsx`)
|
||||||
|
|
||||||
|
**Purpose**: Completes account setup for an invited user via a tokenized URL.
|
||||||
|
|
||||||
|
**States / lifecycle**:
|
||||||
|
|
||||||
|
| 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-01–05 | PasswordField component |
|
||||||
|
| `confirmPassword` | `password` | Must match `password` (BR-U2-06) | PasswordField component |
|
||||||
|
|
||||||
|
**State**:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
**API integration**:
|
||||||
|
- `useValidateInvitation(token)` → `GET /Invitation/validate?token=xxx` (stub in Unit 2)
|
||||||
|
- `useCompleteSetup()` → `POST /Invitation/complete` (stub in Unit 2)
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. LoginPage (update — `src/pages/LoginPage.tsx`)
|
||||||
|
|
||||||
|
**Change from Unit 1**: Add inline field validation on blur (BR-U2-32/33). The API error banner is already in place.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Shared Component — PasswordField
|
||||||
|
|
||||||
|
A reusable wrapper around shadcn `Input` that adds a show/hide toggle.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | HTML id for label association |
|
||||||
|
| `placeholder` | `string` | Input placeholder text |
|
||||||
|
| `...register` | `UseFormRegisterReturn` | react-hook-form register props spread |
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Shared Component — FormBannerError
|
||||||
|
|
||||||
|
A dismissible alert banner rendered above form fields when an API or network error occurs.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `error` | `FormBannerError \| null` | The error to display; `null` means hidden |
|
||||||
|
| `onDismiss` | `() => void` | Called when user dismisses the banner |
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Hooks
|
||||||
|
|
||||||
|
### `src/api/useSetup.ts`
|
||||||
|
|
||||||
|
| Hook | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `useSetupStatus()` | Query | `GET /Setup/status` → `SetupStatus`. `staleTime: Infinity` (session cache). |
|
||||||
|
| `useCreateOwner()` | Mutation | `POST /Setup` with `{ name, email, password }`. |
|
||||||
|
|
||||||
|
### `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.
|
||||||
@@ -41,3 +41,22 @@ export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDe
|
|||||||
export interface SetupStatus {
|
export interface SetupStatus {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetupRequest {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationValidation {
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
isValid: boolean;
|
||||||
|
errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteCompleteRequest {
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { InvitationValidation, InviteCompleteRequest } from '@/api/types';
|
||||||
|
|
||||||
|
interface ValidateState {
|
||||||
|
data: InvitationValidation | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidateInvitation(token: string | undefined): ValidateState {
|
||||||
|
const [state, setState] = useState<ValidateState>({ data: null, isLoading: token !== undefined, error: null });
|
||||||
|
const mounted = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token === undefined) {
|
||||||
|
setState({ data: null, isLoading: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ data: null, isLoading: true, error: null });
|
||||||
|
|
||||||
|
api.get<InvitationValidation>(`/Invitation/validate?token=${encodeURIComponent(token)}`)
|
||||||
|
.then((data) => {
|
||||||
|
if (mounted.current) setState({ data, isLoading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (mounted.current)
|
||||||
|
setState({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompleteSetupState {
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCompleteSetup extends CompleteSetupState {
|
||||||
|
mutate: (data: InviteCompleteRequest) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompleteSetup(): UseCompleteSetup {
|
||||||
|
const [state, setState] = useState<CompleteSetupState>({ isLoading: false, error: null });
|
||||||
|
|
||||||
|
const mutate = useCallback(async (data: InviteCompleteRequest): Promise<void> => {
|
||||||
|
setState({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await api.post('/Invitation/complete', data);
|
||||||
|
setState({ isLoading: false, error: null });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
||||||
|
|
||||||
|
return { ...state, mutate, reset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { SetupRequest, SetupStatus } from '@/api/types';
|
||||||
|
|
||||||
|
// Module-level session cache — one fetch per page load (BR-U2-08).
|
||||||
|
let _setupStatusCache: SetupStatus | null = null;
|
||||||
|
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||||
|
|
||||||
|
export function fetchSetupStatus(): Promise<SetupStatus> {
|
||||||
|
if (_setupStatusCache !== null) return Promise.resolve(_setupStatusCache);
|
||||||
|
if (_setupStatusPromise === null) {
|
||||||
|
_setupStatusPromise = api
|
||||||
|
.get<SetupStatus>('/Setup/status')
|
||||||
|
.then((s) => {
|
||||||
|
_setupStatusCache = s;
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _setupStatusPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateSetupStatusCache(): void {
|
||||||
|
_setupStatusCache = null;
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupStatusState {
|
||||||
|
data: SetupStatus | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetupStatus(): SetupStatusState {
|
||||||
|
const [state, setState] = useState<SetupStatusState>(() =>
|
||||||
|
_setupStatusCache !== null
|
||||||
|
? { data: _setupStatusCache, isLoading: false, error: null }
|
||||||
|
: { data: null, isLoading: true, error: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_setupStatusCache !== null) return;
|
||||||
|
fetchSetupStatus()
|
||||||
|
.then((data) => {
|
||||||
|
if (mounted.current) setState({ data, isLoading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (mounted.current)
|
||||||
|
setState({ data: null, isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateOwnerState {
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCreateOwner extends CreateOwnerState {
|
||||||
|
mutate: (data: SetupRequest) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOwner(): UseCreateOwner {
|
||||||
|
const [state, setState] = useState<CreateOwnerState>({ isLoading: false, error: null });
|
||||||
|
|
||||||
|
const mutate = useCallback(async (data: SetupRequest): Promise<void> => {
|
||||||
|
setState({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await api.post('/Setup', data);
|
||||||
|
_setupStatusCache = { initialized: true };
|
||||||
|
setState({ isLoading: false, error: null });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
||||||
|
|
||||||
|
return { ...state, mutate, reset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { UserRole } from '@/api/types';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
|
interface RoleGuardProps {
|
||||||
|
allowedRoles: UserRole[];
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (user !== null && allowedRoles.includes(user.role)) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredLabel = allowedRoles.join(' or ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="access-denied-message"
|
||||||
|
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold">{t('errors.accessDenied')}</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
{t('errors.accessDeniedDetail', { roles: requiredLabel })}
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FormBannerErrorProps {
|
||||||
|
error: { message: string } | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormBannerError({ error, onDismiss }: FormBannerErrorProps) {
|
||||||
|
if (error === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="form-error-banner"
|
||||||
|
className="flex items-start justify-between gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<span>{error.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
className="mt-0.5 shrink-0 hover:opacity-70"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
import type { UseFormRegisterReturn } from 'react-hook-form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface PasswordFieldProps extends UseFormRegisterReturn {
|
||||||
|
id: string;
|
||||||
|
placeholder?: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordField({ id, placeholder, autoComplete = 'new-password', ...register }: PasswordFieldProps) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
{...register}
|
||||||
|
id={id}
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
data-testid={`${id}-input`}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShow((prev) => !prev)}
|
||||||
|
data-testid={`${id}-toggle`}
|
||||||
|
aria-label={show ? 'Hide password' : 'Show password'}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"cms": "CMS"
|
"cms": "CMS",
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
@@ -27,6 +29,49 @@
|
|||||||
"generic": "Something went wrong. Please try again."
|
"generic": "Something went wrong. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"setup": {
|
||||||
|
"title": "System Setup",
|
||||||
|
"subtitle": "Create the first Owner account to get started.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Full name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"locale": "Language"
|
||||||
|
},
|
||||||
|
"localeOptions": {
|
||||||
|
"en": "English",
|
||||||
|
"nl": "Dutch"
|
||||||
|
},
|
||||||
|
"submit": "Create account",
|
||||||
|
"submitting": "Creating account…",
|
||||||
|
"success": "Account created. You can now sign in.",
|
||||||
|
"errors": {
|
||||||
|
"alreadyInitialized": "This system has already been set up.",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inviteComplete": {
|
||||||
|
"title": "Complete your account",
|
||||||
|
"subtitle": "You have been invited to {{appName}}.",
|
||||||
|
"loading": "Validating your invitation…",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"name": "Full name",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password"
|
||||||
|
},
|
||||||
|
"submit": "Complete setup",
|
||||||
|
"submitting": "Completing setup…",
|
||||||
|
"success": "Account setup complete. You can now sign in.",
|
||||||
|
"errors": {
|
||||||
|
"expired": "This invitation link has expired. Please request a new one.",
|
||||||
|
"used": "This invitation link has already been used.",
|
||||||
|
"notFound": "This invitation link is not valid.",
|
||||||
|
"noToken": "Invalid invitation link.",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"welcome": "Welcome back, {{name}}",
|
"welcome": "Welcome back, {{name}}",
|
||||||
@@ -37,6 +82,9 @@
|
|||||||
"logout": "Sign out"
|
"logout": "Sign out"
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
|
"accessDeniedDetail": "This section requires the {{roles}} role.",
|
||||||
|
"sessionExpired": "Your session has expired. Please sign in again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"cms": "CMS"
|
"cms": "CMS",
|
||||||
|
"profile": "Profiel",
|
||||||
|
"settings": "Instellingen"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Inloggen",
|
"title": "Inloggen",
|
||||||
@@ -27,6 +29,49 @@
|
|||||||
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"setup": {
|
||||||
|
"title": "Systeeminstallatie",
|
||||||
|
"subtitle": "Maak het eerste Owner-account aan om te beginnen.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Volledige naam",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"confirmPassword": "Wachtwoord bevestigen",
|
||||||
|
"locale": "Taal"
|
||||||
|
},
|
||||||
|
"localeOptions": {
|
||||||
|
"en": "Engels",
|
||||||
|
"nl": "Nederlands"
|
||||||
|
},
|
||||||
|
"submit": "Account aanmaken",
|
||||||
|
"submitting": "Account aanmaken…",
|
||||||
|
"success": "Account aangemaakt. Je kunt nu inloggen.",
|
||||||
|
"errors": {
|
||||||
|
"alreadyInitialized": "Dit systeem is al ingesteld.",
|
||||||
|
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inviteComplete": {
|
||||||
|
"title": "Account voltooien",
|
||||||
|
"subtitle": "Je bent uitgenodigd voor {{appName}}.",
|
||||||
|
"loading": "Uitnodiging valideren…",
|
||||||
|
"fields": {
|
||||||
|
"email": "E-mail",
|
||||||
|
"name": "Volledige naam",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"confirmPassword": "Wachtwoord bevestigen"
|
||||||
|
},
|
||||||
|
"submit": "Setup voltooien",
|
||||||
|
"submitting": "Setup voltooien…",
|
||||||
|
"success": "Account-setup voltooid. Je kunt nu inloggen.",
|
||||||
|
"errors": {
|
||||||
|
"expired": "Deze uitnodigingslink is verlopen. Vraag een nieuwe aan.",
|
||||||
|
"used": "Deze uitnodigingslink is al gebruikt.",
|
||||||
|
"notFound": "Deze uitnodigingslink is niet geldig.",
|
||||||
|
"noToken": "Ongeldige uitnodigingslink.",
|
||||||
|
"generic": "Er is iets misgegaan. Probeer het opnieuw."
|
||||||
|
}
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"welcome": "Welkom terug, {{name}}",
|
"welcome": "Welkom terug, {{name}}",
|
||||||
@@ -37,6 +82,9 @@
|
|||||||
"logout": "Uitloggen"
|
"logout": "Uitloggen"
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
|
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
|
||||||
|
"accessDeniedDetail": "Dit gedeelte vereist de rol {{roles}}.",
|
||||||
|
"sessionExpired": "Je sessie is verlopen. Meld je opnieuw aan."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
|
.regex(/[0-9]/, 'Password must contain at least one digit')
|
||||||
|
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setupSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
|
locale: z.enum(['en', 'nl']),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.password !== data.confirmPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inviteCompleteSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.password !== data.confirmPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginFormValues = z.infer<typeof loginSchema>;
|
||||||
|
export type SetupFormValues = z.infer<typeof setupSchema>;
|
||||||
|
export type InviteCompleteFormValues = z.infer<typeof inviteCompleteSchema>;
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { authHandlers } from './auth/handlers';
|
import { authHandlers } from './auth/handlers';
|
||||||
import { userHandlers } from './users/handlers';
|
import { userHandlers } from './users/handlers';
|
||||||
import { setupHandlers } from './setup/handlers';
|
import { setupHandlers } from './setup/handlers';
|
||||||
|
import { invitationHandlers } from './invitation/handlers';
|
||||||
|
|
||||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers];
|
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers];
|
||||||
|
|
||||||
export { authHandlers } from './auth/handlers';
|
export { authHandlers } from './auth/handlers';
|
||||||
export { userHandlers } from './users/handlers';
|
export { userHandlers } from './users/handlers';
|
||||||
export { setupHandlers } from './setup/handlers';
|
export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers } from './setup/handlers';
|
||||||
|
export { invitationHandlers } from './invitation/handlers';
|
||||||
export * from './auth/fixtures';
|
export * from './auth/fixtures';
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import type { InvitationValidation } from '@/api/types';
|
||||||
|
import { API_BASE } from '../auth/fixtures';
|
||||||
|
|
||||||
|
export const invitationHandlers = [
|
||||||
|
http.get(`${API_BASE}/Invitation/validate`, ({ request }) => {
|
||||||
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
|
|
||||||
|
if (token === 'valid-token') {
|
||||||
|
return HttpResponse.json<InvitationValidation>({
|
||||||
|
isValid: true,
|
||||||
|
email: 'invited@example.com',
|
||||||
|
name: null,
|
||||||
|
errorCode: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (token === 'used-token') {
|
||||||
|
return HttpResponse.json<InvitationValidation>({
|
||||||
|
isValid: false,
|
||||||
|
email: '',
|
||||||
|
name: null,
|
||||||
|
errorCode: 'USED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// expired-token or any unknown token
|
||||||
|
return HttpResponse.json<InvitationValidation>({
|
||||||
|
isValid: false,
|
||||||
|
email: '',
|
||||||
|
name: null,
|
||||||
|
errorCode: 'EXPIRED',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API_BASE}/Invitation/complete`, () => new HttpResponse(null, { status: 200 })),
|
||||||
|
];
|
||||||
@@ -7,4 +7,24 @@ export const setupHandlers = [
|
|||||||
http.get(`${API_BASE}/Setup/status`, () =>
|
http.get(`${API_BASE}/Setup/status`, () =>
|
||||||
HttpResponse.json<SetupStatus>({ initialized: true }),
|
HttpResponse.json<SetupStatus>({ initialized: true }),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
http.post(`${API_BASE}/Setup`, () => new HttpResponse(null, { status: 201 })),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Override: returns not-initialized status — use in tests for InitGuard. */
|
||||||
|
export const setupUninitializedHandlers = [
|
||||||
|
http.get(`${API_BASE}/Setup/status`, () =>
|
||||||
|
HttpResponse.json<SetupStatus>({ initialized: false }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Override: POST /Setup returns 409 — system already initialized. */
|
||||||
|
export const setupConflictHandlers = [
|
||||||
|
...setupUninitializedHandlers,
|
||||||
|
http.post(`${API_BASE}/Setup`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ title: 'Conflict', detail: 'System has already been initialized.', status: 409 },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderApp, mockGuest } from '@/test/utils';
|
||||||
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
|
||||||
|
// Reset module-level setup status cache before each test.
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetSetupStatusCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InviteCompletePage', () => {
|
||||||
|
it('shows a loading spinner on mount while validating the token', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
|
// Loading spinner should appear immediately.
|
||||||
|
expect(await screen.findByTestId('invite-loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error state for an expired token', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/invite/complete?token=expired-token');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('invite-name-input')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error state for a used token', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/invite/complete?token=used-token');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error state when no token is provided', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/invite/complete');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the form with a read-only email for a valid token', async () => {
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-name-input')).toBeInTheDocument();
|
||||||
|
const emailInput = screen.getByTestId('invite-email-input') as HTMLInputElement;
|
||||||
|
expect(emailInput.value).toBe('invited@example.com');
|
||||||
|
expect(emailInput.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation errors when submitting an empty form', async () => {
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
|
const submit = await screen.findByTestId('invite-submit-button');
|
||||||
|
await user.click(submit);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-name-error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('invite-password-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success message after a valid submission', async () => {
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/invite/complete?token=valid-token');
|
||||||
|
|
||||||
|
await user.type(await screen.findByTestId('invite-name-input'), 'Bob User');
|
||||||
|
await user.type(screen.getByTestId('invite-password-input'), 'ValidPass1!');
|
||||||
|
await user.type(screen.getByTestId('invite-confirmPassword-input'), 'ValidPass1!');
|
||||||
|
await user.click(screen.getByTestId('invite-submit-button'));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-success')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { PasswordField } from '@/components/ui/PasswordField';
|
||||||
|
import { FormBannerError } from '@/components/ui/FormBannerError';
|
||||||
|
import { useValidateInvitation, useCompleteSetup } from '@/api/useInvitation';
|
||||||
|
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||||
|
import { inviteCompleteSchema, type InviteCompleteFormValues } from '@/lib/schemas/auth';
|
||||||
|
|
||||||
|
function errorKey(code: string | null | undefined): string {
|
||||||
|
switch (code) {
|
||||||
|
case 'EXPIRED':
|
||||||
|
return 'inviteComplete.errors.expired';
|
||||||
|
case 'USED':
|
||||||
|
return 'inviteComplete.errors.used';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return 'inviteComplete.errors.notFound';
|
||||||
|
default:
|
||||||
|
return 'inviteComplete.errors.notFound';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteCompletePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const search = useSearch({ strict: false }) as { token?: string };
|
||||||
|
const token = search.token;
|
||||||
|
|
||||||
|
const { data: invitation, isLoading: validating, error: validateError } = useValidateInvitation(token);
|
||||||
|
const { mutate, isLoading: submitting } = useCompleteSetup();
|
||||||
|
|
||||||
|
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<InviteCompleteFormValues>({
|
||||||
|
resolver: zodResolver(inviteCompleteSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: { name: invitation?.name ?? '', password: '', confirmPassword: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
|
if (token === undefined) return;
|
||||||
|
setBannerError(null);
|
||||||
|
try {
|
||||||
|
await mutate({ token, name: values.name, password: values.password });
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
void navigate({ to: '/login', replace: true });
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NetworkError) {
|
||||||
|
setBannerError({ message: t('errors.network') });
|
||||||
|
} else if (err instanceof ProblemDetailsError) {
|
||||||
|
setBannerError({ message: err.problem.detail ?? t('inviteComplete.errors.generic') });
|
||||||
|
} else {
|
||||||
|
setBannerError({ message: t('inviteComplete.errors.generic') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Loading state ---
|
||||||
|
if (validating) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="invite-loading"
|
||||||
|
className="flex min-h-svh items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<span className="sr-only">{t('inviteComplete.loading')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error / invalid states ---
|
||||||
|
const isInvalid = !token || validateError !== null || (invitation !== null && !invitation.isValid);
|
||||||
|
if (isInvalid) {
|
||||||
|
const code = invitation?.errorCode ?? null;
|
||||||
|
const message = !token
|
||||||
|
? t('inviteComplete.errors.noToken')
|
||||||
|
: validateError !== null
|
||||||
|
? t('errors.network')
|
||||||
|
: t(errorKey(code));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="invite-error"
|
||||||
|
className="flex min-h-svh flex-col items-center justify-center gap-4 p-4 text-center"
|
||||||
|
>
|
||||||
|
<p className="max-w-sm text-sm text-destructive">{message}</p>
|
||||||
|
<Link to="/login" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||||
|
{t('login.title')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Success state ---
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-4">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
data-testid="invite-success"
|
||||||
|
className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400"
|
||||||
|
>
|
||||||
|
{t('inviteComplete.success')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form state ---
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('inviteComplete.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('inviteComplete.subtitle', { appName: t('common.appName') })}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<FormBannerError error={bannerError} onDismiss={() => setBannerError(null)} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-email">{t('inviteComplete.fields.email')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
value={invitation?.email ?? ''}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
data-testid="invite-email-input"
|
||||||
|
className="cursor-default opacity-70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-name">{t('inviteComplete.fields.name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
data-testid="invite-name-input"
|
||||||
|
aria-invalid={errors.name !== undefined}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="invite-name-error">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-password">{t('inviteComplete.fields.password')}</Label>
|
||||||
|
<PasswordField
|
||||||
|
id="invite-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={errors.password !== undefined}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="invite-password-error">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-confirmPassword">
|
||||||
|
{t('inviteComplete.fields.confirmPassword')}
|
||||||
|
</Label>
|
||||||
|
<PasswordField
|
||||||
|
id="invite-confirmPassword"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={errors.confirmPassword !== undefined}
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="invite-confirm-error">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={submitting}
|
||||||
|
data-testid="invite-submit-button"
|
||||||
|
>
|
||||||
|
{submitting ? t('inviteComplete.submitting') : t('inviteComplete.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router';
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { z } from 'zod';
|
|
||||||
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 { 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';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -18,26 +18,13 @@ export function LoginPage() {
|
|||||||
const search = useSearch({ strict: false }) as { redirect?: string };
|
const search = useSearch({ strict: false }) as { redirect?: string };
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const schema = useMemo(
|
|
||||||
() =>
|
|
||||||
z.object({
|
|
||||||
email: z
|
|
||||||
.string()
|
|
||||||
.min(1, t('login.errors.emailRequired'))
|
|
||||||
.email(t('login.errors.emailInvalid')),
|
|
||||||
password: z.string().min(1, t('login.errors.passwordRequired')),
|
|
||||||
}),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<FormValues>({
|
} = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(loginSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
defaultValues: { email: '', password: '' },
|
defaultValues: { email: '', password: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +32,7 @@ export function LoginPage() {
|
|||||||
setServerError(null);
|
setServerError(null);
|
||||||
try {
|
try {
|
||||||
await login(values.email, values.password);
|
await login(values.email, values.password);
|
||||||
await navigate({ to: search.redirect ?? '/dashboard' });
|
await navigate({ to: (search.redirect as string | undefined) ?? '/dashboard' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProblemDetailsError && err.status === 401) {
|
if (err instanceof ProblemDetailsError && err.status === 401) {
|
||||||
setServerError(t('login.errors.invalidCredentials'));
|
setServerError(t('login.errors.invalidCredentials'));
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderApp, mockGuest } from '@/test/utils';
|
||||||
|
import { server } from '@/mocks/server';
|
||||||
|
import { setupUninitializedHandlers, setupConflictHandlers } from '@/mocks/index';
|
||||||
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
|
||||||
|
// Each test starts with a fresh setup status cache so the MSW handler controls
|
||||||
|
// what the InitGuard fetches (BR-U2-08).
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetSetupStatusCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SetupPage', () => {
|
||||||
|
it('renders all five form fields when system is uninitialized', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-name-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-email-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-password-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-confirmPassword-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-locale-select')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-submit-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows inline validation errors when submitting an empty form', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
const submit = await screen.findByTestId('setup-submit-button');
|
||||||
|
await user.click(submit);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-name-error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-email-error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('setup-password-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password error for a weak password (no uppercase)', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
const passwordInput = await screen.findByTestId('setup-password-input');
|
||||||
|
await user.type(passwordInput, 'weakpassword1!');
|
||||||
|
await user.tab();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-password-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows confirm-password error when passwords do not match', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
const passwordInput = await screen.findByTestId('setup-password-input');
|
||||||
|
const confirmInput = screen.getByTestId('setup-confirmPassword-input');
|
||||||
|
await user.type(passwordInput, 'ValidPass1!');
|
||||||
|
await user.type(confirmInput, 'DifferentPass1!');
|
||||||
|
await user.tab();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-confirm-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success message after a valid submission', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
|
||||||
|
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
|
||||||
|
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
|
||||||
|
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
|
||||||
|
await user.click(screen.getByTestId('setup-submit-button'));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-success')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "already initialized" banner on 409 response', async () => {
|
||||||
|
server.use(...setupConflictHandlers);
|
||||||
|
mockGuest();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
await user.type(await screen.findByTestId('setup-name-input'), 'Alice Owner');
|
||||||
|
await user.type(screen.getByTestId('setup-email-input'), 'alice@example.com');
|
||||||
|
await user.type(screen.getByTestId('setup-password-input'), 'ValidPass1!');
|
||||||
|
await user.type(screen.getByTestId('setup-confirmPassword-input'), 'ValidPass1!');
|
||||||
|
await user.click(screen.getByTestId('setup-submit-button'));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /login when system is already initialized', async () => {
|
||||||
|
// Default setupHandlers return { initialized: true } — InitGuard redirects.
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,196 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import i18n from 'i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { PasswordField } from '@/components/ui/PasswordField';
|
||||||
|
import { FormBannerError } from '@/components/ui/FormBannerError';
|
||||||
|
import { useCreateOwner } from '@/api/useSetup';
|
||||||
|
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
|
||||||
|
import { setupSchema, type SetupFormValues } from '@/lib/schemas/auth';
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES = ['en', 'nl'] as const;
|
||||||
|
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
function getDefaultLocale(): SupportedLocale {
|
||||||
|
const lang = navigator.language.split('-')[0];
|
||||||
|
return SUPPORTED_LOCALES.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
/** Public setup placeholder (initial owner creation / invitation completion). */
|
|
||||||
export function SetupPage() {
|
export function SetupPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mutate, isLoading } = useCreateOwner();
|
||||||
|
|
||||||
|
const [bannerError, setBannerError] = useState<{ message: string } | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SetupFormValues>({
|
||||||
|
resolver: zodResolver(setupSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
locale: getDefaultLocale(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLocale = watch('locale');
|
||||||
|
|
||||||
|
// Live locale switch (BR-U2-23).
|
||||||
|
useEffect(() => {
|
||||||
|
void i18n.changeLanguage(currentLocale);
|
||||||
|
}, [currentLocale]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
|
setBannerError(null);
|
||||||
|
try {
|
||||||
|
await mutate({ name: values.name, email: values.email, password: values.password });
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
void navigate({ to: '/login', replace: true });
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ProblemDetailsError && err.status === 409) {
|
||||||
|
setBannerError({ message: t('setup.errors.alreadyInitialized') });
|
||||||
|
} else if (err instanceof NetworkError) {
|
||||||
|
setBannerError({ message: t('errors.network') });
|
||||||
|
} else {
|
||||||
|
setBannerError({ message: t('setup.errors.generic') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
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('common.appName')} — Setup</CardTitle>
|
<CardTitle>{t('setup.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('setup.subtitle')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">Coming soon.</p>
|
{success ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
data-testid="setup-success"
|
||||||
|
className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400"
|
||||||
|
>
|
||||||
|
{t('setup.success')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<FormBannerError
|
||||||
|
error={bannerError}
|
||||||
|
onDismiss={() => setBannerError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setup-name">{t('setup.fields.name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="setup-name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-testid="setup-name-input"
|
||||||
|
aria-invalid={errors.name !== undefined}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="setup-name-error">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setup-email">{t('setup.fields.email')}</Label>
|
||||||
|
<Input
|
||||||
|
id="setup-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
data-testid="setup-email-input"
|
||||||
|
aria-invalid={errors.email !== undefined}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="setup-email-error">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setup-password">{t('setup.fields.password')}</Label>
|
||||||
|
<PasswordField
|
||||||
|
id="setup-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={errors.password !== undefined}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="setup-password-error">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setup-confirmPassword">
|
||||||
|
{t('setup.fields.confirmPassword')}
|
||||||
|
</Label>
|
||||||
|
<PasswordField
|
||||||
|
id="setup-confirmPassword"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={errors.confirmPassword !== undefined}
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-destructive" data-testid="setup-confirm-error">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="setup-locale">{t('setup.fields.locale')}</Label>
|
||||||
|
<select
|
||||||
|
id="setup-locale"
|
||||||
|
data-testid="setup-locale-select"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
{...register('locale')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value as SupportedLocale;
|
||||||
|
setValue('locale', val, { shouldValidate: true });
|
||||||
|
}}
|
||||||
|
value={currentLocale}
|
||||||
|
>
|
||||||
|
<option value="en">{t('setup.localeOptions.en')}</option>
|
||||||
|
<option value="nl">{t('setup.localeOptions.nl')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="setup-submit-button"
|
||||||
|
>
|
||||||
|
{isLoading ? t('setup.submitting') : t('setup.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+85
-10
@@ -7,13 +7,47 @@ import {
|
|||||||
redirect,
|
redirect,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import type { AuthContextValue } from '@/contexts/auth-context';
|
import type { AuthContextValue } from '@/contexts/auth-context';
|
||||||
|
import type { SetupStatus } from '@/api/types';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
import { AppLayout } from '@/components/layout/AppLayout';
|
import { AppLayout } from '@/components/layout/AppLayout';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
auth: AuthContextValue;
|
auth: AuthContextValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InitGuard: module-level session cache for setup status (BR-U2-08).
|
||||||
|
// One network request per page load; subsequent beforeLoad calls return cached
|
||||||
|
// data synchronously via Promise.resolve().
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let _setupStatusCache: SetupStatus | null = null;
|
||||||
|
let _setupStatusPromise: Promise<SetupStatus> | null = null;
|
||||||
|
|
||||||
|
async function fetchSetupStatus(): Promise<SetupStatus> {
|
||||||
|
if (_setupStatusCache !== null) return _setupStatusCache;
|
||||||
|
if (_setupStatusPromise === null) {
|
||||||
|
_setupStatusPromise = api
|
||||||
|
.get<SetupStatus>('/Setup/status')
|
||||||
|
.then((s) => {
|
||||||
|
_setupStatusCache = s;
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _setupStatusPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for tests so each test starts with a clean cache.
|
||||||
|
export function _resetSetupStatusCache(): void {
|
||||||
|
_setupStatusCache = null;
|
||||||
|
_setupStatusPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||||
@@ -22,10 +56,14 @@ function RouteFallback() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function BootstrapSplash() {
|
||||||
* Wrap a per-feature page in a lazy boundary so Vite emits a separate chunk
|
return (
|
||||||
* (NFR-U1-01 / Q1-A). The auth/root shell and login stay eager for fast paint.
|
<div className="flex min-h-svh items-center justify-center text-muted-foreground">
|
||||||
*/
|
<span className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function lazyPage<P extends Record<string, never>>(
|
function lazyPage<P extends Record<string, never>>(
|
||||||
factory: () => Promise<{ [key: string]: ComponentType<P> }>,
|
factory: () => Promise<{ [key: string]: ComponentType<P> }>,
|
||||||
exportName: string,
|
exportName: string,
|
||||||
@@ -40,11 +78,33 @@ function lazyPage<P extends Record<string, never>>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
|
pendingComponent: BootstrapSplash,
|
||||||
|
pendingMs: 0,
|
||||||
|
beforeLoad: async ({ location }) => {
|
||||||
|
let status: SetupStatus;
|
||||||
|
try {
|
||||||
|
status = await fetchSetupStatus();
|
||||||
|
} catch {
|
||||||
|
// Network failure — let routes render; API calls will surface errors.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSetup = location.pathname === '/setup';
|
||||||
|
if (!status.initialized && !onSetup) {
|
||||||
|
throw redirect({ to: '/setup' });
|
||||||
|
}
|
||||||
|
if (status.initialized && onSetup) {
|
||||||
|
throw redirect({ to: '/login' });
|
||||||
|
}
|
||||||
|
},
|
||||||
component: () => <Outlet />,
|
component: () => <Outlet />,
|
||||||
});
|
});
|
||||||
|
|
||||||
// '/' redirects into the protected area; the guard sends guests to /login.
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -59,7 +119,6 @@ const loginRoute = createRoute({
|
|||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
|
||||||
redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
|
redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
|
||||||
}),
|
}),
|
||||||
// Authenticated users never see /login (BR-U1-06).
|
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (context.auth.isAuthenticated) {
|
if (context.auth.isAuthenticated) {
|
||||||
throw redirect({ to: '/dashboard' });
|
throw redirect({ to: '/dashboard' });
|
||||||
@@ -74,7 +133,15 @@ const setupRoute = createRoute({
|
|||||||
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
|
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Layout route guarding every protected page (BR-U1-05).
|
const inviteCompleteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/invite/complete',
|
||||||
|
validateSearch: (search: Record<string, unknown>): { token?: string } => ({
|
||||||
|
token: typeof search.token === 'string' ? search.token : undefined,
|
||||||
|
}),
|
||||||
|
component: lazyPage(() => import('@/pages/InviteCompletePage'), 'InviteCompletePage'),
|
||||||
|
});
|
||||||
|
|
||||||
const authenticatedRoute = createRoute({
|
const authenticatedRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: '_authenticated',
|
id: '_authenticated',
|
||||||
@@ -95,26 +162,34 @@ const dashboardRoute = createRoute({
|
|||||||
const usersRoute = createRoute({
|
const usersRoute = createRoute({
|
||||||
getParentRoute: () => authenticatedRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: '/users',
|
path: '/users',
|
||||||
component: lazyPage(() => import('@/pages/UsersPage'), 'UsersPage'),
|
component: () => (
|
||||||
|
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||||
|
{lazyPage(() => import('@/pages/UsersPage'), 'UsersPage')()}
|
||||||
|
</RoleGuard>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cmsRoute = createRoute({
|
const cmsRoute = createRoute({
|
||||||
getParentRoute: () => authenticatedRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: '/cms',
|
path: '/cms',
|
||||||
component: lazyPage(() => import('@/pages/CmsPage'), 'CmsPage'),
|
component: () => (
|
||||||
|
<RoleGuard allowedRoles={['Owner']}>
|
||||||
|
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
|
||||||
|
</RoleGuard>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const routeTree = rootRoute.addChildren([
|
export const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupRoute,
|
setupRoute,
|
||||||
|
inviteCompleteRoute,
|
||||||
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute]),
|
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
// Real auth is injected per render via RouterProvider's `context` prop.
|
|
||||||
context: { auth: undefined as unknown as AuthContextValue },
|
context: { auth: undefined as unknown as AuthContextValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
|
import { renderApp, renderWithProviders, mockAuthenticated, mockGuest } from '@/test/utils';
|
||||||
|
import { server } from '@/mocks/server';
|
||||||
|
import { setupUninitializedHandlers } from '@/mocks/index';
|
||||||
|
import { _resetSetupStatusCache } from '@/router';
|
||||||
|
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||||
|
|
||||||
describe('Route guards (BR-U1-05, BR-U1-06)', () => {
|
beforeEach(() => {
|
||||||
|
_resetSetupStatusCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProtectedRoute (BR-U1-05, BR-U1-06)', () => {
|
||||||
it('redirects an unauthenticated user from a protected route to /login', async () => {
|
it('redirects an unauthenticated user from a protected route to /login', async () => {
|
||||||
mockGuest();
|
mockGuest();
|
||||||
renderApp('/dashboard');
|
renderApp('/dashboard');
|
||||||
@@ -26,3 +34,54 @@ describe('Route guards (BR-U1-05, BR-U1-06)', () => {
|
|||||||
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('InitGuard (BR-U2-09, BR-U2-10)', () => {
|
||||||
|
it('redirects any route to /setup when system is not initialized', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/login');
|
||||||
|
|
||||||
|
// InitGuard should redirect /login → /setup when not initialized.
|
||||||
|
expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows /setup to render when system is not initialized', async () => {
|
||||||
|
server.use(...setupUninitializedHandlers);
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects /setup to /login when system is already initialized', async () => {
|
||||||
|
// Default handlers return { initialized: true }.
|
||||||
|
mockGuest();
|
||||||
|
renderApp('/setup');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('setup-submit-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RoleGuard (BR-U2-14–BR-U2-20)', () => {
|
||||||
|
it('renders children when the user has a permitted role', () => {
|
||||||
|
const { getByText } = renderWithProviders(
|
||||||
|
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||||
|
<p>Protected content</p>
|
||||||
|
</RoleGuard>,
|
||||||
|
);
|
||||||
|
// mockAuthenticated sets role to 'Owner' via makeAuthResponse — see fixtures.
|
||||||
|
expect(getByText('Protected content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inline Access Denied when role is insufficient', () => {
|
||||||
|
// renderWithProviders uses the guest (no user) state — no role at all.
|
||||||
|
const { getByTestId } = renderWithProviders(
|
||||||
|
<RoleGuard allowedRoles={['Owner']}>
|
||||||
|
<p>Should not appear</p>
|
||||||
|
</RoleGuard>,
|
||||||
|
);
|
||||||
|
expect(getByTestId('access-denied-message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user