Adds application design (awaiting approval)

This commit is contained in:
2026-06-17 20:15:54 +02:00
parent c7154e288f
commit 9e49489f7e
12 changed files with 1171 additions and 22 deletions
@@ -0,0 +1,151 @@
# Application Design — CMS Frontend
## Overview
This document consolidates the complete application design for the CMS frontend feature. It covers both the backend prerequisite changes (Unit 0) and the full frontend React SPA (Units 16).
**Tech decisions confirmed by user:**
- **State management**: React Context (auth) + TanStack Query (server state)
- **HTTP client**: TanStack Query (server state) using native fetch via `ApiClient` wrapper
- **Form validation**: react-hook-form + zod
- **Routing**: TanStack Router — file-based (`src/routes/`)
- **Auth token storage**: Access token in-memory (React context), refresh token in httpOnly cookie
- **Cookie**: `refreshToken`, path `/api/v1/auth`, HttpOnly, Secure, SameSite=Strict
- **CORS origins**: Configured via `appsettings.json → Cors:AllowedOrigins[]` (dotnet-appsettings pattern)
- **User data in context**: Full user object `{ id, email, name, role, isActive }`
- **Error boundaries**: Global + per-page
---
## Architecture at a Glance
```mermaid
graph LR
subgraph FE["Frontend (frontend/)"]
direction TB
Infra["Infrastructure\nApiClient · AuthContext · RouterConfig"]
Guards["Guards\nProtectedRoute · RoleGuard · InitGuard"]
Layout["Layout\nAppLayout · Sidebar · ThemeProvider"]
Hooks["TanStack Query Hooks\nuseAvailability · useUsers · useInvitation · useSetup"]
Pages["Pages\nLogin · Setup · InviteComplete\nDashboard · Users · Profile\nSettings · CMS · 403 · 404"]
end
subgraph BE["Backend (src/)"]
direction TB
CORS["CORS Policy\n(ServiceCollectionExtensions)"]
AuthCtrl["AuthController\n(httpOnly cookie)"]
AuthSvc["AuthService"]
end
Pages --> Hooks
Pages --> Infra
Layout --> Infra
Guards --> Infra
Hooks --> Infra
Infra -->|HTTP + cookie| AuthCtrl
AuthCtrl --> AuthSvc
style Infra fill:#FFC107,stroke:#F57F17,color:#000
style Guards fill:#FF5722,stroke:#BF360C,color:#fff
style Layout fill:#9C27B0,stroke:#4A148C,color:#fff
style Hooks fill:#009688,stroke:#004D40,color:#fff
style Pages fill:#2196F3,stroke:#0D47A1,color:#fff
style CORS fill:#4CAF50,stroke:#2E7D32,color:#fff
style AuthCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff
style AuthSvc fill:#FFC107,stroke:#F57F17,color:#000
```
---
## Unit Decomposition Summary
| Unit | Name | Primary Components |
|------|------|--------------------|
| 0 | Backend Prerequisites | CorsPolicy, AuthController (cookie), appsettings updates |
| 1 | Project Scaffold | ApiClient, AuthContext, RouterConfig, ThemeProvider, ErrorBoundary, `.env` |
| 2 | Auth Pages | LoginPage, SetupPage, InviteCompletePage, ProtectedRoute, RoleGuard, InitGuard |
| 3 | Layout & Navigation | AppLayout, Sidebar |
| 4 | Dashboard | DashboardPage, AvailabilityStatusBadge, useAvailabilityStatus |
| 5 | User Management | UsersPage, InviteUserDialog, useUsers, useInviteUser, useValidateInvitation, useCompleteSetup |
| 6 | Remaining Pages | ProfilePage, SettingsPage, CmsPage, NotFoundPage, AccessDeniedPage, README.md |
---
## Project Structure
```
frontend/
├── src/
│ ├── api/
│ │ ├── client.ts # ApiClient (fetch wrapper, 401 interceptor)
│ │ ├── types.ts # Shared TypeScript interfaces
│ │ ├── useAvailability.ts # TanStack Query hooks
│ │ ├── useUsers.ts
│ │ ├── useInvitation.ts
│ │ └── useSetup.ts
│ ├── auth/
│ │ ├── AuthContext.tsx # AuthContext + AuthProvider
│ │ ├── useAuth.ts # useAuth hook
│ │ ├── ProtectedRoute.tsx
│ │ ├── RoleGuard.tsx
│ │ └── InitGuard.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppLayout.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── ThemeProvider.tsx
│ │ ├── users/
│ │ │ └── InviteUserDialog.tsx
│ │ ├── shared/
│ │ │ └── AvailabilityStatusBadge.tsx
│ │ └── error/
│ │ └── ErrorBoundary.tsx
│ ├── routes/ # TanStack Router file-based routes
│ │ ├── __root.tsx # Root route (InitGuard, ErrorBoundary)
│ │ ├── index.tsx # / → DashboardPage
│ │ ├── login.tsx # /login
│ │ ├── setup.tsx # /setup
│ │ ├── invite.complete.tsx # /invite/complete
│ │ ├── users.tsx # /users (Owner/Admin)
│ │ ├── profile.tsx # /profile
│ │ ├── settings.tsx # /settings (Owner)
│ │ ├── cms.tsx # /cms (Owner)
│ │ ├── 403.tsx # /403
│ │ └── $404.tsx # catch-all
│ ├── routeTree.gen.ts # Generated by TanStack Router CLI
│ ├── main.tsx # App entry point
│ └── app.tsx # QueryClientProvider + AuthProvider + RouterProvider
├── .env.example # Template for VITE_API_BASE_URL
├── .env # gitignored — developer local override
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── vite.config.ts
└── tailwind.config.ts
```
---
## Security Design Summary
| Concern | Design Decision |
|---------|----------------|
| Access token storage | In-memory only (React state in `AuthContext`) |
| Refresh token storage | httpOnly cookie — set/cleared by backend |
| CORS | Named policy `"FrontendPolicy"` with specific origins + `AllowCredentials()` |
| Role enforcement | `RoleGuard` (routing) + `Sidebar` (UX) + backend (authoritative) |
| HTTP calls | Centralised through `ApiClient` only |
| Form validation | zod schemas matching backend password rules |
| Error handling | Global + per-page `ErrorBoundary`; generic user-facing error messages |
---
## Artifacts Index
| Artifact | Path |
|---------|------|
| Component definitions | `inception/application-design/components.md` |
| Method signatures | `inception/application-design/component-methods.md` |
| Service layer | `inception/application-design/services.md` |
| Dependency diagram | `inception/application-design/component-dependency.md` |
| This document | `inception/application-design/application-design.md` |
@@ -0,0 +1,143 @@
# Component Dependencies — CMS Frontend
## Dependency Overview
```mermaid
graph TD
subgraph Backend["Backend (Unit 0)"]
CorsPolicy["CorsPolicy\n(ServiceCollectionExtensions)"]
AuthCtrl["AuthController\n(updated)"]
AuthSvc["AuthService\n(no changes)"]
AuthCtrl --> AuthSvc
end
subgraph Infrastructure["Frontend Infrastructure Layer"]
ApiClient["ApiClient\n(fetch wrapper)"]
AuthContext["AuthContext\n(session state)"]
Router["RouterConfig\n(TanStack Router)"]
AuthContext --> ApiClient
end
subgraph Guards["Auth Guards"]
ProtectedRoute["ProtectedRoute"]
RoleGuard["RoleGuard"]
InitGuard["InitGuard"]
ProtectedRoute --> AuthContext
RoleGuard --> AuthContext
InitGuard --> ApiClient
end
subgraph Layout["Layout Components"]
AppLayout["AppLayout"]
Sidebar["Sidebar"]
ThemeProvider["ThemeProvider"]
AppLayout --> Sidebar
Sidebar --> AuthContext
end
subgraph QueryHooks["TanStack Query Hooks"]
useAvailability["useAvailabilityStatus"]
useUsers["useUsers + useInviteUser"]
useInvitation["useValidateInvitation\nuseCompleteSetup"]
useSetup["useSetupStatus\nuseCreateOwner"]
useAvailability --> ApiClient
useUsers --> ApiClient
useInvitation --> ApiClient
useSetup --> ApiClient
end
subgraph Pages["Page Components"]
LoginPage["LoginPage"]
SetupPage["SetupPage"]
InviteCompletePage["InviteCompletePage"]
DashboardPage["DashboardPage"]
UsersPage["UsersPage"]
ProfilePage["ProfilePage"]
SettingsPage["SettingsPage"]
CmsPage["CmsPage"]
LoginPage --> AuthContext
SetupPage --> useSetup
InviteCompletePage --> useInvitation
DashboardPage --> useAvailability
UsersPage --> useUsers
ProfilePage --> AuthContext
SettingsPage --> useAvailability
end
Router --> Guards
Router --> AppLayout
AppLayout --> Pages
style CorsPolicy fill:#4CAF50,stroke:#2E7D32,color:#fff
style AuthCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff
style AuthSvc fill:#FFC107,stroke:#F57F17,color:#000
style ApiClient fill:#FFC107,stroke:#F57F17,color:#000
style AuthContext fill:#FFC107,stroke:#F57F17,color:#000
style Router fill:#FFC107,stroke:#F57F17,color:#000
style ProtectedRoute fill:#FF5722,stroke:#BF360C,color:#fff
style RoleGuard fill:#FF5722,stroke:#BF360C,color:#fff
style InitGuard fill:#FF5722,stroke:#BF360C,color:#fff
style AppLayout fill:#9C27B0,stroke:#4A148C,color:#fff
style Sidebar fill:#9C27B0,stroke:#4A148C,color:#fff
style ThemeProvider fill:#9C27B0,stroke:#4A148C,color:#fff
style useAvailability fill:#009688,stroke:#004D40,color:#fff
style useUsers fill:#009688,stroke:#004D40,color:#fff
style useInvitation fill:#009688,stroke:#004D40,color:#fff
style useSetup fill:#009688,stroke:#004D40,color:#fff
style LoginPage fill:#2196F3,stroke:#0D47A1,color:#fff
style SetupPage fill:#2196F3,stroke:#0D47A1,color:#fff
style InviteCompletePage fill:#2196F3,stroke:#0D47A1,color:#fff
style DashboardPage fill:#2196F3,stroke:#0D47A1,color:#fff
style UsersPage fill:#2196F3,stroke:#0D47A1,color:#fff
style ProfilePage fill:#2196F3,stroke:#0D47A1,color:#fff
style SettingsPage fill:#2196F3,stroke:#0D47A1,color:#fff
style CmsPage fill:#2196F3,stroke:#0D47A1,color:#fff
```
---
## Dependency Matrix
| Component | Depends On | Used By |
|-----------|-----------|---------|
| **ApiClient** | `import.meta.env.VITE_API_BASE_URL`, `AuthContext.accessToken` | All TanStack Query hooks, `InitGuard` |
| **AuthContext** | `ApiClient` (for login/logout/refresh calls) | `ProtectedRoute`, `RoleGuard`, `Sidebar`, `ProfilePage`, `LoginPage` |
| **RouterConfig** | `ProtectedRoute`, `RoleGuard`, `InitGuard`, `AppLayout` | App root |
| **ProtectedRoute** | `AuthContext` | Router (wraps authenticated routes) |
| **RoleGuard** | `AuthContext` | Router (wraps role-restricted routes) |
| **InitGuard** | `useSetupStatus``ApiClient` | Router (root-level check) |
| **AppLayout** | `Sidebar`, `ThemeProvider` | Authenticated routes |
| **Sidebar** | `AuthContext` | `AppLayout` |
| **useAvailabilityStatus** | `ApiClient` | `DashboardPage`, `SettingsPage` |
| **useUsers / useInviteUser** | `ApiClient` | `UsersPage` |
| **useValidateInvitation / useCompleteSetup** | `ApiClient` | `InviteCompletePage` |
| **useSetupStatus / useCreateOwner** | `ApiClient` | `InitGuard`, `SetupPage` |
| **DashboardPage** | `useAvailabilityStatus`, `AuthContext` | Router |
| **UsersPage** | `useUsers`, `useInviteUser`, `InviteUserDialog` | Router |
| **ProfilePage** | `AuthContext` (user data from context — no API call) | Router |
| **SettingsPage** | `useAvailabilityStatus` | Router |
| **InviteCompletePage** | `useValidateInvitation`, `useCompleteSetup` | Router |
| **LoginPage** | `AuthContext.login()` | Router |
| **SetupPage** | `useCreateOwner` | Router |
---
## Communication Patterns
| Pattern | Where Used |
|---------|-----------|
| **React Context** | Auth state shared from `AuthContext` to guards, sidebar, profile, login page |
| **TanStack Query** | Server state (availability, users, invitation, setup) — cached, auto-refetch |
| **Cookie (httpOnly)** | Browser ↔ Backend: refresh token — set by backend, never read by JS |
| **In-memory state** | Access token held in `AuthContext` — cleared on page refresh (intentional) |
| **LocalStorage** | Theme preference only (`cms-theme`) — no auth data |
---
## Key Dependency Rules (Security Baseline)
- `ApiClient` is the **only** module that makes HTTP calls — no direct `fetch` in pages/components
- `accessToken` is accessed **only** through `AuthContext` — never passed as prop or stored elsewhere
- `refreshToken` is **never accessible to JavaScript** — httpOnly cookie only
- Role checks happen in `RoleGuard` (routing) and `Sidebar` (UX) — backend is authoritative
@@ -0,0 +1,192 @@
# Component Methods — CMS Frontend
> **Note**: Detailed business rules and logic are defined per-unit in the Functional Design stage (CONSTRUCTION phase). This document captures method signatures, inputs, outputs, and high-level purpose only.
---
## Backend — AuthController (updated)
```csharp
// POST /api/v1/auth/login
// Body: LoginRequest { Email, Password }
// Sets: Set-Cookie: refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth
// Returns: { accessToken: string, expiresAt: datetime, user: { id, email, name, role, isActive } }
Task<IActionResult> Login(LoginRequest request)
// POST /api/v1/auth/refresh
// Reads: Cookie: refreshToken
// Sets: Set-Cookie: refreshToken=<newToken>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth
// Returns: { accessToken: string, expiresAt: datetime, user: { id, email, name, role, isActive } }
Task<IActionResult> Refresh() // no body parameter — reads from cookie
// POST /api/v1/auth/revoke
// Requires: Authorization: Bearer <accessToken>
// Reads: Cookie: refreshToken
// Clears: Set-Cookie: refreshToken=; Expires=epoch; Path=/api/v1/auth
// Returns: 204 No Content
Task<IActionResult> Revoke() // no body parameter — reads from cookie
```
## Backend — ServiceCollectionExtensions (updated)
```csharp
// Registers CORS policy "FrontendPolicy" using Cors:AllowedOrigins from configuration
// Called in AddCoreInfrastructure()
static void AddCorsFrontendPolicy(this IServiceCollection services, IConfiguration configuration)
// Adds app.UseCors("FrontendPolicy") to the pipeline
// Called in Program.cs after UseExceptionHandler, before UseAuthentication
static void UseFrontendCors(this WebApplication app)
```
---
## Frontend — ApiClient (`frontend/src/api/client.ts`)
```typescript
// Base configured fetch wrapper; sets Authorization header and credentials: 'include'
// Retries once on 401 after calling authContext.refresh()
function createApiClient(getAccessToken: () => string | null, refresh: () => Promise<void>): ApiClient
// Typed GET request
async function get<T>(path: string, options?: RequestInit): Promise<T>
// Typed POST request
async function post<T>(path: string, body: unknown, options?: RequestInit): Promise<T>
```
## Frontend — AuthContext (`frontend/src/auth/AuthContext.tsx`)
```typescript
interface AuthContextValue {
isAuthenticated: boolean
isLoading: boolean // true during initial session restore
user: User | null // { id, email, name, role, isActive }
accessToken: string | null // in-memory only, never in localStorage
login(email: string, password: string): Promise<void>
logout(): Promise<void>
refresh(): Promise<void> // called by ApiClient on 401; restores token from cookie
}
// Hook
function useAuth(): AuthContextValue
```
## Frontend — Route Guards
```typescript
// ProtectedRoute: renders children if authenticated, redirects to /login otherwise
// Used as a TanStack Router beforeLoad or as a wrapper component
function ProtectedRoute({ children }: { children: ReactNode }): JSX.Element
// RoleGuard: renders children if user.role is in allowedRoles, redirects to /403 otherwise
function RoleGuard({ allowedRoles, children }: { allowedRoles: string[], children: ReactNode }): JSX.Element
// InitGuard: checks GET /setup/status on app load; redirects to /setup if not initialized
function InitGuard({ children }: { children: ReactNode }): JSX.Element
```
## Frontend — TanStack Query hooks (`frontend/src/api/`)
```typescript
// Fetch current availability status; staleTime: 30s
function useAvailabilityStatus(): UseQueryResult<AvailabilityStatusResponse>
// Fetch user list; staleTime: 60s
function useUsers(): UseQueryResult<User[]>
// Invite a user; invalidates useUsers on success
function useInviteUser(): UseMutationResult<InviteResponse, Error, InviteUserRequest>
// Complete invitation setup (public, no auth required)
function useCompleteSetup(): UseMutationResult<void, Error, CompleteSetupRequest>
// Validate invitation token (public)
function useValidateInvitation(token: string): UseQueryResult<InvitationValidationResponse>
```
## Frontend — Page component signatures
```typescript
// All page components are default exports used as TanStack Router route components
export default function LoginPage(): JSX.Element
export default function SetupPage(): JSX.Element
export default function InviteCompletePage(): JSX.Element // reads token from search params
export default function DashboardPage(): JSX.Element
export default function UsersPage(): JSX.Element
export default function ProfilePage(): JSX.Element
export default function SettingsPage(): JSX.Element
export default function CmsPage(): JSX.Element
export default function NotFoundPage(): JSX.Element
export default function AccessDeniedPage(): JSX.Element
```
## Frontend — Shared components
```typescript
// Availability badge with colour-coding
function AvailabilityStatusBadge({ status, message }: {
status: 'Available' | 'Maintenance' | 'Unavailable'
message?: string
}): JSX.Element
// Two-step invite dialog
function InviteUserDialog({ open, onClose }: {
open: boolean
onClose: () => void
}): JSX.Element
// Error boundary — class component wrapping children
class ErrorBoundary extends React.Component<{
fallback?: ReactNode
children: ReactNode
}>
```
---
## TypeScript Type Definitions (`frontend/src/api/types.ts`)
```typescript
interface User {
id: string
email: string
name: string
role: 'Owner' | 'Admin' | 'User'
isActive: boolean
}
interface AuthResponse {
accessToken: string
expiresAt: string // ISO datetime
user: User
}
interface AvailabilityStatusResponse {
status: 'Available' | 'Maintenance' | 'Unavailable'
checkedAt: string // ISO datetime
message?: string
}
interface InviteUserRequest {
email: string
role: 'Admin' | 'User'
}
interface InviteResponse {
inviteLink: string
}
interface CompleteSetupRequest {
token: string
displayName: string
password: string
}
interface InvitationValidationResponse {
valid: boolean
email: string
role: string
}
```
@@ -0,0 +1,196 @@
# Component Definitions — CMS Frontend
## Backend Components (Unit 0)
### CorsPolicy
- **Purpose**: Configure Cross-Origin Resource Sharing for the frontend SPA
- **Responsibilities**:
- Allow the configured frontend origin(s) from `appsettings.json → AllowedOrigins`
- Allow credentials (`AllowCredentials()`) so the browser sends the httpOnly cookie
- Allow required HTTP methods and headers
- **Location**: `src/SlpModularCms.Api/Extensions/ServiceCollectionExtensions.cs` (extended)
- **Configuration**: `appsettings.json → Cors:AllowedOrigins[]`, `appsettings.Development.json` (dev origins), `appsettings.local.json` (developer override)
### AuthController (updated)
- **Purpose**: Authentication HTTP endpoints — updated to set/clear httpOnly cookie
- **Responsibilities**:
- `Login`: Call `IAuthService.AuthenticateAsync()`, set `refreshToken` httpOnly cookie, return access token + user info only in body
- `Refresh`: Read `refreshToken` from cookie (not request body), call `IAuthService.RefreshTokenAsync()`, set new cookie, return new access token
- `Revoke`: Call `IAuthService.RevokeTokenAsync()` with cookie value, clear the cookie
- **Location**: `src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs`
### AuthService (updated)
- **Purpose**: Core authentication business logic — updated for cookie-based refresh flow
- **Responsibilities**:
- `RefreshTokenAsync(accessToken, refreshToken)` — signature unchanged; cookie reading is the controller's responsibility
- No other changes to service layer
---
## Frontend Components (Units 16)
### Infrastructure Layer
#### ApiClient
- **Purpose**: Centralised HTTP client — all API calls go through this module
- **Responsibilities**:
- Base URL from `import.meta.env.VITE_API_BASE_URL`
- Attach `Authorization: Bearer {accessToken}` header on authenticated requests
- Intercept 401 responses — trigger token refresh via `AuthContext.refresh()`, retry original request once
- Expose typed request functions used by TanStack Query
- Send requests with `credentials: 'include'` so the browser includes the httpOnly cookie
- **Location**: `frontend/src/api/client.ts`
#### AuthContext
- **Purpose**: React Context providing authentication state to the entire app
- **Responsibilities**:
- Store in-memory `accessToken` (string | null) — never persisted to localStorage
- Store authenticated user: `{ id, email, name, role, isActive }`
- Expose `login(email, password)`, `logout()`, `refresh()` methods
- On app mount: call `/auth/refresh` via httpOnly cookie to restore session
- Provide `isAuthenticated`, `isLoading`, `user`, `role` derived state
- **Location**: `frontend/src/auth/AuthContext.tsx` + `frontend/src/auth/useAuth.ts`
#### RouterConfig
- **Purpose**: TanStack Router file-based route tree with integrated guards
- **Responsibilities**:
- File-based route structure under `frontend/src/routes/`
- Root route checks `InitGuard` (setup status) before rendering any child
- Authenticated routes wrapped with `ProtectedRoute` check
- Role-specific routes wrapped with `RoleGuard` check
- Redirect to `/login` with `?redirect=` param on auth failure
- **Location**: `frontend/src/routes/` (file-based), `frontend/src/routeTree.gen.ts` (generated)
---
### Auth & Guard Components
#### ProtectedRoute
- **Purpose**: Redirect unauthenticated users to `/login`
- **Responsibilities**:
- Read `isAuthenticated` from `AuthContext`
- If not authenticated: redirect to `/login?redirect={currentPath}`
- If loading (session restore in progress): show loading spinner — no flash of content
- If authenticated: render child route
- **Location**: `frontend/src/auth/ProtectedRoute.tsx`
#### RoleGuard
- **Purpose**: Restrict routes to specific roles
- **Responsibilities**:
- Accept `allowedRoles: string[]` prop
- Read `user.role` from `AuthContext`
- If role not in `allowedRoles`: redirect to `/403`
- If role allowed: render child route
- **Location**: `frontend/src/auth/RoleGuard.tsx`
#### InitGuard
- **Purpose**: Redirect to `/setup` if the system is not initialized
- **Responsibilities**:
- On first app render: call `GET /setup/status`
- If `initialized: false`: redirect all routes to `/setup`
- If `initialized: true`: allow normal routing
- Show loading state during check
- Handle API failure with error page + retry
- **Location**: `frontend/src/auth/InitGuard.tsx`
---
### Layout Components
#### AppLayout
- **Purpose**: Authenticated shell layout wrapping all protected pages
- **Responsibilities**:
- Render `Sidebar` on the left + `<Outlet />` (page content) on the right
- Responsive: full sidebar on desktop, collapsed/hamburger on mobile
- **Location**: `frontend/src/components/layout/AppLayout.tsx`
#### Sidebar
- **Purpose**: Navigation sidebar with role-filtered links
- **Responsibilities**:
- Read `user.role` from `AuthContext` to filter navigation items
- Navigation items: Dashboard (all), User Management (Owner/Admin), System Settings (Owner), CMS (Owner), Profile (all)
- Logout button: calls `AuthContext.logout()`
- Theme toggle: calls `ThemeProvider.toggle()`
- Highlight active route
- Collapsible on mobile
- **Location**: `frontend/src/components/layout/Sidebar.tsx`
#### ThemeProvider
- **Purpose**: Dark/light theme management
- **Responsibilities**:
- Wrap app with `next-themes` provider
- Persist theme preference in `localStorage` under key `cms-theme`
- Default to system preference
- Expose `useTheme()` hook via next-themes
- **Location**: `frontend/src/components/layout/ThemeProvider.tsx`
---
### Page Components
#### LoginPage (`/login`)
- **Purpose**: Unauthenticated login form
- **Responsibilities**: Email + password form, react-hook-form + zod validation, calls `AuthContext.login()`
- **Location**: `frontend/src/routes/login.tsx`
#### SetupPage (`/setup`)
- **Purpose**: First-time system initialization form
- **Responsibilities**: Email + password form (owner creation), calls `POST /setup/owner`, redirects to `/login` on success
- **Location**: `frontend/src/routes/setup.tsx`
#### InviteCompletePage (`/invite/complete`)
- **Purpose**: Invitation token completion form
- **Responsibilities**: Validate token via `GET /users/validate-invitation`, show display name + password form, call `POST /users/complete-setup`
- **Location**: `frontend/src/routes/invite.complete.tsx`
#### DashboardPage (`/`)
- **Purpose**: Authenticated home page
- **Responsibilities**: Welcome widget (user name + role), availability status widget (TanStack Query)
- **Location**: `frontend/src/routes/index.tsx`
#### UsersPage (`/users`)
- **Purpose**: User management page (Owner/Admin only)
- **Responsibilities**: User list (TanStack Query), InviteUserDialog (invite form + share link step)
- **Location**: `frontend/src/routes/users.tsx`
#### ProfilePage (`/profile`)
- **Purpose**: Read-only profile page
- **Responsibilities**: Show `user.name`, `user.email`, `user.role` from `AuthContext`
- **Location**: `frontend/src/routes/profile.tsx`
#### SettingsPage (`/settings`)
- **Purpose**: System settings page (Owner only)
- **Responsibilities**: Show availability status (TanStack Query), show initialized state, placeholder sections
- **Location**: `frontend/src/routes/settings.tsx`
#### CmsPage (`/cms`)
- **Purpose**: CMS management placeholder (Owner only)
- **Responsibilities**: Work-in-progress placeholder with description of future multi-CMS management
- **Location**: `frontend/src/routes/cms.tsx`
#### NotFoundPage (`/404` or catch-all)
- **Purpose**: 404 error page
- **Location**: `frontend/src/routes/$404.tsx`
#### AccessDeniedPage (`/403`)
- **Purpose**: 403 forbidden page shown when role guard fails
- **Location**: `frontend/src/routes/403.tsx`
---
### Shared UI Components
#### InviteUserDialog
- **Purpose**: Modal dialog for inviting a new user
- **Responsibilities**: Two-step: (1) invite form with email + role, (2) share invite link with copy button
- **Location**: `frontend/src/components/users/InviteUserDialog.tsx`
#### AvailabilityStatusBadge
- **Purpose**: Colour-coded availability status indicator
- **Responsibilities**: Display Available (green) / Maintenance (yellow) / Unavailable (red) with reason
- **Location**: `frontend/src/components/shared/AvailabilityStatusBadge.tsx`
#### ErrorBoundary (Global + Per-Page)
- **Purpose**: Catch unexpected React errors
- **Responsibilities**: Global boundary at app root; per-page boundaries for isolated page failures; show user-friendly fallback UI
- **Location**: `frontend/src/components/error/ErrorBoundary.tsx`
@@ -0,0 +1,112 @@
# Services — CMS Frontend
## Service Architecture Overview
The frontend uses **TanStack Query** as the server-state service layer. All API interactions are encapsulated in typed query/mutation hooks. The `ApiClient` handles transport concerns (headers, 401 retry). `AuthContext` manages session state. There is no additional service abstraction layer needed.
---
## Backend Services (Unit 0 changes)
### CORS Service (new)
- **Service**: `ServiceCollectionExtensions.AddCorsFrontendPolicy()`
- **Purpose**: Register named CORS policy `"FrontendPolicy"` for frontend SPA
- **Configuration source**: `IConfiguration``Cors:AllowedOrigins` array
- **Key settings**:
- `WithOrigins(allowedOrigins)` — from config
- `AllowAnyHeader()` + `AllowAnyMethod()`
- `AllowCredentials()` — required for httpOnly cookie
- **dotnet-appsettings pattern**: `AllowedOrigins` stored in `appsettings.json` (production) and `appsettings.Development.json` (dev, e.g. `["http://localhost:5173"]`); `appsettings.local.json` for developer-specific overrides
---
## Frontend Services (TanStack Query hooks)
### Auth Service (`frontend/src/auth/`)
**AuthContext** functions as the auth service — it is the single source of truth for authentication state.
| Operation | Implementation | Notes |
|-----------|----------------|-------|
| Login | `POST /api/v1/auth/login` → stores `accessToken` in memory | httpOnly cookie set by server |
| Session restore | `POST /api/v1/auth/refresh` on app mount → stores new `accessToken` | Reads cookie automatically |
| Logout | `POST /api/v1/auth/revoke` → clears in-memory token | Server clears cookie |
| Token refresh (interceptor) | `POST /api/v1/auth/refresh` on 401 → retry original request | Centralised in ApiClient |
### Availability Service (`frontend/src/api/useAvailability.ts`)
| Hook | Query key | Endpoint | Stale time |
|------|-----------|----------|-----------|
| `useAvailabilityStatus()` | `['availability', 'status']` | `GET /api/v1/availability/status` | 30s |
### Users Service (`frontend/src/api/useUsers.ts`)
| Hook | Query key | Endpoint | Notes |
|------|-----------|----------|-------|
| `useUsers()` | `['users']` | `GET /api/v1/users` | Owner/Admin only |
| `useInviteUser()` | mutation | `POST /api/v1/users/invite` | Invalidates `['users']` on success |
### Invitation Service (`frontend/src/api/useInvitation.ts`)
| Hook | Query key | Endpoint | Notes |
|------|-----------|----------|-------|
| `useValidateInvitation(token)` | `['invitation', token]` | `GET /api/v1/users/validate-invitation?token=` | Public, no auth |
| `useCompleteSetup()` | mutation | `POST /api/v1/users/complete-setup` | Public, no auth |
### Setup Service (`frontend/src/api/useSetup.ts`)
| Hook | Query key | Endpoint | Notes |
|------|-----------|----------|-------|
| `useSetupStatus()` | `['setup', 'status']` | `GET /api/v1/setup/status` | Used by `InitGuard` |
| `useCreateOwner()` | mutation | `POST /api/v1/setup/owner` | Used on `/setup` page |
---
## Service Interaction Diagram
```mermaid
graph TD
App["App Entry\n(main.tsx)"]
InitGuard["InitGuard\n(useSetupStatus)"]
AuthCtx["AuthContext\n(session state)"]
ApiClient["ApiClient\n(fetch + 401 retry)"]
Router["TanStack Router\n(ProtectedRoute + RoleGuard)"]
subgraph Pages["Page Components"]
Login["LoginPage"]
Dashboard["DashboardPage\n(useAvailabilityStatus)"]
Users["UsersPage\n(useUsers, useInviteUser)"]
Invite["InviteCompletePage\n(useValidateInvitation\nuseCompleteSetup)"]
Settings["SettingsPage\n(useAvailabilityStatus)"]
Profile["ProfilePage"]
end
App --> InitGuard
App --> AuthCtx
InitGuard -->|initialized| Router
Router --> Login
Router --> Dashboard
Router --> Users
Router --> Invite
Router --> Settings
Router --> Profile
AuthCtx -->|getAccessToken| ApiClient
AuthCtx -->|refresh| ApiClient
Dashboard --> ApiClient
Users --> ApiClient
Invite --> ApiClient
Settings --> ApiClient
style App fill:#CE93D8,stroke:#6A1B9A,color:#000
style InitGuard fill:#FFC107,stroke:#F57F17,color:#000
style AuthCtx fill:#FFC107,stroke:#F57F17,color:#000
style ApiClient fill:#FFC107,stroke:#F57F17,color:#000
style Router fill:#4CAF50,stroke:#2E7D32,color:#fff
style Login fill:#2196F3,stroke:#0D47A1,color:#fff
style Dashboard fill:#2196F3,stroke:#0D47A1,color:#fff
style Users fill:#2196F3,stroke:#0D47A1,color:#fff
style Invite fill:#2196F3,stroke:#0D47A1,color:#fff
style Settings fill:#2196F3,stroke:#0D47A1,color:#fff
style Profile fill:#2196F3,stroke:#0D47A1,color:#fff
```