Adds user management

This commit is contained in:
2026-06-22 21:14:22 +02:00
parent 5331be4279
commit 6976eb4337
40 changed files with 3392 additions and 146 deletions
@@ -0,0 +1,256 @@
# Code Generation Plan — Unit 5: User Management
**Status**: 🚧 In Progress
**Stories**: US-10 (View list of users), US-11 (Invite a new user), US-12 (Share invite link)
## Unit Context
**Depends on**: Unit 3 (AppLayout, RoleGuard), Unit 4 (TanStack Query / QueryClientProvider)
**Project type**: Brownfield — modify existing files; create new files where listed
**Key observations**:
- `UsersPage.tsx` is a placeholder — full replacement
- `useInvitation.ts` uses raw state hooks — migrate to TanStack Query + align types
- Backend `UsersController` needs 2 new endpoints + invite link URL fix
- 4 shadcn components need to be added: `dialog`, `select`, `table`, `badge`
- Role change (User Management scope includes role promotion per clarification D=B)
- `sonner` toast is already installed; `lucide-react` icons are available
---
## Stories Covered
| Story | Requirement | Implemented in step |
|---|---|---|
| US-10 | View list of users | Steps 4, 9, 11, 12 |
| US-11 | Invite a new user | Steps 4, 9, 10, 11, 12 |
| US-12 | Share invite link (dialog + table row copy) | Steps 4, 9, 10, 11, 12 |
---
## Steps
### PART 1 — Backend
- [ ] **Step 1**: Add shadcn UI components (dialog, select, table, badge)
- Run `pnpm dlx shadcn@latest add dialog select table badge` in `frontend/`
- Installs Radix UI dependencies and creates `src/components/ui/dialog.tsx`, `select.tsx`, `table.tsx`, `badge.tsx`
- [ ] **Step 2**: Fix invite link URL in `UsersController.Invite`
- File: `src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs`
- Change: `"/setup/complete?token=..."``"/invite/complete?token=..."`
- The frontend route is `/invite/complete`, not `/setup/complete`
- [ ] **Step 3**: Add `GetPendingInvitationByEmailAsync` to `IInvitationService`
- File: `src/SlpModularCms.Core/Identity/Services/IInvitationService.cs`
- Add: `Task<(bool IsPending, string? Token)> GetPendingInvitationByEmailAsync(string email);`
- Returns IsPending=true + Token when a non-accepted, non-expired invitation exists for the email
- [ ] **Step 4**: Implement `GetPendingInvitationByEmailAsync` in `InvitationService`
- File: `src/SlpModularCms.Core/Identity/Services/InvitationService.cs`
- Query `Invitations` where `Email == email && !IsAccepted && !IsExpired`
- Return `(true, invitation.Token)` if found; otherwise `(false, null)`
- [ ] **Step 5**: Add backend models `UserDto` and `ChangeRoleRequest`
- File: `src/SlpModularCms.Core/Identity/Models/IdentityRequests.cs`
- Add: `public record UserDto(Guid Id, string Email, string Name, string Role, bool IsActive, DateTimeOffset CreatedAt, bool InvitationPending, string? InviteLink);`
- Add: `public record ChangeRoleRequest(string NewRole);`
- [ ] **Step 6**: Update `UsersController` — add `GET /` and `PUT /{userId}/role`
- File: `src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs`
- Inject `UserManager<ApplicationUser>` (add constructor parameter)
- Add `[HttpGet]` action `GetUsers()`:
- `userManager.Users.ToListAsync()` — get all users
- For each user: `await userManager.GetRolesAsync(user)` — get primary role (first or "User")
- `await _invitationService.GetPendingInvitationByEmailAsync(user.Email)` — check pending
- Map to `UserDto`; set `InviteLink = $"/invite/complete?token={Uri.EscapeDataString(token)}"` when pending
- Authorize: `[Authorize(Policy = "AdminOnly")]`
- Add `[HttpPut("{userId}/role")]` action `ChangeRole(Guid userId, ChangeRoleRequest request)`:
- Find user by ID; return 404 if not found
- Count current owners; if target is Owner and would reduce to 0 → return 400
- Caller (Admin) cannot promote to Owner; only Owner can
- `await userManager.RemoveFromRolesAsync(user, existingRoles)`
- `await userManager.AddToRoleAsync(user, request.NewRole)`
- Return 200 OK
- Authorize: `[Authorize(Policy = "AdminOnly")]`
- [ ] **Step 6b**: Verify no database migration needed
- `GET /Users` and `PUT /{userId}/role` only read/update existing tables: `Users`, `Roles`, `UserRoles`, `Invitations`
- No new columns or tables are introduced in Unit 5
- **No EF Core migration required** — confirm by checking `dotnet ef migrations list` shows no pending model changes
---
### PART 2 — Frontend Types
- [ ] **Step 7**: Extend `frontend/src/api/types.ts`
- Add after `AvailabilityResponse`:
```typescript
export interface UserListItem extends User {
createdAt: string;
invitationPending: boolean;
inviteLink: string | null;
}
export interface InviteUserPayload {
email: string;
role: Exclude<UserRole, 'Owner'>;
}
export interface InviteUserResponse {
inviteLink: string;
}
export interface ChangeRolePayload {
newRole: UserRole;
}
```
- Note: named `InviteUserPayload` (not `InviteUserRequest`) to avoid collision with backend model name in future shared type scenarios
---
### PART 3 — Migrate useInvitation.ts to TanStack Query
- [ ] **Step 8**: Migrate `frontend/src/api/useInvitation.ts` to TanStack Query
- Replace `useState`/`useEffect` implementation with `useQuery` and `useMutation`
- Use `InvitationValidation` and `InviteCompleteRequest` from `./types` (remove local interfaces)
- `useValidateInvitation(token)`:
- `useQuery<InvitationValidation, Error>({ queryKey: ['invitation', 'validate', token], queryFn: () => api.get(...), enabled: !!token })`
- URL: `/api/v1/Invitation/validate?token=${encodeURIComponent(token)}`
- `useCompleteInvitation()`:
- `useMutation<void, Error, InviteCompleteRequest>({ mutationFn: (data) => api.post('/api/v1/Invitation/complete', data) })`
- [ ] **Step 9**: Update `frontend/src/mocks/invitation/handlers.ts`
- Change mock response format from `{ valid, email }` → `{ isValid, email, name, errorCode }`
- Valid token response: `{ isValid: true, email: 'invited@example.com', name: null, errorCode: null }`
- Invalid token response: `{ isValid: false, email: '', name: null, errorCode: 'EXPIRED' }`
- [ ] **Step 10**: Update `frontend/src/pages/InviteCompletePage.tsx`
- Change `validationQuery.data?.valid` → `validationQuery.data?.isValid` (2 occurrences)
- Change `data?.valid && loadingState === 'loading'` → `data?.isValid && loadingState === 'loading'`
- The `data?.email` field is unchanged (same name in `InvitationValidation`)
---
### PART 4 — New Frontend Hooks
- [ ] **Step 11**: Create `frontend/src/api/useUsers.ts`
- `useUsers()` — `useQuery<UserListItem[], Error>({ queryKey: ['users'], queryFn: () => api.get('/api/v1/Users'), staleTime: 30_000 })`
- `useInviteUser()` — `useMutation<InviteUserResponse, Error, InviteUserPayload>({ mutationFn: (data) => api.post('/api/v1/Users/invite', data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }) })`
- `useChangeRole()` — `useMutation<void, Error, { userId: string; newRole: UserRole }>({ mutationFn: ({ userId, newRole }) => api.put(\`/api/v1/Users/${userId}/role\`, { newRole }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }) })`
---
### PART 5 — New Frontend Components
- [ ] **Step 12**: Create `frontend/src/components/users/InviteUserDialog.tsx`
- Props: `{ open: boolean; onOpenChange: (open: boolean) => void }`
- Internal state: `step: 1 | 2`, `inviteLink: string | null`; reset on `!open`
- Step 1 — InviteUserForm:
- `react-hook-form` + zod: `email: z.string().email()`, `role: z.enum(['Administrator', 'User'])`
- Fields: `<Input>` for email, `<Select>` for role (Administrator | User options)
- On submit: call `useInviteUser().mutateAsync(data)`; on success set `inviteLink` + `setStep(2)`
- On error: show `<FormErrorBanner>`
- data-testid: `invite-dialog-email`, `invite-dialog-role`, `invite-dialog-submit`
- Step 2 — ShareLinkView:
- Show invite link in read-only `<Input value={inviteLink} readOnly>`
- "Copy to clipboard" `<Button>`: `navigator.clipboard.writeText(inviteLink)` + `toast.success(t('users.actions.linkCopied'))`
- "Close" `<Button>`: calls `onOpenChange(false)`
- data-testid: `invite-dialog-link-input`, `invite-dialog-copy`, `invite-dialog-close`
- Use shadcn `<Dialog>`, `<DialogContent>`, `<DialogHeader>`, `<DialogTitle>`
- [ ] **Step 13**: Replace `frontend/src/pages/UsersPage.tsx` (full implementation)
- Use `useUsers()`, `useInviteUser()`, `useChangeRole()` from `api/useUsers`
- Use `useAuth()` from `contexts/auth-context` for `currentUser` (role-based action filtering)
- Page structure:
- Header: `<h1>{t('users.title')}</h1>` + `<Button onClick={() => setDialogOpen(true)}>{t('users.inviteButton')}</Button>`
- `<Table>` with columns: Name, Email, Role, Status, Created At, Actions
- Per row: role `<Badge>`, status `<Badge>`, DropdownMenu with:
- "Copy invite link" — visible if `user.invitationPending`, calls `navigator.clipboard.writeText(user.inviteLink!)` + toast
- "Change role" sub-items — role options filtered by currentUser.role (Owner: all 3 roles; Admin: User only)
- `<InviteUserDialog open={dialogOpen} onOpenChange={setDialogOpen} />`
- data-testid: `users-page`, `users-invite-button`, `users-table`, `user-row-{id}`, `user-copy-link-{id}`, `user-change-role-{id}`
---
### PART 6 — Mocks + i18n
- [ ] **Step 14**: Expand `frontend/src/mocks/users/handlers.ts`
- Replace placeholder `GET /Users` with full mock returning 3 users: 1 Owner (active), 1 Admin (active), 1 User (invitationPending=true, inviteLink='/invite/complete?token=pending-token')
- Add `POST /Users/invite` handler: return `{ inviteLink: '/invite/complete?token=new-mock-token' }`
- Add `PUT /Users/:userId/role` handler: return 200 OK
- [ ] **Step 15**: Add `users.*` keys to `frontend/src/i18n/locales/en/translation.json`
- Add `"users"` section with keys: `title`, `inviteButton`, `table.*`, `status.*`, `actions.*`, `invite.*`, `roles.*`
- Full key set defined in `frontend-components.md`
- [ ] **Step 16**: Add `users.*` keys to `frontend/src/i18n/locales/nl/translation.json`
- Dutch translations for all `users.*` keys
---
### PART 7 — Tests
- [ ] **Step 17**: Create `frontend/src/api/useUsers.test.ts`
- `useUsers`: loading state, success with user list, error state
- `useInviteUser`: success (returns inviteLink), API error (400)
- `useChangeRole`: success (200), forbidden (403)
- Use `renderHook` + `QueryClientProvider` from `test/utils.tsx`
- [ ] **Step 18**: Create `frontend/src/pages/UsersPage.test.tsx`
- Renders user table with mocked user list (3 rows)
- "Invite User" button opens InviteUserDialog
- Copy invite link visible only for pending user row
- Change role dropdown items filtered by current user role
- Use `renderApp` from `test/utils.tsx` with mock auth context (Owner role)
- [ ] **Step 19**: Create `frontend/src/components/users/InviteUserDialog.test.tsx`
- Step 1: renders form fields; submit with invalid email shows zod error
- Step 1 → Step 2: successful submit transitions to link view
- Step 2: copy button triggers clipboard write + toast
- API error: shows `FormErrorBanner` message
- Use `renderWithProviders` from `test/utils.tsx`
- [ ] **Step 20**: Update `frontend/src/pages/InviteCompletePage.test.tsx`
- Update any assertions using `valid` → `isValid` in mock responses (via updated invitation mock handlers)
- Verify existing 29 tests still pass after the `isValid` migration
---
### PART 8 — Documentation
- [ ] **Step 21**: Create `aidlc-docs/features/cms-frontend/construction/unit-5/code/code-generation-summary.md`
- List all created and modified files with purpose
- Record test count and build status after completion
---
## File Inventory
### New files
| File | Purpose |
|---|---|
| `frontend/src/components/ui/dialog.tsx` | shadcn Dialog component |
| `frontend/src/components/ui/select.tsx` | shadcn Select component |
| `frontend/src/components/ui/table.tsx` | shadcn Table component |
| `frontend/src/components/ui/badge.tsx` | shadcn Badge component |
| `frontend/src/api/useUsers.ts` | useUsers, useInviteUser, useChangeRole |
| `frontend/src/components/users/InviteUserDialog.tsx` | Two-step invite dialog |
| `frontend/src/api/useUsers.test.ts` | Tests for user hooks |
| `frontend/src/pages/UsersPage.test.tsx` | Tests for UsersPage |
| `frontend/src/components/users/InviteUserDialog.test.tsx` | Tests for invite dialog |
| `aidlc-docs/features/cms-frontend/construction/unit-5/code/code-generation-summary.md` | Summary |
### Modified files
| File | Change |
|---|---|
| `src/.../Controllers/UsersController.cs` | Fix invite URL, add GET /Users, add PUT /{userId}/role |
| `src/.../Services/IInvitationService.cs` | Add GetPendingInvitationByEmailAsync |
| `src/.../Services/InvitationService.cs` | Implement GetPendingInvitationByEmailAsync |
| `src/.../Models/IdentityRequests.cs` | Add UserDto, ChangeRoleRequest |
| `frontend/src/api/types.ts` | Add UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload |
| `frontend/src/api/useInvitation.ts` | Migrate to TanStack Query, align types |
| `frontend/src/mocks/invitation/handlers.ts` | Update to isValid format |
| `frontend/src/pages/InviteCompletePage.tsx` | valid → isValid |
| `frontend/src/pages/UsersPage.tsx` | Replace placeholder with full implementation |
| `frontend/src/mocks/users/handlers.ts` | Expand mocks |
| `frontend/src/i18n/locales/en/translation.json` | Add users.* keys |
| `frontend/src/i18n/locales/nl/translation.json` | Add users.* keys |
| `frontend/src/pages/InviteCompletePage.test.tsx` | Update for isValid |
@@ -0,0 +1,55 @@
# Functional Design Clarifications — Unit 5: User Management
**Status**: 🚧 Awaiting answers
Your answer on Q7 introduces additional requirements that need clarification before designing.
Please fill in the letter after each `[Answer]:` tag.
---
### Clarification A: "Copy link at a later point" — where?
You answered that the invite link should be accessible after the dialog closes. Where should the persisted invite link be visible for users with a pending invitation?
A) In the user table row — an "action" button or icon (e.g. a chain-link icon) next to each pending user that copies the invite link to clipboard directly (recommended — minimal UI, no extra page needed)
B) In an expandable row detail panel within the user table — clicking a row shows extra details including the invite link
C) In a separate user detail modal/drawer — clicking the user opens a drawer with their details + invite link
D) Other
[Answer]: A
---
### Clarification B: "Refresh invite token" — backend approach
Regenerating an invite token requires invalidating the old one and creating a new one. Which backend approach should be used?
A) Add a new backend endpoint `POST /api/v1/Users/{userId}/reinvite` that invalidates any existing pending invitation for that user and generates a fresh token (recommended — clean, explicit, safe)
B) Re-use the existing `POST /api/v1/Users/invite` with the same email — the backend simply creates a new token (the old one remains valid until it expires)
C) Other
[Answer]: C, Never mind this feature. Might be a future feature.
---
### Clarification C: Pending invitation state — backend data
For the frontend to know which users have a pending invitation, the `GET /api/v1/Users` response needs to indicate this. How should the backend expose this?
A) Add `invitationPending: boolean` and `inviteLink: string | null` fields to the user list response — the frontend can show the link directly when `invitationPending = true` (recommended — no extra roundtrip)
B) Add only `invitationPending: boolean` — the invite link is fetched on demand via a separate endpoint when the user wants to copy it
C) Other
[Answer]: A
---
### Clarification D: Scope of "user details" — role promotion
In Q3 you mentioned that an Owner can promote an Administrator to Owner. Is role promotion (changing an existing user's role) in scope for Unit 5, or is it deferred to a later unit/feature?
A) Deferred — Unit 5 only covers inviting new users; role changes for existing users are a separate feature (recommended — keeps Unit 5 focused on the invitation flow)
B) In scope for Unit 5 — add a "Change role" action to the user table alongside the invite functionality
C) Other
[Answer]: B
@@ -0,0 +1,151 @@
# Functional Design Plan — Unit 5: User Management
**Status**: 🚧 In Progress
## Unit Context
**Unit**: Unit 5 — User Management
**Type**: Frontend (React/TypeScript) + minor backend addition
**Depends on**: Unit 3 (AppLayout, RoleGuard), Unit 4 (TanStack Query patterns established)
**Stories Covered**: US-10 (View list of users), US-11 (Invite a new user), US-12 (Share invite link)
**Key Deliverables** (from unit-of-work.md):
- `frontend/src/api/useUsers.ts``useUsers` hook (user list), `useInviteUser` mutation
- Update `frontend/src/api/useInvitation.ts` — align with established patterns
- `frontend/src/components/users/InviteUserDialog.tsx` — two-step: invite form → share link
- `frontend/src/pages/UsersPage.tsx` — replace placeholder with user table + invite dialog
- MSW mocks for user endpoints
- i18n keys for user management (en + nl)
- Unit tests
**API Endpoints** (backend):
- `GET /api/v1/Users` — list all users (**NOT YET IMPLEMENTED in backend — see Q1**)
- `POST /api/v1/Users/invite` — invite a user (requires AdminOnly policy; returns `{ inviteLink: string }`)
- `GET /api/v1/Invitation/validate?token=...` — validate token (anonymous, existing)
- `POST /api/v1/Invitation/complete` — complete invitation (anonymous, existing)
**Key Observations**:
- `UsersPage.tsx` is currently a placeholder ("Coming soon.") — full implementation needed
- `useInvitation.ts` uses raw `useState`/`useEffect` with local interfaces diverging from `types.ts`; Unit 4 established TanStack Query as the standard
- `userHandlers.ts` has a placeholder `GET /Users` MSW mock — needs full implementation
- Backend has no `GET /Users` endpoint yet — decision needed (Q1)
---
## 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: Backend GET /Users endpoint
The frontend needs a list of all users for UsersPage, but `UsersController` currently has no `GET /Users` endpoint. How should this be handled?
A) Add `GET /api/v1/Users` to the backend (`UsersController`) in this unit — returning `id`, `email`, `displayName`, `role`, `isActive` per user (recommended — required for the feature to function end-to-end)
B) Use only the data already available in AuthContext (current logged-in user only) — no user list shown
C) Other
[Answer]: A
---
### Question 2: Who can access User Management?
The backend `POST /Users/invite` has `[Authorize(Policy = "AdminOnly")]`. Which roles should be able to view the UsersPage and invite users?
A) Owner and Administrator can view the user list and invite users; User role has no access to UsersPage (recommended — consistent with backend AdminOnly policy)
B) Owner and Administrator can view the user list; only Owner can invite users
C) All authenticated users can view the user list; only Owner and Administrator can invite
D) Other
[Answer]: A
---
### Question 3: Invitable roles
When inviting a user via the dialog, which roles can be assigned to the invitee?
A) Administrator and User only — Owner role is excluded (Owner is created via initial setup; there can only be one) (recommended — consistent with system design)
B) All three roles: Owner, Administrator, User
C) User only — no role selection; role is always User
D) Other
[Answer]: D, Administrator and User only, but it is false that there can only be 1 Owner. Another owner can make another Admin an owner. Users cannot become an owner directly. There always has to be at least 1 owner
---
### Question 4: User table columns
Which columns should the user table on UsersPage show?
A) Name, Email, Role, Status (Active/Inactive) — four columns, clean and informative (recommended)
B) Name, Email, Role, Status, Created At — five columns including timestamp
C) Email, Role, Status only — minimal
D) Other
[Answer]: B
---
### Question 5: TanStack Query migration for useInvitation.ts
The existing `useInvitation.ts` (from Unit 2) uses raw `useState`/`useEffect` and has local interfaces that diverge from `types.ts`. Unit 4 established TanStack Query as the standard for data fetching. Should `useInvitation.ts` be migrated to TanStack Query in this unit?
A) Yes — migrate `useValidateInvitation` (useQuery) and `useCompleteInvitation` (useMutation) to TanStack Query and align with types.ts (recommended — keeps all data-fetching uniform and removes the divergence)
B) No — leave `useInvitation.ts` as-is; only new hooks (`useUsers`, `useInviteUser`) use TanStack Query
C) Other
[Answer]: A
---
### Question 6: InviteUserDialog — Step 1 form fields
Step 1 of the invite dialog collects the information to send an invitation. What should the form contain?
A) Email address + Role selector (Administrator / User) — two fields, minimal and sufficient (recommended — the backend `InviteUserRequest` only needs email and role)
B) Email address, Role selector, and a display name field so the invitation is personalized
C) Email address only — role defaults to User
D) Other
[Answer]: A
---
### Question 7: InviteUserDialog — Step 2 share link
After submitting the invite form successfully, the backend returns an `inviteLink`. How should Step 2 present the link to the inviting user?
A) Show the full invite link in a read-only text input with a "Copy to clipboard" button + close button (recommended — usable, transparent, and standard pattern)
B) Show only a "Copy link" button — no visible URL in the UI
C) Show a success message only with instructions to share the link manually (no copy button)
D) Other
[Answer]: A, also make it possible to copy the link at a later point and also to refresh an invite token, for example when it is expired or you just require a new one for security reasons. The invite link should also be visible on the user details page for invited users that haven't completed their invitation yet.
---
### Question 8: Unit test scope
Which parts of Unit 5 should have unit tests?
A) `useUsers` hook + `useInviteUser` mutation + `InviteUserDialog` (step 1 → step 2 flow) + `UsersPage` render with table data (recommended — covers all logic and UI paths)
B) `UsersPage` integration test only — renders table + dialog flow end-to-end via MSW
C) `useUsers` and `useInviteUser` hooks only — skip component tests
D) Other
[Answer]: A