16 KiB
16 KiB
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/useEffectwith a module-level session cache - InitGuard strategy:
async beforeLoadon rootRoute + module-levelfetchSetupStatus()singleton (fetches once, caches for the session); uses TanStack Router'spendingComponentfor the loading state - RoleGuard strategy: React wrapper component rendering inline "Access Denied" (no redirect, no /403 route); applied to route
componentwrappers in router.tsx - UserRole note: Backend uses
'Administrator'not'Admin'(confirmed fromsrc/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(notsrc/routes/) - Pages in
src/pages/(notsrc/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
- Add
SetupRequestinterface:{ name: string; email: string; password: string; } - Add
InvitationValidationinterface:{ email: string; name: string | null; isValid: boolean; errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null; } - Add
InviteCompleteRequestinterface:{ token: string; name: string; password: string; }
Step 2 — Create src/lib/schemas/auth.ts
- Create file with shared Zod schemas:
passwordSchema—.min(8)+ 4 regex rules (uppercase, lowercase, digit, non-alphanumeric) matching backendIdentityOptions(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);localeisz.enum(['en', 'nl'])inviteCompleteSchema—{ name, password, confirmPassword }with password match refine
Step 3 — Create src/components/ui/PasswordField.tsx
- Wrapper around shadcn
Inputwith internalshowPassword: booleanstate - Show/hide toggle button using lucide-react
Eye/EyeOfficons - Props:
id: string,placeholder?: string,autoComplete: string,...register(spread from react-hook-form) data-testidon input:{id}-input; on toggle:{id}-togglearia-labelon toggle button for accessibility
Step 4 — Create src/components/ui/FormBannerError.tsx
- Props:
error: { message: string } | null,onDismiss: () => void - Returns
nullwhenerrorisnull - Uses shadcn Card or a
divwithrole="alert",data-testid="form-error-banner", destructive color classes - Dismiss
×button callsonDismiss
Step 5 — Create src/components/auth/RoleGuard.tsx
- Props:
allowedRoles: UserRole[],children: ReactNode - Reads
userfromuseAuth() - If
user.roleis inallowedRoles→ renderchildren - 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
- Module-level cache:
let _cachedStatus: SetupStatus | null = null; useSetupStatus()hook:- State:
{ status: SetupStatus | null; isLoading: boolean; error: Error | null } - Fetches
GET /Setup/statuson first call; subsequent calls return_cachedStatussynchronously
- State:
useCreateOwner()hook — returns{ mutate, isLoading, error }:mutate(data: SetupRequest): Promise<void>callsPOST /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)
useValidateInvitation(token: string | undefined)hook:- Fetches
GET /Invitation/validate?token={token}when token is defined - State:
{ data: InvitationValidation | null; isLoading: boolean; error: Error | null }
- Fetches
useCompleteSetup()hook — returns{ mutate, isLoading, error }:mutate(data: InviteCompleteRequest): Promise<void>callsPOST /Invitation/complete- Throws on error
Step 8 — Extend src/mocks/setup/handlers.ts
- Keep existing
GET /Setup/statushandler (returns{ initialized: true }) - Add
POST /Setup— success scenario: returns201 Createdwith empty body - Add
POST /Setup— already-initialized scenario: export assetupAlreadyInitializedHandlers(or a named variant) returning409 Conflictwith ProblemDetails
Step 9 — Create src/mocks/invitation/handlers.ts
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
- Import
invitationHandlersfrom./invitation/handlers - Add to the combined
handlersarray - Add re-export line for
invitationHandlers
Step 11 — Update src/i18n/locales/en/translation.json
- Add
setupsection:"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
inviteCompletesection:"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
- 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
- Add module-level setup status cache and fetch function above
rootRoute: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
rootRoutewithasync beforeLoad(InitGuard logic) andpendingComponent:- 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)
- On fetch success: if
- Add
/invite/completeroute (public, under rootRoute):path: '/invite/complete'validateSearch: extracttoken?: stringcomponent: lazyInviteCompletePage
- Wrap
usersRoutecomponent withRoleGuard(allowedRoles:['Owner', 'Administrator']) - Wrap
cmsRoutecomponent withRoleGuard(allowedRoles:['Owner']) - Add
inviteCompleteRoutetorouteTree(alongsidesetupRouteandloginRoute) - Import
RoleGuardfrom@/components/auth/RoleGuard - Import
apiClientfrom@/lib/api-client
Step 14 — Update src/pages/LoginPage.tsx
- Remove inline
schemadefinition; importloginSchemafrom@/lib/schemas/auth - Change
useFormmode tomode: '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
- Full implementation replacing the "Coming soon" stub
- Import
setupSchemafrom@/lib/schemas/auth - Import
PasswordFieldandFormBannerError - Import
useCreateOwnerfrom@/api/useSetup - Import
useTranslationandi18nfor locale live-switch - Form fields: name, email, password (PasswordField), confirmPassword (PasswordField), locale (select)
- Locale
selectdefaults toi18n.languageif supported ('en'|'nl'), falls back to'en' onChangeon locale select callsi18n.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, thennavigate({ to: '/login', replace: true })after 1500ms - On error: set banner error with API message or network fallback
data-testidattributes:setup-name-input,setup-email-input,setup-locale-select,setup-submit-button,setup-success,setup-error-bannerautoComplete="off"on name;"email"on email;"new-password"on password fields
Step 16 — Create src/pages/InviteCompletePage.tsx
- Import
inviteCompleteSchemafrom@/lib/schemas/auth - Import
PasswordFieldandFormBannerError - Import
useValidateInvitation,useCompleteSetupfrom@/api/useInvitation - Extract
tokenfrom search params usinguseSearch({ strict: false }) - Call
useValidateInvitation(token)on mount; show loading spinner whileisLoading - If
!tokenordata.isValid === false: show error state withdata.errorCodemapped to i18n key + link to/login - If
data.isValid === true: render form withemailpre-filled and read-only namefield pre-filled fromdata.nameif not null- Form mode:
onTouched - On submit: call
useCompleteSetup().mutate(...), handle success/error - On success: show
t('inviteComplete.success'), navigate to/loginafter 1500ms data-testidattributes:invite-loading,invite-error,invite-name-input,invite-email-input,invite-submit-button,invite-success
Step 17 — Create src/pages/SetupPage.test.tsx
- Render SetupPage with MSW
setupHandlers+POST /Setupsuccess 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
- 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
- Add test: when
initialized: falseMSW returnsGET /Setup/statuswith{ initialized: false }→ navigating to/loginor/dashboardredirects to/setup - Add test: when
initialized: trueand visiting/setup→ redirects to/login - Add test:
RoleGuardwithallowedRoles: ['Owner']renders children whenuser.role === 'Owner' - Add test:
RoleGuardrenders inline "Access Denied" whenuser.role === 'User'
Step 20 — Create code generation summary
- 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 |