Files
slp-modular-cms/aidlc-docs/features/cms-frontend/construction/plans/unit-2-code-generation-plan.md
T
2026-06-21 00:15:28 +02:00

321 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-0105)
- `loginSchema``{ email: z.string().email(), password: z.string().min(1) }` (extracted from LoginPage — see Step 14)
- `setupSchema``{ name, email, password, confirmPassword, locale }` with `.superRefine()` for password match (BR-U2-06); `locale` is `z.enum(['en', 'nl'])`
- `inviteCompleteSchema``{ name, password, confirmPassword }` with password match refine
---
### Step 3 — Create `src/components/ui/PasswordField.tsx`
- [x] Wrapper around shadcn `Input` with internal `showPassword: boolean` state
- [ ] Show/hide toggle button using lucide-react `Eye`/`EyeOff` icons
- [ ] Props: `id: string`, `placeholder?: string`, `autoComplete: string`, `...register` (spread from react-hook-form)
- [ ] `data-testid` on input: `{id}-input`; on toggle: `{id}-toggle`
- [ ] `aria-label` on toggle button for accessibility
---
### Step 4 — Create `src/components/ui/FormBannerError.tsx`
- [x] Props: `error: { message: string } | null`, `onDismiss: () => void`
- [ ] Returns `null` when `error` is `null`
- [ ] Uses shadcn Card or a `div` with `role="alert"`, `data-testid="form-error-banner"`, destructive color classes
- [ ] Dismiss `×` button calls `onDismiss`
---
### Step 5 — Create `src/components/auth/RoleGuard.tsx`
- [x] Props: `allowedRoles: UserRole[]`, `children: ReactNode`
- [ ] Reads `user` from `useAuth()`
- [ ] If `user.role` is in `allowedRoles` → render `children`
- [ ] Otherwise → render inline `AccessDeniedMessage` (heading + body naming the required roles + back-to-dashboard link)
- [ ] `data-testid="access-denied-message"` on the fallback element
---
### Step 6 — Create `src/api/useSetup.ts`
- [x] Module-level cache: `let _cachedStatus: SetupStatus | null = null;`
- [ ] `useSetupStatus()` hook:
- State: `{ status: SetupStatus | null; isLoading: boolean; error: Error | null }`
- Fetches `GET /Setup/status` on first call; subsequent calls return `_cachedStatus` synchronously
- [ ] `useCreateOwner()` hook — returns `{ mutate, isLoading, error }`:
- `mutate(data: SetupRequest): Promise<void>` calls `POST /Setup`
- On success: sets `_cachedStatus = { initialized: true }` (updates session cache)
- Throws on API/network error (caller handles display)
---
### Step 7 — Create `src/api/useInvitation.ts` (stub for Unit 2)
- [x] `useValidateInvitation(token: string | undefined)` hook:
- Fetches `GET /Invitation/validate?token={token}` when token is defined
- State: `{ data: InvitationValidation | null; isLoading: boolean; error: Error | null }`
- [ ] `useCompleteSetup()` hook — returns `{ mutate, isLoading, error }`:
- `mutate(data: InviteCompleteRequest): Promise<void>` calls `POST /Invitation/complete`
- Throws on error
---
### Step 8 — Extend `src/mocks/setup/handlers.ts`
- [x] Keep existing `GET /Setup/status` handler (returns `{ initialized: true }`)
- [ ] Add `POST /Setup` — success scenario: returns `201 Created` with empty body
- [ ] Add `POST /Setup` — already-initialized scenario: export as `setupAlreadyInitializedHandlers` (or a named variant) returning `409 Conflict` with ProblemDetails
---
### Step 9 — Create `src/mocks/invitation/handlers.ts`
- [x] `GET /Invitation/validate?token=valid-token``{ isValid: true, email: "invited@example.com", name: null }`
- [ ] `GET /Invitation/validate?token=expired-token``{ isValid: false, errorCode: "EXPIRED", email: null, name: null }`
- [ ] `GET /Invitation/validate?token=used-token``{ isValid: false, errorCode: "USED", email: null, name: null }`
- [ ] `POST /Invitation/complete` — success: `200 OK`
- [ ] Export as `invitationHandlers`
---
### Step 10 — Update `src/mocks/index.ts`
- [x] Import `invitationHandlers` from `./invitation/handlers`
- [ ] Add to the combined `handlers` array
- [ ] Add re-export line for `invitationHandlers`
---
### Step 11 — Update `src/i18n/locales/en/translation.json`
- [x] Add `setup` section:
```json
"setup": {
"title": "System Setup",
"subtitle": "Create the first Owner account to get started.",
"fields": {
"name": "Full name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"locale": "Language"
},
"localeOptions": { "en": "English", "nl": "Dutch" },
"submit": "Create account",
"submitting": "Creating account…",
"success": "Account created. You can now sign in.",
"errors": {
"alreadyInitialized": "This system has already been set up.",
"generic": "Something went wrong. Please try again."
}
}
```
- [ ] Add `inviteComplete` section:
```json
"inviteComplete": {
"title": "Complete your account",
"subtitle": "You have been invited to {{appName}}.",
"loading": "Validating your invitation…",
"fields": {
"email": "Email",
"name": "Full name",
"password": "Password",
"confirmPassword": "Confirm password"
},
"submit": "Complete setup",
"submitting": "Completing setup…",
"success": "Account setup complete. You can now sign in.",
"errors": {
"expired": "This invitation link has expired. Please request a new one.",
"used": "This invitation link has already been used.",
"notFound": "This invitation link is not valid.",
"noToken": "Invalid invitation link.",
"generic": "Something went wrong. Please try again."
}
}
```
- [ ] Add to `nav`: `"profile": "Profile"`, `"settings": "Settings"`
- [ ] Add to `errors`: `"accessDenied": "You do not have permission to view this page."`, `"sessionExpired": "Your session has expired. Please sign in again."`
---
### Step 12 — Update `src/i18n/locales/nl/translation.json`
- [x] Mirror all keys from Step 11 with Dutch translations:
- `setup.title`: "Systeeminstallatie"
- `setup.subtitle`: "Maak het eerste Owner-account aan om te beginnen."
- `setup.fields.name`: "Volledige naam" / `email`: "E-mail" / `password`: "Wachtwoord" / `confirmPassword`: "Wachtwoord bevestigen" / `locale`: "Taal"
- `setup.localeOptions`: `{ "en": "Engels", "nl": "Nederlands" }`
- `setup.submit`: "Account aanmaken" / `submitting`: "Account aanmaken…" / `success`: "Account aangemaakt. Je kunt nu inloggen."
- `inviteComplete.title`: "Account voltooien" / `subtitle`: "Je bent uitgenodigd voor {{appName}}." / `loading`: "Uitnodiging valideren…"
- `inviteComplete.submit`: "Setup voltooien" / `success`: "Account-setup voltooid. Je kunt nu inloggen."
- All error messages in Dutch
- `nav.profile`: "Profiel" / `nav.settings`: "Instellingen"
- `errors.accessDenied`: "Je hebt geen toestemming om deze pagina te bekijken."
- `errors.sessionExpired`: "Je sessie is verlopen. Meld je opnieuw aan."
---
### Step 13 — Update `src/router.tsx`
- [x] Add module-level setup status cache and fetch function above `rootRoute`:
```ts
let _setupStatusPromise: Promise<SetupStatus> | null = null;
let _setupStatusCache: SetupStatus | null = null;
async function fetchSetupStatus(): Promise<SetupStatus> {
if (_setupStatusCache !== null) return _setupStatusCache;
if (_setupStatusPromise === null) {
_setupStatusPromise = apiClient
.get<SetupStatus>('/Setup/status')
.then((s) => { _setupStatusCache = s; return s; });
}
return _setupStatusPromise;
}
```
- [ ] Update `rootRoute` with `async beforeLoad` (InitGuard logic) and `pendingComponent`:
- On fetch success: if `!initialized && pathname !== '/setup'` → `throw redirect({ to: '/setup' })`
- On fetch success: if `initialized && pathname === '/setup'` → `throw redirect({ to: '/login' })`
- On fetch error: log warning, allow navigation (API calls will also fail)
- `pendingComponent: BootstrapSplash` (reuse existing component from main.tsx — import or duplicate inline)
- `pendingMs: 0` (show spinner immediately)
- [ ] Add `/invite/complete` route (public, under rootRoute):
- `path: '/invite/complete'`
- `validateSearch`: extract `token?: string`
- `component`: lazy `InviteCompletePage`
- [ ] Wrap `usersRoute` component with `RoleGuard` (allowedRoles: `['Owner', 'Administrator']`)
- [ ] Wrap `cmsRoute` component with `RoleGuard` (allowedRoles: `['Owner']`)
- [ ] Add `inviteCompleteRoute` to `routeTree` (alongside `setupRoute` and `loginRoute`)
- [ ] Import `RoleGuard` from `@/components/auth/RoleGuard`
- [ ] Import `apiClient` from `@/lib/api-client`
---
### Step 14 — Update `src/pages/LoginPage.tsx`
- [x] Remove inline `schema` definition; import `loginSchema` from `@/lib/schemas/auth`
- [ ] Change `useForm` mode to `mode: 'onTouched'` (inline validation after first blur; re-validates on change)
- [ ] Keep `reValidateMode: 'onChange'` (default) so errors clear as user types after the first blur
- [ ] No other structural changes; existing banner, submit button, and testids stay
---
### Step 15 — Replace `src/pages/SetupPage.tsx`
- [x] Full implementation replacing the "Coming soon" stub
- [ ] Import `setupSchema` from `@/lib/schemas/auth`
- [ ] Import `PasswordField` and `FormBannerError`
- [ ] Import `useCreateOwner` from `@/api/useSetup`
- [ ] Import `useTranslation` and `i18n` for locale live-switch
- [ ] Form fields: name, email, password (PasswordField), confirmPassword (PasswordField), locale (select)
- [ ] Locale `select` defaults to `i18n.language` if supported (`'en'` | `'nl'`), falls back to `'en'`
- [ ] `onChange` on locale select calls `i18n.changeLanguage(value)` immediately (BR-U2-23)
- [ ] Form mode: `onTouched` (BR-U2-32/33)
- [ ] On submit: call `useCreateOwner().mutate(...)`, handle success/error
- [ ] On success: show `t('setup.success')` in a success alert, then `navigate({ to: '/login', replace: true })` after 1500ms
- [ ] On error: set banner error with API message or network fallback
- [ ] `data-testid` attributes: `setup-name-input`, `setup-email-input`, `setup-locale-select`, `setup-submit-button`, `setup-success`, `setup-error-banner`
- [ ] `autoComplete="off"` on name; `"email"` on email; `"new-password"` on password fields
---
### Step 16 — Create `src/pages/InviteCompletePage.tsx`
- [x] Import `inviteCompleteSchema` from `@/lib/schemas/auth`
- [ ] Import `PasswordField` and `FormBannerError`
- [ ] Import `useValidateInvitation`, `useCompleteSetup` from `@/api/useInvitation`
- [ ] Extract `token` from search params using `useSearch({ strict: false })`
- [ ] Call `useValidateInvitation(token)` on mount; show loading spinner while `isLoading`
- [ ] If `!token` or `data.isValid === false`: show error state with `data.errorCode` mapped to i18n key + link to `/login`
- [ ] If `data.isValid === true`: render form with `email` pre-filled and read-only
- [ ] `name` field pre-filled from `data.name` if not null
- [ ] Form mode: `onTouched`
- [ ] On submit: call `useCompleteSetup().mutate(...)`, handle success/error
- [ ] On success: show `t('inviteComplete.success')`, navigate to `/login` after 1500ms
- [ ] `data-testid` attributes: `invite-loading`, `invite-error`, `invite-name-input`, `invite-email-input`, `invite-submit-button`, `invite-success`
---
### Step 17 — Create `src/pages/SetupPage.test.tsx`
- [x] Render SetupPage with MSW `setupHandlers` + `POST /Setup` success handler
- [ ] Test: all 5 fields render
- [ ] Test: submit with empty fields shows inline errors
- [ ] Test: weak password shows inline error
- [ ] Test: mismatched confirm password shows inline error
- [ ] Test: valid submission shows success message
- [ ] Test: 409 response shows "already initialized" banner
- [ ] Test: locale change updates visible text language
---
### Step 18 — Create `src/pages/InviteCompletePage.test.tsx`
- [x] Render InviteCompletePage with MSW `invitationHandlers`
- [ ] Test: loading state shown on mount
- [ ] Test: invalid token (`?token=expired-token`) shows error state, no form
- [ ] Test: missing token shows error state immediately
- [ ] Test: valid token (`?token=valid-token`) shows form with email read-only
- [ ] Test: successful submission shows success message
---
### Step 19 — Update `src/test/RouteGuard.test.tsx`
- [x] Add test: when `initialized: false` MSW returns `GET /Setup/status` with `{ initialized: false }` → navigating to `/login` or `/dashboard` redirects to `/setup`
- [ ] Add test: when `initialized: true` and visiting `/setup` → redirects to `/login`
- [ ] Add test: `RoleGuard` with `allowedRoles: ['Owner']` renders children when `user.role === 'Owner'`
- [ ] Add test: `RoleGuard` renders inline "Access Denied" when `user.role === 'User'`
---
### Step 20 — Create code generation summary
- [x] Create `aidlc-docs/features/cms-frontend/construction/unit-2/code/code-generation-summary.md`
- [ ] List all created and modified files
- [ ] Document any deviations from the plan
- [ ] Record story coverage
---
## Deviations from Functional Design (pre-noted)
| Design spec | Actual implementation | Reason |
|---|---|---|
| `useSetupStatus()` with `staleTime: Infinity` (TanStack Query style) | Module-level cache + `useState`/`useEffect` | TanStack Query not installed; equivalent session-cache behavior |
| `UserRole = 'Owner' \| 'Admin' \| 'User'` | `UserRole = 'Owner' \| 'Administrator' \| 'User'` | Matches existing `src/api/types.ts` and backend |
| InitGuard as component with `useEffect` | `async beforeLoad` on rootRoute | More idiomatic for TanStack Router; avoids render flash |