Adds front-end set-up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+4084
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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')} />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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 }),
|
||||
),
|
||||
];
|
||||
@@ -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))];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
Vendored
+13
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user