13 KiB
Frontend Components — Unit 2: Authentication Pages
Component Hierarchy
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.roleis inallowedRoles→ renderchildren - 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:
- Page renders with locale pre-selected based on browser language
- User fills in fields; inline errors appear on blur (BR-U2-32)
- User changes locale → immediate language switch (BR-U2-23)
- On submit: Zod validates all fields; inline errors shown if invalid
- If valid: submit button disabled + loading spinner;
POST /Setupcalled - On success: success message displayed; redirect to
/loginafter ~1.5s - 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.subtitlesetup.fields.name,setup.fields.email,setup.fields.password,setup.fields.confirmPassword,setup.fields.localesetup.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:
- Extract
tokenfromuseSearch()(TanStack Router search params) - If no
token→ set state toinvalidimmediately (no API call) - If
tokenpresent → calluseValidateInvitation(token), set state toloading - On validation success → set state to
valid, storeInvitationValidation - On validation failure → set state to
invalidwitherrorCode
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.loadinginviteComplete.fields.*inviteComplete.errors.expired,inviteComplete.errors.used,inviteComplete.errors.notFound,inviteComplete.errors.noTokeninviteComplete.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'(ormode: '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: booleanstate - Renders
<Input type={showPassword ? 'text' : 'password'}> - Toggle button uses an eye / eye-off icon (lucide-react)
autocompleteattribute 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
Alertcomponent 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 insrc/pages/and routing is insrc/router.tsx. Route files (__root.tsx, _authenticated.tsx) follow TanStack Router conventions insrc/root orsrc/routes/as needed by the router configuration.