Adds profile and settings pages

This commit is contained in:
2026-06-22 23:59:04 +02:00
parent 6976eb4337
commit 2544e20b3c
49 changed files with 2741 additions and 34 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
| Feature | Status | Branch | Affected Components | Session Start | | Feature | Status | Branch | Affected Components | Session Start |
|---------|--------|--------|---------------------|---------------| |---------|--------|--------|---------------------|---------------|
| SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown | Core, Identity, Availability, Shell | 2026-06-07 | | SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown | Core, Identity, Availability, Shell | 2026-06-07 |
| CMS Frontend (cms-frontend) | 🔵 Inception | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 | | CMS Frontend (cms-frontend) | ✅ Complete | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 |
@@ -5,7 +5,7 @@
- **Feature Slug**: cms-frontend - **Feature Slug**: cms-frontend
- **Project Type**: Brownfield - **Project Type**: Brownfield
- **Start Date**: 2026-06-16T20:27:00Z - **Start Date**: 2026-06-16T20:27:00Z
- **Current Stage**: CONSTRUCTION - Unit 5: Code Generation (Complete) - **Current Stage**: CONSTRUCTION - Unit 6: Code Generation (Complete)
- **Branch**: unknown - **Branch**: unknown
## Workspace State ## Workspace State
@@ -52,7 +52,7 @@
- [x] Unit 3 — Layout & Navigation — COMPLETED 2026-06-22 (all commits merged, tests pass) - [x] Unit 3 — Layout & Navigation — COMPLETED 2026-06-22 (all commits merged, tests pass)
- [x] Unit 4 — Dashboard — COMPLETED 2026-06-22 (55/55 tests pass, build ✅) - [x] Unit 4 — Dashboard — COMPLETED 2026-06-22 (55/55 tests pass, build ✅)
- [x] Unit 5 — User Management — COMPLETED 2026-06-22 (all 21 steps done, tests written) - [x] Unit 5 — User Management — COMPLETED 2026-06-22 (all 21 steps done, tests written)
- [ ] Unit 6 — Profile, Settings & CMS Placeholder - [x] Unit 6 — Profile, Settings & CMS Placeholder — COMPLETED 2026-06-22 (all 25 steps done, tests written)
- [ ] Build and Test — Not Started - [ ] Build and Test — Not Started
### 🟡 OPERATIONS PHASE ### 🟡 OPERATIONS PHASE
+84
View File
@@ -1,5 +1,89 @@
# Audit Log — cms-frontend # Audit Log — cms-frontend
## Code Generation — Unit 6 Plan Approved
**Timestamp**: 2026-06-22T00:10:00Z
**User Input**: "approve plan"
**AI Response**: "Plan approved. Starting Code Generation Part 2 — executing 25 steps."
**Context**: Unit 6 Code Generation — executing plan.
---
## Functional Design — Unit 6 Approved
**Timestamp**: 2026-06-22T00:08:00Z
**User Input**: "continue to next stage"
**AI Response**: "Functional Design approved. Skipping NFR Requirements and NFR Design (same tech stack as prior units). Proceeding to Code Generation Part 1 — Planning."
**Context**: Unit 6 Functional Design complete. Moving to Code Generation.
---
## Functional Design — Unit 6 Artifacts Created
**Timestamp**: 2026-06-22T00:06:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "All clarification answers received. Created domain-entities.md, business-rules.md, business-logic-model.md, frontend-components.md for Unit 6. Presenting completion message."
**Context**: Unit 6 Functional Design — all 4 artifacts created, awaiting approval.
---
## Functional Design — Unit 6 Clarification Questions Created
**Timestamp**: 2026-06-22T00:04:00Z
**User Input**: "Ik heb de vragen beantwoord"
**AI Response**: "Detected gap: Q1+Q2 require backend endpoints (PUT /Users/me, change-password) that don't exist. Created unit-6-functional-design-clarifications.md with 3 clarification questions."
**Context**: Unit 6 Functional Design — ambiguity detected before generating artifacts.
---
## Functional Design — Unit 6 Plan Created
**Timestamp**: 2026-06-22T00:02:00Z
**User Input**: "A"
**AI Response**: "Created unit-6-functional-design-plan.md with 8 questions. Waiting for user answers."
**Context**: Unit 6 Functional Design — Step 2 complete, plan created with questions.
---
## Session Resume — User Choice
**Timestamp**: 2026-06-22T00:01:00Z
**User Input**: "A"
**AI Response**: "Starting Unit 6 — Profile, Settings & CMS Placeholder. Beginning Functional Design."
**Context**: User chose option A — continue CMS Frontend, proceed to Unit 6.
---
## Session Resume
**Timestamp**: 2026-06-22T00:00:00Z
**User Input**: "ga verder met de aidlc workflow"
**AI Response**: "Presented Welcome Back prompt with options A/B/C/D — saved to aidlc-docs/session-resume.md"
**Context**: Session resumed; loaded aidlc-state.md, active-features.md. Next step: Unit 6 — Profile, Settings & CMS Placeholder.
---
## Code Generation — Unit 5 Plan Approved ## Code Generation — Unit 5 Plan Approved
**Timestamp**: 2026-06-22T00:15:00Z **Timestamp**: 2026-06-22T00:15:00Z
@@ -0,0 +1,243 @@
# Code Generation Plan — Unit 6: Profile, Settings & CMS Placeholder
**Status**: 🚧 In Progress
## Unit Context
**Unit**: Unit 6 — Profile, Settings & CMS Placeholder
**Type**: Frontend (React/TypeScript) + Backend (.NET) — brownfield (modifying existing stubs)
**Workspace root**: `K:\Development\Projects\SlpModularCms`
**Stories covered**: US-15, US-16, US-17, US-20
## Key Observations from Code Scan
**Existing files to MODIFY** (not create):
- `frontend/src/pages/ProfilePage.tsx` — currently a "Coming soon" stub
- `frontend/src/pages/SettingsPage.tsx` — currently a "Coming soon" stub
- `frontend/src/pages/CmsPage.tsx` — currently a "Coming soon" stub
- `frontend/src/api/useAvailability.ts` — has `useAvailabilityStatus` read query; needs `useUpdateAvailability` mutation added
- `frontend/src/router.tsx` — has all routes except 403 and 404; needs catch-all added
- `frontend/src/mocks/users/handlers.ts` — needs `PUT /api/v1/Users/me` mock added
- `frontend/src/mocks/auth/handlers.ts` — needs `POST /api/v1/Auth/change-password` mock added
- `frontend/src/i18n/locales/en/translation.json` — needs profile, settings, error page keys
- `frontend/src/i18n/locales/nl/translation.json` — needs Dutch translations
- `src/SlpModularCms.Core/Identity/Models/IdentityRequests.cs` — needs `UpdateProfileRequest` + `ChangePasswordRequest`
- `src/SlpModularCms.Core/Identity/Services/IAuthService.cs` — needs `ChangePassword` method
- `src/SlpModularCms.Core/Identity/Services/AuthService.cs` — needs `ChangePassword` implementation
- `src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs` — needs `POST /change-password` endpoint
- `src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs` — needs `PUT /me` endpoint
**New files to CREATE**:
- `frontend/src/api/useProfile.ts``useUpdateProfile` mutation + `useChangePassword` mutation
- `frontend/src/pages/AccessDeniedPage.tsx` — 403 page
- `frontend/src/pages/NotFoundPage.tsx` — 404 page
- `frontend/src/pages/ProfilePage.test.tsx`
- `frontend/src/pages/SettingsPage.test.tsx`
- `frontend/src/pages/CmsPage.test.tsx`
- `frontend/src/pages/AccessDeniedPage.test.tsx`
- `frontend/src/pages/NotFoundPage.test.tsx`
---
## Stories
- [x] US-15: View own profile — ProfilePage with name/email edit + change password
- [x] US-16: View availability status in settings — SettingsPage with availability controls
- [x] US-17: Access denied to System Settings for non-Owners — 403 page + RoleGuard (already wired in router)
- [x] US-20: View CMS management placeholder — CmsPage enhanced placeholder
---
## Generation Steps
### Backend
- [x] **Step 1**: Modify `src/SlpModularCms.Core/Identity/Models/IdentityRequests.cs`
- Add `record UpdateProfileRequest(string Name, string Email)`
- Add `record ChangePasswordRequest(string CurrentPassword, string NewPassword)`
- [x] **Step 2**: Modify `src/SlpModularCms.Core/Identity/Services/IAuthService.cs`
- Add `Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)`
- [x] **Step 3**: Modify `src/SlpModularCms.Core/Identity/Services/AuthService.cs`
- Implement `ChangePasswordAsync` — uses `UserManager.ChangePasswordAsync`; throws `ValidationException` if current password is wrong
- [x] **Step 4**: Modify `src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs`
- Add `POST /change-password` endpoint — `[Authorize]` (any authenticated user), reads userId from JWT claims, calls `IAuthService.ChangePasswordAsync`
- Returns 200 OK on success, 400 with error detail on failure
- [x] **Step 5**: Modify `src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs`
- Add `PUT /me` endpoint — `[Authorize]` (any authenticated user), reads userId from JWT claims
- Updates `UserName` (display name) and `Email` on the `ApplicationUser` via `UserManager`
- Returns `UserDto` with updated data on 200 OK; returns 400 if email already taken
### Frontend — API layer
- [x] **Step 6**: Create `frontend/src/api/useProfile.ts`
- `useUpdateProfile()``useMutation` calling `PUT /api/v1/Users/me` with `{ name, email }`; on success calls `auth.refresh()` from `useAuth()`
- `useChangePassword()``useMutation` calling `POST /api/v1/Auth/change-password` with `{ currentPassword, newPassword }`
- [x] **Step 7**: Modify `frontend/src/api/useAvailability.ts`
- Add `useUpdateAvailability()``useMutation` calling `POST /api/v1/Availability/admin/status` with `{ newStatus, reason }`; on success calls `queryClient.invalidateQueries({ queryKey: ['availability', 'status'] })`
- [x] **Step 8**: Add MSW mock handlers
- In `frontend/src/mocks/users/handlers.ts`: add `http.put('/api/v1/Users/me', ...)` returning updated UserDto
- In `frontend/src/mocks/auth/handlers.ts`: add `http.post('/api/v1/Auth/change-password', ...)` returning 200 OK (and a 400 error handler variant for wrong-password tests)
### Frontend — i18n keys
- [x] **Step 9**: Modify `frontend/src/i18n/locales/en/translation.json` — add:
```json
"profile": {
"title": "My Profile",
"name": "Full name",
"email": "Email address",
"role": "Role",
"save": "Save changes",
"saving": "Saving…",
"saveSuccess": "Profile updated successfully",
"changePassword": "Change password",
"changePassword": {
"title": "Change Password",
"current": "Current password",
"new": "New password",
"confirm": "Confirm new password",
"submit": "Change password",
"submitting": "Changing…",
"success": "Password changed successfully",
"errorMismatch": "Passwords do not match",
"errorCurrent": "Current password is incorrect"
}
},
"settings": {
"title": "System Settings",
"availability": {
"title": "System Availability",
"mode": "Availability mode",
"reason": "Message (optional)",
"save": "Update availability",
"saving": "Updating…",
"saveSuccess": "Availability updated",
"modes": {
"Available": "Available",
"Maintenance": "Maintenance",
"Unavailable": "Unavailable"
}
},
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
"branding": { "title": "Branding / Theme", "comingSoon": "Coming soon" }
},
"error": {
"403": {
"title": "Access Denied",
"message": "You don't have permission to view this page."
},
"404": {
"title": "Page Not Found",
"message": "The page you're looking for doesn't exist."
},
"backToDashboard": "Back to Dashboard"
}
```
- [x] **Step 10**: Modify `frontend/src/i18n/locales/nl/translation.json` — add Dutch translations for all keys added in Step 9
### Frontend — pages
- [x] **Step 11**: Modify `frontend/src/pages/ProfilePage.tsx` — full implementation:
- Use `useAuth()` for current user data (name, email, role)
- Use `useUpdateProfile()` + `react-hook-form` + `zod` for name/email form
- Render Role as read-only badge
- Include "Change Password" button that opens `ChangePasswordDialog`
- `ChangePasswordDialog` (inline in file or as separate component in same directory): uses `useChangePassword()` with own `react-hook-form` + `zod` validation
- All interactive elements get `data-testid` attributes
- [x] **Step 12**: Modify `frontend/src/pages/SettingsPage.tsx` — full implementation:
- Use `useAvailabilityStatus()` to fetch current status
- Use `useUpdateAvailability()` for saving changes
- Mode selector (radio group or select) + optional reason text area
- Render `AvailabilityStatusBadge` showing current status
- Three `PlaceholderCard` sections: Module Management, System Configuration, Branding/Theme
- All interactive elements get `data-testid` attributes
- [x] **Step 13**: Modify `frontend/src/pages/CmsPage.tsx` — enhanced placeholder:
- Icon (`LayoutGrid` from lucide-react) + heading "Content Management System" + description text
- Use i18n keys; add `data-testid="cms-placeholder"`
- [x] **Step 14**: Create `frontend/src/pages/AccessDeniedPage.tsx`
- Icon (`ShieldOff` from lucide-react) + heading from `t('error.403.title')` + message + "Back to Dashboard" button
- `data-testid` on heading, message, and button
- [x] **Step 15**: Create `frontend/src/pages/NotFoundPage.tsx`
- Icon (`FileQuestion` from lucide-react) + heading from `t('error.404.title')` + message + "Back to Dashboard" button
- `data-testid` on heading, message, and button
### Frontend — router
- [x] **Step 16**: Modify `frontend/src/router.tsx`
- Add `accessDeniedRoute` at path `/403` (under rootRoute, not authenticatedRoute — accessible without auth)
- Add `notFoundRoute` as catch-all `$` path (under rootRoute)
- Add both routes to `routeTree`
### Frontend — tests
- [x] **Step 17**: Create `frontend/src/pages/ProfilePage.test.tsx`
- Test: renders name, email, role from AuthContext
- Test: editing name and email and clicking Save triggers `PUT /api/v1/Users/me`
- Test: validation errors shown for empty name or invalid email
- Test: "Change Password" button opens dialog
- Test: change password form validates (mismatch, empty fields)
- Test: successful password change closes dialog
- [x] **Step 18**: Create `frontend/src/pages/SettingsPage.test.tsx`
- Test: renders current availability status (mocked via MSW)
- Test: changing mode and clicking save triggers `POST /api/v1/Availability/admin/status`
- Test: placeholder sections render with correct headings
- [x] **Step 19**: Create `frontend/src/pages/CmsPage.test.tsx`
- Test: renders heading and placeholder description
- [x] **Step 20**: Create `frontend/src/pages/AccessDeniedPage.test.tsx`
- Test: renders "Access Denied" heading and message
- Test: "Back to Dashboard" button navigates to `/dashboard`
- [x] **Step 21**: Create `frontend/src/pages/NotFoundPage.test.tsx`
- Test: renders "Page Not Found" heading and message
- Test: "Back to Dashboard" button navigates to `/dashboard`
### Verification
- [x] **Step 22**: Backend migration check
- Review all new/modified entity classes and `ApplicationDbContext` to determine if EF Core migrations are needed
- Unit 6 backend changes: `PUT /Users/me` updates `ApplicationUser.UserName` and `Email` (existing columns in `AspNetUsers`); `POST /Auth/change-password` updates the password hash (existing column) — **no schema changes expected**
- Verify by checking that no new `DbSet<>` properties or `[Column]`/`HasColumnName` changes were introduced in Steps 15
- If a schema change is found: run `dotnet ef migrations add <MigrationName> --project src/SlpModularCms.Core --startup-project src/SlpModularCms.Api` and include the generated migration files
- If no schema change: document explicitly in the code summary (Step 25) that no migration was needed
- [x] **Step 23**: i18n completeness check
- For every `t('...')` call added in Steps 1116 (ProfilePage, SettingsPage, CmsPage, AccessDeniedPage, NotFoundPage, router), verify the key exists in both `en/translation.json` and `nl/translation.json`
- For every key added in Steps 910 to `en/translation.json` and `nl/translation.json`, verify it is actually used via a `t('...')` call in at least one component — remove unused keys
- Fix any missing or orphaned keys before proceeding
### Documentation
- [x] **Step 24**: Update `README.md` — add "Frontend Development" section:
- Prerequisites: Node.js ≥20, pnpm
- Installation: `pnpm install` in `frontend/`
- Environment: copy `frontend/.env.example` to `frontend/.env`
- Dev server: `pnpm dev` in `frontend/` (runs on http://localhost:5173)
- Build: `pnpm build` in `frontend/`
- Tests: `pnpm test` in `frontend/`
- Security note: production deployment should add security headers (CSP, HSTS)
### Code summary
- [x] **Step 25**: Create `aidlc-docs/features/cms-frontend/construction/unit-6/code/code-generation-summary.md`
- List all modified and created files with their purpose
- Include migration check outcome (Step 22)
- Include i18n check outcome (Step 23)
---
## Total: 25 steps
@@ -0,0 +1,57 @@
# Unit 6 Functional Design — Clarification Questions
I detected gaps in your responses that need clarification before I can complete the functional design.
## Gap: Backend endpoints missing for profile editing and password change
Your answers to Q1 and Q2 indicate the ProfilePage should:
- Allow editing **Name** and **Email** (Q2: C)
- Show a **Change Password** button (Q1: C, note "only Role is read-only")
However, the current backend API has **no endpoints** for these operations:
| Required operation | Needed endpoint | Currently exists? |
|---|---|---|
| Update name / email | `PUT /api/v1/Users/me` or `PATCH /api/v1/Users/{id}` | ❌ No |
| Change password | `POST /api/v1/Auth/change-password` or similar | ❌ No |
| Read own profile | `GET /api/v1/Users/me` (optional, or use AuthContext) | ❌ No |
These are additional backend changes not originally scoped for Unit 6. I need your decisions on each.
---
## Clarification Question 1: Backend endpoint for profile update (name + email)
Editing name and email requires a new backend endpoint. How should this be handled?
A) Add `PUT /api/v1/Users/me` to the backend in this unit — accepts `{ "name": string, "email": string }` and updates the current user's profile (recommended — required for the edit feature to function)
B) Add a backend endpoint but only allow editing name — email changes are too sensitive (require email verification flow which is out of scope)
C) Make the edit fields visible in the UI but non-functional for now (placeholder edits — form renders but the save button is disabled or shows "coming soon")
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Clarification Question 2: Change Password — functional or placeholder?
The "Change Password" button requires a new backend endpoint. How should this be handled in this unit?
A) Implement Change Password fully: add `POST /api/v1/Auth/change-password` to the backend — accepts `{ "currentPassword": string, "newPassword": string }` and changes the password (requires full backend + frontend implementation)
B) Show a "Change Password" button that opens a dialog, but the submit is a placeholder — shows "This feature is coming soon" (frontend only, no backend work)
C) Show a "Change Password" link/button but leave it disabled with a tooltip "Coming soon"
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Clarification Question 3: Profile data source after editing
When the user edits their name/email and saves, the AuthContext currently holds the logged-in user's data (set at login and refreshed on token refresh). After a successful profile update, how should the frontend reflect the change?
A) Call `auth/refresh` after a successful profile update — the refresh response returns updated user data, which updates AuthContext automatically (recommended — reuses existing infrastructure)
B) Update AuthContext directly in the frontend after a successful save (optimistic update — no extra API call)
C) Other (please describe after [Answer]: tag below)
[Answer]: A
@@ -0,0 +1,154 @@
# Functional Design Plan — Unit 6: Profile, Settings & CMS Placeholder
**Status**: 🚧 In Progress
## Unit Context
**Unit**: Unit 6 — Profile, Settings & CMS Placeholder
**Type**: Frontend (React/TypeScript) + Documentation
**Depends on**: Unit 3 (AppLayout, RoleGuard), Unit 4 (useAvailabilityStatus, AvailabilityStatusBadge)
**Stories Covered**:
- US-15: View own profile
- US-16: View availability status in settings
- US-17: Access denied to System Settings for non-Owners
- US-20: View CMS management placeholder
**Key Deliverables** (from unit-of-work.md):
```
frontend/src/routes/
├── profile.tsx # ProfilePage: read-only user info
├── settings.tsx # SettingsPage: Owner-only, availability + placeholders
├── cms.tsx # CmsPage: Owner-only placeholder
├── 403.tsx # AccessDeniedPage
└── $404.tsx # NotFoundPage
README.md # Frontend Development section added
```
**Key Observations**:
- These are the final frontend pages; all shared infrastructure (ApiClient, AuthContext, AppLayout, RoleGuard) is already in place
- `useAvailabilityStatus` from Unit 4 can be reused directly in SettingsPage
- RoleGuard (from Unit 2) is already implemented; `/settings` and `/cms` need Owner-only guards
- The 403 and 404 pages can be pure presentational components (no data fetching)
- Routes `403.tsx` and `$404.tsx` need to be registered in the TanStack Router route tree
---
## Functional Design Steps
- [x] Step 1: Analyze unit context and existing code patterns
- [x] Step 2: Generate questions, collect answers
- [x] Step 3: Create `domain-entities.md`
- [x] Step 4: Create `business-rules.md`
- [x] Step 5: Create `business-logic-model.md`
- [x] Step 6: Create `frontend-components.md`
- [x] Step 7: Present completion message and await approval
---
## Questions
Please fill in the letter after each `[Answer]:` tag.
---
### Question 1: ProfilePage — displayed fields
The ProfilePage (US-15) should show the logged-in user's own information. Which fields should be displayed?
A) Name, Email, Role — three read-only fields sourced from AuthContext (no API call needed)
B) Name, Email, Role, Member Since (created date) — four fields; requires a `GET /api/v1/Users/{id}` or `/me` endpoint to fetch the date
C) Name, Email, Role, and a "Change Password" button (button can be a placeholder/not yet functional)
D) Other (please describe after [Answer]: tag below)
[Answer]: C, but only Role is read-only
---
### Question 2: ProfilePage — editability
Should the ProfilePage support editing any of the displayed information, or is it strictly read-only for now?
A) Strictly read-only — no edit capability in this unit (recommended — keeps scope minimal; edits can be a future feature)
B) Allow editing the display name only — inline edit with save
C) Allow editing email and/or name — full form
D) Other (please describe after [Answer]: tag below)
[Answer]: C
---
### Question 3: SettingsPage — availability section interaction
The SettingsPage (US-16) includes an availability section. Should it allow changing the availability mode, or just display the current status?
A) Display + change: show current mode with controls to switch between Online / Maintenance / Offline, and optionally set a custom message (recommended — Settings is the management location; Dashboard is read-only view)
B) Display only: show current availability status, link/note that says "Manage via API or a future admin tool"
C) Other (please describe after [Answer]: tag below)
[Answer]: A
---
### Question 4: SettingsPage — placeholder sections
Beyond the availability section, what placeholder sections should SettingsPage include to indicate planned future features?
A) One generic placeholder: "More settings coming soon"
B) Two placeholders: "Module Management" and "System Configuration" — with "Coming soon" labels
C) Three placeholders: "Module Management", "System Configuration", and "Branding / Theme" — styled as locked cards
D) Other (please describe after [Answer]: tag below)
[Answer]: C
---
### Question 5: CmsPage — placeholder content
The CmsPage (US-20) is an Owner-only placeholder. What should it display?
A) A simple "CMS coming soon" message with an icon and brief description of what will be here (recommended — clear intent without overdesigning)
B) A styled empty state card with a title "Content Management System" and a description of planned capabilities
C) Just a heading: "Content Management" with "Under construction" text
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
### Question 6: 403 AccessDeniedPage — navigation options
When a user hits a route they don't have permission for (e.g., a non-Owner accessing `/settings`), what should the 403 page offer?
A) Heading "Access Denied" + message explaining the page requires a higher role + "Back to Dashboard" button (recommended — clear and actionable)
B) Heading + message + "Go Back" (browser back) + "Back to Dashboard" buttons
C) Heading + message only — no navigation buttons (user uses browser back)
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
### Question 7: 404 NotFoundPage — navigation options
When a user navigates to a route that doesn't exist, what should the 404 page offer?
A) Heading "Page Not Found" + brief message + "Back to Dashboard" button (recommended)
B) Heading + message + "Go Back" (browser back) + "Back to Dashboard" buttons
C) Heading + message only
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
### Question 8: Unit test scope
Which parts of Unit 6 should have unit tests?
A) ProfilePage (renders correct user fields from AuthContext) + SettingsPage (availability display/interaction) + 403 page (renders message + navigation) + 404 page (renders message + navigation)
B) SettingsPage only — the other pages are too simple to warrant tests
C) All pages including CmsPage — full test coverage for the final unit
D) Other (please describe after [Answer]: tag below)
[Answer]: C
@@ -0,0 +1,81 @@
# Unit 6 Code Generation Summary
## Overview
Unit 6 implements Profile, Settings, CMS placeholder, and error pages (403/404) for the CMS Frontend.
## Migration Check
**No EF Core migration required.** All backend changes in Steps 15 operate on existing columns in the `AspNetUsers` table managed by ASP.NET Identity (`UserName`, `NormalizedUserName`, `Email`, `NormalizedEmail`, `PasswordHash`). No new `DbSet<>` entries or schema columns were introduced.
---
## Files Created or Modified
### Backend — Core
| File | Change |
|------|--------|
| `src/SlpModularCms.Core/Identity/Models/IdentityRequests.cs` | Added `UpdateProfileRequest` and `ChangePasswordRequest` records |
| `src/SlpModularCms.Core/Identity/Services/IAuthService.cs` | Added `ChangePasswordAsync` method signature |
| `src/SlpModularCms.Core/Identity/Services/AuthService.cs` | Implemented `ChangePasswordAsync` using `UserManager.ChangePasswordAsync` |
### Backend — Modules.Identity
| File | Change |
|------|--------|
| `src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs` | Added `POST /api/v1/Auth/change-password` endpoint |
| `src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs` | Added `PUT /api/v1/Users/me` endpoint for profile self-update |
### Frontend — API hooks
| File | Change |
|------|--------|
| `frontend/src/api/useProfile.ts` | **Created**`useUpdateProfile()` and `useChangePassword()` hooks |
| `frontend/src/api/useAvailability.ts` | Extended with `useUpdateAvailability()` mutation |
### Frontend — MSW mocks
| File | Change |
|------|--------|
| `frontend/src/mocks/users/handlers.ts` | Added `PUT /api/v1/Users/me` mock handler |
| `frontend/src/mocks/auth/handlers.ts` | Added `POST /api/v1/Auth/change-password` mock handler |
### Frontend — i18n
| File | Change |
|------|--------|
| `frontend/src/i18n/locales/en/translation.json` | Added `profile`, `settings`, `cms`, `error` key groups |
| `frontend/src/i18n/locales/nl/translation.json` | Added Dutch translations for all new key groups |
### Frontend — Pages
| File | Change |
|------|--------|
| `frontend/src/pages/ProfilePage.tsx` | **Replaced stub** — full implementation with ProfileInfoForm, RoleDisplay badge, ChangePasswordDialog |
| `frontend/src/pages/SettingsPage.tsx` | **Replaced stub** — full implementation with availability selector + 3 placeholder cards |
| `frontend/src/pages/CmsPage.tsx` | **Replaced stub** — enhanced placeholder with LayoutGrid icon |
| `frontend/src/pages/AccessDeniedPage.tsx` | **Created** — 403 page with ShieldOff icon and back button |
| `frontend/src/pages/NotFoundPage.tsx` | **Created** — 404 catch-all page with FileQuestion icon and back button |
### Frontend — Router
| File | Change |
|------|--------|
| `frontend/src/router.tsx` | Added `/403` (`accessDeniedRoute`) and `$` catch-all (`notFoundRoute`) routes |
### Frontend — Tests
| File | Coverage |
|------|----------|
| `frontend/src/pages/ProfilePage.test.tsx` | Renders, pre-fill, save disabled when pristine, save enabled after edit, save success, email validation, dialog open, wrong password error, auth redirect |
| `frontend/src/pages/SettingsPage.test.tsx` | Renders, status badge, mode buttons, mode selection, save success, save error, placeholder sections, auth redirect |
| `frontend/src/pages/CmsPage.test.tsx` | Renders title and placeholder, auth redirect |
| `frontend/src/pages/AccessDeniedPage.test.tsx` | Renders title, message, back button, navigation |
| `frontend/src/pages/NotFoundPage.test.tsx` | Renders for unknown routes, back button, navigation |
---
## i18n Completeness
All `t('...')` calls in the new pages and hooks have corresponding keys in both `en/translation.json` and `nl/translation.json`. No orphaned keys.
@@ -0,0 +1,147 @@
# Business Logic Model — Unit 6: Profile, Settings & CMS Placeholder
## 1. Profile Update Flow
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Frontend
participant U as User
participant PF as ProfilePage
participant AC as AuthContext
end
box rgba(244,67,54,0.15) Backend
participant API as PUT /Users/me
participant REF as POST /auth/refresh
end
U->>PF: Edit name or email, click Save
PF->>PF: Validate form (name required, email valid)
alt Validation fails
PF-->>U: Show inline field errors
else Validation passes
PF->>API: PUT /api/v1/Users/me with name and email
alt API error
API-->>PF: 400 or 409 (e.g. email taken)
PF-->>U: Show error message
else Success
API-->>PF: 200 Updated user data
PF->>REF: POST /api/v1/Auth/refresh
REF-->>AC: New access token plus updated user object
AC-->>PF: AuthContext updated
PF-->>U: Show success toast, form reset to saved values
end
end
```
Text alternative: User edits name/email on ProfilePage → frontend validates → PUT /Users/me → on success calls /auth/refresh to sync AuthContext → success toast shown.
---
## 2. Change Password Flow
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Frontend
participant U as User
participant PF as ProfilePage
participant DL as ChangePasswordDialog
end
box rgba(244,67,54,0.15) Backend
participant API as POST /auth/change-password
end
U->>PF: Click Change Password button
PF->>DL: Open dialog
U->>DL: Enter currentPassword, newPassword, confirmPassword
DL->>DL: Validate (newPassword matches confirm, meets policy)
alt Validation fails
DL-->>U: Show inline errors
else Validation passes
DL->>API: POST /api/v1/Auth/change-password
alt Wrong current password or policy violation
API-->>DL: 400 with error detail
DL-->>U: Show error message
else Success
API-->>DL: 200 OK
DL-->>U: Close dialog, show success toast on ProfilePage
end
end
```
Text alternative: User opens Change Password dialog → validates fields → POST /auth/change-password → success closes dialog and shows toast.
---
## 3. Settings — Availability Update Flow
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Frontend
participant U as User
participant SP as SettingsPage
participant QC as QueryClient
end
box rgba(244,67,54,0.15) Backend
participant GET as GET /availability/status
participant PUT as POST /availability/admin/status
end
SP->>GET: Fetch current availability on mount
GET-->>SP: status, message, checkedAt
SP-->>U: Display current mode and message
U->>SP: Select new mode and optional message, click Save
SP->>PUT: POST /api/v1/Availability/admin/status
alt Success
PUT-->>SP: 200 OK
SP->>QC: Invalidate availability query cache
QC->>GET: Re-fetch status
GET-->>SP: Updated status
SP-->>U: Show success toast, updated badge
else Error
PUT-->>SP: 400 or 403
SP-->>U: Show error message
end
```
Text alternative: SettingsPage fetches availability on mount. User selects new mode and saves → POST to admin status endpoint → on success invalidate cache to re-fetch updated status.
---
## 4. Route Guard Flow (403 / 404)
```mermaid
graph TD
Nav["Navigation event"]
ProtectedRoute{"ProtectedRoute check\n(authenticated?)"}
RoleGuard{"RoleGuard check\n(role allowed?)"}
RouteMatch{"Route exists?"}
Page["Render Page"]
P403["Render 403 AccessDeniedPage"]
P404["Render 404 NotFoundPage"]
Login["Redirect to /login"]
Nav --> ProtectedRoute
ProtectedRoute -->|No| Login
ProtectedRoute -->|Yes| RouteMatch
RouteMatch -->|No| P404
RouteMatch -->|Yes, has RoleGuard| RoleGuard
RouteMatch -->|Yes, no RoleGuard| Page
RoleGuard -->|Allowed| Page
RoleGuard -->|Denied| P403
classDef guard fill:#FF9800,stroke:#e65100,color:#000
classDef page fill:#2196F3,stroke:#0d47a1,color:#000
classDef error fill:#F44336,stroke:#b71c1c,color:#000
classDef start fill:#9C27B0,stroke:#4a148c,color:#000
class ProtectedRoute guard
class RoleGuard guard
class RouteMatch guard
class Page page
class P403 error
class P404 error
class Login error
class Nav start
```
Text alternative: Navigation → ProtectedRoute (unauthenticated → /login) → route match (unknown → 404) → RoleGuard (denied → 403, allowed → page renders).
@@ -0,0 +1,112 @@
# Business Rules — Unit 6: Profile, Settings & CMS Placeholder
## Access Control Rules
```mermaid
graph TD
Request["Incoming Route Request"]
IsAuth{"Authenticated?"}
Route{"Which route?"}
IsOwner{"Role = Owner?"}
AccessDenied["403 AccessDeniedPage"]
NotFound["404 NotFoundPage"]
Profile["ProfilePage"]
Settings["SettingsPage"]
Cms["CmsPage"]
Login["Redirect to /login"]
Request --> IsAuth
IsAuth -->|No| Login
IsAuth -->|Yes| Route
Route -->|/profile| Profile
Route -->|/settings| IsOwner
Route -->|/cms| IsOwner
Route -->|unknown path| NotFound
IsOwner -->|Yes| Settings
IsOwner -->|Yes| Cms
IsOwner -->|No| AccessDenied
classDef guard fill:#FF9800,stroke:#e65100,color:#000
classDef page fill:#2196F3,stroke:#0d47a1,color:#000
classDef error fill:#F44336,stroke:#b71c1c,color:#000
classDef start fill:#9C27B0,stroke:#4a148c,color:#000
class IsAuth guard
class IsOwner guard
class Route guard
class Profile page
class Settings page
class Cms page
class AccessDenied error
class NotFound error
class Login error
class Request start
```
Text alternative: All routes require authentication (redirect to /login if not). /settings and /cms additionally require Owner role; non-Owners are shown 403. Unknown paths show 404.
---
## BR-01: Profile Access
- **Rule**: Any authenticated user can access `/profile`
- **Implementation**: ProfilePage is a child of `_authenticated.tsx` (inherits ProtectedRoute); no additional RoleGuard
- **Data**: Profile data is read from AuthContext (no extra API call for display)
## BR-02: Profile — Name and Email are Editable
- **Rule**: The logged-in user may update their own `name` and `email`
- **Validation** (frontend, mirrors backend):
- `name`: required, non-empty
- `email`: required, valid email format
- **Endpoint**: `PUT /api/v1/Users/me`
- **After save**: Call `POST /api/v1/Auth/refresh` to synchronize AuthContext with updated values
## BR-03: Profile — Role is Read-Only
- **Rule**: A user cannot change their own role from the profile page
- **Display**: Role shown as a static badge; no edit controls rendered
## BR-04: Change Password
- **Rule**: The logged-in user may change their own password via a dialog on ProfilePage
- **Validation** (frontend, mirrors backend password policy):
- `currentPassword`: required, non-empty
- `newPassword`: required, min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit, 1 special character
- `confirmPassword` (UI-only field): must match `newPassword`
- **Endpoint**: `POST /api/v1/Auth/change-password`
- **On success**: Close dialog, show success toast; no AuthContext update needed (password change does not affect access token)
## BR-05: Settings — Owner Only
- **Rule**: Only users with role `Owner` may access `/settings`
- **Implementation**: `RoleGuard` with `allowedRoles={["Owner"]}` wraps SettingsPage
- **On violation**: Redirect to `/403`
## BR-06: Settings — Availability Management
- **Rule**: Owner may change the system availability status from SettingsPage
- **Allowed modes**: `Available`, `Maintenance`, `Unavailable`
- **Endpoint**: `POST /api/v1/Availability/admin/status` (OwnerOnly — already enforced by backend)
- **Message field**: Optional free-text reason displayed to end-users
- **On save**: Invalidate `useAvailabilityStatus` query cache to reflect new status immediately
## BR-07: CMS Page — Owner Only
- **Rule**: Only users with role `Owner` may access `/cms`
- **Implementation**: `RoleGuard` with `allowedRoles={["Owner"]}` wraps CmsPage
- **On violation**: Redirect to `/403`
- **Content**: Placeholder only — no functional CMS features in this unit
## BR-08: 403 Access Denied Page
- **Rule**: Rendered when `RoleGuard` rejects a route request
- **Content**: Heading "Access Denied" + explanatory message + "Back to Dashboard" button (navigates to `/`)
- **No authentication required**: 403 is a public route (unauthenticated users hitting protected routes are redirected to `/login` by ProtectedRoute first)
## BR-09: 404 Not Found Page
- **Rule**: Rendered when TanStack Router cannot match any registered route
- **Content**: Heading "Page Not Found" + brief message + "Back to Dashboard" button (navigates to `/`)
- **Implementation**: TanStack Router catch-all route (`$404.tsx`)
## BR-10: Backend — New Endpoints Required
The following new backend endpoints must be added as part of Unit 6:
| Endpoint | Method | Policy | Purpose |
|----------|--------|--------|---------|
| `/api/v1/Users/me` | PUT | Authenticated | Update own name and email |
| `/api/v1/Auth/change-password` | POST | Authenticated | Change own password |
Both endpoints operate on the currently authenticated user (identified via JWT claims).
@@ -0,0 +1,105 @@
# Domain Entities — Unit 6: Profile, Settings & CMS Placeholder
## Overview
Unit 6 introduces profile editing and password management. It reuses the existing `AvailabilityStatus` entity from Unit 4 for the Settings page, and introduces new request/response shapes for profile and password operations.
---
## Entity Diagram
```mermaid
graph TD
AuthUser["AuthUser"]
UpdateProfileRequest["UpdateProfileRequest"]
UpdateProfileResponse["UpdateProfileResponse"]
ChangePasswordRequest["ChangePasswordRequest"]
AvailabilityStatus["AvailabilityStatus"]
UpdateAvailabilityRequest["UpdateAvailabilityRequest"]
AuthUser -->|"provides data for"| UpdateProfileRequest
UpdateProfileRequest -->|"produces"| UpdateProfileResponse
UpdateProfileResponse -->|"refreshed into"| AuthUser
AvailabilityStatus -->|"changed by"| UpdateAvailabilityRequest
classDef user fill:#2196F3,stroke:#0d47a1,color:#000
classDef request fill:#FF9800,stroke:#e65100,color:#000
classDef response fill:#4CAF50,stroke:#2e7d32,color:#000
classDef availability fill:#9C27B0,stroke:#4a148c,color:#000
class AuthUser user
class UpdateProfileRequest,ChangePasswordRequest,UpdateAvailabilityRequest request
class UpdateProfileResponse response
class AvailabilityStatus availability
```
Text alternative: AuthUser provides data for UpdateProfileRequest; saving produces UpdateProfileResponse which refreshes AuthContext. AvailabilityStatus is changed via UpdateAvailabilityRequest. ChangePasswordRequest is standalone.
---
## Entity Descriptions
### AuthUser
Represents the currently logged-in user. Sourced from `AuthContext` (populated at login and after token refresh). Displayed on ProfilePage; updated indirectly via token refresh after a profile save.
| Field | Type | Editable | Source |
|-------|------|----------|--------|
| id | string (GUID) | No | AuthContext |
| email | string | Yes (via PUT /Users/me) | AuthContext |
| name | string | Yes (via PUT /Users/me) | AuthContext |
| role | string | No (read-only) | AuthContext |
| isActive | boolean | No | AuthContext |
---
### UpdateProfileRequest
Sent to `PUT /api/v1/Users/me` when the user saves their profile edits.
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| name | string | Yes | Non-empty, max 100 chars |
| email | string | Yes | Valid email format |
---
### UpdateProfileResponse
Response from `PUT /api/v1/Users/me`. Contains the updated user data. After receiving this, the frontend calls `POST /api/v1/Auth/refresh` to sync AuthContext with the new values.
| Field | Type | Notes |
|-------|------|-------|
| id | string | GUID |
| email | string | Updated value |
| name | string | Updated value |
| role | string | Unchanged |
| isActive | boolean | Unchanged |
---
### ChangePasswordRequest
Sent to `POST /api/v1/Auth/change-password`. Requires current password for verification.
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| currentPassword | string | Yes | Must match current stored password |
| newPassword | string | Yes | Must satisfy backend password policy (≥8 chars, upper, lower, digit, special char) |
---
### AvailabilityStatus (reused from Unit 4)
Retrieved via `GET /api/v1/Availability/status`. Displayed on SettingsPage with controls to change it.
| Field | Type | Notes |
|-------|------|-------|
| status | string | "Available" \| "Maintenance" \| "Unavailable" |
| message | string | Optional custom message |
| checkedAt | datetime | Timestamp of last check |
---
### UpdateAvailabilityRequest
Sent to `POST /api/v1/Availability/admin/status` (OwnerOnly policy — already exists in backend).
| Field | Type | Required | Allowed values |
|-------|------|----------|----------------|
| newStatus | string | Yes | "Available" \| "Maintenance" \| "Unavailable" |
| reason | string | No | Free-form message shown to users |
@@ -0,0 +1,279 @@
# Frontend Components — Unit 6: Profile, Settings & CMS Placeholder
## Component Hierarchy
```mermaid
graph TD
Auth["_authenticated.tsx\n(layout route)"]
Profile["profile.tsx\nProfilePage"]
PIF["ProfileInfoForm\n(name + email edit)"]
CPD["ChangePasswordDialog\n(dialog overlay)"]
RB["RoleDisplay\n(read-only badge)"]
Settings["settings.tsx\nSettingsPage\n[RoleGuard: Owner]"]
AS["AvailabilitySection\n(mode selector + message)"]
ASBI["AvailabilityStatusBadge\n(reused from Unit 4)"]
PM1["PlaceholderCard\nModule Management"]
PM2["PlaceholderCard\nSystem Configuration"]
PM3["PlaceholderCard\nBranding / Theme"]
Cms["cms.tsx\nCmsPage\n[RoleGuard: Owner]"]
P403["403.tsx\nAccessDeniedPage"]
P404["$404.tsx\nNotFoundPage"]
Auth --> Profile
Auth --> Settings
Auth --> Cms
Auth --> P403
Auth --> P404
Profile --> PIF
Profile --> CPD
Profile --> RB
Settings --> AS
AS --> ASBI
Settings --> PM1
Settings --> PM2
Settings --> PM3
classDef layout fill:#4CAF50,stroke:#2e7d32,color:#000
classDef page fill:#2196F3,stroke:#0d47a1,color:#000
classDef component fill:#9C27B0,stroke:#4a148c,color:#000
classDef reused fill:#FF9800,stroke:#e65100,color:#000
classDef error fill:#F44336,stroke:#b71c1c,color:#000
class Auth layout
class Profile page
class Settings page
class Cms page
class PIF component
class CPD component
class RB component
class AS component
class PM1 component
class PM2 component
class PM3 component
class ASBI reused
class P403 error
class P404 error
```
Text alternative: _authenticated layout route contains Profile (with ProfileInfoForm, ChangePasswordDialog, RoleDisplay), Settings (Owner-only: AvailabilitySection with badge + 3 PlaceholderCards), CmsPage (Owner-only), 403, and 404.
---
## Component Specifications
### `profile.tsx` — ProfilePage
**Purpose**: Displays and allows editing of the logged-in user's name and email. Shows role as read-only. Provides Change Password access.
**State**:
- Form state managed by `react-hook-form`
- `isSaving` — boolean (PUT /Users/me in flight)
- `isPasswordDialogOpen` — boolean
**Props**: None (reads from `useAuth()`)
**API integrations**:
- `PUT /api/v1/Users/me` — save profile edits (new hook: `useUpdateProfile`)
- `POST /api/v1/Auth/refresh` — called after successful profile save via `AuthContext.refresh()`
**Sections**:
1. **Profile info card**: Name (text input), Email (text input), Role (read-only badge), Save button
2. **Security card**: "Change Password" button → opens `ChangePasswordDialog`
**Validation** (via `zod`):
```
name: z.string().min(1, "Name is required")
email: z.string().email("Invalid email address")
```
---
### `ProfileInfoForm`
**Purpose**: Form fields for name and email editing. Embedded in ProfilePage.
**Props**:
- `defaultValues: { name: string; email: string }`
- `onSave: (data: UpdateProfileRequest) => Promise<void>`
- `isSaving: boolean`
---
### `ChangePasswordDialog`
**Purpose**: Modal dialog for changing password. Opened from ProfilePage.
**Props**:
- `open: boolean`
- `onClose: () => void`
**State** (internal, via `react-hook-form`):
- `currentPassword`, `newPassword`, `confirmPassword`
**API integration**: `POST /api/v1/Auth/change-password` (new hook: `useChangePassword`)
**Validation** (via `zod`):
```
currentPassword: z.string().min(1, "Required")
newPassword: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "At least 1 uppercase letter")
.regex(/[a-z]/, "At least 1 lowercase letter")
.regex(/[0-9]/, "At least 1 digit")
.regex(/[^a-zA-Z0-9]/, "At least 1 special character")
confirmPassword: z.string()
// .refine: confirmPassword === newPassword
```
**On success**: Close dialog, emit success toast.
---
### `RoleDisplay`
**Purpose**: Read-only badge showing the user's current role. Not editable.
**Props**:
- `role: string`
---
### `settings.tsx` — SettingsPage
**Purpose**: Owner-only management page. Contains availability controls and placeholder sections for future settings.
**State**:
- Availability data from `useAvailabilityStatus()` (reused from Unit 4)
- `selectedMode` — controlled select: `"Available" | "Maintenance" | "Unavailable"`
- `reason` — string (optional message input)
- `isSaving` — boolean
**Props**: None
**API integrations**:
- `GET /api/v1/Availability/status` — via `useAvailabilityStatus` (existing hook)
- `POST /api/v1/Availability/admin/status` — via new `useUpdateAvailability` mutation; on success invalidates `useAvailabilityStatus` query
**Sections**:
1. **Availability section** (`AvailabilitySection`): Current status badge + mode selector (radio or select) + optional message text area + Save button
2. **PlaceholderCard** — "Module Management" (locked, coming soon)
3. **PlaceholderCard** — "System Configuration" (locked, coming soon)
4. **PlaceholderCard** — "Branding / Theme" (locked, coming soon)
---
### `AvailabilitySection`
**Purpose**: Embedded in SettingsPage. Shows current availability and provides controls to change it.
**Props**:
- `currentStatus: AvailabilityStatus`
- `onSave: (request: UpdateAvailabilityRequest) => Promise<void>`
- `isSaving: boolean`
**Sub-component**: Renders `<AvailabilityStatusBadge>` (reused from Unit 4, imported from `components/shared/`)
---
### `PlaceholderCard`
**Purpose**: Reusable locked card for future settings sections.
**Props**:
- `title: string`
- `description?: string`
**Visual**: Dimmed card with lock icon and "Coming soon" label.
---
### `cms.tsx` — CmsPage
**Purpose**: Owner-only placeholder for the future CMS feature.
**Props**: None
**Content**: Icon (e.g. `LayoutGrid` from lucide-react) + heading "Content Management System" + brief description: "This is where you will manage your CMS content. This feature is coming soon."
**State**: None (pure presentational)
---
### `403.tsx` — AccessDeniedPage
**Purpose**: Shown when RoleGuard rejects access to a route.
**Props**: None
**Content**:
- Icon: `ShieldOff` or `Lock` (lucide-react)
- Heading: "Access Denied"
- Message: "You don't have permission to view this page."
- Button: "Back to Dashboard" → navigates to `/`
**State**: None (pure presentational)
---
### `$404.tsx` — NotFoundPage
**Purpose**: TanStack Router catch-all for unknown routes.
**Props**: None
**Content**:
- Icon: `FileQuestion` (lucide-react)
- Heading: "Page Not Found"
- Message: "The page you're looking for doesn't exist."
- Button: "Back to Dashboard" → navigates to `/`
**State**: None (pure presentational)
---
## New API Hooks
| Hook | File | Endpoint | Method |
|------|------|----------|--------|
| `useUpdateProfile` | `src/api/useProfile.ts` | `PUT /api/v1/Users/me` | useMutation |
| `useChangePassword` | `src/api/useProfile.ts` | `POST /api/v1/Auth/change-password` | useMutation |
| `useUpdateAvailability` | `src/api/useAvailability.ts` (extend existing) | `POST /api/v1/Availability/admin/status` | useMutation |
## New Backend Endpoints
| Endpoint | Method | Controller | Policy |
|----------|--------|------------|--------|
| `/api/v1/Users/me` | PUT | UsersController | Authenticated |
| `/api/v1/Auth/change-password` | POST | AuthController | Authenticated |
## i18n Keys Required
**Profile page**:
- `profile.title`, `profile.name`, `profile.email`, `profile.role`, `profile.save`, `profile.saving`, `profile.saveSuccess`
- `profile.changePassword`, `profile.changePassword.current`, `profile.changePassword.new`, `profile.changePassword.confirm`, `profile.changePassword.success`
**Settings page**:
- `settings.title`, `settings.availability.title`, `settings.availability.save`, `settings.availability.saveSuccess`
- `settings.availability.mode.available`, `settings.availability.mode.maintenance`, `settings.availability.mode.unavailable`
- `settings.availability.reason`, `settings.placeholder.comingSoon`
- `settings.modules.title`, `settings.systemConfig.title`, `settings.branding.title`
**Error pages**:
- `error.403.title`, `error.403.message`, `error.404.title`, `error.404.message`, `error.backToDashboard`
## Unit Test Scope (Q8: C — all pages)
| Component | Test focus |
|-----------|------------|
| ProfilePage | Renders AuthContext user data; name/email inputs; save triggers PUT; password dialog opens |
| ChangePasswordDialog | Validation (mismatch, policy); success flow; error handling |
| SettingsPage | Fetches and displays availability; mode change + save triggers POST; placeholder sections render |
| CmsPage | Renders heading and description |
| AccessDeniedPage (403) | Renders heading, message, Back to Dashboard navigates to `/` |
| NotFoundPage (404) | Renders heading, message, Back to Dashboard navigates to `/` |
@@ -0,0 +1,182 @@
# Gap Report: `classDiagram` Coloring Reliably Breaks `domain-entities.md`
**Gap ID**: gap-003
**Reported**: 2026-06-22
**Reporter**: User (via cms-frontend Unit 6 Functional Design session)
**Skill affected**: `aidlc-workflow`
**Rule files affected**:
- `.aidlc-rule-details/construction/functional-design.md` — Step 7, "Diagram types per artifact"
- `.aidlc-rule-details/common/mermaid-diagram-standards.md` — missing validated `classDiagram` example
---
## Problem Description
Every time `domain-entities.md` is generated by the Functional Design stage, the entity diagram is broken on the first attempt. It requires manual correction from the user before it renders.
This happened in both the `slp-modular-cms-api` and `cms-frontend` features. It is a **structural, repeatable failure** caused by a conflict between the diagram type prescribed by the rules and the actual Mermaid behavior.
---
## Root Cause
### Prescribed rule (functional-design.md, Step 7)
```
domain-entities.md → `classDiagram` for entity relationships
(supports `classDef` coloring), NOT `erDiagram`
```
The claim "(supports `classDef` coloring)" is misleading. There are **two failure modes** that make colored `classDiagram` unreliable in practice:
### Failure Mode 1: `class ClassName style` conflicts with body-defined classes
When a class is defined with a body block:
```
class AuthUser {
+string id
+string email
}
```
...and then a `classDef` style is applied using the flowchart-style pattern:
```
classDef user fill:#2196F3,color:#000
class AuthUser user
```
Mermaid interprets `class AuthUser user` as a **second class declaration** that conflicts with the already-defined body. The diagram breaks. This is the #1 failure pattern because the AI model uses the flowchart `classDef`/`class` pattern (which is well-documented in `mermaid-diagram-standards.md`) and applies it to `classDiagram` — where it doesn't work the same way.
### Failure Mode 2: `:::` inline notation is unreliable
The alternative syntax — applying a `classDef` inline in the class header:
```
class AuthUser:::user {
+string id
}
```
...is **not reliably supported across Mermaid versions** and also broke on this user's renderer.
### Contributing factor: No validated `classDiagram` example in standards
`mermaid-diagram-standards.md` provides validated, working examples for:
- `graph LR/TD` (flowchart) ✅
- `sequenceDiagram`
It **does not** provide any validated working example for `classDiagram` with `classDef` coloring. So the AI model has no reliable template to follow and defaults to flowchart patterns — which break in `classDiagram`.
---
## Observed Symptom Pattern
1. AI generates `domain-entities.md` with `classDiagram` + `classDef` coloring
2. Diagram is broken (either body-block conflict or `:::` not supported)
3. User reports broken diagram
4. AI attempts fix with `:::` notation → still broken
5. AI converts to `graph TD` → diagram works
6. **Total: 2 failed attempts before success, requiring user intervention**
This pattern occurred identically in Unit 6 of the `cms-frontend` feature (2026-06-22).
---
## Proven Fix (from this session)
Converting `domain-entities.md` to use `graph TD` instead of `classDiagram` works reliably:
```mermaid
graph TD
AuthUser["AuthUser"]
UpdateProfileRequest["UpdateProfileRequest"]
AuthUser -->|"provides data for"| UpdateProfileRequest
classDef user fill:#2196F3,stroke:#0d47a1,color:#000
classDef request fill:#FF9800,stroke:#e65100,color:#000
class AuthUser user
class UpdateProfileRequest request
```
`graph TD` with `classDef`/`class` is:
- Well-documented with a validated example in `mermaid-diagram-standards.md`
- Consistently supported across Mermaid versions
- Sufficient to express entity relationships (via labeled directed edges)
The entity field details (types, required/optional, descriptions) are better placed in the **tables below the diagram** than in the class body nodes anyway — keeping the diagram clean and the data queryable.
---
## Suggested Fix
### Fix 1 (Primary — required): Update `functional-design.md`
In Step 7, "Diagram types per artifact", change:
**Current**:
```
domain-entities.md → `classDiagram` for entity relationships
(supports `classDef` coloring), NOT `erDiagram`
```
**Replace with**:
```
domain-entities.md → `graph TD` for entity relationships.
- Use labeled edges (|"relationship label"|) to show how entities connect
- Use `classDef`/`class` for coloring (same pattern as flowcharts)
- Do NOT use `classDiagram` — classDef coloring is unreliable in classDiagram
- Do NOT use `erDiagram` — no color support
- Keep entity field details in Markdown tables below the diagram, not in diagram nodes
```
### Fix 2 (Secondary — recommended): Update `mermaid-diagram-standards.md`
Add an explicit warning and a `graph TD` entity diagram example to `mermaid-diagram-standards.md`:
```markdown
## Entity Relationship Diagrams
Use `graph TD` for entity diagrams — NOT `classDiagram` (classDef coloring is
unreliable in classDiagram) and NOT `erDiagram` (no color support).
Example:
graph TD
User["User"]
Order["Order"]
Product["Product"]
User -->|"places"| Order
Order -->|"contains"| Product
classDef entity fill:#2196F3,stroke:#0d47a1,color:#000
classDef value fill:#FF9800,stroke:#e65100,color:#000
class User,Order entity
class Product value
```
---
## Acceptance Criteria for Fix
- [ ] `functional-design.md` Step 7 prescribes `graph TD` for `domain-entities.md`, not `classDiagram`
- [ ] The reason for avoiding `classDiagram` is documented in the rule (prevents future regression)
- [ ] `mermaid-diagram-standards.md` includes a validated `graph TD` entity diagram example
- [ ] A note in `mermaid-diagram-standards.md` explicitly warns against `classDiagram` for colored entity diagrams
- [ ] After the fix, generating `domain-entities.md` produces a working diagram on the first attempt without user correction
---
## Related Files
- Skill: `C:\Users\Bryan\.claude\skills\aidlc-workflow\`
- Primary rule: `.aidlc-rule-details/construction/functional-design.md` — Step 7
- Supporting rule: `.aidlc-rule-details/common/mermaid-diagram-standards.md`
- Example broken file: `K:\Development\Projects\SlpModularCms\aidlc-docs\features\cms-frontend\construction\unit-6\functional-design\domain-entities.md` (fixed in session, now uses `graph TD`)
---
## Workaround (for current sessions)
When generating `domain-entities.md`, use `graph TD` instead of `classDiagram`. Apply the standard `classDef`/`class` pattern. Place entity field details in Markdown tables below the diagram rather than in diagram node bodies.
@@ -0,0 +1,133 @@
# Gap Report: active-features.md Not Updated as Feature Progresses or Completes
**Gap ID**: gap-005
**Reported**: 2026-06-22
**Reporter**: User (via cms-frontend workflow session)
**Skill affected**: `aidlc-workflow`
**Rule files affected**:
- `.aidlc-rule-details/inception/workspace-detection.md` — Step 4d (initial registration)
- `SKILL.md` — Operations section / Key Principles / Workflow Complete block
- `.aidlc-rule-details/construction/build-and-test.md` — Step 8 (Update State Tracking)
- `.aidlc-rule-details/operations/operations.md` — Workflow Complete block
---
## Observed Behavior
`aidlc-docs/active-features.md` is written exactly once — at feature creation (Step 4d of `workspace-detection.md`) — and is **never updated thereafter**. The feature's status row remains frozen at `🔵 Inception` regardless of how far the workflow has advanced.
For `cms-frontend`, all 6 construction units are complete and Build and Test is ready to start, yet `active-features.md` still shows:
```
| CMS Frontend (cms-frontend) | 🔵 Inception | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 |
```
The status emoji guide defined in Step 4d of `workspace-detection.md` describes four states (`🔵 Inception · 🟢 Construction · 🟡 Operations · ✅ Complete`) but **no rule in the skill instructs the model to use any state beyond `🔵 Inception`**.
---
## Expected Behavior
`active-features.md` should be kept in sync with the feature's actual phase throughout the workflow. Specifically:
| Trigger | New status in active-features.md |
|---|---|
| Inception phase complete / first Construction stage begins | `🟢 Construction` |
| Build and Test approved / Operations phase entered | `🟡 Operations` (or `✅ Complete` if Operations is skipped) |
| Workflow complete (Operations placeholder acknowledged) | `✅ Complete` |
When the feature has no Operations phase (as with `cms-frontend`, where the Operations stage is a placeholder), the status should transition directly from `🟢 Construction` to `✅ Complete` upon Build and Test approval.
The `active-features.md` index is the primary multi-feature dashboard. It is read during session resumption (`session-continuity.md`) and displayed to the user in the Welcome Back prompt. Stale status data directly degrades the session-resume experience.
---
## Root Cause
The gap has two dimensions:
**1. Missing transition instructions (phase change)**
No rule file specifies that `active-features.md` must be updated when the workflow transitions from one phase to another. The status emoji guide exists in `workspace-detection.md` Step 4d as documentation, but no subsequent stage instructs the model to write those updated rows.
**2. Missing completion instruction**
`SKILL.md` (Operations section, "Workflow Complete" block) and `operations/operations.md` both declare that the workflow ends after Build and Test approval, and that the model should present a closing summary. Neither file includes an instruction to update `active-features.md` to `✅ Complete`. Similarly, `construction/build-and-test.md` Step 8 ("Update State Tracking") only mentions updating `aidlc-state.md`, not `active-features.md`.
In short: `active-features.md` is treated as a write-once registration file rather than a living index.
---
## Impact
- Session resume (Welcome Back prompt from `session-continuity.md`) shows incorrect phase for all features
- Users cannot determine which features are genuinely in progress vs. complete by looking at `active-features.md`
- Conflict detection (Step 5 of `workspace-detection.md`) may incorrectly flag a completed feature as still active, because it keys on `status not ✅ Complete`
- The multi-feature dashboard is unreliable; the user must manually inspect each feature's `aidlc-state.md` to learn the real status
---
## Proposed Fix
Add explicit `active-features.md` update instructions at the two natural transition points and at workflow completion:
### Fix 1 — Phase transition: Inception → Construction
In `construction/code-generation.md` (or wherever the first construction stage begins), add to the "Update State Tracking" step:
> **Also update `aidlc-docs/active-features.md`**: Change the feature's status column from `🔵 Inception` to `🟢 Construction`.
### Fix 2 — Workflow completion: Build and Test approved
In `construction/build-and-test.md` Step 8 ("Update State Tracking"), extend the existing instruction:
> **Also update `aidlc-docs/active-features.md`**: If the feature has no active Operations phase (Operations is a placeholder), change the feature's status to `✅ Complete`. If an Operations phase will follow, change status to `🟡 Operations`.
Replicate the same instruction in the "Workflow Complete" block in `SKILL.md` (Operations section) and in `operations/operations.md`.
### Fix 3 — Clarify the status emoji guide is actionable
In `workspace-detection.md` Step 4d, add a note below the status emoji guide making clear that status updates are mandatory as the workflow progresses:
> **Note**: Status values are not static. The model MUST update the feature's row in `active-features.md` whenever the workflow transitions between phases, and again when the workflow completes.
### Fix 4 — Session continuity awareness
In `session-continuity.md`, under "MANDATORY: Session Continuity Instructions", add a consistency check:
> When resuming a feature, compare the phase shown in `active-features.md` against `aidlc-state.md`. If they diverge, correct `active-features.md` to match `aidlc-state.md` before presenting the Welcome Back prompt.
---
## Skill Files to Update
| File | Change |
|---|---|
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\.aidlc-rule-details\construction\build-and-test.md` | Step 8: add `active-features.md` update to `✅ Complete` (or `🟡 Operations`) |
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\.aidlc-rule-details\construction\code-generation.md` | State tracking step: add `active-features.md` update to `🟢 Construction` on first construction unit |
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\SKILL.md` | Operations "Workflow Complete" block: add `active-features.md` update to `✅ Complete` |
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\.aidlc-rule-details\operations\operations.md` | Add "Workflow Complete" instructions including `active-features.md` update |
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\.aidlc-rule-details\inception\workspace-detection.md` | Step 4d: add note that status must be updated as workflow progresses |
| `C:\Users\Bryan\.claude\skills\aidlc-workflow\.aidlc-rule-details\common\session-continuity.md` | Add consistency check: correct `active-features.md` on resume if it diverges from `aidlc-state.md` |
---
## Workaround (for current session)
For `cms-frontend` in `K:\Development\Projects\SlpModularCms`: manually update `aidlc-docs/active-features.md` — change status from `🔵 Inception` to `✅ Complete`, because all 6 units are done, Build and Test is the only remaining step, and there is no active Operations phase for this feature.
This gap report was filed as part of applying that manual correction.
---
## Acceptance Criteria for Fix
- [ ] Starting a new Construction stage updates `active-features.md` status to `🟢 Construction`
- [ ] Build and Test approval updates `active-features.md` status to `✅ Complete` (no Operations) or `🟡 Operations` (active Operations)
- [ ] Session resume detects and corrects stale `active-features.md` status automatically
- [ ] Step 4d in `workspace-detection.md` explicitly notes the status is not write-once
- [ ] All six skill files listed above are updated consistently
---
**Opened**: 2026-06-22
**Status**: Open
**Severity**: Medium (dashboard unreliable; session-resume quality degraded; conflict detection may produce false positives)
+17
View File
@@ -0,0 +1,17 @@
**Welcome back! I can see you have an existing AI-DLC workspace with multiple features.**
Based on your active-features.md, here are your features:
| # | Feature | Status | Current Stage | Branch |
|---|---------|--------|---------------|--------|
| 1 | SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | — | unknown |
| 2 | CMS Frontend (cms-frontend) | 🔵 In Progress | Unit 6 — Profile, Settings & CMS Placeholder (Not Started) | unknown |
**What would you like to work on today?**
A) Continue feature: CMS Frontend — Start Unit 6 (Profile, Settings & CMS Placeholder)
B) Continue a different feature: specify number
C) Start a NEW feature
D) Review a previous stage of a specific feature
[Answer]:
+1 -1
View File
@@ -80,7 +80,7 @@ export interface ChangeRolePayload {
newRole: UserRole; newRole: UserRole;
} }
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable'; export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export interface AvailabilityResponse { export interface AvailabilityResponse {
status: AvailabilityStatus; status: AvailabilityStatus;
+15 -2
View File
@@ -1,6 +1,11 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import type { AvailabilityResponse } from './types'; import type { AvailabilityResponse, AvailabilityStatus } from './types';
export interface UpdateAvailabilityPayload {
newStatus: AvailabilityStatus;
reason: string;
}
export function useAvailabilityStatus() { export function useAvailabilityStatus() {
return useQuery<AvailabilityResponse, Error>({ return useQuery<AvailabilityResponse, Error>({
@@ -9,3 +14,11 @@ export function useAvailabilityStatus() {
staleTime: 30_000, staleTime: 30_000,
}); });
} }
export function useUpdateAvailability() {
const queryClient = useQueryClient();
return useMutation<void, Error, UpdateAvailabilityPayload>({
mutationFn: (data) => api.post<void>('/api/v1/Availability/admin/status', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['availability', 'status'] }),
});
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { useAuth } from '@/contexts/auth-context';
import type { UserListItem } from './types';
export interface UpdateProfilePayload {
name: string;
email: string;
}
export interface ChangePasswordPayload {
currentPassword: string;
newPassword: string;
}
export function useUpdateProfile() {
const { refresh } = useAuth();
return useMutation<UserListItem, Error, UpdateProfilePayload>({
mutationFn: (data) => api.put<UserListItem>('/api/v1/Users/me', data),
onSuccess: () => {
// Sync AuthContext with updated name/email via token refresh
void refresh();
},
});
}
export function useChangePassword() {
return useMutation<void, Error, ChangePasswordPayload>({
mutationFn: (data) => api.post<void>('/api/v1/Auth/change-password', data),
});
}
+13 -1
View File
@@ -1,13 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router'; import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { Sidebar } from '@/components/layout/Sidebar'; import { Sidebar } from '@/components/layout/Sidebar';
import { MobileBar } from '@/components/layout/MobileBar'; import { MobileBar } from '@/components/layout/MobileBar';
import { SidebarOverlay } from '@/components/layout/SidebarOverlay'; import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
export function AppLayout() { export function AppLayout() {
const { isAuthenticated, status } = useAuth(); const { isAuthenticated, status } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { location } = useRouterState();
const { data: availability } = useAvailabilityStatus();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -16,6 +21,13 @@ export function AppLayout() {
} }
}, [status, navigate]); }, [status, navigate]);
useEffect(() => {
if (availability?.status === 'NotAvailable') {
const allowed = ALLOWED_WHEN_UNAVAILABLE.some(p => location.pathname.startsWith(p));
if (!allowed) void navigate({ to: '/dashboard' });
}
}, [availability?.status, location.pathname, navigate]);
if (!isAuthenticated) { if (!isAuthenticated) {
return null; return null;
} }
+40 -1
View File
@@ -4,6 +4,7 @@ import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher'; import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu'; import { UserMenu } from './UserMenu';
@@ -18,13 +19,18 @@ interface NavItem {
roles?: Role[]; roles?: Role[];
} }
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' }, { to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] }, { to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] }, { to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
]; ];
const SETTINGS_ITEM: NavItem = {
to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'],
};
interface SidebarProps { interface SidebarProps {
onClose?: () => void; onClose?: () => void;
} }
@@ -32,11 +38,14 @@ interface SidebarProps {
export function Sidebar({ onClose }: SidebarProps = {}) { export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const { data: availability } = useAvailabilityStatus();
const role = user?.role as Role | undefined; const role = user?.role as Role | undefined;
const systemUnavailable = availability?.status === 'NotAvailable';
const visibleItems = NAV_ITEMS.filter( const visibleItems = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role)) (item) => !item.roles || (role && item.roles.includes(role))
); );
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
return ( return (
<aside <aside
@@ -64,6 +73,19 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 p-3">
{visibleItems.map((item) => { {visibleItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const blocked = systemUnavailable && !ALLOWED_WHEN_UNAVAILABLE.some(p => item.to.startsWith(p));
if (blocked) {
return (
<span
key={item.to}
data-testid={item.testId}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium opacity-40 select-none"
>
<Icon className="size-4" />
{t(item.labelKey)}
</span>
);
}
return ( return (
<Link <Link
key={item.to} key={item.to}
@@ -82,6 +104,23 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
})} })}
</nav> </nav>
{showSettings && (
<div className="border-t border-border p-3">
<Link
to={SETTINGS_ITEM.to}
data-testid={SETTINGS_ITEM.testId}
onClick={onClose}
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'),
}}
>
<Settings className="size-4" />
{t(SETTINGS_ITEM.labelKey)}
</Link>
</div>
)}
<div className="flex items-center gap-1 border-t border-border p-3"> <div className="flex items-center gap-1 border-t border-border p-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<UserMenu /> <UserMenu />
@@ -25,7 +25,7 @@ describe('AvailabilityStatusBadge', () => {
}); });
it('renders Unavailable status with red styling', () => { it('renders Unavailable status with red styling', () => {
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />); renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
const label = screen.getByTestId('availability-status-label'); const label = screen.getByTestId('availability-status-label');
expect(label).toHaveTextContent('Unavailable'); expect(label).toHaveTextContent('Unavailable');
@@ -70,7 +70,7 @@ describe('AvailabilityStatusBadge', () => {
it('Unavailable shows a custom reason when provided', () => { it('Unavailable shows a custom reason when provided', () => {
renderWithProviders( renderWithProviders(
<AvailabilityStatusBadge status="Unavailable" message="Emergency shutdown in progress" />, <AvailabilityStatusBadge status="NotAvailable" message="Emergency shutdown in progress" />,
); );
expect(screen.getByTestId('availability-message')).toHaveTextContent( expect(screen.getByTestId('availability-message')).toHaveTextContent(
@@ -79,7 +79,7 @@ describe('AvailabilityStatusBadge', () => {
}); });
it('Unavailable falls back to default translated message when no reason is provided', () => { it('Unavailable falls back to default translated message when no reason is provided', () => {
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />); renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
expect(screen.getByTestId('availability-message')).toHaveTextContent( expect(screen.getByTestId('availability-message')).toHaveTextContent(
'System is currently unavailable.', 'System is currently unavailable.',
@@ -33,7 +33,7 @@ const STATUS_CONFIG: Record<
colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', colorClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
Icon: AlertTriangle, Icon: AlertTriangle,
}, },
Unavailable: { NotAvailable: {
labelKey: 'availability.unavailable', labelKey: 'availability.unavailable',
defaultMessageKey: 'availability.messageUnavailable', defaultMessageKey: 'availability.messageUnavailable',
alwaysDefault: false, alwaysDefault: false,
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import type { UserRole } from '@/api/types';
function roleBadgeVariant(role: UserRole): 'default' | 'secondary' | 'outline' {
if (role === 'Owner') return 'default';
if (role === 'Administrator') return 'secondary';
return 'outline';
}
interface RoleBadgeProps {
role: UserRole;
'data-testid'?: string;
}
export function RoleBadge({ role, 'data-testid': testId }: RoleBadgeProps) {
const { t } = useTranslation();
return (
<Badge variant={roleBadgeVariant(role)} data-testid={testId}>
{t(`users.roles.${role}`)}
</Badge>
);
}
@@ -144,6 +144,60 @@
"insufficientPermissions": "You do not have permission to perform this action." "insufficientPermissions": "You do not have permission to perform this action."
} }
}, },
"profile": {
"title": "My Profile",
"name": "Full name",
"email": "Email address",
"role": "Role",
"save": "Save changes",
"saving": "Saving…",
"saveSuccess": "Profile updated successfully",
"changePassword": "Change password",
"changePasswordDialog": {
"title": "Change Password",
"current": "Current password",
"new": "New password",
"confirm": "Confirm new password",
"submit": "Change password",
"submitting": "Changing…",
"success": "Password changed successfully",
"errorCurrent": "Current password is incorrect"
}
},
"settings": {
"title": "System Settings",
"availability": {
"title": "System Availability",
"mode": "Availability mode",
"reason": "Message (optional)",
"save": "Update availability",
"saving": "Updating…",
"saveSuccess": "Availability updated",
"modes": {
"Available": "Available",
"Maintenance": "Maintenance",
"NotAvailable": "Unavailable"
}
},
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
"branding": { "title": "Branding / Theme", "comingSoon": "Coming soon" }
},
"cms": {
"title": "Content Management System",
"description": "This is where you will manage your CMS content. This feature is coming soon."
},
"error": {
"403": {
"title": "Access Denied",
"message": "You don't have permission to view this page."
},
"404": {
"title": "Page Not Found",
"message": "The page you're looking for doesn't exist."
},
"backToDashboard": "Back to Dashboard"
},
"errors": { "errors": {
"network": "Unable to reach the server. Check your connection and try again.", "network": "Unable to reach the server. Check your connection and try again.",
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",
@@ -144,6 +144,60 @@
"insufficientPermissions": "Je hebt geen toestemming om deze actie uit te voeren." "insufficientPermissions": "Je hebt geen toestemming om deze actie uit te voeren."
} }
}, },
"profile": {
"title": "Mijn profiel",
"name": "Volledige naam",
"email": "E-mailadres",
"role": "Rol",
"save": "Wijzigingen opslaan",
"saving": "Opslaan…",
"saveSuccess": "Profiel succesvol bijgewerkt",
"changePassword": "Wachtwoord wijzigen",
"changePasswordDialog": {
"title": "Wachtwoord wijzigen",
"current": "Huidig wachtwoord",
"new": "Nieuw wachtwoord",
"confirm": "Nieuw wachtwoord bevestigen",
"submit": "Wachtwoord wijzigen",
"submitting": "Bezig…",
"success": "Wachtwoord succesvol gewijzigd",
"errorCurrent": "Huidig wachtwoord is onjuist"
}
},
"settings": {
"title": "Systeeminstellingen",
"availability": {
"title": "Systeembeschikbaarheid",
"mode": "Beschikbaarheidsmodus",
"reason": "Bericht (optioneel)",
"save": "Beschikbaarheid bijwerken",
"saving": "Bijwerken…",
"saveSuccess": "Beschikbaarheid bijgewerkt",
"modes": {
"Available": "Beschikbaar",
"Maintenance": "Onderhoud",
"NotAvailable": "Niet beschikbaar"
}
},
"modules": { "title": "Modulebeheer", "comingSoon": "Binnenkort beschikbaar" },
"systemConfig": { "title": "Systeemconfiguratie", "comingSoon": "Binnenkort beschikbaar" },
"branding": { "title": "Huisstijl / Thema", "comingSoon": "Binnenkort beschikbaar" }
},
"cms": {
"title": "Content Management Systeem",
"description": "Hier beheert u straks uw CMS-inhoud. Deze functie is binnenkort beschikbaar."
},
"error": {
"403": {
"title": "Toegang geweigerd",
"message": "Je hebt geen toestemming om deze pagina te bekijken."
},
"404": {
"title": "Pagina niet gevonden",
"message": "De pagina die je zoekt bestaat niet."
},
"backToDashboard": "Terug naar dashboard"
},
"errors": { "errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.", "network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
"generic": "Er is iets misgegaan. Probeer het opnieuw.", "generic": "Er is iets misgegaan. Probeer het opnieuw.",
+11
View File
@@ -34,4 +34,15 @@ export const authHandlers = [
http.post(`${API_BASE}/api/v1/auth/revoke`, () => { http.post(`${API_BASE}/api/v1/auth/revoke`, () => {
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
http.post(`${API_BASE}/api/v1/Auth/change-password`, async ({ request }) => {
const body = (await request.json()) as { currentPassword: string; newPassword: string };
if (body.currentPassword === TEST_CREDENTIALS.password) {
return new HttpResponse(null, { status: 200 });
}
return HttpResponse.json(
problem(400, 'Incorrect password', 'Current password is incorrect.'),
{ status: 400 },
);
}),
]; ];
@@ -8,4 +8,6 @@ export const availabilityHandlers = [
message: '', message: '',
}), }),
), ),
http.post('*/Availability/admin/status', () => new HttpResponse(null, { status: 200 })),
]; ];
+12
View File
@@ -91,6 +91,18 @@ export const userHandlers = [
http.put(`${API_BASE}/api/v1/Users/:userId/active`, () => new HttpResponse(null, { status: 200 })), http.put(`${API_BASE}/api/v1/Users/:userId/active`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/me`, async ({ request }) => {
const body = (await request.json()) as { name: string; email: string };
const owner = mockUsers.find((u) => u.role === 'Owner') ?? mockUsers[0];
const updated: UserListItem = {
...owner,
name: body.name,
email: body.email,
};
mockUsers = mockUsers.map((u) => (u.id === owner.id ? updated : u));
return HttpResponse.json(updated);
}),
http.delete(`${API_BASE}/api/v1/Users/:userId`, ({ params }) => { http.delete(`${API_BASE}/api/v1/Users/:userId`, ({ params }) => {
const { userId } = params as { userId: string }; const { userId } = params as { userId: string };
const ownerUser = mockUsers.find((u) => u.role === 'Owner'); const ownerUser = mockUsers.find((u) => u.role === 'Owner');
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('AccessDeniedPage', () => {
it('renders the 403 title and message', async () => {
renderApp('/403');
expect(await screen.findByTestId('access-denied-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('access-denied-message')).toBeInTheDocument();
});
it('renders the back to dashboard button', async () => {
renderApp('/403');
expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('navigates to login when back button is clicked as guest', async () => {
mockGuest();
renderApp('/403');
const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 });
await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { ShieldOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
export function AccessDeniedPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<ShieldOff className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold" data-testid="access-denied-title">
{t('error.403.title')}
</h1>
<p className="text-muted-foreground max-w-sm" data-testid="access-denied-message">
{t('error.403.message')}
</p>
<Button
onClick={() => void navigate({ to: '/dashboard' })}
data-testid="access-denied-back-button"
>
{t('error.backToDashboard')}
</Button>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('CmsPage', () => {
it('renders the CMS page title and placeholder', async () => {
mockAuthenticated();
renderApp('/cms');
expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('cms-placeholder')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/cms');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+7 -4
View File
@@ -1,14 +1,17 @@
import { LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
/** Placeholder; CMS management (Owner-only, US-18/US-20) arrives in a later unit. */
export function CmsPage() { export function CmsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="space-y-2"> <div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<LayoutGrid className="size-12 text-muted-foreground" />
<h1 className="text-2xl font-semibold" data-testid="cms-title"> <h1 className="text-2xl font-semibold" data-testid="cms-title">
{t('nav.cms')} {t('cms.title')}
</h1> </h1>
<p className="text-muted-foreground">Coming soon.</p> <p className="text-muted-foreground max-w-sm" data-testid="cms-placeholder">
{t('cms.description')}
</p>
</div> </div>
); );
} }
+34
View File
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockGuest } from '@/test/utils';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('NotFoundPage', () => {
it('renders for an unknown route', async () => {
renderApp('/this-route-does-not-exist');
expect(await screen.findByTestId('not-found-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('not-found-message')).toBeInTheDocument();
});
it('renders the back to dashboard button', async () => {
renderApp('/some/nonexistent/path');
expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('navigates to login when back button is clicked as guest', async () => {
mockGuest();
renderApp('/this-does-not-exist');
const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 });
await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { FileQuestion } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
export function NotFoundPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<FileQuestion className="size-12 text-muted-foreground" />
<h1 className="text-2xl font-semibold" data-testid="not-found-title">
{t('error.404.title')}
</h1>
<p className="text-muted-foreground max-w-sm" data-testid="not-found-message">
{t('error.404.message')}
</p>
<Button
onClick={() => void navigate({ to: '/dashboard' })}
data-testid="not-found-back-button"
>
{t('error.backToDashboard')}
</Button>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('ProfilePage', () => {
it('renders the page title and profile form', async () => {
mockAuthenticated();
renderApp('/profile');
expect(await screen.findByTestId('profile-title', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('profile-name-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-email-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument();
});
it('pre-fills name and email from authenticated user', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId<HTMLInputElement>('profile-name-input');
const emailInput = screen.getByTestId<HTMLInputElement>('profile-email-input');
expect(nameInput.value).toBe('Test Owner');
expect(emailInput.value).toBe('owner@example.com');
});
it('save button is disabled when form is pristine', async () => {
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('profile-name-input');
expect(screen.getByTestId('profile-save-button')).toBeDisabled();
});
it('save button enables after editing a field', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId('profile-name-input');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Name');
expect(screen.getByTestId('profile-save-button')).not.toBeDisabled();
});
it('saves profile successfully and shows success toast', async () => {
mockAuthenticated();
renderApp('/profile');
const nameInput = await screen.findByTestId('profile-name-input');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'New Name');
await userEvent.click(screen.getByTestId('profile-save-button'));
expect(await screen.findByText(/profile updated/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('shows validation error for invalid email', async () => {
mockAuthenticated();
renderApp('/profile');
// Type invalid email — this makes form dirty and triggers validation on submit
const emailInput = await screen.findByTestId('profile-email-input');
await userEvent.tripleClick(emailInput);
await userEvent.type(emailInput, 'not-an-email');
const saveButton = screen.getByTestId('profile-save-button');
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
expect(await screen.findByTestId('profile-email-error')).toBeInTheDocument();
});
it('opens change password dialog when button is clicked', async () => {
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('change-password-button');
await userEvent.click(screen.getByTestId('change-password-button'));
expect(await screen.findByTestId('change-password-dialog')).toBeInTheDocument();
});
it('shows error in dialog when current password is wrong', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Auth/change-password`, () =>
HttpResponse.json({ title: 'Bad Request' }, { status: 400 }),
),
);
mockAuthenticated();
renderApp('/profile');
await screen.findByTestId('change-password-button');
await userEvent.click(screen.getByTestId('change-password-button'));
await screen.findByTestId('change-password-dialog');
// PasswordField auto-generates testid as ${id}-input
await userEvent.type(screen.getByTestId('currentPassword-input'), 'WrongPass1!');
await userEvent.type(screen.getByTestId('newPassword-input'), 'NewPassword1!');
await userEvent.type(screen.getByTestId('confirmPassword-input'), 'NewPassword1!');
await userEvent.click(screen.getByTestId('change-password-submit'));
expect(await screen.findByTestId('current-password-error')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/profile');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+277 -3
View File
@@ -1,13 +1,287 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useAuth } from '@/contexts/auth-context';
import { useUpdateProfile, useChangePassword } from '@/api/useProfile';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { PasswordField } from '@/components/ui/PasswordField';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ProblemDetailsError } from '@/lib/api-client';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Enter a valid email address'),
});
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Required'),
newPassword: z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'At least 1 uppercase letter')
.regex(/[a-z]/, 'At least 1 lowercase letter')
.regex(/[0-9]/, 'At least 1 digit')
.regex(/[^a-zA-Z0-9]/, 'At least 1 special character'),
confirmPassword: z.string().min(1, 'Required'),
})
.refine((d) => d.newPassword === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type ProfileFormData = z.infer<typeof profileSchema>;
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
function ChangePasswordDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const changePassword = useChangePassword();
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
const onSubmit = async (data: ChangePasswordFormData) => {
try {
await changePassword.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
toast.success(t('profile.changePasswordDialog.success'));
reset();
onClose();
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
setError('currentPassword', {
message: t('profile.changePasswordDialog.errorCurrent'),
});
} else {
setError('root', { message: t('errors.generic') });
}
}
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent data-testid="change-password-dialog">
<DialogHeader>
<DialogTitle>{t('profile.changePasswordDialog.title')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{errors.root && (
<p className="text-sm text-destructive" data-testid="change-password-error">
{errors.root.message}
</p>
)}
<div className="space-y-1">
<Label htmlFor="currentPassword">
{t('profile.changePasswordDialog.current')}
</Label>
<PasswordField
id="currentPassword"
data-testid="current-password-input"
{...register('currentPassword')}
/>
{errors.currentPassword && (
<p className="text-sm text-destructive" data-testid="current-password-error">
{errors.currentPassword.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="newPassword">
{t('profile.changePasswordDialog.new')}
</Label>
<PasswordField
id="newPassword"
data-testid="new-password-input"
{...register('newPassword')}
/>
{errors.newPassword && (
<p className="text-sm text-destructive" data-testid="new-password-error">
{errors.newPassword.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="confirmPassword">
{t('profile.changePasswordDialog.confirm')}
</Label>
<PasswordField
id="confirmPassword"
data-testid="confirm-password-input"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive" data-testid="confirm-password-error">
{errors.confirmPassword.message}
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
data-testid="change-password-cancel"
>
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="change-password-submit"
>
{isSubmitting
? t('profile.changePasswordDialog.submitting')
: t('profile.changePasswordDialog.submit')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export function ProfilePage() { export function ProfilePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth();
const updateProfile = useUpdateProfile();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
});
const onSubmit = async (data: ProfileFormData) => {
try {
await updateProfile.mutateAsync(data);
toast.success(t('profile.saveSuccess'));
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('errors.generic'));
} else {
toast.error(t('errors.generic'));
}
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-6 max-w-lg">
<h1 className="text-2xl font-semibold" data-testid="profile-title"> <h1 className="text-2xl font-semibold" data-testid="profile-title">
{t('nav.profile')} {t('profile.title')}
</h1> </h1>
<p className="text-muted-foreground">Coming soon.</p>
{/* Profile info card */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('profile.title')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">{t('profile.name')}</Label>
<Input
id="name"
data-testid="profile-name-input"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive" data-testid="profile-name-error">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="email">{t('profile.email')}</Label>
<Input
id="email"
type="email"
data-testid="profile-email-input"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive" data-testid="profile-email-error">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-1">
<Label>{t('profile.role')}</Label>
<div>
{user?.role
? <RoleBadge role={user.role} data-testid="profile-role-badge" />
: <span data-testid="profile-role-badge"></span>}
</div>
</div>
<Button
type="submit"
disabled={isSubmitting || !isDirty}
data-testid="profile-save-button"
>
{isSubmitting ? t('profile.saving') : t('profile.save')}
</Button>
</form>
</CardContent>
</Card>
{/* Security card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Security</CardTitle>
</CardHeader>
<CardContent>
<Button
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
data-testid="change-password-button"
>
{t('profile.changePassword')}
</Button>
</CardContent>
</Card>
<ChangePasswordDialog
open={passwordDialogOpen}
onClose={() => setPasswordDialogOpen(false)}
/>
</div> </div>
); );
} }
+90
View File
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
describe('SettingsPage', () => {
it('renders the page title', async () => {
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('settings-title', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('shows the current availability status badge', async () => {
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('availability-badge', {}, { timeout: 5000 })).toBeInTheDocument();
});
it('renders all three availability mode buttons', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 });
expect(screen.getByTestId('mode-option-Available')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument();
});
it('selecting a mode highlights that mode button', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 });
const maintenanceBtn = screen.getByTestId('mode-option-Maintenance');
await userEvent.click(maintenanceBtn);
expect(maintenanceBtn.className).toContain('bg-primary');
});
it('saves availability and shows success toast', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/availability updated/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('shows error toast when save fails', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Availability/admin/status`, () =>
HttpResponse.json({ title: 'Error' }, { status: 500 }),
),
);
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('renders all placeholder sections', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('settings-title', {}, { timeout: 5000 });
expect(screen.getByTestId('placeholder-settings.modules.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/settings');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
+128 -3
View File
@@ -1,13 +1,138 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAvailabilityStatus, useUpdateAvailability } from '@/api/useAvailability';
import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusBadge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import type { AvailabilityStatus } from '@/api/types';
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
const { t } = useTranslation();
return (
<Card className="opacity-60">
<CardHeader className="flex flex-row items-center gap-2">
<Lock className="size-4 text-muted-foreground" />
<CardTitle className="text-base">{t(titleKey)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground" data-testid={`placeholder-${titleKey}`}>
{t(comingSoonKey)}
</p>
</CardContent>
</Card>
);
}
export function SettingsPage() { export function SettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: availability, isLoading } = useAvailabilityStatus();
const updateAvailability = useUpdateAvailability();
const [selectedMode, setSelectedMode] = useState<AvailabilityStatus>('Available');
const [reason, setReason] = useState('');
useEffect(() => {
if (availability) {
setSelectedMode(availability.status);
setReason(availability.message ?? '');
}
}, [availability]);
const handleSave = async () => {
try {
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
toast.success(t('settings.availability.saveSuccess'));
} catch {
toast.error(t('errors.generic'));
}
};
const modes: AvailabilityStatus[] = ['Available', 'Maintenance', 'NotAvailable'];
return ( return (
<div className="space-y-2"> <div className="space-y-6 max-w-xl">
<h1 className="text-2xl font-semibold" data-testid="settings-title"> <h1 className="text-2xl font-semibold" data-testid="settings-title">
{t('nav.settings')} {t('settings.title')}
</h1> </h1>
<p className="text-muted-foreground">Coming soon.</p>
{/* Availability section */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.availability.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : availability ? (
<AvailabilityStatusBadge
status={availability.status}
message={availability.message}
/>
) : null}
<div className="space-y-2">
<Label>{t('settings.availability.mode')}</Label>
<div className="flex gap-2 flex-wrap" data-testid="availability-mode-selector">
{modes.map((mode) => (
<button
key={mode}
type="button"
onClick={() => setSelectedMode(mode)}
data-testid={`mode-option-${mode}`}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
selectedMode === mode
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background hover:bg-accent'
}`}
>
{t(`settings.availability.modes.${mode}`)}
</button>
))}
</div>
</div>
<div className="space-y-1">
<Label htmlFor="availability-reason">
{t('settings.availability.reason')}
</Label>
<textarea
id="availability-reason"
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
data-testid="availability-reason-input"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={t('settings.availability.reason')}
/>
</div>
<Button
onClick={handleSave}
disabled={updateAvailability.isPending}
data-testid="availability-save-button"
>
{updateAvailability.isPending
? t('settings.availability.saving')
: t('settings.availability.save')}
</Button>
</CardContent>
</Card>
<PlaceholderCard
titleKey="settings.modules.title"
comingSoonKey="settings.modules.comingSoon"
/>
<PlaceholderCard
titleKey="settings.systemConfig.title"
comingSoonKey="settings.systemConfig.comingSoon"
/>
<PlaceholderCard
titleKey="settings.branding.title"
comingSoonKey="settings.branding.comingSoon"
/>
</div> </div>
); );
} }
+2 -9
View File
@@ -33,18 +33,13 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/components/users/InviteUserDialog'; import { InviteUserDialog } from '@/components/users/InviteUserDialog';
import { DeleteUserDialog } from '@/components/users/DeleteUserDialog'; import { DeleteUserDialog } from '@/components/users/DeleteUserDialog';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/api/useUsers'; import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/api/useUsers';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import { ProblemDetailsError } from '@/lib/api-client'; import { ProblemDetailsError } from '@/lib/api-client';
import type { UserRole, UserListItem } from '@/api/types'; import type { UserRole, UserListItem } from '@/api/types';
function roleBadgeVariant(role: UserRole) {
if (role === 'Owner') return 'default';
if (role === 'Administrator') return 'secondary';
return 'outline';
}
function statusBadgeVariant(user: UserListItem) { function statusBadgeVariant(user: UserListItem) {
if (user.invitationPending) return 'outline'; if (user.invitationPending) return 'outline';
if (!user.isActive) return 'destructive'; if (!user.isActive) return 'destructive';
@@ -248,9 +243,7 @@ export function UsersPage() {
<TableCell className="font-medium">{user.name}</TableCell> <TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell> <TableCell>
<Badge variant={roleBadgeVariant(user.role)}> <RoleBadge role={user.role} />
{t(`users.roles.${user.role}`)}
</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusBadgeVariant(user)}> <Badge variant={statusBadgeVariant(user)}>
+9
View File
@@ -11,6 +11,7 @@ import type { SetupStatus } from '@/api/types';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import { AppLayout } from '@/components/layout/AppLayout'; import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { NotFoundPage } from '@/pages/NotFoundPage';
import { RoleGuard } from '@/components/auth/RoleGuard'; import { RoleGuard } from '@/components/auth/RoleGuard';
export interface RouterContext { export interface RouterContext {
@@ -92,6 +93,7 @@ function lazyPage<P extends Record<string, never>>(
const rootRoute = createRootRouteWithContext<RouterContext>()({ const rootRoute = createRootRouteWithContext<RouterContext>()({
pendingComponent: BootstrapSplash, pendingComponent: BootstrapSplash,
pendingMs: 0, pendingMs: 0,
notFoundComponent: NotFoundPage,
beforeLoad: async ({ location }) => { beforeLoad: async ({ location }) => {
let status: SetupStatus; let status: SetupStatus;
try { try {
@@ -205,11 +207,18 @@ const profileRoute = createRoute({
component: lazyPage(() => import('@/pages/ProfilePage'), 'ProfilePage'), component: lazyPage(() => import('@/pages/ProfilePage'), 'ProfilePage'),
}); });
const accessDeniedRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/403',
component: lazyPage(() => import('@/pages/AccessDeniedPage'), 'AccessDeniedPage'),
});
export const routeTree = rootRoute.addChildren([ export const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
loginRoute, loginRoute,
setupRoute, setupRoute,
inviteCompleteRoute, inviteCompleteRoute,
accessDeniedRoute,
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]), authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
]); ]);
+5 -1
View File
@@ -1,10 +1,14 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { cleanup } from '@testing-library/react'; import { cleanup, configure } from '@testing-library/react';
import { server } from '@/mocks/server'; import { server } from '@/mocks/server';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import '@/i18n/config'; import '@/i18n/config';
// Increase findBy/waitFor timeout to handle parallel test environments
// where lazy-loaded route components may take >1000ms to resolve.
configure({ asyncUtilTimeout: 8000 });
// jsdom is missing a few browser APIs that Radix/sonner touch. // jsdom is missing a few browser APIs that Radix/sonner touch.
const globalAny = globalThis as unknown as { const globalAny = globalThis as unknown as {
matchMedia?: unknown; matchMedia?: unknown;
+2
View File
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { AuthProvider } from '@/contexts/AuthProvider'; import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { routeTree } from '@/router'; import { routeTree } from '@/router';
import { server } from '@/mocks/server'; import { server } from '@/mocks/server';
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures'; import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
@@ -68,6 +69,7 @@ export function renderApp(initialPath = '/') {
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<AuthProvider> <AuthProvider>
<AppHarness initialPath={initialPath} /> <AppHarness initialPath={initialPath} />
<Toaster />
</AuthProvider> </AuthProvider>
</I18nextProvider> </I18nextProvider>
</QueryClientProvider>, </QueryClientProvider>,
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
testTimeout: 15000,
css: true, css: true,
coverage: { coverage: {
provider: 'v8', provider: 'v8',
+4
View File
@@ -24,6 +24,10 @@ orchestrator.RegisterModuleServices(builder.Services);
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {
options.Conventions.Add(new ApiPrefixConvention("api/v1")); options.Conventions.Add(new ApiPrefixConvention("api/v1"));
})
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
}); });
var app = builder.Build(); var app = builder.Build();
@@ -5,6 +5,8 @@ public record LoginRequest(string Email, string Password);
public record InviteUserRequest(string Email, string Role); public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password); public record CompleteSetupRequest(string Token, string Password);
public record ChangeRoleRequest(string NewRole); public record ChangeRoleRequest(string NewRole);
public record UpdateProfileRequest(string Name, string Email);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SetUserActiveRequest(bool IsActive); public record SetUserActiveRequest(bool IsActive);
public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt); public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
public record UserDto( public record UserDto(
@@ -77,6 +77,19 @@ public class AuthService : IAuthService
} }
} }
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
{
var user = await _userManager.FindByIdAsync(userId.ToString())
?? throw new UnauthorizedException("Gebruiker niet gevonden.");
var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
throw new ValidationException(errors);
}
}
private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user) private async Task<TokenResponse> GenerateTokenResponseAsync(ApplicationUser user)
{ {
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
@@ -21,4 +21,9 @@ public interface IAuthService
/// Trekt een refresh token in (uitloggen). /// Trekt een refresh token in (uitloggen).
/// </summary> /// </summary>
Task RevokeTokenAsync(string refreshToken); Task RevokeTokenAsync(string refreshToken);
/// <summary>
/// Wijzigt het wachtwoord van de opgegeven gebruiker.
/// </summary>
Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword);
} }
@@ -22,10 +22,20 @@ public class AvailabilityMiddleware
_logger = logger; _logger = logger;
} }
// Paths that are always accessible regardless of system availability.
// Auth and Setup must stay open so admins can log in and the frontend
// can determine whether the system is initialized.
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
];
public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService) public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService)
{ {
// Bypass voor status endpoint var path = context.Request.Path.Value ?? string.Empty;
if (context.Request.Path.StartsWithSegments("/api/availability/status")) if (_bypassPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{ {
await _next(context); await _next(context);
return; return;
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -61,6 +62,20 @@ public class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub");
if (!Guid.TryParse(userIdClaim, out var userId))
return Unauthorized();
await _authService.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword);
return Ok();
}
private void SetTokenCookie(string refreshToken) private void SetTokenCookie(string refreshToken)
{ {
Response.Cookies.Append("refreshToken", refreshToken, GetCookieOptions()); Response.Cookies.Append("refreshToken", refreshToken, GetCookieOptions());
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -75,6 +76,51 @@ public class UsersController : ControllerBase
return Ok(result.OrderBy(u => u.CreatedAt)); return Ok(result.OrderBy(u => u.CreatedAt));
} }
[HttpPut("me")]
[Authorize]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub");
if (!Guid.TryParse(userIdClaim, out var userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null) return NotFound();
// Check email uniqueness if email is changing
if (!string.Equals(user.Email, request.Email, StringComparison.OrdinalIgnoreCase))
{
var existing = await _userManager.FindByEmailAsync(request.Email);
if (existing is not null)
return BadRequest(new { detail = "Email is already in use." });
}
user.DisplayName = request.Name;
user.Email = request.Email;
user.UserName = request.Email;
user.NormalizedEmail = request.Email.ToUpperInvariant();
user.NormalizedUserName = request.Email.ToUpperInvariant();
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
return StatusCode(500, new { detail = "Failed to update profile." });
var roles = await _userManager.GetRolesAsync(user);
var role = roles.FirstOrDefault() ?? "User";
return Ok(new UserDto(
user.Id,
user.Email ?? string.Empty,
user.DisplayName ?? user.Email ?? string.Empty,
role,
user.IsActive,
user.CreatedAt,
false,
null));
}
[HttpPost("invite")] [HttpPost("invite")]
public async Task<IActionResult> Invite([FromBody] InviteUserRequest request) public async Task<IActionResult> Invite([FromBody] InviteUserRequest request)
{ {