13 KiB
Frontend Components — Unit 2: Authentication Pages
Component Hierarchy
graph TD
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: 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
SetupPage
Path: src/pages/SetupPage.tsx
Purpose: Collect first Owner account credentials and system initialization
Props: None (route component)
State:
formData: { name, email, password, confirmPassword, language }isLoading: boolean (during submit)error: FormError | null (banner error)
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")
Validation:
- Real-time: Zod schema on blur/change
- Submit: Full form validation, show errors inline + banner
Submission:
- POST
/Setupwith form data - Success: Show success message, redirect to
/loginafter delay (2–3 seconds) - Error: Show banner with backend error message
- Network error: Show "Network error. Please try again."
Data Attributes:
data-testid="setup-form"— form elementdata-testid="setup-name-input"— name fielddata-testid="setup-email-input"— email fielddata-testid="setup-password-input"— password fielddata-testid="setup-confirm-password-input"— confirm password fielddata-testid="setup-language-select"— language dropdowndata-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
- Returns:
i18n Keys:
setup.title,setup.nameLabel,setup.emailLabel,setup.passwordLabel,setup.confirmPasswordLabel,setup.languageLabel,setup.submitButton,setup.successMessage
InviteCompletePage
Path: src/pages/InviteCompletePage.tsx
Purpose: Complete user invitation and create non-Owner user account
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)
Lifecycle:
- Mount: Extract token from URL
- 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
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)
Validation:
- Real-time: Zod schema on blur/change
- Submit: Full form validation, show errors inline + banner
Submission:
- POST
/Invitation/completewith { token, name, password } - Success: Show success message, redirect to
/loginafter 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 elementdata-testid="invite-email-input"— email field (read-only)data-testid="invite-name-input"— name fielddata-testid="invite-password-input"— password fielddata-testid="invite-confirm-password-input"— confirm password fielddata-testid="invite-submit-button"— submit buttondata-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 }
- Returns:
- Custom hook
useCompleteInvitation()— POST /Invitation/complete- Returns:
{ mutate, isLoading, error }
- Returns:
i18n Keys:
inviteComplete.title,inviteComplete.emailLabel,inviteComplete.nameLabel,inviteComplete.passwordLabel,inviteComplete.confirmPasswordLabel,inviteComplete.submitButton,inviteComplete.loadingMessage,inviteComplete.invalidTokenMessage,inviteComplete.requestNewInvitationLink,inviteComplete.successMessage
InitGuard Hook
Path: src/auth/InitGuard.tsx or src/contexts/useInitGuard.ts
Purpose: Ensure system initialization before accessing protected routes
Hook Signature:
function useInitGuard(): {
initialized: boolean | null;
isLoading: boolean;
error: Error | null;
}
Implementation:
- Runs in
__root.tsxroutebeforeLoad - Calls
GET /Setup/statuson app mount - Caches result for entire session (
staleTime: Infinity) - Returns
{ initialized, isLoading, error }
Router Integration (in src/router.tsx):
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)
RoleGuard Hook/Component
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:
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):
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:
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:
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:
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:
interface FormErrorBannerProps {
error: FormError | null;
onDismiss: () => void;
}
Layout:
- Red/warning background
- Error message text
- Close (×) button
- Fade animation on dismiss
Used by: SetupPage, InviteCompletePage
FieldError
Path: src/components/ui/FieldError.tsx
Purpose: Display inline field-level error
Props:
interface FieldErrorProps {
message?: string;
}
Layout:
- Small red text below input
- Only shown if message exists
Used by: All form fields (SetupPage, InviteCompletePage, LoginPage)
Consistency with Unit 1
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
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)
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