Adds front-end set-up

This commit is contained in:
2026-06-20 17:04:17 +02:00
parent efd1569c26
commit 7dfc3a9692
62 changed files with 6875 additions and 3 deletions
+43
View File
@@ -7,6 +7,7 @@ Een modulaire monolith CMS gebouwd met .NET 10.
- `src/SlpModularCms.Api`: De host applicatie en API shell.
- `src/SlpModularCms.Core`: Kern functionaliteiten, data modellen en interfaces.
- `src/SlpModularCms.Modules.*`: Onafhankelijke functionele modules.
- `frontend/`: De CMS admin web-UI (Vite + React + TypeScript). Bewust buiten `src/` gehouden om de .NET solution schoon te houden.
## Development Setup
@@ -59,6 +60,48 @@ De API gebruikt een beveiligde flow voor authenticatie:
- **Rate Limiting**: Login endpoints hebben rate limiting (Fixed window 5/min, Sliding window 20/min).
- **Error Handling**: Foutmeldingen volgen de RFC 9457 `ProblemDetails` standaard.
## Frontend Development (CMS Admin UI)
De frontend is een Vite + React 19 + TypeScript single-page application in de map `frontend/`. Hij gebruikt TanStack Router, Tailwind v4 (hoofdkleur `#ac0000`) met shadcn/ui-stijl componenten, react-i18next (NL/EN), en MSW voor mocking in tests.
### Vereisten
- Node.js 20+ (getest met v24)
- pnpm 9+ (getest met v11)
### Snel starten
```powershell
cd frontend
pnpm install
pnpm dev
```
De dev-server draait op `http://localhost:5173`.
### Configuratie
De frontend leest de API-basis-URL uit een environment-variabele (zie `frontend/.env.example`):
```
VITE_API_BASE_URL=http://localhost:5000
```
Kopieer `.env.example` naar `.env.local` en pas de waarde aan indien nodig. Optioneel kan met `VITE_ENABLE_MSW=true` de MSW-mockbackend in de browser worden ingeschakeld voor frontend-ontwikkeling zonder draaiende API.
### Vereiste backend
De app verwacht de .NET API (Unit 0) draaiend op de geconfigureerde origin met:
- **CORS** die de frontend-origin (`http://localhost:5173`) toestaat met `credentials`.
- De `httpOnly` `refreshToken` cookie op pad `/api/v1/auth` (silent refresh bij opstarten).
- RFC 9457 `ProblemDetails` foutmeldingen.
> Let op: de API-poort in `.env.example` (`5000`) moet overeenkomen met de werkelijke API-poort en de `Cors:AllowedOrigins` configuratie van de backend.
### Scripts
```powershell
pnpm dev # ontwikkelserver (HMR)
pnpm build # type-check (tsc) + productie-build
pnpm preview # productie-build lokaal bekijken
pnpm test # unit/integratietests (Vitest + Testing Library + MSW)
pnpm test:coverage # tests met coverage-rapport
pnpm lint # ESLint
pnpm format # Prettier (4-space indent)
```
## Database Migraties
Alle database commando's moeten worden uitgevoerd vanaf de **root** van de projectmap.
@@ -5,7 +5,7 @@
- **Feature Slug**: cms-frontend
- **Project Type**: Brownfield
- **Start Date**: 2026-06-16T20:27:00Z
- **Current Stage**: CONSTRUCTION - Unit 0: Backend Prerequisites
- **Current Stage**: CONSTRUCTION - Unit 1: Code Generation complete (awaiting user review)
- **Branch**: unknown
## Workspace State
@@ -14,7 +14,7 @@
- **Workspace Root**: K:\Development\Projects\SlpModularCms
## Code Location Rules
- **Application Code**: src/SlpModularCms.Frontend/ (new React app)
- **Application Code**: frontend/ (new Vite + React app; kept outside src/ to keep the .NET solution clean — see unit-1-code-generation-plan.md)
- **Feature Documentation**: aidlc-docs/features/cms-frontend/ only
- **Shared Artifacts**: aidlc-docs/_shared/
- **Structure patterns**: See code-generation.md Critical Rules
@@ -47,7 +47,7 @@
### 🟢 CONSTRUCTION PHASE (per unit)
- [x] Unit 0 — Backend Prerequisites (CORS + httpOnly cookie) — COMPLETED 2026-06-19T11:45:00Z
- [ ] Unit 1 — Project Scaffold & Infrastructure
- [~] Unit 1 — Project Scaffold & Infrastructure — Code Generation done (build/lint/test green); awaiting user review 2026-06-20
- [ ] Unit 2 — Authentication Pages
- [ ] Unit 3 — Layout & Navigation
- [ ] Unit 4 — Dashboard
+24
View File
@@ -187,3 +187,27 @@
**Context**: User requested removal of a plan note; plan updated accordingly.
---
## Code Generation (Unit 1) — Plan Approved & Generation Started
**Timestamp**: 2026-06-20T00:00:00Z
**User Input**: "ga verder met de aidlc-workflow [@unit-1-code-generation-plan.md]"
**AI Response**: "Interpreted as approval of the Unit 1 code generation plan. Resolved frontend location discrepancy in favor of the plan (`frontend/` subfolder) and updated aidlc-state.md. Proceeding to Part 2 — Generation."
**Context**: Code Generation stage, Unit 1 (Project Scaffold & Infrastructure). Verified tooling: node v24.13.0, pnpm 11.7.0. Backend Unit 0 complete.
---
## Code Generation (Unit 1) — Generation Complete
**Timestamp**: 2026-06-20T17:00:00Z
**User Input**: "ga verder [@unit-1-code-generation-plan.md]"
**AI Response**: "Executed all 15 plan steps. Scaffolded Vite + React 19 + TypeScript app in frontend/ with TanStack Router (guards), Tailwind v4 (#ac0000) + shadcn-style primitives, AuthContext (silent refresh + 401 retry), ApiClient with ProblemDetailsError, react-i18next (lazy NL/EN), MSW mocks, login/dashboard/layout, and 10 passing tests. Verification: pnpm build OK, pnpm test 10/10, pnpm lint clean, format clean, dev server boots (HTTP 200). Recorded 5 deviations (React 19, code-based router, hand-authored shadcn, locales in src, MSW aligned to real backend) in the plan. Presented 2-option completion message."
**Context**: Construction phase, Unit 1 Code Generation (Part 2). Auth-related coverage >70% (NFR-U1-04). Awaiting user approval to continue to Unit 2.
---
@@ -0,0 +1,113 @@
# Code Generation Plan — Unit 1: Project Scaffold & Infrastructure
**Status**: ✅ Generation complete 2026-06-20 — all steps executed and verified (build, lint, tests green). Awaiting user review.
## Plan Context
- **Unit**: Unit 1 — Frontend Project Scaffold & Infrastructure
- **Type**: Greenfield (new Vite + React + TypeScript application)
- **Workspace Root**: `K:\Development\Projects\SlpModularCms` (frontend placed in `frontend/` subfolder to keep .NET solution clean)
- **Stories Covered**: US-01 (Login), US-02 (Silent Refresh), US-03 (Protected Routes), US-04 (Dashboard Shell), US-05 (User Menu + Language Switcher), US-06 (401 Intercept), US-07 (Error Handling), US-13 (Password rules alignment already done in backend), US-18/US-20 (CMS Management Owner-only — out of scope for Unit 1)
- **Dependencies**: Backend Unit 0 (CORS, httpOnly refresh cookie, ProblemDetails) must be complete and running on localhost:5000
- **NFR Traceability**: NFR-U1-01 to NFR-U1-07 fully addressed via chosen patterns (Q1-A, Q2-A, Q3-B, Q4-B, Q5-B, Q6-A)
## Generation Steps
### Step 1: Bootstrap Vite + React + TypeScript Project
- [x] Run `pnpm create vite@latest frontend --template react-ts` in repo root
- [x] `cd frontend && pnpm install`
- [x] Verify dev server works on http://localhost:5173 (boots in ~267ms, HTTP 200)
- [x] Initial Vite scaffold in place
### Step 2: Install Core Dependencies
- [x] Install TanStack Router: `@tanstack/react-router`
- [x] Install Tailwind v4: `tailwindcss @tailwindcss/vite` (v4 Vite plugin, not postcss/autoprefixer)
- [x] Install react-i18next + detector: `react-i18next i18next i18next-browser-languagedetector`
- [x] Install testing stack: `vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event msw jsdom @vitest/coverage-v8`
- [x] Install UI primitives: `lucide-react sonner class-variance-authority clsx tailwind-merge @radix-ui/react-dropdown-menu @radix-ui/react-slot @radix-ui/react-label react-hook-form zod @hookform/resolvers`
- [x] shadcn primitives hand-authored for Tailwind v4 (CSS variables, primary `#ac0000`) — see Deviations re: `shadcn init`
### Step 3: Configure Tooling (ESLint, Prettier, 4-space indent)
- [x] ESLint flat config + `.prettierrc` enforcing 4-space indentation
- [x] Added `pnpm format` and `pnpm lint` scripts (+ `format:check`, `test`, `test:coverage`)
- [x] Configured `tsconfig.app.json` paths (`@/*``src/*`) and Vite alias
### Step 4: Environment Configuration
- [x] Created `.env.example` with `VITE_API_BASE_URL=http://localhost:5000`
- [x] Created `.env.local` (gitignored via `*.local`)
- [x] Extended `src/vite-env.d.ts` with typed `ImportMetaEnv` (per Q6-A)
- [x] Added `useAppConfig`/`getAppConfig` with Zod (dev-only validation) in `src/lib/config.ts`
### Step 5: Project Folder Structure
- [x] Created `src/components/ui/`, `src/components/layout/`, `src/lib/`, `src/contexts/`, `src/i18n/`, `src/mocks/{auth,users,setup}/`, `src/pages/`, `src/test/`, `src/api/`, `src/i18n/locales/{en,nl}/`
- [x] Note: route components live in `src/pages/` + central `src/router.tsx` (code-based routing — see Deviations); locales in `src/i18n/locales/` (see Deviations)
### Step 6: Implement ApiClient (fetch wrapper)
- [x] `src/lib/api-client.ts` with `credentials: 'include'`, JSON handling, `ProblemDetailsError` + `NetworkError` classes (BR-U1-03, BR-U1-08)
- [x] 401 intercept + single retry via refresh handler (BR-U1-04)
- [x] Exported typed singleton `api`
### Step 7: Implement AuthContext + Silent Refresh
- [x] `src/contexts/auth-context.ts` (context + `useAuth`) and `src/contexts/AuthProvider.tsx` with `user`, `accessToken` (memory only), `expiresAt`, `login`, `logout`, `refresh`
- [x] Silent refresh on mount using httpOnly cookie (BR-U1-01)
- [x] `useAuth()` hook provided
### Step 8: Setup i18n with Lazy Loading
- [x] `src/i18n/config.ts` using react-i18next + detector; English eager (fallback), other locales lazy via dynamic import (Q4-B)
- [x] `LanguageSwitcher` component (shadcn dropdown) wired into the topbar
- [x] `en/translation.json` and `nl/translation.json` with initial keys (common, nav, login, dashboard, userMenu, errors)
### Step 9: Setup MSW for Development & Tests
- [x] `src/mocks/browser.ts` and `src/mocks/server.ts`; worker generated at `public/mockServiceWorker.js`
- [x] Feature-scoped `authHandlers` (login, refresh, revoke), `userHandlers`, `setupHandlers` (per Q3-B) — aligned to real backend (no `/me`; user comes from login/refresh)
- [x] `src/mocks/index.ts` barrel
### Step 10: TanStack Router Setup + Guards
- [x] Router configured in `src/router.tsx` (`createRouter` + `RouterProvider` in `main.tsx`)
- [x] `_authenticated` layout route with `beforeLoad` guard (redirect to `/login`) (BR-U1-05)
- [x] Public routes: `/login`, `/setup`
- [x] Protected routes under `_authenticated`: `/dashboard`, `/users`, `/cms`
### Step 11: Core UI Components & Pages (Automation-Friendly)
- [x] Login page with form, `data-testid="login-form-submit-button"`, email/password fields, error banner
- [x] AppLayout with Sidebar (nav links), Topbar (user menu + LanguageSwitcher)
- [x] Dashboard shell (placeholder)
- [x] Stable `data-testid` attributes on interactive elements (BR-U1-11)
### Step 12: Example Tests (Vitest + RTL + MSW)
- [x] `src/test/setup.ts` with MSW server + jsdom polyfills; `src/test/utils.tsx` providers wrapper
- [x] `LoginPage.test.tsx` — successful login, validation errors, invalid credentials
- [x] `AuthContext.test.tsx` — silent refresh success/failure, 401 retry flow, refresh-failure clears session
- [x] `RouteGuard.test.tsx` — guest redirect, authenticated access, authed-from-login redirect
- [x] Coverage on auth-related code >70% (AuthProvider 92.7%, api-client 84.8%, auth-context 80%, LoginPage 84.2%) (NFR-U1-04)
- [x] `pnpm test` script
### Step 13: shadcn Theme & Styling
- [x] Tailwind v4 with primary `#ac0000` (CSS variables, light + dark tokens)
- [x] Consistent spacing, typography, and focus-visible rings (Q2-A)
- [x] Responsive sidebar (hidden < md) + main content area
### Step 14: Documentation & README
- [x] Root `README.md` "Frontend Development (CMS Admin UI)" section (Dutch, matching the file)
- [x] `.env.example` explained + required backend (localhost:5000, CORS, httpOnly cookies)
### Step 15: Final Verification
- [x] `pnpm build` succeeds (tsc + vite, only non-fatal vendor chunk-size + cosmetic glob warnings)
- [x] All tests pass (`pnpm test` → 10/10)
- [x] `pnpm lint` clean, `pnpm format:check` clean
- [x] Dev server smoke: boots (267ms), serves HTTP 200, login/guard/401/language covered by integration tests
## Deviations from the Original Plan (with rationale)
1. **React 19 instead of React 18**: the current Vite `react-ts` template scaffolds React 19.2 (stable). All chosen libraries support it; downgrading would fight the ecosystem. No business-rule impact.
2. **Code-based TanStack Router (`src/router.tsx`) instead of file-based routes**: avoids the route-tree codegen plugin, making `build`/`test` deterministic with no generated `routeTree.gen.ts`. All routing business rules (BR-U1-05/06, guard, `_authenticated` layer) are fully satisfied. Per-feature pages are lazy-loaded (Q1-A / NFR-U1-01) via `React.lazy` boundaries.
3. **Hand-authored shadcn primitives instead of `npx shadcn init`**: the CLI is interactive and Tailwind-v4 setup is config-driven; primitives (Button, Input, Label, Card, DropdownMenu, Toaster) were authored directly with the `#ac0000` theme. Same end result, no interactive prompt.
4. **Locales in `src/i18n/locales/` instead of `public/locales/`**: enables real per-language code-split chunks via dynamic `import()` and works in Vitest without network mocking. English (fallback) is eager; `nl` is a separate chunk. Files under `public/` are static assets not meant to be imported.
5. **MSW handlers aligned to the real backend**: no `/me` endpoint exists; the user object is returned by login/refresh. Setup status mocked at `/Setup/status` to match the backend route.
## Notes
- No pre-commit hooks (NFR-U1-06)
- No Sentry/OpenTelemetry (NFR-U1-03)
- Basic a11y only (NFR-U1-02)
- Bundle optimized via lazy per-feature routes (NFR-U1-01); remaining ~573 kB chunk is vendor code (React/TanStack/Radix/i18next/zod) — acceptable for a scaffold, revisit with vendor chunking if it grows
- All patterns from NFR Design (Unit 1) are followed
---
@@ -0,0 +1,91 @@
# Code Generation Summary — Unit 1: Project Scaffold & Infrastructure
**Generated**: 2026-06-20
**Application code location**: `frontend/` (workspace root)
**Type**: Greenfield (Vite + React 19 + TypeScript)
## Overview
This unit delivers the frontend scaffold and shared infrastructure for the CMS admin UI: build tooling, theme, the authentication session model (silent refresh + 401 retry), routing with guards, i18n, mocking, and an example login → dashboard flow with tests.
## Tech Stack (as built)
| Concern | Choice |
|---|---|
| Build / dev | Vite 8 |
| UI | React 19 + TypeScript (strict) |
| Routing | TanStack Router (code-based) with `_authenticated` guard layer |
| Styling | Tailwind v4 (`@tailwindcss/vite`), primary `#ac0000`, shadcn/ui-style primitives |
| i18n | react-i18next + browser language detector, lazy locale chunks (NL/EN) |
| Forms/validation | react-hook-form + zod |
| Toasts | sonner |
| Mocking | MSW (browser + node) |
| Testing | Vitest + Testing Library + MSW (jsdom) |
## Created Files (application code under `frontend/`)
### Config & tooling
- `vite.config.ts` — React + Tailwind plugins, `@` alias, Vitest config (jsdom, coverage)
- `tsconfig.app.json` — strict, `@/*` paths, test/jest-dom types
- `package.json` — scripts: dev/build/preview/test/test:watch/test:coverage/lint/format
- `.prettierrc` — 4-space indentation (BR-U1-12)
- `eslint.config.js` — flat config + unused-vars `^_` + targeted react-refresh overrides
- `pnpm-workspace.yaml``allowBuilds` for msw/esbuild/oxide
- `.env.example`, `.env.local``VITE_API_BASE_URL` (BR-U1-09)
- `index.html`, `src/index.css` — title + Tailwind theme tokens (light/dark, `#ac0000`)
- `src/vite-env.d.ts` — typed `ImportMetaEnv` (Q6-A)
### Core libraries
- `src/api/types.ts``User`, `AuthResponse`, `ProblemDetails`, `SetupStatus`, etc. (`name`, not `naam`; BR-U1-10)
- `src/lib/utils.ts``cn()`
- `src/lib/config.ts``getAppConfig()` with dev-only Zod validation (Q6-A)
- `src/lib/api-client.ts``ApiClient`, `ProblemDetailsError`, `NetworkError`; credentials, 401 refresh+retry (BR-U1-03/04/08)
- `src/contexts/auth-context.ts` — context + `useAuth()`
- `src/contexts/AuthProvider.tsx` — in-memory session, silent refresh on mount (BR-U1-01/02/14)
### i18n
- `src/i18n/config.ts` — init + lazy `changeLanguage` (Q4-B)
- `src/i18n/LanguageSwitcher.tsx`
- `src/i18n/locales/en/translation.json`, `src/i18n/locales/nl/translation.json`
### Routing & layout
- `src/router.tsx` — route tree, guards (BR-U1-05/06), lazy feature routes (Q1-A)
- `src/main.tsx` — providers + bootstrap splash + optional MSW
- `src/components/layout/{AppLayout,Sidebar,Topbar,UserMenu}.tsx`
### UI primitives
- `src/components/ui/{button,input,label,card,dropdown-menu,sonner}.tsx`
### Pages
- `src/pages/{LoginPage,DashboardPage,UsersPage,CmsPage,SetupPage}.tsx`
### Mocks (Q3-B)
- `src/mocks/auth/{handlers,fixtures}.ts`, `src/mocks/users/handlers.ts`, `src/mocks/setup/handlers.ts`
- `src/mocks/{index,browser,server}.ts`, `public/mockServiceWorker.js`
### Tests
- `src/test/setup.ts`, `src/test/utils.tsx`
- `src/contexts/AuthContext.test.tsx`, `src/pages/LoginPage.test.tsx`, `src/test/RouteGuard.test.tsx`
### Documentation
- Root `README.md` — "Frontend Development (CMS Admin UI)" section + project-structure note
## Verification Results
- **Build** (`pnpm build`): ✅ tsc + vite succeed (non-fatal vendor chunk-size warning + cosmetic en-glob warning)
- **Tests** (`pnpm test`): ✅ 3 files, 10/10 passing
- **Coverage** (auth-related, NFR-U1-04 >70%): AuthProvider 92.7%, api-client 84.8%, auth-context 80%, LoginPage 84.2%
- **Lint** (`pnpm lint`): ✅ clean
- **Format** (`pnpm format:check`): ✅ clean
- **Dev smoke**: ✅ boots ~267ms, HTTP 200, title `SlpModularCms`
## Story Coverage
| Story | Status in Unit 1 |
|---|---|
| US-01 Login | ✅ Login page + auth flow |
| US-02 Silent Refresh | ✅ AuthProvider on-mount refresh |
| US-03 Protected Routes | ✅ `_authenticated` guard |
| US-04 Dashboard Shell | ✅ Layout + dashboard placeholder |
| US-05 User Menu + Language Switcher | ✅ Topbar UserMenu + LanguageSwitcher |
| US-06 401 Intercept | ✅ ApiClient refresh+retry |
| US-07 Error Handling | ✅ ProblemDetailsError + inline/toast surfaces |
| US-18/US-20 CMS Mgmt | ⏭️ Out of scope for Unit 1 (placeholder page only) |
See `../../plans/unit-1-code-generation-plan.md` for full step checklist and deviations.
+4
View File
@@ -0,0 +1,4 @@
# Base URL of the SlpModularCms .NET API (Unit 0 backend).
# The backend must be running with CORS configured to allow this origin
# and to send the httpOnly refresh-token cookie (credentials: include).
VITE_API_BASE_URL=http://localhost:5000
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+9
View File
@@ -0,0 +1,9 @@
{
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"endOfLine": "lf"
}
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+39
View File
@@ -0,0 +1,39 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage', 'public/mockServiceWorker.js']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
// Entry and route-tree files legitimately export non-components.
{
files: ['src/main.tsx', 'src/router.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SlpModularCms</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.21.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"react-i18next": "^17.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"msw": "^2.14.6",
"prettier": "^3.8.4",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.9"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+4084
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
msw: true
esbuild: true
'@tailwindcss/oxide': true
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+43
View File
@@ -0,0 +1,43 @@
// Domain types shared across the frontend. Names align with backend payloads
// (the user property is `name`, never `naam`) — see BR-U1-10.
// Roles aligned with backend authorization.
export type UserRole = 'Owner' | 'Administrator' | 'User';
export interface User {
id: string; // UUID
email: string;
name: string;
role: UserRole;
isActive: boolean;
}
export interface AuthResponse {
accessToken: string; // JWT access token
expiresAt: string; // ISO timestamp
user: User;
}
export interface LoginRequest {
email: string;
password: string;
}
// RFC 9457 ProblemDetails for standardized error handling.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
// Extension members (e.g. traceId, errors).
[extension: string]: unknown;
}
// Convenience wrapper for API results (optional usage).
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: ProblemDetails };
// Setup status (used by guards during bootstrap).
export interface SetupStatus {
initialized: boolean;
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { Sidebar } from '@/components/layout/Sidebar';
import { Topbar } from '@/components/layout/Topbar';
/**
* Shell for authenticated routes. Renders the persistent chrome and reacts to
* runtime auth loss (e.g. a failed refresh during an API call) by redirecting
* to /login (BR-U1-14).
*/
export function AppLayout() {
const { isAuthenticated, status } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (status === 'guest') {
void navigate({ to: '/login' });
}
}, [status, navigate]);
if (!isAuthenticated) {
return null;
}
return (
<div className="flex min-h-svh">
<Sidebar />
<div className="flex flex-1 flex-col">
<Topbar />
<main className="flex-1 p-6" data-testid="app-main">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavItem {
to: string;
labelKey: string;
icon: LucideIcon;
testId: string;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' },
];
export function Sidebar() {
const { t } = useTranslation();
return (
<aside
className="hidden w-64 shrink-0 border-r border-border bg-card md:flex md:flex-col"
data-testid="app-sidebar"
>
<div className="flex h-16 items-center gap-2 border-b border-border px-6">
<span className="h-3 w-3 rounded-full bg-primary" aria-hidden="true" />
<span className="text-lg font-semibold">{t('common.appName')}</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
data-testid={item.testId}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
activeProps={{
className: cn('bg-accent text-accent-foreground'),
}}
>
<Icon className="size-4" />
{t(item.labelKey)}
</Link>
);
})}
</nav>
</aside>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { UserMenu } from '@/components/layout/UserMenu';
export function Topbar() {
return (
<header
className="flex h-16 items-center justify-end gap-1 border-b border-border bg-background px-6"
data-testid="app-topbar"
>
<LanguageSwitcher />
<UserMenu />
</header>
);
}
@@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LogOut, User as UserIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
export function UserMenu() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
await navigate({ to: '/login' });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={user?.name ?? 'User menu'}
data-testid="user-menu-button"
>
<UserIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="font-medium" data-testid="user-menu-name">
{user?.name}
</span>
<span className="text-xs font-normal text-muted-foreground">
{user?.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void handleLogout();
}}
data-testid="user-menu-logout"
>
<LogOut />
{t('userMenu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components
export { buttonVariants };
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return (
<h2
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
export function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return <p className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />;
}
@@ -0,0 +1,70 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
/** A checkable indicator for selected items (e.g. the active language). */
export function DropdownMenuCheck({ checked }: { checked: boolean }) {
return <Check className={cn('ml-auto', checked ? 'opacity-100' : 'opacity-0')} />;
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
export const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
+6
View File
@@ -0,0 +1,6 @@
import { Toaster as SonnerToaster } from 'sonner';
/** App toast surface (NFR-U1-03 / Q5-B). */
export function Toaster() {
return <SonnerToaster position="top-right" richColors closeButton />;
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { I18nextProvider } from 'react-i18next';
import type { ReactNode } from 'react';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { api } from '@/lib/api-client';
import { server } from '@/mocks/server';
import { API_BASE, mockUser } from '@/mocks/auth/fixtures';
import { mockGuest } from '@/test/utils';
import i18n from '@/i18n/config';
function wrapper({ children }: { children: ReactNode }) {
return (
<I18nextProvider i18n={i18n}>
<AuthProvider>{children}</AuthProvider>
</I18nextProvider>
);
}
describe('AuthContext', () => {
it('hydrates the session via silent refresh on mount (BR-U1-01)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe(mockUser.email);
expect(result.current.accessToken).toBe('mock-access-token');
});
it('falls back to guest when silent refresh fails', async () => {
mockGuest();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('guest'));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('refreshes and retries once on a 401 (BR-U1-04)', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
let calls = 0;
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () => {
calls += 1;
if (calls === 1) {
return HttpResponse.json(
{ status: 401, title: 'Unauthorized' },
{ status: 401 },
);
}
return HttpResponse.json({ value: 'ok' });
}),
);
let data: unknown;
await act(async () => {
data = await api.get('/api/v1/widgets');
});
expect(calls).toBe(2);
expect(data).toEqual({ value: 'ok' });
});
it('clears the session when the 401 refresh also fails', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
// Both the protected call and the refresh now fail.
server.use(
http.get(`${API_BASE}/api/v1/widgets`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json({ status: 401 }, { status: 401 }),
),
);
await act(async () => {
await expect(api.get('/api/v1/widgets')).rejects.toThrow();
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(false));
});
});
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse } from '@/api/types';
import { api } from '@/lib/api-client';
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/contexts/auth-context';
/**
* Owns the in-memory authentication session. On mount it attempts a silent
* refresh using the httpOnly cookie (BR-U1-01) and wires the ApiClient's
* 401 interceptor to this provider's refresh/clear logic (BR-U1-04).
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContextValue['user']>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const applySession = useCallback((data: AuthResponse) => {
setUser(data.user);
setAccessToken(data.accessToken);
setExpiresAt(data.expiresAt);
api.setAccessToken(data.accessToken);
setStatus('authenticated');
}, []);
const clearSession = useCallback(() => {
setUser(null);
setAccessToken(null);
setExpiresAt(null);
api.setAccessToken(null);
setStatus('guest');
}, []);
const refresh = useCallback(async (): Promise<string | null> => {
try {
const data = await api.post<AuthResponse>('/api/v1/auth/refresh', undefined, {
skipAuthRefresh: true,
});
applySession(data);
return data.accessToken;
} catch {
clearSession();
return null;
}
}, [applySession, clearSession]);
const login = useCallback(
async (email: string, password: string) => {
const data = await api.post<AuthResponse>(
'/api/v1/auth/login',
{ email, password },
{ skipAuthRefresh: true },
);
applySession(data);
},
[applySession],
);
const logout = useCallback(async () => {
try {
await api.post('/api/v1/auth/revoke', undefined, { skipAuthRefresh: true });
} catch {
// Revoke is best-effort; clear local state regardless.
} finally {
clearSession();
}
}, [clearSession]);
// Wire the ApiClient interceptor hooks to this provider.
useEffect(() => {
api.setRefreshHandler(refresh);
api.setAuthFailureHandler(clearSession);
return () => {
api.setRefreshHandler(null);
api.setAuthFailureHandler(null);
};
}, [refresh, clearSession]);
// Silent refresh on app mount (BR-U1-01). This intentionally synchronizes
// React state with the external session (httpOnly cookie) on startup.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
}, [refresh]);
const value = useMemo<AuthContextValue>(
() => ({
status,
user,
accessToken,
expiresAt,
isAuthenticated: status === 'authenticated',
login,
logout,
refresh,
}),
[status, user, accessToken, expiresAt, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+27
View File
@@ -0,0 +1,27 @@
import { createContext, useContext } from 'react';
import type { User } from '@/api/types';
export type AuthStatus = 'loading' | 'authenticated' | 'guest';
export interface AuthContextValue {
status: AuthStatus;
user: User | null;
/** Access token, kept only in memory (BR-U1-02). */
accessToken: string | null;
expiresAt: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Performs a cookie-based silent refresh; resolves to the new token or null. */
refresh: () => Promise<string | null>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheck,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
changeLanguage,
LANGUAGE_LABELS,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from '@/i18n/config';
/** Language selector for the topbar; lazy-loads the chosen locale (Q4-B). */
export function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const current = (i18n.resolvedLanguage ?? 'en') as SupportedLanguage;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('userMenu.language')}
data-testid="language-switcher-button"
>
<Languages />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('userMenu.language')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{SUPPORTED_LANGUAGES.map((lng) => (
<DropdownMenuItem
key={lng}
onSelect={() => {
void changeLanguage(lng);
}}
data-testid={`language-option-${lng}`}
>
{LANGUAGE_LABELS[lng]}
<DropdownMenuCheck checked={current === lng} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+54
View File
@@ -0,0 +1,54 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en/translation.json';
export const SUPPORTED_LANGUAGES = ['en', 'nl'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
en: 'English',
nl: 'Nederlands',
};
// English (the fallback) is bundled eagerly so the UI never flashes raw keys.
// Other locales are lazy-loaded on demand (Q4-B / NFR-U1-05).
const loaded = new Set<string>(['en']);
async function loadLocale(lng: string): Promise<void> {
if (loaded.has(lng) || !SUPPORTED_LANGUAGES.includes(lng as SupportedLanguage)) {
return;
}
const module = await import(`./locales/${lng}/translation.json`);
i18n.addResourceBundle(lng, 'translation', module.default, true, true);
loaded.add(lng);
}
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES as unknown as string[],
nonExplicitSupportedLngs: true,
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
react: { useSuspense: false },
});
// Ensure the detected language is loaded after init.
void loadLocale(i18n.resolvedLanguage ?? 'en');
/** Lazy-load the target locale, then switch to it (persisted by the detector). */
export async function changeLanguage(lng: SupportedLanguage): Promise<void> {
await loadLocale(lng);
await i18n.changeLanguage(lng);
}
export default i18n;
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Loading…",
"cancel": "Cancel",
"save": "Save",
"retry": "Retry"
},
"nav": {
"dashboard": "Dashboard",
"users": "Users",
"cms": "CMS"
},
"login": {
"title": "Sign in",
"subtitle": "Sign in to your SlpModularCms account",
"email": "Email",
"emailPlaceholder": "you@example.com",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"errors": {
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"passwordRequired": "Password is required",
"invalidCredentials": "Invalid email or password",
"generic": "Something went wrong. Please try again."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {{name}}",
"placeholder": "Your dashboard widgets will appear here."
},
"userMenu": {
"language": "Language",
"logout": "Sign out"
},
"errors": {
"network": "Unable to reach the server. Check your connection and try again."
}
}
@@ -0,0 +1,42 @@
{
"common": {
"appName": "SlpModularCms",
"loading": "Laden…",
"cancel": "Annuleren",
"save": "Opslaan",
"retry": "Opnieuw"
},
"nav": {
"dashboard": "Dashboard",
"users": "Gebruikers",
"cms": "CMS"
},
"login": {
"title": "Inloggen",
"subtitle": "Log in op je SlpModularCms-account",
"email": "E-mail",
"emailPlaceholder": "jij@voorbeeld.nl",
"password": "Wachtwoord",
"submit": "Inloggen",
"submitting": "Bezig met inloggen…",
"errors": {
"emailRequired": "E-mail is verplicht",
"emailInvalid": "Voer een geldig e-mailadres in",
"passwordRequired": "Wachtwoord is verplicht",
"invalidCredentials": "Ongeldige e-mail of wachtwoord",
"generic": "Er is iets misgegaan. Probeer het opnieuw."
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welkom terug, {{name}}",
"placeholder": "Je dashboard-widgets verschijnen hier."
},
"userMenu": {
"language": "Taal",
"logout": "Uitloggen"
},
"errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw."
}
}
+118
View File
@@ -0,0 +1,118 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
/*
* Design tokens. Primary brand color is #ac0000 per BR-U1-07.
* shadcn/ui-style semantic variables consumed by component primitives.
*/
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #ac0000;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f5e6e6;
--accent-foreground: #ac0000;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #ac0000;
--radius: 0.5rem;
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #18181b;
--card-foreground: #fafafa;
--popover: #18181b;
--popover-foreground: #fafafa;
--primary: #e11d1d;
--primary-foreground: #fafafa;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #3a1212;
--accent-foreground: #fafafa;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #e11d1d;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
}
@layer base {
* {
border-color: var(--color-border);
}
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Visible focus ring for keyboard navigation (NFR-U1-02 / Q2-A). */
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
}
+155
View File
@@ -0,0 +1,155 @@
import type { ProblemDetails } from '@/api/types';
import { getAppConfig } from '@/lib/config';
/**
* Typed error thrown for any non-2xx API response. Wraps an RFC 9457
* ProblemDetails body so callers can present context-aware messages
* (BR-U1-08, NFR-U1-05 / Q5-B) instead of raw stack traces.
*/
export class ProblemDetailsError extends Error {
readonly status: number;
readonly problem: ProblemDetails;
constructor(status: number, problem: ProblemDetails) {
super(problem.title ?? problem.detail ?? `Request failed with status ${status}`);
this.name = 'ProblemDetailsError';
this.status = status;
this.problem = problem;
}
}
/** Thrown when the network request itself fails (offline, timeout, DNS). */
export class NetworkError extends Error {
constructor(message = 'Network request failed') {
super(message);
this.name = 'NetworkError';
}
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
/** Parsed and JSON-serialized automatically when present. */
body?: unknown;
/** Skip the 401 refresh+retry interceptor (used by the refresh call itself). */
skipAuthRefresh?: boolean;
}
type RefreshHandler = () => Promise<string | null>;
type AuthFailureHandler = () => void;
/**
* Thin fetch wrapper. All calls send credentials so the httpOnly refresh
* cookie travels with the request (BR-U1-03). The access token is held only
* in memory and injected per request (BR-U1-02).
*/
export class ApiClient {
private readonly baseUrl: string;
private accessToken: string | null = null;
private refreshHandler: RefreshHandler | null = null;
private authFailureHandler: AuthFailureHandler | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
setAccessToken(token: string | null): void {
this.accessToken = token;
}
/** Registered by AuthContext: performs a cookie-based refresh, returns the new token or null. */
setRefreshHandler(handler: RefreshHandler | null): void {
this.refreshHandler = handler;
}
/** Registered by AuthContext: invoked when refresh fails and auth must be cleared. */
setAuthFailureHandler(handler: AuthFailureHandler | null): void {
this.authFailureHandler = handler;
}
get<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'GET' });
}
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'POST', body });
}
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'PUT', body });
}
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>(path, { ...options, method: 'DELETE' });
}
private async request<T>(path: string, options: RequestOptions): Promise<T> {
let response = await this.rawFetch(path, options);
// 401 intercept: try a single cookie-based refresh, then retry once (BR-U1-04).
if (response.status === 401 && !options.skipAuthRefresh && this.refreshHandler !== null) {
const newToken = await this.refreshHandler();
if (newToken !== null) {
response = await this.rawFetch(path, options);
} else {
this.authFailureHandler?.();
}
}
return this.parse<T>(response);
}
private async rawFetch(path: string, options: RequestOptions): Promise<Response> {
const { body, skipAuthRefresh: _skip, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
if (body !== undefined && body !== null) {
finalHeaders.set('Content-Type', 'application/json');
}
if (this.accessToken !== null) {
finalHeaders.set('Authorization', `Bearer ${this.accessToken}`);
}
try {
return await fetch(`${this.baseUrl}${path}`, {
...rest,
headers: finalHeaders,
credentials: 'include',
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
});
} catch (cause) {
throw new NetworkError(cause instanceof Error ? cause.message : undefined);
}
}
private async parse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const data = text.length > 0 ? safeJsonParse(text) : undefined;
if (!response.ok) {
const problem: ProblemDetails = isProblemDetails(data)
? data
: { status: response.status, title: response.statusText };
throw new ProblemDetailsError(response.status, problem);
}
return data as T;
}
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isProblemDetails(value: unknown): value is ProblemDetails {
return typeof value === 'object' && value !== null;
}
/** Shared singleton API client configured from the typed environment. */
export const api = new ApiClient(getAppConfig().apiBaseUrl);
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/**
* Application configuration sourced from Vite env (Q6-A).
* Strong typing comes from vite-env.d.ts; the optional Zod parse below runs
* once and only warns in development — production trusts the build-time env.
*/
const configSchema = z.object({
apiBaseUrl: z.string().url(),
});
export type AppConfig = z.infer<typeof configSchema>;
let cached: AppConfig | null = null;
export function getAppConfig(): AppConfig {
if (cached !== null) {
return cached;
}
const raw: AppConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
};
if (import.meta.env.DEV) {
const result = configSchema.safeParse(raw);
if (!result.success) {
// Development-only warning; never throws so the app still boots.
console.warn('[config] Invalid environment configuration:', result.error.format());
}
}
cached = raw;
return cached;
}
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge conditional class names and resolve Tailwind conflicts.
* Standard shadcn/ui helper.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+52
View File
@@ -0,0 +1,52 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import './index.css';
import './i18n/config';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
function BootstrapSplash() {
return (
<div className="flex min-h-svh items-center justify-center text-muted-foreground">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/**
* Renders the router only once the initial silent refresh has settled, so the
* route guards see a definitive auth state rather than the transient loading one.
*/
function InnerApp() {
const auth = useAuth();
if (auth.status === 'loading') {
return <BootstrapSplash />;
}
return <RouterProvider router={router} context={{ auth }} />;
}
async function enableMocking(): Promise<void> {
if (import.meta.env.VITE_ENABLE_MSW !== 'true') {
return;
}
const { worker } = await import('@/mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
void enableMocking().then(() => {
const rootElement = document.getElementById('root');
if (rootElement === null) {
throw new Error('Root element #root not found');
}
createRoot(rootElement).render(
<StrictMode>
<AuthProvider>
<InnerApp />
<Toaster />
</AuthProvider>
</StrictMode>,
);
});
+27
View File
@@ -0,0 +1,27 @@
import type { AuthResponse, User } from '@/api/types';
/** Base URL the ApiClient targets; mocks must match the absolute URL. */
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000';
export const TEST_CREDENTIALS = {
email: 'owner@example.com',
password: 'Password123!',
};
export const mockUser: User = {
id: '11111111-1111-1111-1111-111111111111',
email: TEST_CREDENTIALS.email,
name: 'Test Owner',
role: 'Owner',
isActive: true,
};
export function makeAuthResponse(overrides: Partial<AuthResponse> = {}): AuthResponse {
return {
accessToken: 'mock-access-token',
// Fixed timestamp keeps fixtures deterministic.
expiresAt: '2099-01-01T00:00:00.000Z',
user: mockUser,
...overrides,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { http, HttpResponse } from 'msw';
import type { LoginRequest, ProblemDetails } from '@/api/types';
import { API_BASE, makeAuthResponse, TEST_CREDENTIALS } from './fixtures';
function problem(status: number, title: string, detail?: string): ProblemDetails {
return {
type: 'about:blank',
title,
status,
detail,
traceId: '00-mock-trace-00',
};
}
/**
* Default auth mocks aligned with the real backend contract:
* POST /api/v1/auth/login, /refresh, /revoke. Tests override these per case
* via `server.use(...)` to simulate failures and 401 flows.
*/
export const authHandlers = [
http.post(`${API_BASE}/api/v1/auth/login`, async ({ request }) => {
const body = (await request.json()) as LoginRequest;
if (body.email === TEST_CREDENTIALS.email && body.password === TEST_CREDENTIALS.password) {
return HttpResponse.json(makeAuthResponse());
}
return HttpResponse.json(problem(401, 'Invalid email or password'), { status: 401 });
}),
// By default refresh succeeds (simulates a valid httpOnly cookie present).
http.post(`${API_BASE}/api/v1/auth/refresh`, () => {
return HttpResponse.json(makeAuthResponse());
}),
http.post(`${API_BASE}/api/v1/auth/revoke`, () => {
return new HttpResponse(null, { status: 204 });
}),
];
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './index';
/** MSW worker for development in the browser. */
export const worker = setupWorker(...handlers);
+11
View File
@@ -0,0 +1,11 @@
import { authHandlers } from './auth/handlers';
import { userHandlers } from './users/handlers';
import { setupHandlers } from './setup/handlers';
/** All default MSW handlers, composed from feature folders (Q3-B). */
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers];
export { authHandlers } from './auth/handlers';
export { userHandlers } from './users/handlers';
export { setupHandlers } from './setup/handlers';
export * from './auth/fixtures';
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from 'msw/node';
import { handlers } from './index';
/** MSW server for the Node/jsdom test environment. */
export const server = setupServer(...handlers);
+10
View File
@@ -0,0 +1,10 @@
import { http, HttpResponse } from 'msw';
import type { SetupStatus } from '@/api/types';
import { API_BASE } from '../auth/fixtures';
/** Setup status mock (used by public bootstrap guards). */
export const setupHandlers = [
http.get(`${API_BASE}/Setup/status`, () =>
HttpResponse.json<SetupStatus>({ initialized: true }),
),
];
+17
View File
@@ -0,0 +1,17 @@
import { http, HttpResponse } from 'msw';
import type { User } from '@/api/types';
import { API_BASE, mockUser } from '../auth/fixtures';
const mockUsers: User[] = [
mockUser,
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
},
];
/** Placeholder user-management mocks; expanded in Unit 5. */
export const userHandlers = [http.get(`${API_BASE}/Users`, () => HttpResponse.json(mockUsers))];
+14
View File
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next';
/** Placeholder; CMS management (Owner-only, US-18/US-20) arrives in a later unit. */
export function CmsPage() {
const { t } = useTranslation();
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold" data-testid="cms-title">
{t('nav.cms')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/contexts/auth-context';
export function DashboardPage() {
const { t } = useTranslation();
const { user } = useAuth();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold" data-testid="dashboard-title">
{t('dashboard.title')}
</h1>
<p className="text-muted-foreground" data-testid="dashboard-welcome">
{t('dashboard.welcome', { name: user?.name ?? '' })}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t('dashboard.title')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('dashboard.placeholder')}</p>
</CardContent>
</Card>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { TEST_CREDENTIALS } from '@/mocks/auth/fixtures';
describe('LoginPage', () => {
it('shows validation errors when submitting an empty form', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
const submit = await screen.findByTestId('login-form-submit-button');
await user.click(submit);
expect(await screen.findByTestId('login-email-error')).toBeInTheDocument();
expect(await screen.findByTestId('login-password-error')).toBeInTheDocument();
});
it('logs in with valid credentials and lands on the dashboard', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), TEST_CREDENTIALS.email);
await user.type(screen.getByTestId('login-password-input'), TEST_CREDENTIALS.password);
await user.click(screen.getByTestId('login-form-submit-button'));
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
});
it('shows an error banner on invalid credentials', async () => {
mockGuest();
const user = userEvent.setup();
renderApp('/login');
await user.type(await screen.findByTestId('login-email-input'), 'wrong@example.com');
await user.type(screen.getByTestId('login-password-input'), 'wrongpassword');
await user.click(screen.getByTestId('login-form-submit-button'));
const banner = await screen.findByTestId('login-error');
expect(banner).toBeInTheDocument();
// Still on the login page.
await waitFor(() =>
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument(),
);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { NetworkError, ProblemDetailsError } from '@/lib/api-client';
export function LoginPage() {
const { t } = useTranslation();
const { login } = useAuth();
const navigate = useNavigate();
const search = useSearch({ strict: false }) as { redirect?: string };
const [serverError, setServerError] = useState<string | null>(null);
const schema = useMemo(
() =>
z.object({
email: z
.string()
.min(1, t('login.errors.emailRequired'))
.email(t('login.errors.emailInvalid')),
password: z.string().min(1, t('login.errors.passwordRequired')),
}),
[t],
);
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
const onSubmit = handleSubmit(async (values) => {
setServerError(null);
try {
await login(values.email, values.password);
await navigate({ to: search.redirect ?? '/dashboard' });
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 401) {
setServerError(t('login.errors.invalidCredentials'));
} else if (err instanceof NetworkError) {
setServerError(t('errors.network'));
} else {
setServerError(t('login.errors.generic'));
}
}
});
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('login.title')}</CardTitle>
<CardDescription>{t('login.subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} noValidate className="space-y-4">
{serverError !== null && (
<div
role="alert"
data-testid="login-error"
className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder')}
data-testid="login-email-input"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && (
<p
className="text-sm text-destructive"
data-testid="login-email-error"
>
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password')}</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
data-testid="login-password-input"
aria-invalid={errors.password !== undefined}
{...register('password')}
/>
{errors.password && (
<p
className="text-sm text-destructive"
data-testid="login-password-error"
>
{errors.password.message}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
data-testid="login-form-submit-button"
>
{isSubmitting ? t('login.submitting') : t('login.submit')}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
/** Public setup placeholder (initial owner creation / invitation completion). */
export function SetupPage() {
const { t } = useTranslation();
return (
<div className="flex min-h-svh items-center justify-center bg-secondary/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t('common.appName')} Setup</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Coming soon.</p>
</CardContent>
</Card>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next';
/** Placeholder; full user management arrives in Unit 5. */
export function UsersPage() {
const { t } = useTranslation();
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold" data-testid="users-title">
{t('nav.users')}
</h1>
<p className="text-muted-foreground">Coming soon.</p>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { lazy, Suspense, type ComponentType } from 'react';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
} from '@tanstack/react-router';
import type { AuthContextValue } from '@/contexts/auth-context';
import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage';
export interface RouterContext {
auth: AuthContextValue;
}
function RouteFallback() {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/**
* Wrap a per-feature page in a lazy boundary so Vite emits a separate chunk
* (NFR-U1-01 / Q1-A). The auth/root shell and login stay eager for fast paint.
*/
function lazyPage<P extends Record<string, never>>(
factory: () => Promise<{ [key: string]: ComponentType<P> }>,
exportName: string,
) {
const Loaded = lazy(() => factory().then((module) => ({ default: module[exportName] })));
return function LazyRouteComponent() {
return (
<Suspense fallback={<RouteFallback />}>
<Loaded {...({} as P)} />
</Suspense>
);
};
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
});
// '/' redirects into the protected area; the guard sends guests to /login.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/dashboard' });
},
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
}),
// Authenticated users never see /login (BR-U1-06).
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/dashboard' });
}
},
component: LoginPage,
});
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup',
component: lazyPage(() => import('@/pages/SetupPage'), 'SetupPage'),
});
// Layout route guarding every protected page (BR-U1-05).
const authenticatedRoute = createRoute({
getParentRoute: () => rootRoute,
id: '_authenticated',
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.href } });
}
},
component: AppLayout,
});
const dashboardRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/dashboard',
component: lazyPage(() => import('@/pages/DashboardPage'), 'DashboardPage'),
});
const usersRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/users',
component: lazyPage(() => import('@/pages/UsersPage'), 'UsersPage'),
});
const cmsRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/cms',
component: lazyPage(() => import('@/pages/CmsPage'), 'CmsPage'),
});
export const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
setupRoute,
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute]),
]);
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
// Real auth is injected per render via RouterProvider's `context` prop.
context: { auth: undefined as unknown as AuthContextValue },
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
describe('Route guards (BR-U1-05, BR-U1-06)', () => {
it('redirects an unauthenticated user from a protected route to /login', async () => {
mockGuest();
renderApp('/dashboard');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-title')).not.toBeInTheDocument();
});
it('allows an authenticated user to reach a protected route', async () => {
mockAuthenticated();
renderApp('/dashboard');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
});
it('redirects an authenticated user away from /login to the dashboard', async () => {
mockAuthenticated();
renderApp('/login');
expect(await screen.findByTestId('dashboard-title')).toBeInTheDocument();
expect(screen.queryByTestId('login-form-submit-button')).not.toBeInTheDocument();
});
});
+45
View File
@@ -0,0 +1,45 @@
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from '@/mocks/server';
import { api } from '@/lib/api-client';
import '@/i18n/config';
// jsdom is missing a few browser APIs that Radix/sonner touch.
const globalAny = globalThis as unknown as {
matchMedia?: unknown;
ResizeObserver?: unknown;
};
if (typeof globalAny.matchMedia === 'undefined') {
globalAny.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
if (typeof globalAny.ResizeObserver === 'undefined') {
globalAny.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
// Reset shared client state between tests.
api.setAccessToken(null);
localStorage.clear();
});
afterAll(() => server.close());
+64
View File
@@ -0,0 +1,64 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, type ReactElement } from 'react';
import { render } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { I18nextProvider } from 'react-i18next';
import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { routeTree } from '@/router';
import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
import i18n from '@/i18n/config';
/** Force the silent-refresh-on-mount to fail, leaving the app in guest state. */
export function mockGuest(): void {
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
HttpResponse.json({ status: 401, title: 'Unauthorized' }, { status: 401 }),
),
);
}
/** Force the silent-refresh-on-mount to succeed, leaving the app authenticated. */
export function mockAuthenticated(): void {
server.use(
http.post(`${API_BASE}/api/v1/auth/refresh`, () => HttpResponse.json(makeAuthResponse())),
);
}
/** Render an arbitrary element wrapped in the i18n + auth providers. */
export function renderWithProviders(ui: ReactElement) {
return render(
<I18nextProvider i18n={i18n}>
<AuthProvider>{ui}</AuthProvider>
</I18nextProvider>,
);
}
function AppHarness({ initialPath }: { initialPath: string }) {
const auth = useAuth();
const [router] = useState(() =>
createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [initialPath] }),
context: { auth },
}),
);
if (auth.status === 'loading') {
return null;
}
return <RouterProvider router={router} context={{ auth }} />;
}
/** Render the full application router at a given path, inside all providers. */
export function renderApp(initialPath = '/') {
return render(
<I18nextProvider i18n={i18n}>
<AuthProvider>
<AppHarness initialPath={initialPath} />
</AuthProvider>
</I18nextProvider>,
);
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the SlpModularCms .NET API (e.g. http://localhost:5000). */
readonly VITE_API_BASE_URL: string;
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
readonly VITE_ENABLE_MSW?: string;
// Add future typed env flags here.
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases (resolved relative to this config file) */
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
/// <reference types="vitest/config" />
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/test/**',
'src/mocks/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
},
},
});