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

16 KiB
Raw Blame History

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

  • Add SetupRequest interface: { name: string; email: string; password: string; }
  • Add InvitationValidation interface: { email: string; name: string | null; isValid: boolean; errorCode: 'EXPIRED' | 'USED' | 'NOT_FOUND' | null; }
  • Add InviteCompleteRequest interface: { 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 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

  • 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

  • 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

  • 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

  • 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)

  • 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

  • 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

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

  • Add setup section:
    "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:
    "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 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

  • 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

  • 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

  • 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

  • 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

  • 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: 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

  • 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