From ab93a5c7d15a490111b645ac2b2211bf3d4cb2c4 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Sun, 21 Jun 2026 00:15:28 +0200 Subject: [PATCH] Adds auth pages --- .../features/cms-frontend/aidlc-state.md | 6 +- aidlc-docs/features/cms-frontend/audit.md | 60 ++++ .../plans/unit-2-code-generation-plan.md | 320 +++++++++++++++++ .../plans/unit-2-functional-design-plan.md | 230 +++++++++++++ .../unit-2/code/code-generation-summary.md | 70 ++++ .../functional-design/business-logic-model.md | 235 +++++++++++++ .../functional-design/business-rules.md | 124 +++++++ .../functional-design/domain-entities.md | 188 ++++++++++ .../functional-design/frontend-components.md | 324 ++++++++++++++++++ frontend/src/api/types.ts | 19 + frontend/src/api/useInvitation.ts | 74 ++++ frontend/src/api/useSetup.ts | 95 +++++ frontend/src/components/auth/RoleGuard.tsx | 36 ++ .../src/components/ui/FormBannerError.tsx | 28 ++ frontend/src/components/ui/PasswordField.tsx | 38 ++ frontend/src/i18n/locales/en/translation.json | 52 ++- frontend/src/i18n/locales/nl/translation.json | 52 ++- frontend/src/lib/schemas/auth.ts | 52 +++ frontend/src/mocks/index.ts | 6 +- frontend/src/mocks/invitation/handlers.ts | 35 ++ frontend/src/mocks/setup/handlers.ts | 20 ++ .../src/pages/InviteCompletePage.test.tsx | 77 +++++ frontend/src/pages/InviteCompletePage.tsx | 209 +++++++++++ frontend/src/pages/LoginPage.tsx | 25 +- frontend/src/pages/SetupPage.test.tsx | 108 ++++++ frontend/src/pages/SetupPage.tsx | 187 +++++++++- frontend/src/router.tsx | 95 ++++- frontend/src/test/RouteGuard.test.tsx | 65 +++- 28 files changed, 2785 insertions(+), 45 deletions(-) create mode 100644 aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md create mode 100644 aidlc-docs/features/cms-frontend/construction/plans/unit-2-functional-design-plan.md create mode 100644 aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md create mode 100644 aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-logic-model.md create mode 100644 aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-rules.md create mode 100644 aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/domain-entities.md create mode 100644 aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/frontend-components.md create mode 100644 frontend/src/api/useInvitation.ts create mode 100644 frontend/src/api/useSetup.ts create mode 100644 frontend/src/components/auth/RoleGuard.tsx create mode 100644 frontend/src/components/ui/FormBannerError.tsx create mode 100644 frontend/src/components/ui/PasswordField.tsx create mode 100644 frontend/src/lib/schemas/auth.ts create mode 100644 frontend/src/mocks/invitation/handlers.ts create mode 100644 frontend/src/pages/InviteCompletePage.test.tsx create mode 100644 frontend/src/pages/InviteCompletePage.tsx create mode 100644 frontend/src/pages/SetupPage.test.tsx diff --git a/aidlc-docs/features/cms-frontend/aidlc-state.md b/aidlc-docs/features/cms-frontend/aidlc-state.md index e98e43d..8b3c5b2 100644 --- a/aidlc-docs/features/cms-frontend/aidlc-state.md +++ b/aidlc-docs/features/cms-frontend/aidlc-state.md @@ -5,7 +5,7 @@ - **Feature Slug**: cms-frontend - **Project Type**: Brownfield - **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 ## Workspace State @@ -47,8 +47,8 @@ ### 🟒 CONSTRUCTION PHASE (per unit) - [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 -- [ ] Unit 2 β€” Authentication Pages +- [x] Unit 1 β€” Project Scaffold & Infrastructure β€” COMPLETED 2026-06-20T17:30:00Z +- [x] Unit 2 β€” Authentication Pages β€” COMPLETED 2026-06-21 - [ ] Unit 3 β€” Layout & Navigation - [ ] Unit 4 β€” Dashboard - [ ] Unit 5 β€” User Management diff --git a/aidlc-docs/features/cms-frontend/audit.md b/aidlc-docs/features/cms-frontend/audit.md index 619ff3b..89594ed 100644 --- a/aidlc-docs/features/cms-frontend/audit.md +++ b/aidlc-docs/features/cms-frontend/audit.md @@ -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. --- + +## 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). + +--- diff --git a/aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md b/aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md new file mode 100644 index 0000000..0c36c64 --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md @@ -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` 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` 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 | null = null; + let _setupStatusCache: SetupStatus | null = null; + + async function fetchSetupStatus(): Promise { + if (_setupStatusCache !== null) return _setupStatusCache; + if (_setupStatusPromise === null) { + _setupStatusPromise = apiClient + .get('/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 | diff --git a/aidlc-docs/features/cms-frontend/construction/plans/unit-2-functional-design-plan.md b/aidlc-docs/features/cms-frontend/construction/plans/unit-2-functional-design-plan.md new file mode 100644 index 0000000..dce5a08 --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/plans/unit-2-functional-design-plan.md @@ -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 + diff --git a/aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md b/aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md new file mode 100644 index 0000000..d9c828a --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md @@ -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 diff --git a/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-logic-model.md b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-logic-model.md new file mode 100644 index 0000000..6ccc511 --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-logic-model.md @@ -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
already cached?"} + B -- Yes --> E + B -- No --> C["Fetch GET /Setup/status"] + C --> D{"Request
outcome"} + D -- Network error --> ERR["Unable to reach server"] + D -- Success --> E{"initialized?"} + E -- false --> F{"On /setup
already?"} + F -- Yes --> G([Render /setup page]) + F -- No --> H([Redirect to /setup]) + E -- true --> I{"On /setup
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:
user present?"} + B -- Yes --> C([Render requested page]) + B -- No --> D{"Restoring
session?"} + D -- Yes --> E([Loading spinner]) + E --> B + D -- No --> F["Redirect to /login
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
role restriction?"} + B -- No restriction --> C([Render page]) + B -- Owner only --> D{"user.role
= Owner?"} + B -- Owner or Admin --> E{"user.role
= 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
Name Β· Email Β· Password
Confirm Password Β· Locale"] + B --> C{"Locale
changed?"} + C -- Yes --> D["Apply i18n.changeLanguage"] + D --> B + C -- No --> E["User submits form"] + E --> F{"Zod
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
result"} + F -- Network error --> G(["Error: unable to connect"]) + F -- isValid=false --> H(["Error: expired / used / not found"]) + F -- isValid=true --> I["Show form
email read-only Β· Name Β· Password
Confirm Password"] + I --> J["User submits form"] + J --> K{"Zod
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
RoleGuard: Owner or Admin"] + AuthLayout --> Settings["/settings Β· SettingsPage
RoleGuard: Owner"] + AuthLayout --> CMS["/cms Β· CmsPage
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. diff --git a/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-rules.md b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-rules.md new file mode 100644 index 0000000..938fa4e --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/business-rules.md @@ -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. | diff --git a/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/domain-entities.md b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/domain-entities.md new file mode 100644 index 0000000..6f72f05 --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/domain-entities.md @@ -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 { + <> + 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 { + <> + 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. diff --git a/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/frontend-components.md b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/frontend-components.md new file mode 100644 index 0000000..7c0be2f --- /dev/null +++ b/aidlc-docs/features/cms-frontend/construction/unit-2/functional-design/frontend-components.md @@ -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
UsersPage"] + AuthLayout --> RoleGuardSettings["RoleGuard Owner
SettingsPage"] + AuthLayout --> RoleGuardCms["RoleGuard Owner
CmsPage"] + SetupPage --> useSetup["useSetup
useSetupStatus Β· useCreateOwner"] + InviteCompletePage --> useInvitation["useInvitation stub
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=` (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: + + + +``` + +**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 `` +- 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. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c8ea19e..27587cd 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -41,3 +41,22 @@ export type ApiResult = { ok: true; data: T } | { ok: false; error: ProblemDe export interface SetupStatus { initialized: boolean; } + +export interface SetupRequest { + name: string; + email: string; + password: string; +} + +export interface InvitationValidation { + email: string; + name: string | null; + isValid: boolean; + errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null; +} + +export interface InviteCompleteRequest { + token: string; + name: string; + password: string; +} diff --git a/frontend/src/api/useInvitation.ts b/frontend/src/api/useInvitation.ts new file mode 100644 index 0000000..e40df20 --- /dev/null +++ b/frontend/src/api/useInvitation.ts @@ -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({ 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(`/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; + reset: () => void; +} + +export function useCompleteSetup(): UseCompleteSetup { + const [state, setState] = useState({ isLoading: false, error: null }); + + const mutate = useCallback(async (data: InviteCompleteRequest): Promise => { + 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 }; +} diff --git a/frontend/src/api/useSetup.ts b/frontend/src/api/useSetup.ts new file mode 100644 index 0000000..144c62f --- /dev/null +++ b/frontend/src/api/useSetup.ts @@ -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 | null = null; + +export function fetchSetupStatus(): Promise { + if (_setupStatusCache !== null) return Promise.resolve(_setupStatusCache); + if (_setupStatusPromise === null) { + _setupStatusPromise = api + .get('/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(() => + _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; + reset: () => void; +} + +export function useCreateOwner(): UseCreateOwner { + const [state, setState] = useState({ isLoading: false, error: null }); + + const mutate = useCallback(async (data: SetupRequest): Promise => { + 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 }; +} diff --git a/frontend/src/components/auth/RoleGuard.tsx b/frontend/src/components/auth/RoleGuard.tsx new file mode 100644 index 0000000..72b32c7 --- /dev/null +++ b/frontend/src/components/auth/RoleGuard.tsx @@ -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 ( +
+

{t('errors.accessDenied')}

+

+ {t('errors.accessDeniedDetail', { roles: requiredLabel })} +

+ + {t('nav.dashboard')} + +
+ ); +} diff --git a/frontend/src/components/ui/FormBannerError.tsx b/frontend/src/components/ui/FormBannerError.tsx new file mode 100644 index 0000000..54c3229 --- /dev/null +++ b/frontend/src/components/ui/FormBannerError.tsx @@ -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 ( +
+ {error.message} + +
+ ); +} diff --git a/frontend/src/components/ui/PasswordField.tsx b/frontend/src/components/ui/PasswordField.tsx new file mode 100644 index 0000000..641eebc --- /dev/null +++ b/frontend/src/components/ui/PasswordField.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index bb81da9..d3025c9 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -9,7 +9,9 @@ "nav": { "dashboard": "Dashboard", "users": "Users", - "cms": "CMS" + "cms": "CMS", + "profile": "Profile", + "settings": "Settings" }, "login": { "title": "Sign in", @@ -27,6 +29,49 @@ "generic": "Something went wrong. Please try again." } }, + "setup": { + "title": "System Setup", + "subtitle": "Create the first Owner account to get started.", + "fields": { + "name": "Full name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "locale": "Language" + }, + "localeOptions": { + "en": "English", + "nl": "Dutch" + }, + "submit": "Create account", + "submitting": "Creating account…", + "success": "Account created. You can now sign in.", + "errors": { + "alreadyInitialized": "This system has already been set up.", + "generic": "Something went wrong. Please try again." + } + }, + "inviteComplete": { + "title": "Complete your account", + "subtitle": "You have been invited to {{appName}}.", + "loading": "Validating your invitation…", + "fields": { + "email": "Email", + "name": "Full name", + "password": "Password", + "confirmPassword": "Confirm password" + }, + "submit": "Complete setup", + "submitting": "Completing setup…", + "success": "Account setup complete. You can now sign in.", + "errors": { + "expired": "This invitation link has expired. Please request a new one.", + "used": "This invitation link has already been used.", + "notFound": "This invitation link is not valid.", + "noToken": "Invalid invitation link.", + "generic": "Something went wrong. Please try again." + } + }, "dashboard": { "title": "Dashboard", "welcome": "Welcome back, {{name}}", @@ -37,6 +82,9 @@ "logout": "Sign out" }, "errors": { - "network": "Unable to reach the server. Check your connection and try again." + "network": "Unable to reach the server. Check your connection and try again.", + "accessDenied": "You do not have permission to view this page.", + "accessDeniedDetail": "This section requires the {{roles}} role.", + "sessionExpired": "Your session has expired. Please sign in again." } } diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json index 9dfeb46..02473aa 100644 --- a/frontend/src/i18n/locales/nl/translation.json +++ b/frontend/src/i18n/locales/nl/translation.json @@ -9,7 +9,9 @@ "nav": { "dashboard": "Dashboard", "users": "Gebruikers", - "cms": "CMS" + "cms": "CMS", + "profile": "Profiel", + "settings": "Instellingen" }, "login": { "title": "Inloggen", @@ -27,6 +29,49 @@ "generic": "Er is iets misgegaan. Probeer het opnieuw." } }, + "setup": { + "title": "Systeeminstallatie", + "subtitle": "Maak het eerste Owner-account aan om te beginnen.", + "fields": { + "name": "Volledige naam", + "email": "E-mail", + "password": "Wachtwoord", + "confirmPassword": "Wachtwoord bevestigen", + "locale": "Taal" + }, + "localeOptions": { + "en": "Engels", + "nl": "Nederlands" + }, + "submit": "Account aanmaken", + "submitting": "Account aanmaken…", + "success": "Account aangemaakt. Je kunt nu inloggen.", + "errors": { + "alreadyInitialized": "Dit systeem is al ingesteld.", + "generic": "Er is iets misgegaan. Probeer het opnieuw." + } + }, + "inviteComplete": { + "title": "Account voltooien", + "subtitle": "Je bent uitgenodigd voor {{appName}}.", + "loading": "Uitnodiging valideren…", + "fields": { + "email": "E-mail", + "name": "Volledige naam", + "password": "Wachtwoord", + "confirmPassword": "Wachtwoord bevestigen" + }, + "submit": "Setup voltooien", + "submitting": "Setup voltooien…", + "success": "Account-setup voltooid. Je kunt nu inloggen.", + "errors": { + "expired": "Deze uitnodigingslink is verlopen. Vraag een nieuwe aan.", + "used": "Deze uitnodigingslink is al gebruikt.", + "notFound": "Deze uitnodigingslink is niet geldig.", + "noToken": "Ongeldige uitnodigingslink.", + "generic": "Er is iets misgegaan. Probeer het opnieuw." + } + }, "dashboard": { "title": "Dashboard", "welcome": "Welkom terug, {{name}}", @@ -37,6 +82,9 @@ "logout": "Uitloggen" }, "errors": { - "network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw." + "network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.", + "accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.", + "accessDeniedDetail": "Dit gedeelte vereist de rol {{roles}}.", + "sessionExpired": "Je sessie is verlopen. Meld je opnieuw aan." } } diff --git a/frontend/src/lib/schemas/auth.ts b/frontend/src/lib/schemas/auth.ts new file mode 100644 index 0000000..8de77ad --- /dev/null +++ b/frontend/src/lib/schemas/auth.ts @@ -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; +export type SetupFormValues = z.infer; +export type InviteCompleteFormValues = z.infer; diff --git a/frontend/src/mocks/index.ts b/frontend/src/mocks/index.ts index 4d729c8..195f1e7 100644 --- a/frontend/src/mocks/index.ts +++ b/frontend/src/mocks/index.ts @@ -1,11 +1,13 @@ import { authHandlers } from './auth/handlers'; import { userHandlers } from './users/handlers'; import { setupHandlers } from './setup/handlers'; +import { invitationHandlers } from './invitation/handlers'; /** All default MSW handlers, composed from feature folders (Q3-B). */ -export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers]; +export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers]; export { authHandlers } from './auth/handlers'; export { userHandlers } from './users/handlers'; -export { setupHandlers } from './setup/handlers'; +export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers } from './setup/handlers'; +export { invitationHandlers } from './invitation/handlers'; export * from './auth/fixtures'; diff --git a/frontend/src/mocks/invitation/handlers.ts b/frontend/src/mocks/invitation/handlers.ts new file mode 100644 index 0000000..8f44fce --- /dev/null +++ b/frontend/src/mocks/invitation/handlers.ts @@ -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({ + isValid: true, + email: 'invited@example.com', + name: null, + errorCode: null, + }); + } + if (token === 'used-token') { + return HttpResponse.json({ + isValid: false, + email: '', + name: null, + errorCode: 'USED', + }); + } + // expired-token or any unknown token + return HttpResponse.json({ + isValid: false, + email: '', + name: null, + errorCode: 'EXPIRED', + }); + }), + + http.post(`${API_BASE}/Invitation/complete`, () => new HttpResponse(null, { status: 200 })), +]; diff --git a/frontend/src/mocks/setup/handlers.ts b/frontend/src/mocks/setup/handlers.ts index aa29764..a3dc92f 100644 --- a/frontend/src/mocks/setup/handlers.ts +++ b/frontend/src/mocks/setup/handlers.ts @@ -7,4 +7,24 @@ export const setupHandlers = [ http.get(`${API_BASE}/Setup/status`, () => HttpResponse.json({ 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({ 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 }, + ), + ), ]; diff --git a/frontend/src/pages/InviteCompletePage.test.tsx b/frontend/src/pages/InviteCompletePage.test.tsx new file mode 100644 index 0000000..12b9600 --- /dev/null +++ b/frontend/src/pages/InviteCompletePage.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/pages/InviteCompletePage.tsx b/frontend/src/pages/InviteCompletePage.tsx new file mode 100644 index 0000000..cdc1636 --- /dev/null +++ b/frontend/src/pages/InviteCompletePage.tsx @@ -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({ + 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 ( +
+ + {t('inviteComplete.loading')} +
+ ); + } + + // --- 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 ( +
+

{message}

+ + {t('login.title')} + +
+ ); + } + + // --- Success state --- + if (success) { + return ( +
+

+ {t('inviteComplete.success')} +

+
+ ); + } + + // --- Form state --- + return ( +
+ + + {t('inviteComplete.title')} + {t('inviteComplete.subtitle', { appName: t('common.appName') })} + + +
+ setBannerError(null)} /> + +
+ + +
+ +
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index c5a69c4..dc746cf 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,15 +1,15 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useAuth } from '@/contexts/auth-context'; import { NetworkError, ProblemDetailsError } from '@/lib/api-client'; +import { loginSchema, type LoginFormValues } from '@/lib/schemas/auth'; export function LoginPage() { const { t } = useTranslation(); @@ -18,26 +18,13 @@ export function LoginPage() { const search = useSearch({ strict: false }) as { redirect?: string }; const [serverError, setServerError] = useState(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; - const { register, handleSubmit, formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), + } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onTouched', defaultValues: { email: '', password: '' }, }); @@ -45,7 +32,7 @@ export function LoginPage() { setServerError(null); try { await login(values.email, values.password); - await navigate({ to: search.redirect ?? '/dashboard' }); + await navigate({ to: (search.redirect as string | undefined) ?? '/dashboard' }); } catch (err) { if (err instanceof ProblemDetailsError && err.status === 401) { setServerError(t('login.errors.invalidCredentials')); diff --git a/frontend/src/pages/SetupPage.test.tsx b/frontend/src/pages/SetupPage.test.tsx new file mode 100644 index 0000000..a8c9371 --- /dev/null +++ b/frontend/src/pages/SetupPage.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 15709ac..c5394b1 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -1,17 +1,196 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import i18n from 'i18next'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { PasswordField } from '@/components/ui/PasswordField'; +import { FormBannerError } from '@/components/ui/FormBannerError'; +import { useCreateOwner } from '@/api/useSetup'; +import { NetworkError, ProblemDetailsError } from '@/lib/api-client'; +import { setupSchema, type SetupFormValues } from '@/lib/schemas/auth'; + +const SUPPORTED_LOCALES = ['en', 'nl'] as const; +type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +function getDefaultLocale(): SupportedLocale { + const lang = navigator.language.split('-')[0]; + return SUPPORTED_LOCALES.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'en'; +} -/** Public setup placeholder (initial owner creation / invitation completion). */ export function SetupPage() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mutate, isLoading } = useCreateOwner(); + + const [bannerError, setBannerError] = useState<{ message: string } | null>(null); + const [success, setSuccess] = useState(false); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + 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 (
- {t('common.appName')} β€” Setup + {t('setup.title')} + {t('setup.subtitle')} -

Coming soon.

+ {success ? ( +

+ {t('setup.success')} +

+ ) : ( +
+ setBannerError(null)} + /> + +
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ +
+ + +
+ + + + )}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 4b6e5fa..bb683c7 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -7,13 +7,47 @@ import { redirect, } from '@tanstack/react-router'; import type { AuthContextValue } from '@/contexts/auth-context'; +import type { SetupStatus } from '@/api/types'; +import { api } from '@/lib/api-client'; import { AppLayout } from '@/components/layout/AppLayout'; import { LoginPage } from '@/pages/LoginPage'; +import { RoleGuard } from '@/components/auth/RoleGuard'; export interface RouterContext { auth: AuthContextValue; } +// --------------------------------------------------------------------------- +// InitGuard: module-level session cache for setup status (BR-U2-08). +// One network request per page load; subsequent beforeLoad calls return cached +// data synchronously via Promise.resolve(). +// --------------------------------------------------------------------------- +let _setupStatusCache: SetupStatus | null = null; +let _setupStatusPromise: Promise | null = null; + +async function fetchSetupStatus(): Promise { + if (_setupStatusCache !== null) return _setupStatusCache; + if (_setupStatusPromise === null) { + _setupStatusPromise = api + .get('/Setup/status') + .then((s) => { + _setupStatusCache = s; + return s; + }) + .catch((err: unknown) => { + _setupStatusPromise = null; + throw err; + }); + } + return _setupStatusPromise; +} + +// Exposed for tests so each test starts with a clean cache. +export function _resetSetupStatusCache(): void { + _setupStatusCache = null; + _setupStatusPromise = null; +} + function RouteFallback() { return (
@@ -22,10 +56,14 @@ function RouteFallback() { ); } -/** - * Wrap a per-feature page in a lazy boundary so Vite emits a separate chunk - * (NFR-U1-01 / Q1-A). The auth/root shell and login stay eager for fast paint. - */ +function BootstrapSplash() { + return ( +
+ +
+ ); +} + function lazyPage

>( factory: () => Promise<{ [key: string]: ComponentType

}>, exportName: string, @@ -40,11 +78,33 @@ function lazyPage

>( }; } +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + const rootRoute = createRootRouteWithContext()({ + 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: () => , }); -// '/' redirects into the protected area; the guard sends guests to /login. const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', @@ -59,7 +119,6 @@ const loginRoute = createRoute({ validateSearch: (search: Record): { redirect?: string } => ({ redirect: typeof search.redirect === 'string' ? search.redirect : undefined, }), - // Authenticated users never see /login (BR-U1-06). beforeLoad: ({ context }) => { if (context.auth.isAuthenticated) { throw redirect({ to: '/dashboard' }); @@ -74,7 +133,15 @@ const setupRoute = createRoute({ component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'), }); -// Layout route guarding every protected page (BR-U1-05). +const inviteCompleteRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invite/complete', + validateSearch: (search: Record): { token?: string } => ({ + token: typeof search.token === 'string' ? search.token : undefined, + }), + component: lazyPage(() => import('@/pages/InviteCompletePage'), 'InviteCompletePage'), +}); + const authenticatedRoute = createRoute({ getParentRoute: () => rootRoute, id: '_authenticated', @@ -95,26 +162,34 @@ const dashboardRoute = createRoute({ const usersRoute = createRoute({ getParentRoute: () => authenticatedRoute, path: '/users', - component: lazyPage(() => import('@/pages/UsersPage'), 'UsersPage'), + component: () => ( + + {lazyPage(() => import('@/pages/UsersPage'), 'UsersPage')()} + + ), }); const cmsRoute = createRoute({ getParentRoute: () => authenticatedRoute, path: '/cms', - component: lazyPage(() => import('@/pages/CmsPage'), 'CmsPage'), + component: () => ( + + {lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()} + + ), }); export const routeTree = rootRoute.addChildren([ indexRoute, loginRoute, setupRoute, + inviteCompleteRoute, authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute]), ]); export const router = createRouter({ routeTree, defaultPreload: 'intent', - // Real auth is injected per render via RouterProvider's `context` prop. context: { auth: undefined as unknown as AuthContextValue }, }); diff --git a/frontend/src/test/RouteGuard.test.tsx b/frontend/src/test/RouteGuard.test.tsx index 615d149..867dc2f 100644 --- a/frontend/src/test/RouteGuard.test.tsx +++ b/frontend/src/test/RouteGuard.test.tsx @@ -1,8 +1,16 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { screen } from '@testing-library/react'; -import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils'; +import { renderApp, renderWithProviders, mockAuthenticated, mockGuest } from '@/test/utils'; +import { server } from '@/mocks/server'; +import { setupUninitializedHandlers } from '@/mocks/index'; +import { _resetSetupStatusCache } from '@/router'; +import { RoleGuard } from '@/components/auth/RoleGuard'; -describe('Route guards (BR-U1-05, BR-U1-06)', () => { +beforeEach(() => { + _resetSetupStatusCache(); +}); + +describe('ProtectedRoute (BR-U1-05, BR-U1-06)', () => { it('redirects an unauthenticated user from a protected route to /login', async () => { mockGuest(); renderApp('/dashboard'); @@ -26,3 +34,54 @@ describe('Route guards (BR-U1-05, BR-U1-06)', () => { expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument(); }); }); + +describe('InitGuard (BR-U2-09, BR-U2-10)', () => { + it('redirects any route to /setup when system is not initialized', async () => { + server.use(...setupUninitializedHandlers); + mockGuest(); + renderApp('/login'); + + // InitGuard should redirect /login β†’ /setup when not initialized. + expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument(); + expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument(); + }); + + it('allows /setup to render when system is not initialized', async () => { + server.use(...setupUninitializedHandlers); + mockGuest(); + renderApp('/setup'); + + expect(await screen.findByTestId('setup-submit-button')).toBeInTheDocument(); + }); + + it('redirects /setup to /login when system is already initialized', async () => { + // Default handlers return { initialized: true }. + mockGuest(); + renderApp('/setup'); + + expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); + expect(screen.queryByTestId('setup-submit-button')).not.toBeInTheDocument(); + }); +}); + +describe('RoleGuard (BR-U2-14–BR-U2-20)', () => { + it('renders children when the user has a permitted role', () => { + const { getByText } = renderWithProviders( + +

Protected content

+ , + ); + // 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( + +

Should not appear

+
, + ); + expect(getByTestId('access-denied-message')).toBeInTheDocument(); + }); +});