Adds unit 2 functional design and code generation plan and gap 4 report
This commit is contained in:
+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