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
@@ -0,0 +1,86 @@
# Unit 5 Code Generation Summary
## What Was Built
Unit 5 implements the **User Management** feature: listing users, inviting new users, and changing roles.
---
## Backend Changes
### Bug Fixes
- **UsersController**: Fixed invite link URL from `/setup/complete?token=…` to `/invite/complete?token=…`
### New Interface Method
- `IInvitationService.GetPendingInvitationByEmailAsync(string email)` — returns `(bool IsPending, string? Token)`
### New Service Implementation
- `InvitationService.GetPendingInvitationByEmailAsync` — queries `Invitations` for a non-accepted, non-expired record by email
### New Models (`IdentityRequests.cs`)
- `UserDto` — maps a user to the API response including `InvitationPending` and `InviteLink`
- `ChangeRoleRequest` — carries `NewRole`
### Updated Controller (`UsersController`)
- Now injects `UserManager<ApplicationUser>` alongside `IInvitationService`
- `GET /api/v1/Users` — returns all users with role, status, and pending invite info
- `PUT /api/v1/Users/{userId}/role` — enforces RBAC rules (Owner constraint, Admin can only promote User→Admin)
### Database Migration
- **No migration needed** — all tables (`Users`, `Roles`, `UserRoles`, `Invitations`) already exist
---
## Frontend Changes
### New UI Components (`frontend/src/components/ui/`)
| File | Component |
|------|-----------|
| `dialog.tsx` | Dialog, DialogContent, DialogHeader, DialogTitle, etc. |
| `select.tsx` | Select, SelectTrigger, SelectContent, SelectItem, etc. |
| `table.tsx` | Table, TableHeader, TableBody, TableRow, TableHead, TableCell |
| `badge.tsx` | Badge with `default / secondary / destructive / outline` variants |
### New API Hook (`useUsers.ts`)
- `useUsers()``GET /api/v1/Users`, staleTime 30s, queryKey `['users']`
- `useInviteUser()``POST /api/v1/Users/invite`, invalidates `['users']`
- `useChangeRole()``PUT /api/v1/Users/{userId}/role`, invalidates `['users']`
### Migrated Hook (`useInvitation.ts`)
- Converted from `useState/useEffect` to TanStack Query
- Uses `InvitationValidation` from `types.ts` (field `isValid`, not the old local `valid`)
### Updated Files
| File | Change |
|------|--------|
| `types.ts` | Added `UserListItem`, `InviteUserPayload`, `InviteUserResponse`, `ChangeRolePayload` |
| `InviteCompletePage.tsx` | `validationQuery.data?.valid``validationQuery.data?.isValid` (2 places) |
| `mocks/invitation/handlers.ts` | Response shape updated to `{ isValid, email, name, errorCode }` |
| `mocks/users/handlers.ts` | Full user list + invite + change-role mock handlers |
### New Page & Component
- `UsersPage.tsx` — table with role/status badges, per-row dropdown (copy link, change role)
- `components/users/InviteUserDialog.tsx` — 2-step dialog: email+role form → invite link display
### i18n
- `en/translation.json` and `nl/translation.json``users.*` key section added (title, table columns, status labels, role labels, invite dialog, action labels)
---
## Tests
| File | Coverage |
|------|---------|
| `useUsers.test.ts` | `useUsers`, `useInviteUser`, `useChangeRole` — happy path + error |
| `UsersPage.test.tsx` | Render, table rows, pending badge, copy link action, API error, dialog open |
| `InviteUserDialog.test.tsx` | Form validation, successful flow (step 2), API error, dialog reset |
| `InviteCompletePage.test.tsx` | No changes needed — mock now returns correct `isValid` shape |
---
## Key Design Decisions
- **`isValid` vs `valid`**: The existing `InvitationValidation` type in `types.ts` already uses `isValid`. The old `useInvitation.ts` had a local interface with `valid`. Migration aligns the hook to the canonical type.
- **No server-side invite email**: Invite flow is link-only (per functional design). The link is shown in the dialog step 2 and copied to clipboard.
- **Role RBAC in frontend**: `availableRolesFor()` in `UsersPage` restricts the dropdown options; backend enforces the same rules server-side.
- **Pending invite for accepted users**: `GetPendingInvitationByEmailAsync` only returns a token if `!IsAccepted && !IsExpired` — accepted users correctly show no pending invite.
@@ -0,0 +1,118 @@
# Business Logic Model — Unit 5: User Management
## Process Overview
Unit 5 covers three main user flows:
1. **Invite User** — two-step dialog: fill in email + role → copy generated invite link
2. **Copy Pending Invite Link** — one-click copy from the user table row for pending invitations
3. **Change Role** — dropdown action in the user table row; optimistic or confirmed update
---
## Flow 1 — Invite User
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Browser
participant U as Gebruiker
participant FE as UsersPage
participant DLG as InviteUserDialog
end
box rgba(99,179,237,0.4) Backend
participant API as Users API
end
U->>FE: Click Invite User button
FE->>DLG: Open dialog at Step 1
DLG-->>U: Show form with email and role fields
U->>DLG: Enter email and select role
U->>DLG: Submit form
DLG->>DLG: Validate with zod schema
alt Validation fails
DLG-->>U: Show inline field errors
else Validation passes
DLG->>API: POST /api/v1/Users/invite with email and role
alt API error
API-->>DLG: 400 or 409 error response
DLG-->>U: Show FormErrorBanner with error message
else API success
API-->>DLG: 200 with inviteLink
DLG->>DLG: Advance to Step 2
DLG-->>U: Show invite link with Copy button
U->>DLG: Click Copy to clipboard
DLG->>FE: Invalidate useUsers query
FE->>API: GET /api/v1/Users
API-->>FE: Updated user list with new pending user
FE-->>U: Table refreshed
U->>DLG: Close dialog
end
end
```
Text alternative: User opens dialog, fills email and role, submits; on success the backend returns an invite link shown in Step 2 with a copy button; closing the dialog triggers a user list refresh.
---
## Flow 2 — Copy Pending Invite Link from Table Row
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Browser
participant U as Gebruiker
participant Row as UserTableRow
end
U->>Row: Click copy-link icon on pending user row
Row->>Row: Read inviteLink from UserListItem
Row->>Row: navigator.clipboard.writeText with inviteLink
Row-->>U: Show toast "Invite link copied to clipboard"
```
Text alternative: Copy invite link is a client-side operation — the link is already in the fetched user list (inviteLink field); no API call required.
---
## Flow 3 — Change User Role
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Browser
participant U as Gebruiker
participant Row as UserTableRow
participant FE as UsersPage
end
box rgba(99,179,237,0.4) Backend
participant API as Users API
end
U->>Row: Open role dropdown for a user
Row-->>U: Show available roles based on requester role
U->>Row: Select new role
Row->>API: PUT /api/v1/Users/{userId}/role with newRole
alt API error
API-->>Row: 403 or 409 error
Row-->>U: Show toast with error message
else API success
API-->>Row: 200 OK
Row->>FE: Invalidate useUsers query
FE->>API: GET /api/v1/Users
API-->>FE: Updated user list with new role
FE-->>U: Table row updated with new role badge
end
```
Text alternative: User selects a new role from a dropdown in the table row; on backend success the user list is re-fetched to reflect the updated role.
---
## useInvitation.ts Migration (supporting InviteCompletePage)
`useInvitation.ts` was created in Unit 2 with raw `useState`/`useEffect`. In Unit 5 it is migrated to TanStack Query for consistency. This does not change the behavior of `InviteCompletePage` — only the internal implementation of the hooks changes.
| Hook | Before (Unit 2) | After (Unit 5) |
|---|---|---|
| `useValidateInvitation(token)` | useState + useEffect | useQuery (auto-fetches on mount) |
| `useCompleteInvitation()` | useState + useCallback | useMutation |
| Types | Local interfaces in useInvitation.ts | Shared types from types.ts |
Local types (`InvitationValidationResponse`, `InvitationCompleteRequest`, `InvitationCompleteResponse`) are removed and replaced with `InvitationValidation` and `InviteCompleteRequest` from `types.ts`.
@@ -0,0 +1,114 @@
# Business Rules — Unit 5: User Management
## Rule Index
| ID | Rule | Enforced In |
|---|---|---|
| BR-U5-01 | Only Owner and Administrator can access UsersPage | Frontend (RoleGuard), Backend (AdminOnly policy) |
| BR-U5-02 | Owner role cannot be assigned via invitation | Frontend (role selector), Backend |
| BR-U5-03 | At least one Owner must exist at all times | Backend (change role endpoint) |
| BR-U5-04 | Owner can change any user's role (subject to BR-U5-03) | Backend (change role endpoint) |
| BR-U5-05 | Administrator can only promote User to Administrator | Backend (change role endpoint) |
| BR-U5-06 | Copy invite link action is only shown for users where invitationPending = true | Frontend (conditional rendering) |
| BR-U5-07 | Invite form requires valid email format and role selection | Frontend (zod validation) |
| BR-U5-08 | An email already registered cannot be invited again | Backend (invite endpoint validation) |
---
## Decision Flow 1 — Invite User Authorization
```mermaid
graph TD
invite_req["Invite User request"]
check_access{"Requester is Owner\nor Administrator?"}
check_email{"Email has valid format?"}
check_registered{"Email already registered?"}
check_role{"Role is Administrator\nor User?"}
success_invite["Create invitation token\nReturn inviteLink"]
deny_403["403 Forbidden"]
err_email["Validation error\nInvalid email format"]
err_registered["Validation error\nEmail already in use"]
err_role["Validation error\nOwner role cannot be invited"]
invite_req --> check_access
check_access -->|No| deny_403
check_access -->|Yes| check_email
check_email -->|No| err_email
check_email -->|Yes| check_registered
check_registered -->|Yes| err_registered
check_registered -->|No| check_role
check_role -->|Owner| err_role
check_role -->|Administrator or User| success_invite
classDef start_node fill:#2196F3,stroke:#0d47a1,stroke-width:1px,color:#000
classDef decision_node fill:#FF9800,stroke:#e65100,stroke-width:1px,color:#000
classDef success_node fill:#4CAF50,stroke:#2e7d32,stroke-width:1px,color:#000
classDef error_node fill:#F44336,stroke:#b71c1c,stroke-width:1px,color:#000
class invite_req start_node
class check_access,check_email,check_registered,check_role decision_node
class success_invite success_node
class deny_403,err_email,err_registered,err_role error_node
```
Text alternative: Invite request checked for requester access, email validity, registration status, and role — only valid Administrator/User invites proceed; all others are blocked.
---
## Decision Flow 2 — Change Role Authorization
```mermaid
graph TD
change_req["Change Role request\nPUT /Users/{userId}/role"]
req_role{"Requester role?"}
deny_user["403 Forbidden\nUser role cannot change roles"]
admin_target{"Target user's current role?"}
deny_admin["403 Forbidden\nAdmin cannot change Owner or Admin roles"]
owner_check{"Would this leave\nzero Owners?"}
deny_last["Blocked\nAt least 1 Owner required"]
success_change["Update user role\nReturn 200 OK"]
change_req --> req_role
req_role -->|User| deny_user
req_role -->|Administrator| admin_target
req_role -->|Owner| owner_check
admin_target -->|Owner or Administrator| deny_admin
admin_target -->|User| success_change
owner_check -->|Yes| deny_last
owner_check -->|No| success_change
classDef start_node fill:#2196F3,stroke:#0d47a1,stroke-width:1px,color:#000
classDef decision_node fill:#FF9800,stroke:#e65100,stroke-width:1px,color:#000
classDef success_node fill:#4CAF50,stroke:#2e7d32,stroke-width:1px,color:#000
classDef error_node fill:#F44336,stroke:#b71c1c,stroke-width:1px,color:#000
class change_req start_node
class req_role,admin_target,owner_check decision_node
class success_change success_node
class deny_user,deny_admin,deny_last error_node
```
Text alternative: Role change checked for requester level — User is blocked; Administrator can only change User-role targets; Owner can change any role, blocked only if it would leave zero Owners.
---
## Frontend Validation Rules (InviteUserForm — zod schema)
```typescript
const inviteUserSchema = z.object({
email: z.string().email({ message: 'Valid email address required' }),
role: z.enum(['Administrator', 'User'], {
required_error: 'Role selection is required',
}),
});
```
## Frontend Access Control Rules (RoleGuard / conditional rendering)
| Element | Visibility condition |
|---|---|
| UsersPage route | `role === 'Owner' \|\| role === 'Administrator'` |
| Invite User button | Always visible on UsersPage (same role requirement as page) |
| Copy invite link action | `user.invitationPending === true` |
| Change role dropdown | Visible to all UsersPage visitors; available target roles differ by requester role (see BR-U5-04, BR-U5-05) |
| Owner option in role dropdown | Only shown when requester is Owner |
@@ -0,0 +1,92 @@
# Domain Entities — Unit 5: User Management
## Overview
Unit 5 introduces user listing, invitation management, and role changes. Two new backend endpoints are required: `GET /api/v1/Users` (list with invitation state) and `PUT /api/v1/Users/{userId}/role` (role assignment). The frontend extends `types.ts` with new interfaces for these operations.
## Entity Diagram
```mermaid
graph LR
UserListItem["UserListItem\nid, email, name, role\nisActive, createdAt\ninvitationPending, inviteLink"]
UserRole["UserRole\nOwner, Administrator, User"]
InviteUserRequest["InviteUserRequest\nemail, role"]
InviteUserResponse["InviteUserResponse\ninviteLink"]
ChangeRoleRequest["ChangeRoleRequest\nnewRole"]
UserListItem -->|has role| UserRole
InviteUserRequest -->|assigns role| UserRole
ChangeRoleRequest -->|assigns| UserRole
classDef entity fill:#2196F3,stroke:#0d47a1,stroke-width:1px,color:#000
classDef request fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000
classDef enumType fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000
class UserListItem entity
class InviteUserRequest,InviteUserResponse,ChangeRoleRequest request
class UserRole enumType
```
Text alternative: UserListItem (blauw) heeft een UserRole enum (geel); InviteUserRequest en ChangeRoleRequest (groen) wijzen een UserRole toe.
## Field Descriptions
### UserListItem — GET /api/v1/Users response item
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique user identifier |
| email | string | User's email address |
| name | string | Display name (DisplayName ?? Email fallback, consistent with AuthResponse) |
| role | UserRole | Current role: Owner, Administrator, or User |
| isActive | bool | Whether the account is active |
| createdAt | string (ISO 8601) | Account creation timestamp |
| invitationPending | bool | True when the user has not yet completed invitation setup |
| inviteLink | string \| null | Full invite URL for pending users; null for active accounts |
### InviteUserRequest — POST /api/v1/Users/invite
| Field | Type | Description |
|---|---|---|
| email | string | Email address of the invitee |
| role | 'Administrator' \| 'User' | Role to assign — Owner is excluded by business rule |
### InviteUserResponse — POST /api/v1/Users/invite response
| Field | Type | Description |
|---|---|---|
| inviteLink | string | Relative URL for the invitation (e.g. `/invite/complete?token=...`) |
### ChangeRoleRequest — PUT /api/v1/Users/{userId}/role
| Field | Type | Description |
|---|---|---|
| newRole | UserRole | Role to assign to the target user |
## Type Extensions — frontend/src/api/types.ts
The existing `User` type covers the logged-in session user. Unit 5 adds:
```typescript
// User item returned by GET /api/v1/Users
export interface UserListItem extends User {
createdAt: string; // ISO 8601
invitationPending: boolean;
inviteLink: string | null;
}
// POST /api/v1/Users/invite
export interface InviteUserRequest {
email: string;
role: Exclude<UserRole, 'Owner'>; // Owner cannot be invited
}
export interface InviteUserResponse {
inviteLink: string;
}
// PUT /api/v1/Users/{userId}/role
export interface ChangeRoleRequest {
newRole: UserRole;
}
```
@@ -0,0 +1,295 @@
# Frontend Components — Unit 5: User Management
## Component Hierarchy
```mermaid
graph TD
role_guard["RoleGuard\n(Owner or Admin only)"]
users_page["UsersPage\n(pages/UsersPage.tsx)"]
page_header["Page Header\nh1 + Invite button"]
user_table["UserTable\n(shadcn Table)"]
table_row["UserTableRow\n(×n per user)"]
row_actions["RowActionsMenu\n(shadcn DropdownMenu)"]
copy_link["CopyInviteLinkAction\n(if invitationPending)"]
change_role["ChangeRoleAction\n(role-filtered options)"]
invite_dialog["InviteUserDialog\n(shadcn Dialog)"]
step1["Step1InviteForm\n(email + role select)"]
step2["Step2ShareLinkView\n(link + copy button)"]
role_guard --> users_page
users_page --> page_header
users_page --> user_table
users_page --> invite_dialog
page_header --> invite_dialog
user_table --> table_row
table_row --> row_actions
row_actions --> copy_link
row_actions --> change_role
invite_dialog --> step1
invite_dialog --> step2
classDef guard_node fill:#FF9800,stroke:#e65100,stroke-width:1px,color:#000
classDef page_node fill:#2196F3,stroke:#0d47a1,stroke-width:1px,color:#000
classDef table_node fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef action_node fill:#9C27B0,stroke:#4a148c,stroke-width:1px,color:#000
classDef dialog_node fill:#4CAF50,stroke:#2e7d32,stroke-width:1px,color:#000
class role_guard guard_node
class users_page,page_header page_node
class user_table,table_row table_node
class row_actions,copy_link,change_role action_node
class invite_dialog,step1,step2 dialog_node
```
Text alternative: RoleGuard wraps UsersPage; the page contains a header (with Invite button), a UserTable (rows with DropdownMenu actions), and an InviteUserDialog (Step 1 form → Step 2 share link).
---
## Component Specifications
### UsersPage — `pages/UsersPage.tsx`
**Purpose**: Main user management page. Fetches user list, renders table and invite dialog.
**State**:
- `dialogOpen: boolean` — controls InviteUserDialog visibility
- `dialogStep: 1 | 2` — which dialog step is shown
- `generatedLink: string | null` — invite link returned after successful invite
**API integration**:
- `useUsers()` — fetches user list on mount; staleTime: 30s
**Render structure**:
- RoleGuard enforced at router level (`router.tsx`)
- Page title (`t('nav.users')`)
- "Invite User" button (opens dialog)
- UserTable with fetched data
- InviteUserDialog (controlled by state above)
---
### UserTable (inline in UsersPage or extracted component)
**Purpose**: Renders the shadcn Table with one row per UserListItem.
**Columns**:
| Column | Field | Notes |
|---|---|---|
| Name | `user.name` | Plain text |
| Email | `user.email` | Plain text |
| Role | `user.role` | Colored badge (Owner=red, Admin=orange, User=blue) |
| Status | `user.isActive` + `user.invitationPending` | "Active", "Inactive", or "Pending invite" badge |
| Created | `user.createdAt` | Formatted as locale date string |
| Actions | — | DropdownMenu (see RowActionsMenu) |
**Props**:
```typescript
interface UserTableProps {
users: UserListItem[];
currentUser: User; // from AuthContext — for role-based action filtering
onRoleChange: (userId: string, newRole: UserRole) => void;
}
```
---
### RowActionsMenu — DropdownMenu per row
**Purpose**: Contextual actions per user row.
**Actions**:
1. **Copy invite link** — visible only when `user.invitationPending === true`; calls `navigator.clipboard.writeText(user.inviteLink!)` and shows a toast
2. **Change role** — opens an inline role picker (sub-menu or inline select); available roles depend on requester:
- Requester is Owner: all three roles shown (Owner, Administrator, User)
- Requester is Administrator: only User → Administrator promotion (target must be User role)
**Props**:
```typescript
interface RowActionsMenuProps {
user: UserListItem;
currentUser: User;
onRoleChange: (userId: string, newRole: UserRole) => void;
}
```
---
### InviteUserDialog — `components/users/InviteUserDialog.tsx`
**Purpose**: Two-step modal dialog for inviting a new user.
**Props**:
```typescript
interface InviteUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Step 1 — InviteUserForm**:
- Fields: email (text input), role (Select: Administrator | User)
- Validation: zod schema (see business-rules.md)
- On submit: calls `useInviteUser()` mutation
- On success: transitions to Step 2 with the returned inviteLink
- On error: shows `FormErrorBanner` with backend error message
**Step 2 — Step2ShareLinkView**:
- Read-only text input displaying the full invite link
- "Copy to clipboard" button (copies link, shows toast)
- "Close" button (closes dialog, triggers `useUsers` query invalidation)
**State** (internal):
```typescript
const [step, setStep] = useState<1 | 2>(1);
const [inviteLink, setInviteLink] = useState<string | null>(null);
// Reset to step 1 on open
useEffect(() => { if (!open) { setStep(1); setInviteLink(null); } }, [open]);
```
---
## Hooks
### useUsers — `api/useUsers.ts`
```typescript
// GET /api/v1/Users
export function useUsers(): UseQueryResult<UserListItem[]>
```
- **Query key**: `['users']`
- **staleTime**: 30 000 ms (30s)
- **Invalidated by**: useInviteUser.onSuccess, useChangeRole.onSuccess
### useInviteUser — `api/useUsers.ts`
```typescript
// POST /api/v1/Users/invite
export function useInviteUser(): UseMutationResult<InviteUserResponse, Error, InviteUserRequest>
```
- **onSuccess**: invalidates `['users']` query
### useChangeRole — `api/useUsers.ts`
```typescript
// PUT /api/v1/Users/{userId}/role
export function useChangeRole(): UseMutationResult<void, Error, { userId: string; newRole: UserRole }>
```
- **onSuccess**: invalidates `['users']` query
### useValidateInvitation (migrated) — `api/useInvitation.ts`
```typescript
// GET /api/v1/Invitation/validate?token=...
export function useValidateInvitation(token: string | null): UseQueryResult<InvitationValidation>
```
- Migrated from useState/useEffect to useQuery
- Uses `InvitationValidation` from types.ts (replacing local interface)
- **enabled**: `!!token` (skip query when token is null)
### useCompleteInvitation (migrated) — `api/useInvitation.ts`
```typescript
// POST /api/v1/Invitation/complete
export function useCompleteInvitation(): UseMutationResult<void, Error, InviteCompleteRequest>
```
- Migrated from useState/useCallback to useMutation
- Uses `InviteCompleteRequest` from types.ts
---
## Form Validation Rules
### InviteUserForm (zod)
| Field | Rule | Error message |
|---|---|---|
| email | `z.string().email()` | "Valid email address required" |
| role | `z.enum(['Administrator', 'User'])` | "Role selection is required" |
---
## API Integration Summary
| Component / Hook | Endpoint | Method | Auth required |
|---|---|---|---|
| useUsers | /api/v1/Users | GET | Yes (Owner / Admin) |
| useInviteUser | /api/v1/Users/invite | POST | Yes (Owner / Admin) |
| useChangeRole | /api/v1/Users/{userId}/role | PUT | Yes (Owner / Admin) |
| useValidateInvitation | /api/v1/Invitation/validate | GET | No |
| useCompleteInvitation | /api/v1/Invitation/complete | POST | No |
---
## i18n Keys (en + nl)
```json
{
"users": {
"title": "Users",
"inviteButton": "Invite User",
"table": {
"name": "Name",
"email": "Email",
"role": "Role",
"status": "Status",
"createdAt": "Created",
"actions": "Actions"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"pendingInvite": "Pending invite"
},
"actions": {
"copyInviteLink": "Copy invite link",
"changeRole": "Change role",
"linkCopied": "Invite link copied to clipboard"
},
"invite": {
"title": "Invite User",
"emailLabel": "Email address",
"roleLabel": "Role",
"submitButton": "Send invitation",
"step2Title": "Invitation created",
"step2Description": "Share the link below with the invitee.",
"copyLink": "Copy link",
"close": "Close"
},
"roles": {
"Owner": "Owner",
"Administrator": "Administrator",
"User": "User"
}
}
}
```
---
## MSW Mocks (Unit 5 additions)
Extend `frontend/src/mocks/users/handlers.ts`:
| Handler | Method | Path | Response |
|---|---|---|---|
| List users | GET | /api/v1/Users | Array of UserListItem (including 1 pending) |
| Invite user | POST | /api/v1/Users/invite | `{ inviteLink: '/invite/complete?token=mock-token' }` |
| Change role | PUT | /api/v1/Users/:userId/role | 200 OK (no body) |
---
## Unit Test Scope
| Test file | Coverage |
|---|---|
| `api/useUsers.test.ts` | useUsers (loading, success, error); useInviteUser (success, API error); useChangeRole (success, 403) |
| `pages/UsersPage.test.tsx` | Renders table with user list; Invite button opens dialog; Step 1 → Step 2 transition; copy invite link toast |
| `components/users/InviteUserDialog.test.tsx` | Step 1 form validation; successful invite shows Step 2; error shows FormErrorBanner |
| `api/useInvitation.test.ts` | useValidateInvitation (valid token, invalid token); useCompleteInvitation (success, error) — replaces Unit 2 tests |