Adds auth pages
This commit is contained in:
+235
@@ -0,0 +1,235 @@
|
||||
# Business Logic Model — Unit 2: Authentication Pages
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Flow 1: App Initialization — InitGuard
|
||||
|
||||
**Trigger**: Every page load / app mount (runs in the root route)
|
||||
**Purpose**: Ensure the system is initialized before rendering any route
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Flow 2: Protected Route Access — ProtectedRoute
|
||||
|
||||
**Trigger**: User navigates to any route inside the authenticated layout
|
||||
**Purpose**: Prevent unauthenticated access to protected pages
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Flow 3: Role-Restricted Route Access — RoleGuard
|
||||
|
||||
**Trigger**: Authenticated user navigates to a role-restricted route
|
||||
**Purpose**: Enforce per-route role requirements
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
|
||||
class A start
|
||||
class B,D,E decision
|
||||
class C terminal
|
||||
class F denied
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Business Rules — Unit 2: Authentication Pages
|
||||
|
||||
## Password Validation (Shared Schema)
|
||||
|
||||
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).
|
||||
|
||||
| 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]/)` |
|
||||
|
||||
These rules mirror the backend `IdentityOptions.Password` configuration exactly. Any change to backend password rules must also update this schema.
|
||||
|
||||
---
|
||||
|
||||
## Confirm Password
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## Email Validation
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| BR-U2-07 | `email` must pass Zod `.email()` (RFC-compliant format). Validated client-side before submission. |
|
||||
|
||||
---
|
||||
|
||||
## System Initialization Guard (InitGuard)
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## Authentication Guard (ProtectedRoute)
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## Role Guard (RoleGuard)
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## SetupPage 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. |
|
||||
|
||||
---
|
||||
|
||||
## InviteCompletePage 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`. |
|
||||
|
||||
---
|
||||
|
||||
## Form Error Handling (Application-Wide Standard)
|
||||
|
||||
These rules define the error handling pattern that applies to ALL forms in the application (SetupPage, InviteCompletePage, LoginPage).
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 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. |
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Domain Entities — Unit 2: Authentication Pages
|
||||
|
||||
## Overview
|
||||
|
||||
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'
|
||||
```
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### SetupFormData
|
||||
|
||||
Collected by the `SetupPage` form. Submitted to `POST /Setup`.
|
||||
|
||||
| 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 |
|
||||
|
||||
**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)
|
||||
|
||||
---
|
||||
|
||||
### InvitationToken
|
||||
|
||||
Represents the token extracted from the URL query string on the `InviteCompletePage`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `token` | `string` | Raw JWT or opaque token from `?token=xxx` in the URL |
|
||||
|
||||
---
|
||||
|
||||
### InvitationValidation
|
||||
|
||||
Returned by `GET /Invitation/validate?token=xxx` (stub in Unit 2; full implementation in Unit 5).
|
||||
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
### InviteCompleteFormData
|
||||
|
||||
Collected by the `InviteCompletePage` form. Submitted to `POST /Invitation/complete`.
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
### FormBannerError
|
||||
|
||||
Represents an API-level or network-level error surfaced as a dismissible banner above a form (BR-U2-21/22).
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `message` | `string` | User-facing error message |
|
||||
| `type` | `'api' \| 'network'` | Source of the error |
|
||||
|
||||
---
|
||||
|
||||
### TokenValidationState
|
||||
|
||||
Represents the loading/success/error lifecycle of the invitation token validation on page mount.
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
+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