Adds profile and settings pages
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
| Feature | Status | Branch | Affected Components | Session Start |
|
||||
|---------|--------|--------|---------------------|---------------|
|
||||
| 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
|
||||
- **Project Type**: Brownfield
|
||||
- **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
|
||||
|
||||
## Workspace State
|
||||
@@ -52,7 +52,7 @@
|
||||
- [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 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
|
||||
|
||||
### 🟡 OPERATIONS PHASE
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# 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
|
||||
|
||||
**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 1–5
|
||||
- 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 11–16 (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 9–10 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
|
||||
+57
@@ -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 1–5 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.
|
||||
+147
@@ -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).
|
||||
+112
@@ -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).
|
||||
+105
@@ -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 |
|
||||
+279
@@ -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)
|
||||
@@ -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]:
|
||||
@@ -80,7 +80,7 @@ export interface ChangeRolePayload {
|
||||
newRole: UserRole;
|
||||
}
|
||||
|
||||
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
|
||||
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
|
||||
|
||||
export interface AvailabilityResponse {
|
||||
status: AvailabilityStatus;
|
||||
|
||||
@@ -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 type { AvailabilityResponse } from './types';
|
||||
import type { AvailabilityResponse, AvailabilityStatus } from './types';
|
||||
|
||||
export interface UpdateAvailabilityPayload {
|
||||
newStatus: AvailabilityStatus;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function useAvailabilityStatus() {
|
||||
return useQuery<AvailabilityResponse, Error>({
|
||||
@@ -9,3 +14,11 @@ export function useAvailabilityStatus() {
|
||||
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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
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 { useAvailabilityStatus } from '@/api/useAvailability';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { MobileBar } from '@/components/layout/MobileBar';
|
||||
import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
|
||||
|
||||
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
||||
|
||||
export function AppLayout() {
|
||||
const { isAuthenticated, status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { location } = useRouterState();
|
||||
const { data: availability } = useAvailabilityStatus();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -16,6 +21,13 @@ export function AppLayout() {
|
||||
}
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useAvailabilityStatus } from '@/api/useAvailability';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
@@ -18,13 +19,18 @@ interface NavItem {
|
||||
roles?: Role[];
|
||||
}
|
||||
|
||||
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
const SETTINGS_ITEM: NavItem = {
|
||||
to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'],
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -32,11 +38,14 @@ interface SidebarProps {
|
||||
export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { data: availability } = useAvailabilityStatus();
|
||||
|
||||
const role = user?.role as Role | undefined;
|
||||
const systemUnavailable = availability?.status === 'NotAvailable';
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.roles || (role && item.roles.includes(role))
|
||||
);
|
||||
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -64,6 +73,19 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{visibleItems.map((item) => {
|
||||
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 (
|
||||
<Link
|
||||
key={item.to}
|
||||
@@ -82,6 +104,23 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
|
||||
})}
|
||||
</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-1 min-w-0">
|
||||
<UserMenu />
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('AvailabilityStatusBadge', () => {
|
||||
});
|
||||
|
||||
it('renders Unavailable status with red styling', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
|
||||
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
|
||||
|
||||
const label = screen.getByTestId('availability-status-label');
|
||||
expect(label).toHaveTextContent('Unavailable');
|
||||
@@ -70,7 +70,7 @@ describe('AvailabilityStatusBadge', () => {
|
||||
|
||||
it('Unavailable shows a custom reason when provided', () => {
|
||||
renderWithProviders(
|
||||
<AvailabilityStatusBadge status="Unavailable" message="Emergency shutdown in progress" />,
|
||||
<AvailabilityStatusBadge status="NotAvailable" message="Emergency shutdown in progress" />,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
renderWithProviders(<AvailabilityStatusBadge status="Unavailable" />);
|
||||
renderWithProviders(<AvailabilityStatusBadge status="NotAvailable" />);
|
||||
|
||||
expect(screen.getByTestId('availability-message')).toHaveTextContent(
|
||||
'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',
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
Unavailable: {
|
||||
NotAvailable: {
|
||||
labelKey: 'availability.unavailable',
|
||||
defaultMessageKey: 'availability.messageUnavailable',
|
||||
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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"network": "Unable to reach the server. Check your connection and try again.",
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
|
||||
@@ -144,6 +144,60 @@
|
||||
"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": {
|
||||
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
|
||||
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
|
||||
@@ -34,4 +34,15 @@ export const authHandlers = [
|
||||
http.post(`${API_BASE}/api/v1/auth/revoke`, () => {
|
||||
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: '',
|
||||
}),
|
||||
),
|
||||
|
||||
http.post('*/Availability/admin/status', () => new HttpResponse(null, { status: 200 })),
|
||||
];
|
||||
|
||||
@@ -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/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 }) => {
|
||||
const { userId } = params as { userId: string };
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,17 @@
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Placeholder; CMS management (Owner-only, US-18/US-20) arrives in a later unit. */
|
||||
export function CmsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
{t('nav.cms')}
|
||||
{t('cms.title')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 { 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() {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<h1 className="text-2xl font-semibold" data-testid="profile-title">
|
||||
{t('nav.profile')}
|
||||
{t('profile.title')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<h1 className="text-2xl font-semibold" data-testid="settings-title">
|
||||
{t('nav.settings')}
|
||||
{t('settings.title')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,18 +33,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { InviteUserDialog } from '@/components/users/InviteUserDialog';
|
||||
import { DeleteUserDialog } from '@/components/users/DeleteUserDialog';
|
||||
import { RoleBadge } from '@/components/shared/RoleBadge';
|
||||
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/api/useUsers';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { ProblemDetailsError } from '@/lib/api-client';
|
||||
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) {
|
||||
if (user.invitationPending) return 'outline';
|
||||
if (!user.isActive) return 'destructive';
|
||||
@@ -248,9 +243,7 @@ export function UsersPage() {
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={roleBadgeVariant(user.role)}>
|
||||
{t(`users.roles.${user.role}`)}
|
||||
</Badge>
|
||||
<RoleBadge role={user.role} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusBadgeVariant(user)}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SetupStatus } from '@/api/types';
|
||||
import { api } from '@/lib/api-client';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage';
|
||||
import { RoleGuard } from '@/components/auth/RoleGuard';
|
||||
|
||||
export interface RouterContext {
|
||||
@@ -92,6 +93,7 @@ function lazyPage<P extends Record<string, never>>(
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
pendingComponent: BootstrapSplash,
|
||||
pendingMs: 0,
|
||||
notFoundComponent: NotFoundPage,
|
||||
beforeLoad: async ({ location }) => {
|
||||
let status: SetupStatus;
|
||||
try {
|
||||
@@ -205,11 +207,18 @@ const profileRoute = createRoute({
|
||||
component: lazyPage(() => import('@/pages/ProfilePage'), 'ProfilePage'),
|
||||
});
|
||||
|
||||
const accessDeniedRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/403',
|
||||
component: lazyPage(() => import('@/pages/AccessDeniedPage'), 'AccessDeniedPage'),
|
||||
});
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
loginRoute,
|
||||
setupRoute,
|
||||
inviteCompleteRoute,
|
||||
accessDeniedRoute,
|
||||
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import '@testing-library/jest-dom/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 { api } from '@/lib/api-client';
|
||||
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.
|
||||
const globalAny = globalThis as unknown as {
|
||||
matchMedia?: unknown;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { AuthProvider } from '@/contexts/AuthProvider';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { routeTree } from '@/router';
|
||||
import { server } from '@/mocks/server';
|
||||
import { API_BASE, makeAuthResponse } from '@/mocks/auth/fixtures';
|
||||
@@ -68,6 +69,7 @@ export function renderApp(initialPath = '/') {
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AuthProvider>
|
||||
<AppHarness initialPath={initialPath} />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
testTimeout: 15000,
|
||||
css: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
@@ -24,6 +24,10 @@ orchestrator.RegisterModuleServices(builder.Services);
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Conventions.Add(new ApiPrefixConvention("api/v1"));
|
||||
})
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -5,6 +5,8 @@ public record LoginRequest(string Email, string Password);
|
||||
public record InviteUserRequest(string Email, string Role);
|
||||
public record CompleteSetupRequest(string Token, string Password);
|
||||
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 PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
|
||||
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)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
@@ -21,4 +21,9 @@ public interface IAuthService
|
||||
/// Trekt een refresh token in (uitloggen).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
// Bypass voor status endpoint
|
||||
if (context.Request.Path.StartsWithSegments("/api/availability/status"))
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (_bypassPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -61,6 +62,20 @@ public class AuthController : ControllerBase
|
||||
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)
|
||||
{
|
||||
Response.Cookies.Append("refreshToken", refreshToken, GetCookieOptions());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -75,6 +76,51 @@ public class UsersController : ControllerBase
|
||||
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")]
|
||||
public async Task<IActionResult> Invite([FromBody] InviteUserRequest request)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user