Adds unit 2 functional design and code generation plan and gap 4 report
This commit is contained in:
+228
-305
@@ -1,320 +1,243 @@
|
||||
# Code Generation Plan — Unit 2: Authentication Pages
|
||||
|
||||
**Status**: ✅ Complete
|
||||
**Status**: 📋 Ready for approval
|
||||
|
||||
## 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
|
||||
**Unit**: Unit 2 — Authentication Pages
|
||||
**Type**: Frontend (React/TypeScript with TanStack Router)
|
||||
**Depends on**: Unit 1 (ApiClient, AuthContext, router.tsx, MSW, shadcn primitives)
|
||||
**Stories Covered**: US-01, US-02, US-03, US-04, US-05, US-06, US-07, US-13, US-14 (9 user stories)
|
||||
|
||||
## 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 |
|
||||
**Key Deliverables**:
|
||||
- SetupPage component (first Owner account creation with language preference)
|
||||
- InviteCompletePage component (user invitation completion with token validation)
|
||||
- InitGuard hook (system initialization status check before route access)
|
||||
- RoleGuard hook (role-based access control for protected routes)
|
||||
- API hooks: useSetup, useValidateInvitation, useCompleteInvitation
|
||||
- Shared password validation schema (src/lib/schemas/auth.ts)
|
||||
- Error handling components (FormErrorBanner, FieldError)
|
||||
- i18n translations (extend translation.json for setup/invite/errors)
|
||||
- MSW mock handlers (extend setupHandlers, new invitationHandlers)
|
||||
- Unit tests for all components, hooks, and API integration
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
## Code Generation 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 1: Create Shared Password Validation Schema
|
||||
- [ ] Create `src/lib/schemas/auth.ts` with:
|
||||
- `passwordSchema` — Zod validation (8+ chars, uppercase, digit, special)
|
||||
- `confirmPasswordSchema` — Confirm password field
|
||||
- Export both for use in SetupPage, InviteCompletePage, LoginPage
|
||||
- [ ] Validate schema with LoginPage password validation (ensure alignment)
|
||||
- [ ] **Story traceability**: US-01 (login), US-06 (setup), US-13 (invite complete)
|
||||
|
||||
### Step 2: Create FormErrorBanner Component
|
||||
- [ ] Create `src/components/ui/FormErrorBanner.tsx`:
|
||||
- Props: `error: FormError | null`, `onDismiss: () => void`
|
||||
- Red background with error message
|
||||
- Dismissible close button
|
||||
- Fade animation on dismiss
|
||||
- [ ] Add `data-testid="form-error-banner"` attribute
|
||||
- [ ] **Story traceability**: US-06, US-13 (error display)
|
||||
|
||||
### Step 3: Create FieldError Component
|
||||
- [ ] Create `src/components/ui/FieldError.tsx`:
|
||||
- Props: `message?: string`
|
||||
- Small red text below input field
|
||||
- Only render if message exists
|
||||
- [ ] Add `data-testid="field-error-[fieldname]"` attributes
|
||||
- [ ] **Story traceability**: US-06, US-13 (field validation)
|
||||
|
||||
### Step 4: Create API Hooks — Setup
|
||||
- [ ] Create `src/api/useSetup.ts`:
|
||||
- `useSetup()` hook for POST /Setup
|
||||
- Returns: `{ mutate, isLoading, error }`
|
||||
- Request: `{ name, email, password, language }`
|
||||
- Response: `SetupResponse` with user data
|
||||
- Error handling: ProblemDetails format
|
||||
- [ ] Add error logging for debugging
|
||||
- [ ] **Story traceability**: US-06 (initialize system)
|
||||
|
||||
### Step 5: Create API Hooks — Invitation
|
||||
- [ ] Create `src/api/useInvitation.ts`:
|
||||
- `useValidateInvitation(token: string)` — GET /Invitation/validate
|
||||
- Auto-runs on mount if token provided
|
||||
- Returns: `{ data, isLoading, error }`
|
||||
- `useCompleteInvitation()` — POST /Invitation/complete
|
||||
- Returns: `{ mutate, isLoading, error }`
|
||||
- Request: `{ token, name, password }`
|
||||
- Response: `InvitationCompletionResponse` with user data
|
||||
- Error handling: ProblemDetails format
|
||||
- [ ] Add loading/error state management
|
||||
- [ ] **Story traceability**: US-13, US-14 (complete invitation)
|
||||
|
||||
### Step 6: Extend Password Schema in LoginPage
|
||||
- [ ] Modify `src/pages/LoginPage.tsx`:
|
||||
- Update to use shared `passwordSchema` from `src/lib/schemas/auth.ts`
|
||||
- Ensure no duplicate definitions
|
||||
- Maintain existing LoginPage functionality
|
||||
- [ ] Run tests to ensure no regressions
|
||||
- [ ] **Story traceability**: US-01 (login)
|
||||
|
||||
### Step 7: Create SetupPage Component
|
||||
- [ ] Create `src/pages/SetupPage.tsx`:
|
||||
- Form fields: name, email, password, confirmPassword, language
|
||||
- Use react-hook-form + Zod (shared passwordSchema)
|
||||
- Form state: `{ isLoading, error }`
|
||||
- Submit: POST /Setup via useSetup hook
|
||||
- Validation: Real-time inline (onBlur) + banner (onSubmit)
|
||||
- Success: Show success message → redirect to /login after 2–3 sec
|
||||
- Error: Show error banner with backend message
|
||||
- [ ] Add data-testid attributes (form, fields, button)
|
||||
- [ ] Use shadcn primitives (Button, Input, Label, Card)
|
||||
- [ ] Use FormErrorBanner + FieldError for error display
|
||||
- [ ] Add i18n keys: `setup.*`
|
||||
- [ ] **Story traceability**: US-06 (initialize system), US-07 (redirect to setup)
|
||||
|
||||
### Step 8: Create InviteCompletePage Component
|
||||
- [ ] Create `src/pages/InviteCompletePage.tsx`:
|
||||
- Extract token from URL query param
|
||||
- On mount: validate token via useValidateInvitation
|
||||
- States: loading → form/error
|
||||
- If valid: Show form (email read-only, name, password, confirmPassword)
|
||||
- If invalid: Show error state with link for new invitation
|
||||
- Submit: POST /Invitation/complete
|
||||
- Validation: Real-time inline + banner
|
||||
- Success: Show success message → redirect to /login
|
||||
- Error: Show error banner
|
||||
- [ ] Add data-testid attributes
|
||||
- [ ] Use shadcn primitives
|
||||
- [ ] Use FormErrorBanner + FieldError
|
||||
- [ ] Add i18n keys: `inviteComplete.*`
|
||||
- [ ] **Story traceability**: US-13, US-14
|
||||
|
||||
### Step 9: Create InitGuard Hook
|
||||
- [ ] Create `src/auth/InitGuard.tsx`:
|
||||
- Hook: `useInitGuard(): { initialized, isLoading, error }`
|
||||
- Calls `GET /Setup/status` on app mount
|
||||
- Caching: `staleTime: Infinity` (session-level)
|
||||
- Integrate into `src/router.tsx` — `__root.tsx` route `beforeLoad`
|
||||
- Bypass routes: `/setup`, `/login`, `/invite/complete`
|
||||
- [ ] **Story traceability**: US-07
|
||||
|
||||
### Step 10: Create RoleGuard Integration
|
||||
- [ ] Implement RoleGuard logic in `src/router.tsx`:
|
||||
- `/users`: Owner or Admin role required
|
||||
- `/settings`: Owner role required
|
||||
- `/cms`: Owner role required
|
||||
- Page-level inline "Access Denied" message if denied
|
||||
- [ ] **Story traceability**: Role-restricted routes
|
||||
|
||||
### Step 11: Extend i18n Translations
|
||||
- [ ] Modify `src/i18n/locales/en/translation.json`:
|
||||
- Add `setup`, `inviteComplete`, extend `errors` sections
|
||||
- [ ] Modify `src/i18n/locales/nl/translation.json`:
|
||||
- Mirror structure in Dutch
|
||||
- [ ] Verify both locales are valid JSON
|
||||
- [ ] **Story traceability**: All UI strings
|
||||
|
||||
### Step 12: Extend MSW Mock Handlers — Setup
|
||||
- [ ] Modify `src/mocks/setup/`:
|
||||
- Extend `GET /Setup/status`
|
||||
- Add `POST /Setup` handler
|
||||
- After POST: mark `initialized: true`
|
||||
- [ ] **Story traceability**: US-06
|
||||
|
||||
### Step 13: Create MSW Mock Handlers — Invitation
|
||||
- [ ] Create `src/mocks/invitation/index.ts`:
|
||||
- Export `invitationHandlers`
|
||||
- `GET /Invitation/validate?token=xxx`
|
||||
- `POST /Invitation/complete`
|
||||
- Add to `src/mocks/browser.ts` and `src/mocks/server.ts`
|
||||
- [ ] **Story traceability**: US-13, US-14
|
||||
|
||||
### Step 14: Update MSW Handler Index
|
||||
- [ ] Modify `src/mocks/index.ts`:
|
||||
- Export `invitationHandlers`
|
||||
|
||||
### Step 15: Create Unit Tests — SetupPage
|
||||
- [ ] Create `src/pages/SetupPage.test.tsx`:
|
||||
- Successful setup flow
|
||||
- Validation errors (inline + banner)
|
||||
- Backend errors
|
||||
- Network error handling
|
||||
- Language selection
|
||||
- [ ] Target: >70% coverage
|
||||
- [ ] **Story traceability**: US-06
|
||||
|
||||
### Step 16: Create Unit Tests — InviteCompletePage
|
||||
- [ ] Create `src/pages/InviteCompletePage.test.tsx`:
|
||||
- Token validation (valid/invalid)
|
||||
- Form fill and submission
|
||||
- Error handling
|
||||
- Read-only email field
|
||||
- [ ] Target: >70% coverage
|
||||
- [ ] **Story traceability**: US-13, US-14
|
||||
|
||||
### Step 17: Create Unit Tests — InitGuard Hook
|
||||
- [ ] Create `src/auth/InitGuard.test.tsx`:
|
||||
- Initialized true/false scenarios
|
||||
- Loading/error states
|
||||
- Session cache behavior
|
||||
- [ ] Target: >80% coverage
|
||||
- [ ] **Story traceability**: US-07
|
||||
|
||||
### Step 18: Create Unit Tests — RoleGuard
|
||||
- [ ] Create tests for RoleGuard logic:
|
||||
- Owner access to restricted routes
|
||||
- Non-Owner denied with inline message
|
||||
- Public routes bypass
|
||||
- [ ] Target: >80% coverage
|
||||
|
||||
### Step 19: Create Unit Tests — API Hooks
|
||||
- [ ] Create `src/api/useSetup.test.ts` and `src/api/useInvitation.test.ts`:
|
||||
- Success, error, loading states
|
||||
- Token validation
|
||||
- [ ] Target: >75% coverage
|
||||
|
||||
### Step 20: Create Integration Tests
|
||||
- [ ] Create `src/test/integration/unit-2-auth-flow.test.tsx`:
|
||||
- Setup flow end-to-end
|
||||
- Invitation flow end-to-end
|
||||
- Role guard integration
|
||||
- 401 refresh + retry
|
||||
- [ ] Target: Happy path coverage
|
||||
|
||||
### Step 21: Update Router Configuration
|
||||
- [ ] Modify `src/router.tsx`:
|
||||
- Add InitGuard to `__root.tsx`
|
||||
- Add `/setup` route (SetupPage)
|
||||
- Add `/invite/complete` route (InviteCompletePage)
|
||||
- Add RoleGuard checks for protected routes
|
||||
- [ ] **Story traceability**: All routing stories
|
||||
|
||||
### Step 22: Update README & Documentation
|
||||
- [ ] Update `frontend/README.md`:
|
||||
- New Unit 2 features section
|
||||
- Authentication flow documentation
|
||||
- Role-based access control
|
||||
- [ ] Add brief JSDoc comments to new hooks/components
|
||||
|
||||
### Step 23: Final Verification
|
||||
- [ ] [ ] `pnpm build` — no errors
|
||||
- [ ] [ ] `pnpm lint` — all checks pass
|
||||
- [ ] [ ] `pnpm format:check` — formatting correct
|
||||
- [ ] [ ] `pnpm test` — all tests pass
|
||||
- [ ] [ ] Dev server boots, test all flows
|
||||
- [ ] [ ] Coverage: auth-related >70%, core >80%
|
||||
- [ ] [ ] All data-testid attributes present
|
||||
|
||||
### Step 24: Commit Changes
|
||||
- [ ] Stage all files
|
||||
- [ ] Create commit with proper message
|
||||
- [ ] Reference stories: US-01–US-07, US-13–US-14
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
## Total Steps: 24
|
||||
|
||||
---
|
||||
**Estimated Scope**: 600–800 LOC, 4–6 hours, >70% coverage
|
||||
|
||||
### 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 |
|
||||
**Ready for Approval**: Yes
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Unit 1 already provided the following that Unit 2 builds on:
|
||||
- [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
|
||||
- [x] Step 4: Generate functional design artifacts — COMPLETED
|
||||
|
||||
---
|
||||
|
||||
|
||||
+302
-202
@@ -2,234 +2,334 @@
|
||||
|
||||
## 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.
|
||||
Unit 2 implements three core business logic flows:
|
||||
1. **System Initialization** — First Owner setup
|
||||
2. **User Invitation Completion** — New users complete their account
|
||||
3. **Role-Based Access Control** — Guard pages by user role
|
||||
|
||||
All flows depend on the authentication foundation (Unit 1: AuthContext, ApiClient, MSW).
|
||||
|
||||
---
|
||||
|
||||
## Flow 1: App Initialization — InitGuard
|
||||
## System Initialization Flow
|
||||
|
||||
**Trigger**: Every page load / app mount (runs in the root route)
|
||||
**Purpose**: Ensure the system is initialized before rendering any route
|
||||
### Trigger
|
||||
- User visits app when `GET /Setup/status` returns `initialized: false`
|
||||
- `InitGuard` in `__root.tsx` redirects to `/setup` (blocking all other routes)
|
||||
|
||||
### SetupPage Form Collects
|
||||
- **Name** (required string)
|
||||
- **Email** (required, valid email format)
|
||||
- **Password** (required, backend rules: min 8 chars, uppercase, digit, special char)
|
||||
- **Confirm Password** (required, must match password)
|
||||
- **Language Preference** (dropdown: English / Nederlands — determines i18n locale and stored for future use)
|
||||
|
||||
### Business Rules (Setup)
|
||||
- Password must conform to backend validation rules (enforced via Zod schema matching backend)
|
||||
- Email must be a valid email format
|
||||
- Name can be any non-empty string
|
||||
- Form is only shown when system is not initialized
|
||||
- Success redirects to `/login` (user must log in after setup to verify email/password)
|
||||
|
||||
### Endpoint Integration
|
||||
- `POST /Setup` payload: `{ name, email, password, language }` (language stored for future use)
|
||||
- Backend creates first Owner user account
|
||||
- Backend returns setup status (updated to `initialized: true`)
|
||||
|
||||
### System Initialization Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([App mounts]) --> B{"Setup status<br/>already cached?"}
|
||||
B -- Yes --> E
|
||||
B -- No --> C["Fetch GET /Setup/status"]
|
||||
C --> D{"Request<br/>outcome"}
|
||||
D -- Network error --> ERR["Unable to reach server"]
|
||||
D -- Success --> E{"initialized?"}
|
||||
E -- false --> F{"On /setup<br/>already?"}
|
||||
F -- Yes --> G([Render /setup page])
|
||||
F -- No --> H([Redirect to /setup])
|
||||
E -- true --> I{"On /setup<br/>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
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant SetupPage as SetupPage<br/>(Frontend)
|
||||
participant Backend as Backend API<br/>POST /Setup
|
||||
participant LoginPage as LoginPage<br/>(Redirect)
|
||||
|
||||
User->>SetupPage: Opens /setup
|
||||
SetupPage->>SetupPage: Render form (name, email, password, language)
|
||||
User->>SetupPage: Fill form
|
||||
User->>SetupPage: Submit
|
||||
SetupPage->>Backend: POST /Setup {name, email, password, language}
|
||||
alt Setup Success
|
||||
Backend-->>SetupPage: 201 {user: Owner, status: initialized}
|
||||
SetupPage->>SetupPage: Show success message
|
||||
SetupPage->>LoginPage: Redirect to /login
|
||||
LoginPage->>User: User logs in to verify credentials
|
||||
else Setup Fails
|
||||
Backend-->>SetupPage: 400 {detail: error message}
|
||||
SetupPage->>SetupPage: Show error banner
|
||||
User->>SetupPage: Fix and retry
|
||||
end
|
||||
```
|
||||
|
||||
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).
|
||||
Text alternative: Sequence diagram showing user opening SetupPage, filling form with name/email/password/language, submitting to backend. On success: backend returns Owner user data and redirect to LoginPage. On failure: backend returns error, shown as banner, user can retry.
|
||||
|
||||
---
|
||||
|
||||
## Flow 2: Protected Route Access — ProtectedRoute
|
||||
## User Invitation Completion Flow
|
||||
|
||||
**Trigger**: User navigates to any route inside the authenticated layout
|
||||
**Purpose**: Prevent unauthenticated access to protected pages
|
||||
### Trigger
|
||||
- User clicks invitation link: `/invite/complete?token=xxx`
|
||||
- `InviteCompletePage` validates token on mount (GET request to backend)
|
||||
|
||||
### InviteCompletePage Form Collects (if token valid)
|
||||
- **Email** (read-only, shown from invitation data)
|
||||
- **Name** (required string)
|
||||
- **Password** (required, backend rules same as setup)
|
||||
- **Confirm Password** (required, must match password)
|
||||
|
||||
### Business Rules (Invitation)
|
||||
- Token validation happens on page mount with loading state
|
||||
- If token invalid/expired: show error state directly (no form)
|
||||
- If token valid: show form with email pre-filled (read-only)
|
||||
- Password must conform to backend rules
|
||||
- Name can be any non-empty string
|
||||
- Success redirects to `/login` (user logs in to verify account)
|
||||
|
||||
### Endpoint Integration
|
||||
- `GET /Invitation/validate?token=xxx` — validate token and retrieve email
|
||||
- `POST /Invitation/complete` payload: `{ token, name, password }` — complete invitation
|
||||
- Both endpoints return user data if successful
|
||||
|
||||
### User Invitation Completion Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Route navigation]) --> B{"AuthContext:<br/>user present?"}
|
||||
B -- Yes --> C([Render requested page])
|
||||
B -- No --> D{"Restoring<br/>session?"}
|
||||
D -- Yes --> E([Loading spinner])
|
||||
E --> B
|
||||
D -- No --> F["Redirect to /login<br/>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
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant InvitePage as InviteCompletePage<br/>(Frontend)
|
||||
participant ValidateAPI as Backend API<br/>GET /Invitation/validate
|
||||
participant CompleteAPI as Backend API<br/>POST /Invitation/complete
|
||||
participant LoginPage as LoginPage<br/>(Redirect)
|
||||
|
||||
User->>InvitePage: Click invitation link /invite/complete?token=xxx
|
||||
InvitePage->>InvitePage: Show loading spinner
|
||||
InvitePage->>ValidateAPI: GET /Invitation/validate?token=xxx
|
||||
alt Token Valid
|
||||
ValidateAPI-->>InvitePage: {valid: true, email: user@example.com}
|
||||
InvitePage->>InvitePage: Show form (email read-only, name, password)
|
||||
User->>InvitePage: Fill name & password
|
||||
User->>InvitePage: Submit
|
||||
InvitePage->>CompleteAPI: POST /Invitation/complete {token, name, password}
|
||||
alt Completion Success
|
||||
CompleteAPI-->>InvitePage: 201 {user: User, message: success}
|
||||
InvitePage->>InvitePage: Show success message
|
||||
InvitePage->>LoginPage: Redirect to /login
|
||||
else Completion Fails
|
||||
CompleteAPI-->>InvitePage: 400 {detail: error}
|
||||
InvitePage->>InvitePage: Show error banner
|
||||
User->>InvitePage: Retry
|
||||
end
|
||||
else Token Invalid/Expired
|
||||
ValidateAPI-->>InvitePage: {valid: false, error: Invalid token}
|
||||
InvitePage->>InvitePage: Show error state (no form)
|
||||
InvitePage->>User: Offer link to request new invitation
|
||||
end
|
||||
```
|
||||
|
||||
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.
|
||||
Text alternative: Sequence diagram showing user clicking invitation link, InviteCompletePage validating token with loading state. If valid: form appears with email read-only, user fills name/password and submits. On success: redirects to login. On failure: shows error banner. If token invalid: shows error state with option to request new invitation.
|
||||
|
||||
---
|
||||
|
||||
## Flow 3: Role-Restricted Route Access — RoleGuard
|
||||
## Role-Based Access Control Flow
|
||||
|
||||
**Trigger**: Authenticated user navigates to a role-restricted route
|
||||
**Purpose**: Enforce per-route role requirements
|
||||
### Trigger
|
||||
- Authenticated user navigates to a protected route (e.g., `/users`, `/settings`)
|
||||
- `RoleGuard` checks `user.role` from AuthContext
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Authenticated navigation]) --> B{"Route has<br/>role restriction?"}
|
||||
B -- No restriction --> C([Render page])
|
||||
B -- Owner only --> D{"user.role<br/>= Owner?"}
|
||||
B -- Owner or Admin --> E{"user.role<br/>= Owner or Admin?"}
|
||||
D -- Yes --> C
|
||||
D -- No --> F([Inline Access Denied])
|
||||
E -- Yes --> C
|
||||
E -- No --> F
|
||||
### Role Model
|
||||
- **Owner** — system administrator, can manage users, system settings, CMS
|
||||
- **Admin** — (reserved for future use) may have limited permissions
|
||||
- **User** — standard user, can only view dashboard and own profile
|
||||
|
||||
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
|
||||
### Route Access Rules
|
||||
| Route | Required Role(s) | Behavior if Denied |
|
||||
|-------|------------------|-------------------|
|
||||
| `/setup` | None (public) | N/A |
|
||||
| `/login` | None (public) | N/A |
|
||||
| `/invite/complete` | None (public, token-authenticated) | N/A |
|
||||
| `/dashboard` | Any authenticated | N/A |
|
||||
| `/profile` | Any authenticated | N/A |
|
||||
| `/users` | Owner or Admin | Show inline "Access Denied" message |
|
||||
| `/settings` | Owner only | Show inline "Access Denied" message |
|
||||
| `/cms` | Owner only | Show inline "Access Denied" message |
|
||||
|
||||
class A start
|
||||
class B,D,E decision
|
||||
class C terminal
|
||||
class F denied
|
||||
```
|
||||
### Business Rules (Role Guard)
|
||||
- Redirect logic happens in TanStack Router `beforeLoad` hook (not page-level)
|
||||
- When access denied: show inline "Access Denied" message within the page component (not a separate route)
|
||||
- Toast notifications are NOT used for access denied (inline message only)
|
||||
- All routes under `_authenticated` layout require authentication (ProtectedRoute guard already enforces this)
|
||||
|
||||
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<br/>Name · Email · Password<br/>Confirm Password · Locale"]
|
||||
B --> C{"Locale<br/>changed?"}
|
||||
C -- Yes --> D["Apply i18n.changeLanguage"]
|
||||
D --> B
|
||||
C -- No --> E["User submits form"]
|
||||
E --> F{"Zod<br/>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<br/>result"}
|
||||
F -- Network error --> G(["Error: unable to connect"])
|
||||
F -- isValid=false --> H(["Error: expired / used / not found"])
|
||||
F -- isValid=true --> I["Show form<br/>email read-only · Name · Password<br/>Confirm Password"]
|
||||
I --> J["User submits form"]
|
||||
J --> K{"Zod<br/>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:
|
||||
### Role-Based Access Control Decision Flow Diagram
|
||||
|
||||
```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<br/>RoleGuard: Owner or Admin"]
|
||||
AuthLayout --> Settings["/settings · SettingsPage<br/>RoleGuard: Owner"]
|
||||
AuthLayout --> CMS["/cms · CmsPage<br/>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
|
||||
user["Authenticated User<br/>Navigates to Route"]
|
||||
route["Route Requires Role?"]
|
||||
check["RoleGuard Checks<br/>user.role from AuthContext"]
|
||||
|
||||
match["User Role Matches<br/>Required Role(s)?"]
|
||||
|
||||
allow["✓ Access Allowed<br/>Render Page"]
|
||||
deny["✗ Access Denied<br/>Show Inline Message"]
|
||||
|
||||
msg["Message: You do not have<br/>permission to access this page"]
|
||||
|
||||
user --> route
|
||||
route -->|No role required| allow
|
||||
route -->|Role required<br/>e.g., /users, /settings| check
|
||||
|
||||
check --> match
|
||||
match -->|Yes<br/>Owner or Admin| allow
|
||||
match -->|No<br/>Insufficient role| deny
|
||||
|
||||
deny --> msg
|
||||
msg --> deny
|
||||
|
||||
classDef decision fill:#2196F3,stroke:#0D47A1,color:#fff,stroke-width:2px
|
||||
classDef allowed fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
|
||||
classDef denied fill:#F44336,stroke:#C62828,color:#fff,stroke-width:2px
|
||||
classDef message fill:#FF9800,stroke:#E65100,color:#fff,stroke-width:2px
|
||||
|
||||
class route,check,match decision
|
||||
class allow allowed
|
||||
class deny denied
|
||||
class msg message
|
||||
```
|
||||
|
||||
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.
|
||||
Text alternative: Decision flow diagram for role-based access control. User navigates to route. If route requires no role: access allowed. If role required: RoleGuard checks user.role from AuthContext. If role matches required roles: access allowed and render page. If insufficient role: access denied, show inline message to user.
|
||||
|
||||
---
|
||||
|
||||
## InitGuard Logic
|
||||
|
||||
### Purpose
|
||||
Ensure system initialization is complete before users access authenticated features.
|
||||
|
||||
### Implementation
|
||||
- Placed in `__root.tsx` route, runs before all routes
|
||||
- Calls `GET /Setup/status` on app mount (or when AuthContext is ready)
|
||||
- Caching strategy: **session-level cache** (once loaded, never re-fetch during the session)
|
||||
- Rationale: setup operations redirect back to `/login` anyway, which reloads the app
|
||||
- `staleTime: Infinity` (cache for entire session)
|
||||
|
||||
### Routes That Bypass InitGuard
|
||||
- `/setup` — setup page (accessible even if not initialized)
|
||||
- `/login` — public login (not checked, public route)
|
||||
- `/invite/complete` — public invitation completion (not checked, token-authenticated)
|
||||
- All other routes redirect to `/setup` if `initialized: false`
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Form Submission Errors
|
||||
- Network errors (e.g., 500, connection failure)
|
||||
- Backend validation errors (e.g., email already exists, password too weak)
|
||||
- Token errors (invalid/expired invitation token)
|
||||
|
||||
### Error Display Pattern (Combination of A + B)
|
||||
1. **Real-time inline validation** — Show errors next to fields as user types (via Zod schema)
|
||||
2. **Form-level banner after submit** — After form submission, show a dismissible error banner at the top of the form with the full error message from the backend
|
||||
- Consistent with LoginPage pattern (already implemented in Unit 1)
|
||||
- Example: "Setup failed: Email already in use"
|
||||
|
||||
### Specific Error Cases
|
||||
- **Invalid token on InviteCompletePage mount** — Show full-page error state with action (e.g., "Request a new invitation link")
|
||||
- **Backend validation errors** — Combine inline (from Zod) + banner (from API response)
|
||||
- **Network errors** — Banner only: "Network error. Please try again."
|
||||
|
||||
---
|
||||
|
||||
## Translation (i18n) Structure
|
||||
|
||||
All new pages use keys added to existing `src/i18n/locales/{en,nl}/translation.json` files.
|
||||
|
||||
### New Translation Keys (extend existing file)
|
||||
```json
|
||||
{
|
||||
"setup": {
|
||||
"title": "Initialize System",
|
||||
"nameLabel": "Name",
|
||||
"emailLabel": "Email",
|
||||
"passwordLabel": "Password",
|
||||
"confirmPasswordLabel": "Confirm Password",
|
||||
"languageLabel": "Language Preference",
|
||||
"submitButton": "Create Owner Account",
|
||||
"successMessage": "Account created. Please log in."
|
||||
},
|
||||
"inviteComplete": {
|
||||
"title": "Complete Your Account",
|
||||
"emailLabel": "Email",
|
||||
"nameLabel": "Name",
|
||||
"passwordLabel": "Password",
|
||||
"confirmPasswordLabel": "Confirm Password",
|
||||
"submitButton": "Complete Setup",
|
||||
"loadingMessage": "Validating invitation...",
|
||||
"invalidTokenMessage": "This invitation link is invalid or has expired.",
|
||||
"requestNewInvitationLink": "Request a new invitation link",
|
||||
"successMessage": "Account created. Please log in."
|
||||
},
|
||||
"errors": {
|
||||
"accessDenied": "You do not have permission to access this page.",
|
||||
"setupRequired": "System setup required. Please initialize the system first.",
|
||||
"invalidInvitationToken": "Invalid or expired invitation token."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Context Integration
|
||||
|
||||
### AuthContext Usage in Unit 2
|
||||
- `useAuth()` hook provides current `user` (for role checks) and `accessToken`
|
||||
- RoleGuard reads `user.role` to determine route access
|
||||
- SetupPage and InviteCompletePage do NOT call `AuthContext.login()` on success (user must log in manually)
|
||||
- Login operations still go through LoginPage → `AuthContext.login()` (existing Unit 1 flow)
|
||||
|
||||
---
|
||||
|
||||
## MSW Mock Handlers
|
||||
|
||||
### New Mock Handlers for Unit 2
|
||||
|
||||
**Setup Handlers** (`src/mocks/setup/`)
|
||||
```javascript
|
||||
// POST /Setup — create first Owner account
|
||||
// Request: { name, email, password, language }
|
||||
// Response: { status: 201, message: "System initialized", user: {...} }
|
||||
|
||||
// GET /Setup/status — check initialization status
|
||||
// Response: { initialized: true/false, created_at: ISO timestamp }
|
||||
```
|
||||
|
||||
**Invitation Handlers** (`src/mocks/invitation/`)
|
||||
```javascript
|
||||
// GET /Invitation/validate?token=xxx — validate invitation token
|
||||
// Response: { valid: true, email: "user@example.com" } or { valid: false, error: "Invalid token" }
|
||||
|
||||
// POST /Invitation/complete — complete invitation
|
||||
// Request: { token, name, password }
|
||||
// Response: { status: 201, user: { id, name, email, role }, message: "Account created" }
|
||||
```
|
||||
|
||||
All handlers align with the real backend API (no extra `/me` endpoint; user data comes from setup/invitation responses).
|
||||
|
||||
---
|
||||
|
||||
## Password Validation Schema
|
||||
|
||||
**Location**: `src/lib/schemas/auth.ts`
|
||||
|
||||
The schema is shared by:
|
||||
- LoginPage (Unit 1 — already exists, password only)
|
||||
- SetupPage (Unit 2 — new, password + confirm)
|
||||
- InviteCompletePage (Unit 2 — new, password + confirm)
|
||||
|
||||
Backend rules:
|
||||
- Minimum 8 characters
|
||||
- At least 1 uppercase letter
|
||||
- At least 1 digit
|
||||
- At least 1 special character (!@#$%^&*()-_=+[]{}|;:,.<>?)
|
||||
|
||||
Zod schema validates on both fields + cross-field `confirm password` match.
|
||||
|
||||
+195
-85
@@ -1,124 +1,234 @@
|
||||
# Business Rules — Unit 2: Authentication Pages
|
||||
|
||||
## Password Validation (Shared Schema)
|
||||
## Setup Form Validation Rules
|
||||
|
||||
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).
|
||||
### Field-Level Rules
|
||||
|
||||
| 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]/)` |
|
||||
| Field | Type | Rules | Error Message |
|
||||
|-------|------|-------|---------------|
|
||||
| **Name** | string | Required, 1–255 chars | "Name is required" |
|
||||
| **Email** | string | Required, valid email format | "Enter a valid email address" |
|
||||
| **Password** | string | Zod schema matching backend (8+ chars, 1 uppercase, 1 digit, 1 special) | Backend-specific error (e.g., "Password must contain uppercase") |
|
||||
| **Confirm Password** | string | Required, must match Password field | "Passwords do not match" |
|
||||
| **Language** | enum | "en" or "nl" | (dropdown, always valid) |
|
||||
|
||||
These rules mirror the backend `IdentityOptions.Password` configuration exactly. Any change to backend password rules must also update this schema.
|
||||
### Cross-Field Rules
|
||||
- `Confirm Password` must equal `Password` (checked on blur and form validation)
|
||||
|
||||
---
|
||||
|
||||
## Confirm Password
|
||||
## Invitation Completion Form Validation Rules
|
||||
|
||||
| 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. |
|
||||
### Field-Level Rules
|
||||
|
||||
| Field | Type | Rules | Error Message |
|
||||
|-------|------|-------|---------------|
|
||||
| **Email** | string | Read-only (from token validation) | N/A |
|
||||
| **Name** | string | Required, 1–255 chars | "Name is required" |
|
||||
| **Password** | string | Zod schema matching backend | Backend-specific error |
|
||||
| **Confirm Password** | string | Required, must match Password field | "Passwords do not match" |
|
||||
|
||||
### Token Validation Rules
|
||||
- Token comes from query param (`?token=xxx`)
|
||||
- Validated on page mount via `GET /Invitation/validate?token=xxx`
|
||||
- If valid: backend returns `{ valid: true, email: "..." }`
|
||||
- If invalid/expired: backend returns `{ valid: false, error: "..." }`
|
||||
- **Loading state** shown while validating
|
||||
- **Error state** shown if validation fails (no form rendered)
|
||||
|
||||
---
|
||||
|
||||
## Email Validation
|
||||
## Initialization Status Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| BR-U2-07 | `email` must pass Zod `.email()` (RFC-compliant format). Validated client-side before submission. |
|
||||
### InitGuard Behavior
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| App loads, `GET /Setup/status` returns `{ initialized: true }` | Allow all routes; proceed normally |
|
||||
| App loads, `GET /Setup/status` returns `{ initialized: false }` | Redirect to `/setup` (InitGuard blocks other routes) |
|
||||
| User navigates to `/setup` directly | Show setup form (accessible regardless of init status) |
|
||||
| User navigates to any other route while not initialized | Redirect to `/setup` (InitGuard blocks) |
|
||||
| User completes setup (`POST /Setup` succeeds) | App redirects to `/login`, session resets, init status re-fetched on next app load |
|
||||
| Network error while checking init status | Treat as "not initialized" and redirect to `/setup` (fail-safe) |
|
||||
|
||||
### Caching Strategy
|
||||
- `staleTime: Infinity` (session-level cache, never re-fetch after first successful load)
|
||||
- Rationale: Setup operation ends with redirect to `/login`, which reloads the app
|
||||
- No manual re-fetching needed during the session
|
||||
|
||||
---
|
||||
|
||||
## System Initialization Guard (InitGuard)
|
||||
## Role-Based Access Control Rules
|
||||
|
||||
| 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. |
|
||||
### Role Definition
|
||||
- **Owner**: Created during system initialization (`POST /Setup`)
|
||||
- **Admin**: Reserved for future use (not used in Unit 2–6)
|
||||
- **User**: Created via user invitations (`POST /Invitation/complete`)
|
||||
|
||||
### Route Access Rules
|
||||
|
||||
| Route | Required Role(s) | Behavior if Denied | Guard Component |
|
||||
|-------|------------------|--------------------|-----------------|
|
||||
| `/setup` | None (public, no auth required) | N/A | None |
|
||||
| `/login` | None (public, no auth required) | N/A | None |
|
||||
| `/invite/complete` | None (public, token-authenticated) | N/A | None |
|
||||
| `/dashboard` | Any authenticated | N/A | ProtectedRoute (Unit 1) |
|
||||
| `/profile` | Any authenticated | N/A | ProtectedRoute (Unit 1) |
|
||||
| `/users` | Owner **or** Admin | Inline "Access Denied" message | RoleGuard (this unit) |
|
||||
| `/settings` | Owner only | Inline "Access Denied" message | RoleGuard (this unit) |
|
||||
| `/cms` | Owner only | Inline "Access Denied" message | RoleGuard (this unit) |
|
||||
|
||||
### Access Denied Behavior
|
||||
- When user lacks required role for a route:
|
||||
- **DO**: Show inline "Access Denied" message within the page (e.g., in a styled container)
|
||||
- **DO NOT**: Use toast notifications
|
||||
- **DO NOT**: Redirect to separate `/403` route
|
||||
- **DO**: Make it clear the page exists but access is denied
|
||||
- **Example**: "You do not have permission to access this page. Contact your administrator."
|
||||
|
||||
### Guard Implementation
|
||||
- `RoleGuard` implemented as TanStack Router `beforeLoad` hook in route definitions
|
||||
- Evaluates `user.role` from AuthContext at route load time
|
||||
- Returns redirect-to-self if denied (allows inline error display via page component state)
|
||||
- OR: page component checks role and displays error inline
|
||||
|
||||
---
|
||||
|
||||
## Authentication Guard (ProtectedRoute)
|
||||
## Error Handling Rules
|
||||
|
||||
| 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. |
|
||||
### Form Submission Error Display
|
||||
|
||||
Two-part error handling (combining inline validation + banner):
|
||||
|
||||
#### Part 1: Real-Time Inline Validation
|
||||
- Zod schema validates as user types/blurs on field
|
||||
- Show error message below the field in red text
|
||||
- Example: `<span class="text-red-600 text-sm">{error}</span>`
|
||||
- Clears when user fixes the error
|
||||
|
||||
#### Part 2: Form-Level Banner (After Submit)
|
||||
- After form submission, if backend returns error:
|
||||
- Show dismissible banner at the top of the form with full error message
|
||||
- Banner includes close button
|
||||
- Banner persists until user dismisses or form is reset
|
||||
- Consistent with LoginPage pattern (Unit 1)
|
||||
- If network error (no response):
|
||||
- Banner: "Network error. Please try again."
|
||||
|
||||
### Specific Error Scenarios
|
||||
|
||||
| Scenario | Banner Message | Inline Errors |
|
||||
|----------|----------------|---------------|
|
||||
| Email already exists on setup | "Setup failed: This email is already registered" | None (only from backend) |
|
||||
| Password too weak | "Setup failed: [backend message about password rules]" | Pre-validated by Zod |
|
||||
| Network timeout on setup | "Network error. Please try again." | None |
|
||||
| Invalid invitation token on load | Full-page error (InitGuard redirect to setup OR error page) | N/A |
|
||||
| Token expired during form fill | Error shown after submit: "Invitation link expired" | N/A |
|
||||
| Invalid form data before submit | Inline errors only (Zod validation) | Below each field |
|
||||
|
||||
---
|
||||
|
||||
## Role Guard (RoleGuard)
|
||||
## Translation Key Rules
|
||||
|
||||
| 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. |
|
||||
### Locales
|
||||
- **English** (en) — eager load (fallback language)
|
||||
- **Nederlands** (nl) — lazy load (per-route chunk)
|
||||
|
||||
### Translation Keys Scope
|
||||
- All Unit 2 keys added to existing `translation.json` files (not separate namespace files)
|
||||
- Organized by feature: `setup`, `inviteComplete`, `errors` sections
|
||||
- Used with `i18next` pattern: `t('setup.title')`, `t('errors.accessDenied')`
|
||||
|
||||
### Language Preference Storage
|
||||
- SetupPage collects language preference at account creation
|
||||
- Stored in backend user profile (for future use)
|
||||
- Frontend respects this preference on login (future: read from user data)
|
||||
|
||||
---
|
||||
|
||||
## SetupPage Rules
|
||||
## Password Schema 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. |
|
||||
### Location
|
||||
`src/lib/schemas/auth.ts` — shared by LoginPage, SetupPage, InviteCompletePage
|
||||
|
||||
### Validation Rules (Backend-Aligned)
|
||||
```typescript
|
||||
const passwordSchema = z.string()
|
||||
.min(8, "At least 8 characters")
|
||||
.regex(/[A-Z]/, "At least one uppercase letter")
|
||||
.regex(/[0-9]/, "At least one digit")
|
||||
.regex(/[!@#$%^&*\(\)\-_=+\[\]{}|;:,.<>?]/, "At least one special character");
|
||||
|
||||
const confirmPasswordSchema = z.string()
|
||||
.min(1, "Confirm password is required");
|
||||
|
||||
const setupFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
email: z.string().email(),
|
||||
password: passwordSchema,
|
||||
confirmPassword: confirmPasswordSchema,
|
||||
language: z.enum(['en', 'nl'])
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"]
|
||||
});
|
||||
```
|
||||
|
||||
### Shared Usage
|
||||
- `passwordSchema` — used by LoginPage, SetupPage, InviteCompletePage
|
||||
- `confirmPasswordSchema` — used only when form collects both password fields (setup, invite)
|
||||
|
||||
---
|
||||
|
||||
## InviteCompletePage Rules
|
||||
## MSW Mock Handler 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`. |
|
||||
### Handler Organization
|
||||
- **Setup handlers**: Extend existing `src/mocks/setup/` → add `POST /Setup` handler
|
||||
- **Invitation handlers**: New `src/mocks/invitation/` → add `GET /Invitation/validate` + `POST /Invitation/complete` handlers
|
||||
|
||||
### Setup Handler Behaviors
|
||||
|
||||
#### `GET /Setup/status`
|
||||
- Always returns `{ initialized: true/false }`
|
||||
- Default: `{ initialized: false }` on app first load
|
||||
- After `POST /Setup` succeeds: automatically return `{ initialized: true }` for subsequent requests
|
||||
|
||||
#### `POST /Setup`
|
||||
- Request: `{ name, email, password, language }`
|
||||
- Validates email not already used (mock: always accepts if password valid)
|
||||
- Returns `{ status: 201, message: "System initialized", user: { id, name, email, role: "Owner" } }`
|
||||
- Error: `{ status: 400, detail: "Email already in use" }` (ProblemDetails format)
|
||||
|
||||
### Invitation Handler Behaviors
|
||||
|
||||
#### `GET /Invitation/validate?token=xxx`
|
||||
- Mock tokens: `valid-token-123`, `expired-token-456`
|
||||
- Valid token: returns `{ valid: true, email: "invited@example.com" }`
|
||||
- Invalid/expired: returns `{ valid: false, error: "Invalid or expired token" }` (ProblemDetails format)
|
||||
|
||||
#### `POST /Invitation/complete`
|
||||
- Request: `{ token, name, password }`
|
||||
- Validates token and password
|
||||
- Returns `{ status: 201, user: { id, name, email, role: "User" }, message: "Account created" }`
|
||||
- Error: `{ status: 400, detail: "Invalid token" }` (ProblemDetails format)
|
||||
|
||||
---
|
||||
|
||||
## Form Error Handling (Application-Wide Standard)
|
||||
## Consistency Rules
|
||||
|
||||
These rules define the error handling pattern that applies to ALL forms in the application (SetupPage, InviteCompletePage, LoginPage).
|
||||
### Form Pattern Consistency
|
||||
- All forms use `react-hook-form` + `zod` (LoginPage pattern from Unit 1)
|
||||
- Error display: inline (on blur) + banner (on submit)
|
||||
- Submission feedback: disable submit button during request
|
||||
- Success: redirect or success message
|
||||
|
||||
| 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. |
|
||||
### Navigation Consistency
|
||||
- After setup: redirect to `/login` (user must log in)
|
||||
- After invitation completion: redirect to `/login` (user must log in)
|
||||
- After login: redirect to `/dashboard` (existing pattern from Unit 1)
|
||||
|
||||
---
|
||||
|
||||
## 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. |
|
||||
### Component API Consistency
|
||||
- All hooks follow React conventions (use* prefix)
|
||||
- All components accept standard React props (className, etc.)
|
||||
- All pages rendered in `src/pages/` directory (consistent with LoginPage)
|
||||
- All routes defined in `src/router.tsx` (code-based routing)
|
||||
|
||||
+255
-145
@@ -1,188 +1,298 @@
|
||||
# Domain Entities — Unit 2: Authentication Pages
|
||||
|
||||
## Overview
|
||||
## User (from backend, extended in Unit 2)
|
||||
|
||||
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'
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "Owner" | "Admin" | "User";
|
||||
createdAt: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
### Lifecycle in Unit 2
|
||||
- **Created during setup**: `POST /Setup` creates first Owner
|
||||
- **Created during invitation**: `POST /Invitation/complete` creates User
|
||||
- **Returned by**: Login, Refresh, Setup, Invitation completion
|
||||
- **Stored in**: AuthContext (in-memory, cleared on logout)
|
||||
|
||||
### Unit 2 Attributes Used
|
||||
- `id` — for authorization checks and API calls
|
||||
- `name` — displayed in user menu (Unit 3+)
|
||||
- `email` — shown read-only in InviteCompletePage
|
||||
- `role` — used by RoleGuard to determine route access
|
||||
|
||||
---
|
||||
|
||||
### SetupFormData
|
||||
## SetupStatus
|
||||
|
||||
Collected by the `SetupPage` form. Submitted to `POST /Setup`.
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface SetupStatus {
|
||||
initialized: boolean;
|
||||
createdAt?: string; // ISO timestamp, present only if initialized = true
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
### Lifecycle in Unit 2
|
||||
- **Fetched by**: InitGuard on app mount
|
||||
- **Stored in**: React Query cache (`staleTime: Infinity`)
|
||||
- **Used by**: InitGuard to determine redirect logic
|
||||
- **Returned by**: `GET /Setup/status`
|
||||
|
||||
**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)
|
||||
### States
|
||||
- `initialized: false` — system requires setup; redirect to `/setup`
|
||||
- `initialized: true` — system ready; allow normal flow
|
||||
|
||||
---
|
||||
|
||||
### InvitationToken
|
||||
## InvitationToken
|
||||
|
||||
Represents the token extracted from the URL query string on the `InviteCompletePage`.
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface InvitationToken {
|
||||
token: string; // from query param: ?token=xxx
|
||||
valid: boolean;
|
||||
email: string; // present only if valid = true
|
||||
error?: string; // present only if valid = false
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `token` | `string` | Raw JWT or opaque token from `?token=xxx` in the URL |
|
||||
### Lifecycle in Unit 2
|
||||
- **Source**: URL query param (`/invite/complete?token=xxx`)
|
||||
- **Validated by**: `GET /Invitation/validate?token=xxx` on page mount
|
||||
- **Stored in**: Local component state (InviteCompletePage)
|
||||
- **Used for**: Form submission to `POST /Invitation/complete`
|
||||
|
||||
### States
|
||||
- Token extracted from URL
|
||||
- Validation in progress (loading state)
|
||||
- Validation succeeded (`valid: true`, show form)
|
||||
- Validation failed (`valid: false`, show error)
|
||||
- Submission in progress
|
||||
- Submission succeeded (redirect to login)
|
||||
- Submission failed (show banner error)
|
||||
|
||||
---
|
||||
|
||||
### InvitationValidation
|
||||
## PasswordCredential
|
||||
|
||||
Returned by `GET /Invitation/validate?token=xxx` (stub in Unit 2; full implementation in Unit 5).
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface PasswordCredential {
|
||||
password: string;
|
||||
confirmPassword?: string; // present in setup/invite forms, absent in login
|
||||
}
|
||||
```
|
||||
|
||||
| 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` |
|
||||
### Validation
|
||||
- Both fields validated by Zod schema (src/lib/schemas/auth.ts)
|
||||
- `password`: 8+ chars, 1 uppercase, 1 digit, 1 special char (backend-aligned)
|
||||
- `confirmPassword`: must equal `password`
|
||||
|
||||
### Usage
|
||||
- **SetupPage**: Both `password` and `confirmPassword` required
|
||||
- **InviteCompletePage**: Both `password` and `confirmPassword` required
|
||||
- **LoginPage**: Only `password` (no confirm, already implemented in Unit 1)
|
||||
|
||||
---
|
||||
|
||||
### InviteCompleteFormData
|
||||
## FormError
|
||||
|
||||
Collected by the `InviteCompletePage` form. Submitted to `POST /Invitation/complete`.
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface FormError {
|
||||
field?: string; // field name if field-specific
|
||||
message: string;
|
||||
code?: string; // backend error code
|
||||
}
|
||||
```
|
||||
|
||||
| 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) |
|
||||
### Lifecycle
|
||||
- Extracted from backend response or validation failure
|
||||
- Displayed inline (per field) or in banner (form-level)
|
||||
- Cleared on field blur (inline) or form reset (banner)
|
||||
|
||||
### Examples
|
||||
- `{ field: "password", message: "At least 8 characters" }` — inline
|
||||
- `{ message: "Email already in use" }` — banner
|
||||
- `{ message: "Network error. Please try again." }` — banner
|
||||
|
||||
---
|
||||
|
||||
### FormBannerError
|
||||
## RoleGuard Context
|
||||
|
||||
Represents an API-level or network-level error surfaced as a dismissible banner above a form (BR-U2-21/22).
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface RoleGuardContext {
|
||||
userRole: "Owner" | "Admin" | "User" | null;
|
||||
requiredRole: "Owner" | "Admin" | "User" | ("Owner" | "Admin")[];
|
||||
hasAccess: boolean;
|
||||
accessDeniedMessage: string;
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `message` | `string` | User-facing error message |
|
||||
| `type` | `'api' \| 'network'` | Source of the error |
|
||||
### Lifecycle
|
||||
- Evaluated on route load (via TanStack Router `beforeLoad`)
|
||||
- `userRole` read from AuthContext
|
||||
- `requiredRole` defined per route (hardcoded in route definition)
|
||||
- `hasAccess` calculated as: `userRole in requiredRole(s)`
|
||||
- `accessDeniedMessage` shown inline if `hasAccess = false`
|
||||
|
||||
### Example Routes
|
||||
```typescript
|
||||
// /users — Owner or Admin
|
||||
{ requiredRole: ["Owner", "Admin"], message: "Owner or Admin access required" }
|
||||
|
||||
// /settings — Owner only
|
||||
{ requiredRole: "Owner", message: "Owner access required" }
|
||||
|
||||
// /cms — Owner only
|
||||
{ requiredRole: "Owner", message: "Owner access required" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TokenValidationState
|
||||
## AuthContext State (Unit 2 Extends Unit 1)
|
||||
|
||||
Represents the loading/success/error lifecycle of the invitation token validation on page mount.
|
||||
### Extended State
|
||||
```typescript
|
||||
interface AuthContextState {
|
||||
// From Unit 1
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
expiresAt: number | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
| 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 |
|
||||
// New in Unit 2
|
||||
userRole: "Owner" | "Admin" | "User" | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Unit 2
|
||||
- `useAuth()` returns the state + methods
|
||||
- `user.role` checked by RoleGuard for access control
|
||||
- `userRole` derived from `user.role` for convenience
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationship Diagram
|
||||
## Language Preference
|
||||
|
||||
### Type Definition
|
||||
```typescript
|
||||
interface LanguagePreference {
|
||||
code: "en" | "nl"; // ISO 639-1
|
||||
label: string; // "English" | "Nederlands"
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
- Collected in SetupPage form
|
||||
- Sent to backend in `POST /Setup`
|
||||
- Stored in user profile (backend)
|
||||
- Retrieved on login (future: Unit 3+, stored in AuthContext)
|
||||
- Used by i18next to set app locale
|
||||
|
||||
### Available Locales
|
||||
- **en**: English (fallback, eager load)
|
||||
- **nl**: Nederlands (lazy load)
|
||||
|
||||
---
|
||||
|
||||
## API Response Types
|
||||
|
||||
### Setup Response
|
||||
```typescript
|
||||
interface SetupResponse {
|
||||
message: string;
|
||||
user: User; // newly created Owner
|
||||
}
|
||||
```
|
||||
|
||||
### Invitation Validation Response
|
||||
```typescript
|
||||
interface InvitationValidationResponse {
|
||||
valid: boolean;
|
||||
email?: string; // present if valid
|
||||
error?: string; // present if invalid
|
||||
}
|
||||
```
|
||||
|
||||
### Invitation Completion Response
|
||||
```typescript
|
||||
interface InvitationCompletionResponse {
|
||||
message: string;
|
||||
user: User; // newly created User
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response (ProblemDetails)
|
||||
```typescript
|
||||
interface ProblemDetails {
|
||||
type: string; // e.g., "https://example.com/errors/validation-error"
|
||||
title: string; // e.g., "Bad Request"
|
||||
status: number; // HTTP status
|
||||
detail: string; // user-facing message
|
||||
instance?: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships & Dependencies
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class User {
|
||||
+string id
|
||||
+string name
|
||||
+string email
|
||||
+UserRole role
|
||||
}
|
||||
|
||||
class UserRole {
|
||||
<<enumeration>>
|
||||
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 {
|
||||
<<enumeration>>
|
||||
loading
|
||||
valid
|
||||
invalid
|
||||
}
|
||||
|
||||
User --> UserRole : has
|
||||
AuthSession --> User : contains
|
||||
InvitationToken --> InvitationValidation : resolves to
|
||||
InviteCompleteFormData --> InvitationValidation : pre-filled from
|
||||
TokenValidationState --> InviteCompleteFormData : gates display of
|
||||
graph LR
|
||||
User["User<br/>(created by Setup<br/>or Invitation)"]
|
||||
Auth["AuthContext<br/>(stores User)"]
|
||||
Setup["SetupStatus<br/>(session cache)"]
|
||||
Guard["InitGuard<br/>(checks status)"]
|
||||
Token["InvitationToken<br/>(email tied)"]
|
||||
Role["RoleGuard<br/>(reads role)"]
|
||||
Cred["PasswordCredential<br/>(Zod schema)"]
|
||||
|
||||
User -->|stored in| Auth
|
||||
Auth -->|provides role to| Role
|
||||
Setup -->|controls| Guard
|
||||
Token -->|creates| User
|
||||
Cred -->|validates| User
|
||||
Role -->|controls access| Auth
|
||||
|
||||
classDef entity fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
|
||||
classDef context fill:#2196F3,stroke:#0D47A1,color:#fff,stroke-width:2px
|
||||
classDef guard fill:#FF9800,stroke:#E65100,color:#fff,stroke-width:2px
|
||||
classDef validation fill:#9C27B0,stroke:#4A148C,color:#fff,stroke-width:2px
|
||||
|
||||
class User,Setup,Token entity
|
||||
class Auth,Role context
|
||||
class Guard guard
|
||||
class Cred validation
|
||||
```
|
||||
|
||||
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.
|
||||
Text alternative: Entity relationship diagram showing User as central entity (green), created by Setup or Invitation, stored in AuthContext (blue). SetupStatus controls InitGuard (orange) for initialization checks. InvitationToken creates User and is tied to email. PasswordCredential (purple) validates User credentials. RoleGuard reads User.role from AuthContext to control route access. Arrows show dependencies and data flow between entities.
|
||||
|
||||
### Data Flow
|
||||
1. App loads → InitGuard fetches SetupStatus
|
||||
2. If not initialized → redirect to SetupPage
|
||||
3. User completes setup → creates Owner User, redirects to LoginPage
|
||||
4. OR: User clicks invitation link → InviteCompletePage validates token
|
||||
5. User completes invitation → creates User, redirects to LoginPage
|
||||
6. LoginPage → creates session via AuthContext.login()
|
||||
7. AuthContext stores User (with role) in memory
|
||||
8. RoleGuard reads User.role to control route access
|
||||
|
||||
---
|
||||
|
||||
## Constraint Notes
|
||||
|
||||
- All entities align with backend schema (no frontend-only fields)
|
||||
- Timestamps are ISO 8601 strings (backend provides)
|
||||
- Role is enum (fixed set: Owner, Admin, User)
|
||||
- Email is unique key (backend enforces)
|
||||
- Passwords never stored in frontend state (only `accessToken` in memory)
|
||||
|
||||
+385
-263
@@ -4,321 +4,443 @@
|
||||
|
||||
```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<br/>UsersPage"]
|
||||
AuthLayout --> RoleGuardSettings["RoleGuard Owner<br/>SettingsPage"]
|
||||
AuthLayout --> RoleGuardCms["RoleGuard Owner<br/>CmsPage"]
|
||||
SetupPage --> useSetup["useSetup<br/>useSetupStatus · useCreateOwner"]
|
||||
InviteCompletePage --> useInvitation["useInvitation stub<br/>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
|
||||
root["__root.tsx<br/>(Root Layout)"]
|
||||
initguard["InitGuard<br/>(wrapper/hook)"]
|
||||
errorboundary["ErrorBoundary<br/>(from Unit 1)"]
|
||||
|
||||
auth["_authenticated.tsx<br/>(Protected Layout)"]
|
||||
protroute["ProtectedRoute<br/>(from Unit 1)"]
|
||||
roleguard["RoleGuard<br/>(NEW)"]
|
||||
|
||||
setup["SetupPage<br/>NEW /setup"]
|
||||
invite["InviteCompletePage<br/>NEW /invite/complete"]
|
||||
login["LoginPage<br/>from Unit 1 /login"]
|
||||
|
||||
dashboard["DashboardPage<br/>/dashboard<br/>(all roles)"]
|
||||
profile["ProfilePage<br/>/profile<br/>(all roles)"]
|
||||
users["UsersPage<br/>/users<br/>(Owner/Admin)"]
|
||||
settings["SettingsPage<br/>/settings<br/>(Owner)"]
|
||||
cms["CmsPage<br/>/cms<br/>(Owner)"]
|
||||
|
||||
root --> initguard
|
||||
root --> errorboundary
|
||||
errorboundary --> auth
|
||||
auth --> protroute
|
||||
auth --> roleguard
|
||||
|
||||
root --> setup
|
||||
root --> invite
|
||||
root --> login
|
||||
|
||||
protroute --> dashboard
|
||||
protroute --> profile
|
||||
roleguard --> users
|
||||
roleguard --> settings
|
||||
roleguard --> cms
|
||||
|
||||
classDef rootComponent fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
|
||||
classDef guardComponent fill:#FF9800,stroke:#E65100,color:#fff,stroke-width:2px
|
||||
classDef publicPage fill:#FFC107,stroke:#F57F17,color:#000,stroke-width:2px
|
||||
classDef protectedPage fill:#2196F3,stroke:#0D47A1,color:#fff,stroke-width:2px
|
||||
classDef restrictedPage fill:#E91E63,stroke:#880E4F,color:#fff,stroke-width:2px
|
||||
|
||||
class root rootComponent
|
||||
class initguard,errorboundary,protroute,roleguard guardComponent
|
||||
class setup,invite,login publicPage
|
||||
class dashboard,profile protectedPage
|
||||
class users,settings,cms restrictedPage
|
||||
```
|
||||
|
||||
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`
|
||||
Text alternative: Component hierarchy showing root layout at top, with InitGuard and ErrorBoundary branching down to the _authenticated layout containing ProtectedRoute and RoleGuard. Unprotected routes (Setup, Invite, Login) branch directly from root; protected routes branch from ProtectedRoute (Dashboard, Profile) or RoleGuard (Users, Settings, CMS), with color indicating access level (green=root, orange=guard, yellow=public, blue=protected, pink=owner-only).
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### 1. InitGuard (embedded in `__root` route)
|
||||
### SetupPage
|
||||
|
||||
Not a standalone component — implemented as logic within the `__root.tsx` route using TanStack Router's `beforeLoad` or as a React effect on mount.
|
||||
**Path**: `src/pages/SetupPage.tsx`
|
||||
|
||||
| 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 }` |
|
||||
**Purpose**: Collect first Owner account credentials and system initialization
|
||||
|
||||
---
|
||||
|
||||
### 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=<currentPath>` (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:
|
||||
<RoleGuard allowedRoles={['Owner']}>
|
||||
<SettingsPage />
|
||||
</RoleGuard>
|
||||
```
|
||||
|
||||
**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 |
|
||||
**Props**: None (route component)
|
||||
|
||||
**State**:
|
||||
- `formData`: { name, email, password, confirmPassword, language }
|
||||
- `isLoading`: boolean (during submit)
|
||||
- `error`: FormError | null (banner error)
|
||||
|
||||
| State | Type | Description |
|
||||
|---|---|---|
|
||||
| `bannerError` | `FormBannerError \| null` | API/network error shown above form |
|
||||
| `isSuccess` | `boolean` | True after successful submission (shows success state) |
|
||||
**Form Fields**:
|
||||
- Text input: **Name** (required, 1–255 chars)
|
||||
- Email input: **Email** (required, valid email)
|
||||
- Password input: **Password** (required, 8+ chars, upper, digit, special)
|
||||
- Password input: **Confirm Password** (required, must match password)
|
||||
- Dropdown select: **Language Preference** (options: "English", "Nederlands")
|
||||
|
||||
**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)
|
||||
**Validation**:
|
||||
- Real-time: Zod schema on blur/change
|
||||
- Submit: Full form validation, show errors inline + banner
|
||||
|
||||
**API integration**: `useCreateOwner()` mutation from `src/api/useSetup.ts`
|
||||
**Submission**:
|
||||
- POST `/Setup` with form data
|
||||
- Success: Show success message, redirect to `/login` after delay (2–3 seconds)
|
||||
- Error: Show banner with backend error message
|
||||
- Network error: Show "Network error. Please try again."
|
||||
|
||||
**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.*`
|
||||
**Data Attributes**:
|
||||
- `data-testid="setup-form"` — form element
|
||||
- `data-testid="setup-name-input"` — name field
|
||||
- `data-testid="setup-email-input"` — email field
|
||||
- `data-testid="setup-password-input"` — password field
|
||||
- `data-testid="setup-confirm-password-input"` — confirm password field
|
||||
- `data-testid="setup-language-select"` — language dropdown
|
||||
- `data-testid="setup-submit-button"` — submit button
|
||||
|
||||
**Layout**:
|
||||
- Card container (from Unit 1 shadcn primitives)
|
||||
- Heading: "Initialize System" (translated: `t('setup.title')`)
|
||||
- Form fields in vertical stack
|
||||
- Submit button: "Create Owner Account" (translated: `t('setup.submitButton')`)
|
||||
- Error banner (if error exists): dismissible, red background
|
||||
- Success message (after submit): inline, success styling
|
||||
|
||||
**API Hooks**:
|
||||
- Custom hook `useSetup()` — POST /Setup
|
||||
- Returns: `{ mutate, isLoading, error }`
|
||||
- Error format: backend ProblemDetails
|
||||
|
||||
**i18n Keys**:
|
||||
- `setup.title`, `setup.nameLabel`, `setup.emailLabel`, `setup.passwordLabel`, `setup.confirmPasswordLabel`, `setup.languageLabel`, `setup.submitButton`, `setup.successMessage`
|
||||
|
||||
---
|
||||
|
||||
### 5. InviteCompletePage (`src/pages/InviteCompletePage.tsx`)
|
||||
### InviteCompletePage
|
||||
|
||||
**Purpose**: Completes account setup for an invited user via a tokenized URL.
|
||||
**Path**: `src/pages/InviteCompletePage.tsx`
|
||||
|
||||
**States / lifecycle**:
|
||||
**Purpose**: Complete user invitation and create non-Owner user account
|
||||
|
||||
| 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 |
|
||||
**Props**: None (route component; token from URL query param)
|
||||
|
||||
**State**:
|
||||
- `token`: string (from URL query param)
|
||||
- `invitationEmail`: string | null (from validation)
|
||||
- `loadingState`: "loading" | "ready" | "error" | "submitting" | "success"
|
||||
- `formData`: { name, password, confirmPassword }
|
||||
- `error`: FormError | null (banner error)
|
||||
|
||||
| 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 |
|
||||
**Lifecycle**:
|
||||
1. Mount: Extract token from URL
|
||||
2. Validation: GET `/Invitation/validate?token=xxx`
|
||||
- Show loading state
|
||||
- If valid: show form with email read-only
|
||||
- If invalid: show error state with "Request new invitation" link
|
||||
|
||||
**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`
|
||||
**Form Fields** (shown only if token valid):
|
||||
- Text input: **Email** (read-only, pre-filled from validation)
|
||||
- Text input: **Name** (required, 1–255 chars)
|
||||
- Password input: **Password** (required, 8+ chars, upper, digit, special)
|
||||
- Password input: **Confirm Password** (required, must match password)
|
||||
|
||||
**API integration**:
|
||||
- `useValidateInvitation(token)` → `GET /Invitation/validate?token=xxx` (stub in Unit 2)
|
||||
- `useCompleteSetup()` → `POST /Invitation/complete` (stub in Unit 2)
|
||||
**Validation**:
|
||||
- Real-time: Zod schema on blur/change
|
||||
- Submit: Full form validation, show errors inline + banner
|
||||
|
||||
**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`
|
||||
**Submission**:
|
||||
- POST `/Invitation/complete` with { token, name, password }
|
||||
- Success: Show success message, redirect to `/login` after delay (2–3 seconds)
|
||||
- Error: Show banner with backend error message
|
||||
- Token expired during form fill: Show "Token expired. Request a new invitation."
|
||||
|
||||
**Data Attributes**:
|
||||
- `data-testid="invite-complete-form"` — form element
|
||||
- `data-testid="invite-email-input"` — email field (read-only)
|
||||
- `data-testid="invite-name-input"` — name field
|
||||
- `data-testid="invite-password-input"` — password field
|
||||
- `data-testid="invite-confirm-password-input"` — confirm password field
|
||||
- `data-testid="invite-submit-button"` — submit button
|
||||
- `data-testid="invite-loading-spinner"` — loading indicator
|
||||
|
||||
**States & Layout**:
|
||||
- **Loading**: Spinner + "Validating invitation..." (translated: `t('inviteComplete.loadingMessage')`)
|
||||
- **Ready** (token valid): Form with all fields visible
|
||||
- **Error** (token invalid): Full-page error message + link to request new invitation
|
||||
- **Success**: Success message + redirect message
|
||||
|
||||
**Headings & Labels**:
|
||||
- Heading: "Complete Your Account" (translated: `t('inviteComplete.title')`)
|
||||
- Email label: "Email" (read-only)
|
||||
- Form fields, error messages, button text all translated
|
||||
|
||||
**API Hooks**:
|
||||
- Custom hook `useValidateInvitation(token)` — GET /Invitation/validate
|
||||
- Returns: `{ data: { valid, email }, isLoading, error }`
|
||||
- Custom hook `useCompleteInvitation()` — POST /Invitation/complete
|
||||
- Returns: `{ mutate, isLoading, error }`
|
||||
|
||||
**i18n Keys**:
|
||||
- `inviteComplete.title`, `inviteComplete.emailLabel`, `inviteComplete.nameLabel`, `inviteComplete.passwordLabel`, `inviteComplete.confirmPasswordLabel`, `inviteComplete.submitButton`, `inviteComplete.loadingMessage`, `inviteComplete.invalidTokenMessage`, `inviteComplete.requestNewInvitationLink`, `inviteComplete.successMessage`
|
||||
|
||||
---
|
||||
|
||||
### 6. LoginPage (update — `src/pages/LoginPage.tsx`)
|
||||
### InitGuard Hook
|
||||
|
||||
**Change from Unit 1**: Add inline field validation on blur (BR-U2-32/33). The API error banner is already in place.
|
||||
**Path**: `src/auth/InitGuard.tsx` or `src/contexts/useInitGuard.ts`
|
||||
|
||||
**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
|
||||
**Purpose**: Ensure system initialization before accessing protected routes
|
||||
|
||||
**Hook Signature**:
|
||||
```typescript
|
||||
function useInitGuard(): {
|
||||
initialized: boolean | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Runs in `__root.tsx` route `beforeLoad`
|
||||
- Calls `GET /Setup/status` on app mount
|
||||
- Caches result for entire session (`staleTime: Infinity`)
|
||||
- Returns `{ initialized, isLoading, error }`
|
||||
|
||||
**Router Integration** (in `src/router.tsx`):
|
||||
```typescript
|
||||
export const rootRoute = createRootRoute({
|
||||
component: () => {
|
||||
const { initialized, isLoading } = useInitGuard();
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!initialized) {
|
||||
return <Navigate to="/setup" />;
|
||||
}
|
||||
return <RootLayout />;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Logic**:
|
||||
- If `isLoading`: show spinner
|
||||
- If not initialized: redirect to `/setup` (blocks all other routes)
|
||||
- If initialized: proceed normally
|
||||
|
||||
**Bypass Routes**:
|
||||
- `/setup` — always accessible (InitGuard not checked)
|
||||
- `/login` — always accessible (public route)
|
||||
- `/invite/complete` — always accessible (token-authenticated, public)
|
||||
|
||||
**Cache Duration**: Infinite (session-scoped)
|
||||
|
||||
---
|
||||
|
||||
### 7. Shared Component — PasswordField
|
||||
### RoleGuard Hook/Component
|
||||
|
||||
A reusable wrapper around shadcn `Input` that adds a show/hide toggle.
|
||||
**Path**: `src/auth/RoleGuard.tsx` or inline in route `beforeLoad`
|
||||
|
||||
**Purpose**: Enforce role-based access to protected routes
|
||||
|
||||
**Implementation**: TanStack Router `beforeLoad` hook in each protected route
|
||||
|
||||
**Logic**:
|
||||
```typescript
|
||||
const authenticatedRoute = createRoute({
|
||||
getParentRoute: () => _authenticatedRoute,
|
||||
path: '/users',
|
||||
beforeLoad: ({ context }) => {
|
||||
const { user } = context; // from AuthContext
|
||||
if (!user || !['Owner', 'Admin'].includes(user.role)) {
|
||||
// Denied — return error or redirect with flag
|
||||
throw new Error('Access denied'); // or set flag in context
|
||||
}
|
||||
},
|
||||
component: UsersPage
|
||||
});
|
||||
```
|
||||
|
||||
**Alternative: Page-Level Guard**:
|
||||
- Route allows access, component checks role
|
||||
- If denied: show inline error message instead of redirect
|
||||
- **User's preference (Q10-C)**: Inline message (not separate /403 route)
|
||||
|
||||
**Routes & Requirements**:
|
||||
|
||||
| Route | Required Role(s) | If Denied |
|
||||
|-------|------------------|-----------|
|
||||
| `/users` | Owner or Admin | Inline message |
|
||||
| `/settings` | Owner | Inline message |
|
||||
| `/cms` | Owner | Inline message |
|
||||
|
||||
**Page-Level Implementation** (if using page component):
|
||||
```typescript
|
||||
function UsersPage() {
|
||||
const { user } = useAuth();
|
||||
const hasAccess = user?.role === 'Owner' || user?.role === 'Admin';
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="p-6 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-yellow-800">{t('errors.accessDenied')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <UsersPageContent />;
|
||||
}
|
||||
```
|
||||
|
||||
**Data Attributes**:
|
||||
- `data-testid="access-denied-message"` — error message container
|
||||
|
||||
**i18n Keys**:
|
||||
- `errors.accessDenied`
|
||||
|
||||
---
|
||||
|
||||
## Shared Components & Hooks (Unit 2 Creates or Extends)
|
||||
|
||||
### useSetup Hook
|
||||
|
||||
**Path**: `src/api/useSetup.ts`
|
||||
|
||||
**Purpose**: Handle POST /Setup API call
|
||||
|
||||
**Signature**:
|
||||
```typescript
|
||||
function useSetup() {
|
||||
return {
|
||||
mutate: (data: SetupFormData) => Promise<SetupResponse>,
|
||||
isLoading: boolean,
|
||||
error: ProblemDetails | null
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Uses `api.post('/Setup', data)`
|
||||
- Returns ProblemDetails error if backend rejects
|
||||
|
||||
---
|
||||
|
||||
### useValidateInvitation Hook
|
||||
|
||||
**Path**: `src/api/useInvitation.ts`
|
||||
|
||||
**Purpose**: Validate invitation token
|
||||
|
||||
**Signature**:
|
||||
```typescript
|
||||
function useValidateInvitation(token: string) {
|
||||
return {
|
||||
data: { valid: boolean, email?: string } | null,
|
||||
isLoading: boolean,
|
||||
error: ProblemDetails | null
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Calls `api.get('/Invitation/validate', { params: { token } })`
|
||||
- Auto-runs on mount if token provided
|
||||
|
||||
---
|
||||
|
||||
### useCompleteInvitation Hook
|
||||
|
||||
**Path**: `src/api/useInvitation.ts`
|
||||
|
||||
**Purpose**: Complete invitation and create user
|
||||
|
||||
**Signature**:
|
||||
```typescript
|
||||
function useCompleteInvitation() {
|
||||
return {
|
||||
mutate: (data: InvitationCompletionData) => Promise<InvitationCompletionResponse>,
|
||||
isLoading: boolean,
|
||||
error: ProblemDetails | null
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- Uses `api.post('/Invitation/complete', data)`
|
||||
- Returns ProblemDetails error if backend rejects
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Components
|
||||
|
||||
### FormErrorBanner
|
||||
|
||||
**Path**: `src/components/ui/FormErrorBanner.tsx`
|
||||
|
||||
**Purpose**: Display dismissible form-level error message
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface FormErrorBannerProps {
|
||||
error: FormError | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | HTML id for label association |
|
||||
| `placeholder` | `string` | Input placeholder text |
|
||||
| `...register` | `UseFormRegisterReturn` | react-hook-form register props spread |
|
||||
**Layout**:
|
||||
- Red/warning background
|
||||
- Error message text
|
||||
- Close (×) button
|
||||
- Fade animation on dismiss
|
||||
|
||||
**Behaviour**:
|
||||
- Internal `showPassword: boolean` state
|
||||
- Renders `<Input type={showPassword ? 'text' : 'password'}>`
|
||||
- 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`
|
||||
**Used by**: SetupPage, InviteCompletePage
|
||||
|
||||
---
|
||||
|
||||
### 8. Shared Component — FormBannerError
|
||||
### FieldError
|
||||
|
||||
A dismissible alert banner rendered above form fields when an API or network error occurs.
|
||||
**Path**: `src/components/ui/FieldError.tsx`
|
||||
|
||||
**Purpose**: Display inline field-level error
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface FieldErrorProps {
|
||||
message?: string;
|
||||
}
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `error` | `FormBannerError \| null` | The error to display; `null` means hidden |
|
||||
| `onDismiss` | `() => void` | Called when user dismisses the banner |
|
||||
**Layout**:
|
||||
- Small red text below input
|
||||
- Only shown if message exists
|
||||
|
||||
**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`
|
||||
**Used by**: All form fields (SetupPage, InviteCompletePage, LoginPage)
|
||||
|
||||
---
|
||||
|
||||
## API Hooks
|
||||
## Consistency with Unit 1
|
||||
|
||||
### `src/api/useSetup.ts`
|
||||
### Reused from Unit 1:
|
||||
- shadcn Input, Button, Label, Card, DropdownMenu (from Tailwind v4 + primitives)
|
||||
- LoginPage form pattern (react-hook-form + zod, error handling)
|
||||
- AuthContext and useAuth hook
|
||||
- ApiClient and error handling (ProblemDetails)
|
||||
- MSW mock setup and handlers
|
||||
- i18n (react-i18next with lazy locale loading)
|
||||
- TanStack Router and beforeLoad guards
|
||||
- Test setup and testing utilities
|
||||
|
||||
| Hook | Type | Description |
|
||||
|---|---|---|
|
||||
| `useSetupStatus()` | Query | `GET /Setup/status` → `SetupStatus`. `staleTime: Infinity` (session cache). |
|
||||
| `useCreateOwner()` | Mutation | `POST /Setup` with `{ name, email, password }`. |
|
||||
### New in Unit 2:
|
||||
- SetupPage (form + setup flow)
|
||||
- InviteCompletePage (form + invitation flow)
|
||||
- InitGuard (route guard hook)
|
||||
- RoleGuard (route guard hook)
|
||||
- useSetup, useValidateInvitation, useCompleteInvitation (API hooks)
|
||||
- FormErrorBanner component
|
||||
- FieldError component
|
||||
- New i18n keys (setup, inviteComplete, errors sections)
|
||||
- New MSW handlers (setup, invitation)
|
||||
|
||||
### `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.
|
||||
### Pattern Consistency:
|
||||
- All forms use react-hook-form + zod
|
||||
- All API calls use api client
|
||||
- All errors follow ProblemDetails format
|
||||
- All components use shadcn primitives
|
||||
- All routes defined in src/router.tsx
|
||||
- All pages in src/pages/
|
||||
- All tests use Vitest + RTL + MSW
|
||||
|
||||
Reference in New Issue
Block a user