# 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 |