Adds auth pages
This commit is contained in:
+324
@@ -0,0 +1,324 @@
|
||||
# Frontend Components — Unit 2: Authentication Pages
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### 1. InitGuard (embedded in `__root` route)
|
||||
|
||||
Not a standalone component — implemented as logic within the `__root.tsx` route using TanStack Router's `beforeLoad` or as a React effect on mount.
|
||||
|
||||
| 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 }` |
|
||||
|
||||
---
|
||||
|
||||
### 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 |
|
||||
|
||||
**State**:
|
||||
|
||||
| State | Type | Description |
|
||||
|---|---|---|
|
||||
| `bannerError` | `FormBannerError \| null` | API/network error shown above form |
|
||||
| `isSuccess` | `boolean` | True after successful submission (shows success state) |
|
||||
|
||||
**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)
|
||||
|
||||
**API integration**: `useCreateOwner()` mutation from `src/api/useSetup.ts`
|
||||
|
||||
**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.*`
|
||||
|
||||
---
|
||||
|
||||
### 5. InviteCompletePage (`src/pages/InviteCompletePage.tsx`)
|
||||
|
||||
**Purpose**: Completes account setup for an invited user via a tokenized URL.
|
||||
|
||||
**States / lifecycle**:
|
||||
|
||||
| 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 |
|
||||
|
||||
**State**:
|
||||
|
||||
| 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 |
|
||||
|
||||
**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`
|
||||
|
||||
**API integration**:
|
||||
- `useValidateInvitation(token)` → `GET /Invitation/validate?token=xxx` (stub in Unit 2)
|
||||
- `useCompleteSetup()` → `POST /Invitation/complete` (stub in Unit 2)
|
||||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
||||
### 6. LoginPage (update — `src/pages/LoginPage.tsx`)
|
||||
|
||||
**Change from Unit 1**: Add inline field validation on blur (BR-U2-32/33). The API error banner is already in place.
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
### 7. Shared Component — PasswordField
|
||||
|
||||
A reusable wrapper around shadcn `Input` that adds a show/hide toggle.
|
||||
|
||||
**Props**:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | HTML id for label association |
|
||||
| `placeholder` | `string` | Input placeholder text |
|
||||
| `...register` | `UseFormRegisterReturn` | react-hook-form register props spread |
|
||||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
||||
### 8. Shared Component — FormBannerError
|
||||
|
||||
A dismissible alert banner rendered above form fields when an API or network error occurs.
|
||||
|
||||
**Props**:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `error` | `FormBannerError \| null` | The error to display; `null` means hidden |
|
||||
| `onDismiss` | `() => void` | Called when user dismisses the banner |
|
||||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
||||
## API Hooks
|
||||
|
||||
### `src/api/useSetup.ts`
|
||||
|
||||
| Hook | Type | Description |
|
||||
|---|---|---|
|
||||
| `useSetupStatus()` | Query | `GET /Setup/status` → `SetupStatus`. `staleTime: Infinity` (session cache). |
|
||||
| `useCreateOwner()` | Mutation | `POST /Setup` with `{ name, email, password }`. |
|
||||
|
||||
### `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.
|
||||
Reference in New Issue
Block a user