Files

13 KiB
Raw Permalink Blame History

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, 1255 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 /Setup with form data
  • Success: Show success message, redirect to /login after delay (23 seconds)
  • Error: Show banner with backend error message
  • Network error: Show "Network error. Please try again."

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

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:

  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

Form Fields (shown only if token valid):

  • Text input: Email (read-only, pre-filled from validation)
  • Text input: Name (required, 1255 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/complete with { token, name, password }
  • Success: Show success message, redirect to /login after delay (23 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

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.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):

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