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
@@ -5,7 +5,7 @@
- **Feature Slug**: cms-frontend - **Feature Slug**: cms-frontend
- **Project Type**: Brownfield - **Project Type**: Brownfield
- **Start Date**: 2026-06-16T20:27:00Z - **Start Date**: 2026-06-16T20:27:00Z
- **Current Stage**: CONSTRUCTION - Unit 4: Functional Design - **Current Stage**: CONSTRUCTION - Unit 5: Code Generation (Complete)
- **Branch**: unknown - **Branch**: unknown
## Workspace State ## Workspace State
@@ -50,8 +50,8 @@
- [x] Unit 1 — Project Scaffold & Infrastructure — COMPLETED 2026-06-20T17:30:00Z - [x] Unit 1 — Project Scaffold & Infrastructure — COMPLETED 2026-06-20T17:30:00Z
- [x] Unit 2 — Authentication Pages — COMPLETED 2026-06-22 (29/29 tests pass, build ✅, runtime bugs fixed) - [x] Unit 2 — Authentication Pages — COMPLETED 2026-06-22 (29/29 tests pass, build ✅, runtime bugs fixed)
- [x] Unit 3 — Layout & Navigation — COMPLETED 2026-06-22 (all commits merged, tests pass) - [x] Unit 3 — Layout & Navigation — COMPLETED 2026-06-22 (all commits merged, tests pass)
- [ ] Unit 4 — Dashboard - [x] Unit 4 — Dashboard — COMPLETED 2026-06-22 (55/55 tests pass, build ✅)
- [ ] Unit 5 — User Management - [x] Unit 5 — User Management — COMPLETED 2026-06-22 (all 21 steps done, tests written)
- [ ] Unit 6 — Profile, Settings & CMS Placeholder - [ ] Unit 6 — Profile, Settings & CMS Placeholder
- [ ] Build and Test — Not Started - [ ] Build and Test — Not Started
+48
View File
@@ -1,5 +1,53 @@
# Audit Log — cms-frontend # Audit Log — cms-frontend
## Code Generation — Unit 5 Plan Approved
**Timestamp**: 2026-06-22T00:15:00Z
**User Input**: "Approve plan"
**AI Response**: "Plan approved. Starting Code Generation Part 2 (execution of 21 steps)."
**Context**: Unit 5 Code Generation — executing plan.
---
## Code Generation — Unit 5 Approved to Start
**Timestamp**: 2026-06-22T00:10:00Z
**User Input**: "Continue to code generation"
**AI Response**: "Functional Design approved. Creating code generation plan for Unit 5."
**Context**: Unit 5 Functional Design complete; proceeding to Code Generation Part 1 (Planning).
---
## Functional Design — Unit 5 Clarification Answers Received
**Timestamp**: 2026-06-22T00:05:00Z
**User Input**: "ik heb de verduidelijkingen ingevuld" (clarification answers: A=action icon in table row, B=deferred feature, C=invitationPending+inviteLink in list response, D=role promotion in scope for Unit 5)
**AI Response**: "Generated 4 functional design artifacts: domain-entities.md, business-rules.md, business-logic-model.md, frontend-components.md"
**Context**: Unit 5 Functional Design — artifacts complete; awaiting user approval.
---
## Session Resume — Unit 5 Start
**Timestamp**: 2026-06-22T00:00:00Z
**User Input**: "hervat aidlc workflow en ga verder naar unit 5"
**AI Response**: "Resumed session. Loaded aidlc-state.md — Units 04 complete. Starting Unit 5 (User Management) Functional Design. Created unit-5-functional-design-plan.md with 8 questions."
**Context**: Construction phase, Unit 5 — Functional Design begins.
---
## Workspace Detection — Initial Request ## Workspace Detection — Initial Request
**Timestamp**: 2026-06-16T20:27:00Z **Timestamp**: 2026-06-16T20:27:00Z
@@ -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 |
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2
View File
@@ -16,8 +16,10 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18", "@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10", "@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
+126
View File
@@ -11,12 +11,18 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.79.0(react@19.2.7)) version: 5.4.0(react-hook-form@7.79.0(react@19.2.7))
'@radix-ui/react-dialog':
specifier: ^1.1.17
version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.18 specifier: ^2.1.18
version: 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.10 specifier: ^2.1.10
version: 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-select':
specifier: ^2.3.1
version: 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7) version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
@@ -438,6 +444,9 @@ packages:
'@oxc-project/types@0.133.0': '@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
'@radix-ui/primitive@1.1.4': '@radix-ui/primitive@1.1.4':
resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
@@ -485,6 +494,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-dialog@1.1.17':
resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.2': '@radix-ui/react-direction@1.1.2':
resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
peerDependencies: peerDependencies:
@@ -642,6 +664,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-select@2.3.1':
resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.3.0': '@radix-ui/react-slot@1.3.0':
resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==}
peerDependencies: peerDependencies:
@@ -696,6 +731,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-previous@1.1.2':
resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.2': '@radix-ui/react-use-rect@1.1.2':
resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==}
peerDependencies: peerDependencies:
@@ -714,6 +758,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-visually-hidden@1.2.6':
resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.2': '@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
@@ -2541,6 +2598,8 @@ snapshots:
'@oxc-project/types@0.133.0': {} '@oxc-project/types@0.133.0': {}
'@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.4': {} '@radix-ui/primitive@1.1.4': {}
'@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
@@ -2576,6 +2635,28 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@@ -2732,6 +2813,36 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/number': 1.1.2
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
@@ -2773,6 +2884,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/rect': 1.1.2 '@radix-ui/rect': 1.1.2
@@ -2787,6 +2904,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/rect@1.1.2': {} '@radix-ui/rect@1.1.2': {}
'@rolldown/binding-android-arm64@1.0.3': '@rolldown/binding-android-arm64@1.0.3':
+3 -2
View File
@@ -1,4 +1,5 @@
allowBuilds: allowBuilds:
msw: true
esbuild: true
'@tailwindcss/oxide': true '@tailwindcss/oxide': true
esbuild: true
msw: true
confirmModulesPurge: false
+19
View File
@@ -61,6 +61,25 @@ export interface InviteCompleteRequest {
password: string; password: string;
} }
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;
}
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable'; export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
export interface AvailabilityResponse { export interface AvailabilityResponse {
+15 -77
View File
@@ -1,83 +1,21 @@
import { useState, useCallback, useEffect } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '../lib/api-client'; import { api } from '@/lib/api-client';
import type { InvitationValidation, InviteCompleteRequest } from './types';
interface InvitationValidationResponse {
valid: boolean;
email?: string;
error?: string;
}
interface InvitationCompleteRequest {
token: string;
name: string;
password: string;
}
interface InvitationCompleteResponse {
message: string;
user: {
id: string;
name: string;
email: string;
role: string;
};
}
export function useValidateInvitation(token: string | null) { export function useValidateInvitation(token: string | null) {
const [data, setData] = useState<InvitationValidationResponse | null>(null); return useQuery<InvitationValidation, Error>({
const [isPending, setIsPending] = useState(false); queryKey: ['invitation', 'validate', token],
const [error, setError] = useState<Error | null>(null); queryFn: () =>
api.get<InvitationValidation>(
useEffect(() => { `/api/v1/Invitation/validate?token=${encodeURIComponent(token!)}`,
if (!token) return; ),
enabled: !!token,
const fetchValidation = async () => { retry: false,
setIsPending(true); });
setError(null);
try {
const url = `/api/v1/Invitation/validate?token=${encodeURIComponent(token)}`;
const response = await api.get(url);
setData(response as InvitationValidationResponse);
} catch (err: unknown) {
setError(err instanceof Error ? err : new Error(String(err)));
setData(null);
} finally {
setIsPending(false);
}
};
fetchValidation();
}, [token]);
return {
data,
isPending,
error
};
} }
export function useCompleteInvitation() { export function useCompleteInvitation() {
const [isPending, setIsPending] = useState(false); return useMutation<void, Error, InviteCompleteRequest>({
const [error, setError] = useState<Error | null>(null); mutationFn: (data) => api.post('/api/v1/Invitation/complete', data),
});
const mutateAsync = useCallback(async (data: InvitationCompleteRequest): Promise<InvitationCompleteResponse> => {
setIsPending(true);
setError(null);
try {
const response = await api.post('/api/v1/Invitation/complete', data);
return response as InvitationCompleteResponse;
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
throw error;
} finally {
setIsPending(false);
}
}, []);
return {
mutateAsync,
isPending,
error
};
} }
+104
View File
@@ -0,0 +1,104 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockUsers } from '@/mocks/users/handlers';
import { useUsers, useInviteUser, useChangeRole } from './useUsers';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('useUsers', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
expect(result.current.isPending).toBe(true);
});
it('returns the users list on success', async () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toHaveLength(getMockUsers().length);
expect(result.current.data?.[0].email).toBe(getMockUsers()[0].email);
});
it('enters error state on 5xx', async () => {
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
describe('useInviteUser', () => {
it('returns inviteLink on successful mutation', async () => {
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'new@example.com', role: 'User' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.inviteLink).toContain('/invite/complete');
});
it('fails when server returns 400', async () => {
server.use(
http.post(`${USERS_URL}/invite`, () =>
HttpResponse.json({ detail: 'already exists' }, { status: 400 }),
),
);
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'dup@example.com', role: 'User' }).catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useChangeRole', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
newRole: 'User',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 403', async () => {
server.use(
http.put(`${USERS_URL}/:userId/role`, () => new HttpResponse(null, { status: 403 })),
);
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', newRole: 'Administrator' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
+45
View File
@@ -0,0 +1,45 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload, UserRole } from './types';
export function useUsers() {
return useQuery<UserListItem[], Error>({
queryKey: ['users'],
queryFn: () => api.get<UserListItem[]>('/api/v1/Users'),
staleTime: 30_000,
});
}
export function useInviteUser() {
const queryClient = useQueryClient();
return useMutation<InviteUserResponse, Error, InviteUserPayload>({
mutationFn: (data) => api.post<InviteUserResponse>('/api/v1/Users/invite', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useChangeRole() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; newRole: UserRole }>({
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
api.put<void>(`/api/v1/Users/${userId}/role`, { newRole } satisfies ChangeRolePayload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useSetUserActive() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; isActive: boolean }>({
mutationFn: ({ userId, isActive }) =>
api.put<void>(`/api/v1/Users/${userId}/active`, { isActive }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId: string) => api.delete<void>(`/api/v1/Users/${userId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+38 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import * as React from 'react'; import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check } from 'lucide-react'; import { Check, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root; export const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -64,6 +64,43 @@ export const DropdownMenuSeparator = React.forwardRef<
)); ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
export const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
/** A checkable indicator for selected items (e.g. the active language). */ /** A checkable indicator for selected items (e.g. the active language). */
export function DropdownMenuCheck({ checked }: { checked: boolean }) { export function DropdownMenuCheck({ checked }: { checked: boolean }) {
return <Check className={cn('ml-auto', checked ? 'opacity-100' : 'opacity-0')} />; return <Check className={cn('ml-auto', checked ? 'opacity-100' : 'opacity-0')} />;
+147
View File
@@ -0,0 +1,147 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
),
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import type { UserListItem } from '@/api/types';
interface DeleteUserDialogProps {
user: UserListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending: boolean;
}
export function DeleteUserDialog({
user,
open,
onOpenChange,
onConfirm,
isPending,
}: DeleteUserDialogProps) {
const { t } = useTranslation();
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="delete-user-dialog">
<DialogHeader>
<DialogTitle>{t('users.delete.title')}</DialogTitle>
<DialogDescription>
{user.invitationPending
? t('users.delete.descriptionPending', { email: user.email })
: t('users.delete.description', { name: user.name, email: user.email })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
data-testid="delete-user-cancel"
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isPending}
data-testid="delete-user-confirm"
>
{isPending ? '...' : t('users.delete.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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 } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
async function openDialog() {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-invite-button', {}, { timeout: 5000 });
await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
}
describe('InviteUserDialog', () => {
it('shows email and role fields on open', async () => {
await openDialog();
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-role')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-submit')).toBeInTheDocument();
});
it('shows validation error when submitting with empty email', async () => {
await openDialog();
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-email-error')).toBeInTheDocument();
});
it('shows validation error when submitting without selecting a role', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'new@example.com');
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-role-error')).toBeInTheDocument();
});
it('advances to step 2 (invite link) after successful submission', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'new@example.com');
// Open role dropdown and select "User" (use role=option to avoid ambiguity)
await userEvent.click(screen.getByTestId('invite-dialog-role'));
const options = await screen.findAllByRole('option');
const userOption = options.find((o) => o.textContent === 'User');
await userEvent.click(userOption!);
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('invite-dialog-link-input')).toBeInTheDocument();
expect(screen.getByTestId('invite-dialog-copy')).toBeInTheDocument();
});
it('shows error banner when API returns 400', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Users/invite`, () =>
HttpResponse.json({ detail: 'User already exists.' }, { status: 400 }),
),
);
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'dup@example.com');
await userEvent.click(screen.getByTestId('invite-dialog-role'));
const opts = await screen.findAllByRole('option');
const userOpt = opts.find((o) => o.textContent === 'User');
await userEvent.click(userOpt!);
await userEvent.click(screen.getByTestId('invite-dialog-submit'));
expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument();
});
it('resets to step 1 when dialog is closed and reopened', async () => {
await openDialog();
await userEvent.type(screen.getByTestId('invite-dialog-email'), 'test@example.com');
// Close the dialog by pressing Escape
await userEvent.keyboard('{Escape}');
// Reopen
await userEvent.click(screen.getByTestId('users-invite-button'));
const emailInput = screen.getByTestId('invite-dialog-email') as HTMLInputElement;
expect(emailInput.value).toBe('');
});
});
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { FieldError } from '@/components/ui/FieldError';
import { useInviteUser } from '@/api/useUsers';
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(['Administrator', 'User']),
});
type InviteFormData = z.infer<typeof inviteSchema>;
interface InviteUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const inviteUser = useInviteUser();
useEffect(() => {
if (!open) {
setStep(1);
setInviteLink(null);
inviteUser.reset();
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
});
const onSubmit = handleSubmit(async (values) => {
try {
const response = await inviteUser.mutateAsync(values);
setInviteLink(response.inviteLink);
setStep(2);
} catch {
// error shown via inviteUser.error
}
});
const handleCopy = async () => {
if (!inviteLink) return;
await navigator.clipboard.writeText(`${window.location.origin}${inviteLink}`);
toast.success(t('users.actions.linkCopied'));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 1 ? t('users.invite.title') : t('users.invite.step2Title')}
</DialogTitle>
</DialogHeader>
{step === 1 && (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<FormErrorBanner
error={inviteUser.error ? { message: inviteUser.error.message } : null}
onDismiss={() => inviteUser.reset()}
/>
<div className="space-y-2">
<Label htmlFor="invite-email">{t('users.invite.emailLabel')}</Label>
<Input
id="invite-email"
type="email"
placeholder="user@example.com"
data-testid="invite-dialog-email"
aria-invalid={errors.email !== undefined}
{...register('email')}
/>
{errors.email && (
<FieldError message={errors.email.message} testId="invite-email-error" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">{t('users.invite.roleLabel')}</Label>
<Select
onValueChange={(val) =>
setValue('role', val as 'Administrator' | 'User', {
shouldValidate: true,
})
}
>
<SelectTrigger
id="invite-role"
data-testid="invite-dialog-role"
aria-invalid={errors.role !== undefined}
>
<SelectValue placeholder={t('users.invite.rolePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Administrator">
{t('users.roles.Administrator')}
</SelectItem>
<SelectItem value="User">{t('users.roles.User')}</SelectItem>
</SelectContent>
</Select>
{errors.role && (
<FieldError message={errors.role.message} testId="invite-role-error" />
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || inviteUser.isPending}
data-testid="invite-dialog-submit"
>
{isSubmitting || inviteUser.isPending
? '...'
: t('users.invite.submitButton')}
</Button>
</form>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('users.invite.step2Description')}
</p>
<div className="space-y-2">
<Label>{t('users.invite.linkLabel')}</Label>
<Input
value={`${window.location.origin}${inviteLink ?? ''}`}
readOnly
data-testid="invite-dialog-link-input"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleCopy}
data-testid="invite-dialog-copy"
>
{t('users.invite.copyLink')}
</Button>
<Button
className="flex-1"
onClick={() => onOpenChange(false)}
data-testid="invite-dialog-close"
>
{t('users.invite.close')}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -77,6 +77,73 @@
"language": "Language", "language": "Language",
"logout": "Sign out" "logout": "Sign out"
}, },
"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",
"deactivate": "Deactivate",
"activate": "Activate",
"delete": "Delete user",
"linkCopied": "Invite link copied to clipboard"
},
"active": {
"successDeactivated": "{{name}} has been deactivated.",
"successActivated": "{{name}} has been activated."
},
"delete": {
"title": "Delete user",
"description": "Are you sure you want to delete {{name}} ({{email}})? This action cannot be undone.",
"descriptionPending": "Are you sure you want to delete the pending invitation for {{email}}? This action cannot be undone.",
"confirmButton": "Delete",
"success": "{{name}} has been deleted."
},
"invite": {
"title": "Invite User",
"emailLabel": "Email address",
"roleLabel": "Role",
"rolePlaceholder": "Select a role",
"submitButton": "Send invitation",
"step2Title": "Invitation created",
"step2Description": "Share the link below with the invitee.",
"linkLabel": "Invite link",
"copyLink": "Copy link",
"close": "Close"
},
"roles": {
"Owner": "Owner",
"Administrator": "Administrator",
"User": "User"
},
"ownerAssign": {
"title": "Assign Owner role",
"description": "You are about to make <strong>{{name}}</strong> an Owner. Owners have full access to the system, including the ability to manage and remove other Owners. Are you sure?",
"confirmButton": "Make Owner"
},
"selfRoleChange": {
"title": "Change your own role",
"description": "You are about to change your own role to <strong>{{role}}</strong>. You will be logged out automatically so the change takes effect immediately.",
"confirmButton": "Change and log out"
},
"errors": {
"lastOwnerRequired": "At least one Owner must remain. Assign another Owner before changing this role.",
"insufficientPermissions": "You do not have permission to perform this action."
}
},
"errors": { "errors": {
"network": "Unable to reach the server. Check your connection and try again.", "network": "Unable to reach the server. Check your connection and try again.",
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",
@@ -77,6 +77,73 @@
"language": "Taal", "language": "Taal",
"logout": "Uitloggen" "logout": "Uitloggen"
}, },
"users": {
"title": "Gebruikers",
"inviteButton": "Gebruiker uitnodigen",
"table": {
"name": "Naam",
"email": "E-mail",
"role": "Rol",
"status": "Status",
"createdAt": "Aangemaakt",
"actions": "Acties"
},
"status": {
"active": "Actief",
"inactive": "Inactief",
"pendingInvite": "Uitnodiging in behandeling"
},
"actions": {
"copyInviteLink": "Uitnodigingslink kopiëren",
"changeRole": "Rol wijzigen",
"deactivate": "Deactiveren",
"activate": "Activeren",
"delete": "Gebruiker verwijderen",
"linkCopied": "Uitnodigingslink gekopieerd naar klembord"
},
"active": {
"successDeactivated": "{{name}} is gedeactiveerd.",
"successActivated": "{{name}} is geactiveerd."
},
"delete": {
"title": "Gebruiker verwijderen",
"description": "Weet je zeker dat je {{name}} ({{email}}) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"descriptionPending": "Weet je zeker dat je de openstaande uitnodiging voor {{email}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"confirmButton": "Verwijderen",
"success": "{{name}} is verwijderd."
},
"invite": {
"title": "Gebruiker uitnodigen",
"emailLabel": "E-mailadres",
"roleLabel": "Rol",
"rolePlaceholder": "Selecteer een rol",
"submitButton": "Uitnodiging versturen",
"step2Title": "Uitnodiging aangemaakt",
"step2Description": "Deel de onderstaande link met de genodigde.",
"linkLabel": "Uitnodigingslink",
"copyLink": "Link kopiëren",
"close": "Sluiten"
},
"roles": {
"Owner": "Eigenaar",
"Administrator": "Beheerder",
"User": "Gebruiker"
},
"ownerAssign": {
"title": "Eigenaarrol toewijzen",
"description": "Je staat op het punt <strong>{{name}}</strong> eigenaar te maken. Eigenaars hebben volledige toegang tot het systeem, inclusief het kunnen beheren en verwijderen van andere eigenaars. Weet je het zeker?",
"confirmButton": "Eigenaar maken"
},
"selfRoleChange": {
"title": "Eigen rol wijzigen",
"description": "Je staat op het punt je eigen rol te wijzigen naar <strong>{{role}}</strong>. Je wordt daarna automatisch uitgelogd zodat de wijziging direct ingaat.",
"confirmButton": "Wijzigen en uitloggen"
},
"errors": {
"lastOwnerRequired": "Er moet minimaal één Eigenaar blijven. Wijs eerst een andere Eigenaar aan voor je deze rol wijzigt.",
"insufficientPermissions": "Je hebt geen toestemming om deze actie uit te voeren."
}
},
"errors": { "errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.", "network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
"generic": "Er is iets misgegaan. Probeer het opnieuw.", "generic": "Er is iets misgegaan. Probeer het opnieuw.",
+10 -35
View File
@@ -7,21 +7,14 @@ export const invitationHandlers = [
if (token === 'valid-token') { if (token === 'valid-token') {
return HttpResponse.json( return HttpResponse.json(
{ { isValid: true, email: 'invited@example.com', name: null, errorCode: null },
valid: true, { status: 200 },
email: 'invited@example.com'
},
{ status: 200 }
); );
} }
// Any other token is invalid/expired
return HttpResponse.json( return HttpResponse.json(
{ { isValid: false, email: '', name: null, errorCode: 'EXPIRED' },
valid: false, { status: 200 },
error: 'Invalid or expired token'
},
{ status: 200 }
); );
}), }),
@@ -31,39 +24,21 @@ export const invitationHandlers = [
if (!body.token || !body.name || !body.password) { if (!body.token || !body.name || !body.password) {
return HttpResponse.json( return HttpResponse.json(
{ { type: 'about:blank', title: 'Bad Request', status: 400, detail: 'Missing required fields' },
type: 'about:blank', { status: 400 },
title: 'Bad Request',
status: 400,
detail: 'Missing required fields'
},
{ status: 400 }
); );
} }
if (body.token !== 'valid-token') { if (body.token !== 'valid-token') {
return HttpResponse.json( return HttpResponse.json(
{ { type: 'about:blank', title: 'Bad Request', status: 400, detail: 'Invalid or expired token' },
type: 'about:blank', { status: 400 },
title: 'Bad Request',
status: 400,
detail: 'Invalid or expired token'
},
{ status: 400 }
); );
} }
return HttpResponse.json( return HttpResponse.json(
{ { message: 'Account created', user: { id: '2', name: body.name, email: 'invited@example.com', role: 'User' } },
message: 'Account created', { status: 201 },
user: {
id: '2',
name: body.name,
email: 'invited@example.com',
role: 'User'
}
},
{ status: 201 }
); );
}), }),
]; ];
+94 -5
View File
@@ -1,17 +1,106 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import type { User } from '@/api/types'; import type { UserListItem } from '@/api/types';
import { API_BASE, mockUser } from '../auth/fixtures'; import { API_BASE, mockUser } from '../auth/fixtures';
const mockUsers: User[] = [ let mockUsers: UserListItem[] = [
mockUser, {
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{ {
id: '22222222-2222-2222-2222-222222222222', id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com', email: 'admin@example.com',
name: 'Admin User', name: 'Admin User',
role: 'Administrator', role: 'Administrator',
isActive: true, isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
}, },
]; ];
/** Placeholder user-management mocks; expanded in Unit 5. */ export const resetMockUsers = () => {
export const userHandlers = [http.get(`${API_BASE}/Users`, () => HttpResponse.json(mockUsers))]; mockUsers = [
{
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
},
];
};
export const getMockUsers = () => mockUsers;
export const userHandlers = [
http.get(`${API_BASE}/api/v1/Users`, () => HttpResponse.json(mockUsers)),
http.post(`${API_BASE}/api/v1/Users/invite`, async ({ request }) => {
const body = (await request.json()) as { email: string; role: string };
const token = `mock-token-${Date.now()}`;
const inviteLink = `/invite/complete?token=${token}`;
const newUser: UserListItem = {
id: crypto.randomUUID(),
email: body.email,
name: body.email,
role: body.role as UserListItem['role'],
isActive: true,
createdAt: new Date().toISOString(),
invitationPending: true,
inviteLink,
};
mockUsers = [...mockUsers, newUser];
return HttpResponse.json({ inviteLink });
}),
http.put(`${API_BASE}/api/v1/Users/:userId/role`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/:userId/active`, () => new HttpResponse(null, { status: 200 })),
http.delete(`${API_BASE}/api/v1/Users/:userId`, ({ params }) => {
const { userId } = params as { userId: string };
const ownerUser = mockUsers.find((u) => u.role === 'Owner');
if (userId === ownerUser?.id) {
return HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
);
}
mockUsers = mockUsers.filter((u) => u.id !== userId);
return new HttpResponse(null, { status: 204 });
}),
];
+2 -2
View File
@@ -37,11 +37,11 @@ export function InviteCompletePage() {
} }
} else if (validationQuery.isPending && loadingState !== 'submitting') { } else if (validationQuery.isPending && loadingState !== 'submitting') {
// Still loading // Still loading
} else if (validationQuery.error || (validationQuery.data && !validationQuery.data.valid)) { } else if (validationQuery.error || (validationQuery.data && !validationQuery.data.isValid)) {
if (loadingState === 'loading') { if (loadingState === 'loading') {
setLoadingState('error'); setLoadingState('error');
} }
} else if (validationQuery.data?.valid && loadingState === 'loading') { } else if (validationQuery.data?.isValid && loadingState === 'loading') {
setLoadingState('ready'); setLoadingState('ready');
} }
+1
View File
@@ -89,6 +89,7 @@ export function LoginPage() {
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="••••••••"
autoComplete="current-password" autoComplete="current-password"
data-testid="login-password-input" data-testid="login-password-input"
aria-invalid={errors.password !== undefined} aria-invalid={errors.password !== undefined}
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockUsers } from '@/mocks/users/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('UsersPage', () => {
it('renders the page title and invite button', async () => {
mockAuthenticated();
renderApp('/users');
expect(await screen.findByTestId('users-page', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('users-invite-button')).toBeInTheDocument();
});
it('renders the users table with all mock users', async () => {
mockAuthenticated();
renderApp('/users');
const table = await screen.findByTestId('users-table');
expect(table).toBeInTheDocument();
for (const user of getMockUsers()) {
expect(screen.getByTestId(`user-row-${user.id}`)).toBeInTheDocument();
}
});
it('shows "Pending invite" badge for invitation-pending users', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const row = screen.getByTestId(`user-row-${pendingUser.id}`);
expect(within(row).getByText('Pending invite')).toBeInTheDocument();
});
it('shows copy invite link action for pending user', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const actionsBtn = screen.getByTestId(`user-actions-${pendingUser.id}`);
await userEvent.click(actionsBtn);
expect(screen.getByTestId(`user-copy-link-${pendingUser.id}`)).toBeInTheDocument();
});
it('shows an error message when the API fails', async () => {
mockAuthenticated();
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
renderApp('/users');
await screen.findByTestId('users-page');
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
it('opens the invite dialog when the invite button is clicked', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-invite-button');
await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
});
});
+439 -6
View File
@@ -1,14 +1,447 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MoreHorizontal, Link as LinkIcon, Trash2, UserX, UserCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/components/users/InviteUserDialog';
import { DeleteUserDialog } from '@/components/users/DeleteUserDialog';
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';
return 'secondary';
}
function statusLabel(user: UserListItem, t: (key: string) => string) {
if (user.invitationPending) return t('users.status.pendingInvite');
if (!user.isActive) return t('users.status.inactive');
return t('users.status.active');
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' });
}
/** Placeholder; full user management arrives in Unit 5. */
export function UsersPage() { export function UsersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user: currentUser, logout } = useAuth();
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserListItem | null>(null);
const [selfRoleChangeTarget, setSelfRoleChangeTarget] = useState<{
userId: string;
newRole: UserRole;
} | null>(null);
const [ownerAssignTarget, setOwnerAssignTarget] = useState<{
userId: string;
userName: string;
} | null>(null);
const { data: users, isPending, isError } = useUsers();
const changeRole = useChangeRole();
const setUserActive = useSetUserActive();
const deleteUser = useDeleteUser();
const availableRolesFor = (user: UserListItem): UserRole[] => {
const isSelf = user.id === currentUser?.id;
if (currentUser?.role === 'Owner') return ['Owner', 'Administrator', 'User'];
if (currentUser?.role === 'Administrator') {
// Admins can demote themselves to User; they cannot touch other Admins or Owners
if (isSelf) return ['Administrator', 'User'];
if (user.role === 'User') return ['Administrator'];
}
return [];
};
const isOwnerAction = (user: UserListItem) =>
currentUser?.role === 'Owner' && user.id !== currentUser.id;
const canToggleActive = (user: UserListItem) =>
isOwnerAction(user) && !user.invitationPending;
const canDelete = (user: UserListItem) => isOwnerAction(user);
const hasAnyActions = (user: UserListItem) =>
user.invitationPending ||
availableRolesFor(user).length > 0 ||
canToggleActive(user) ||
canDelete(user);
const handleCopyInviteLink = async (user: UserListItem) => {
if (!user.inviteLink) return;
await navigator.clipboard.writeText(`${window.location.origin}${user.inviteLink}`);
toast.success(t('users.actions.linkCopied'));
};
const handleRoleChange = (user: UserListItem, newRole: UserRole) => {
if (user.id === currentUser?.id) {
setSelfRoleChangeTarget({ userId: user.id, newRole });
return;
}
if (newRole === 'Owner') {
setOwnerAssignTarget({ userId: user.id, userName: user.name });
return;
}
void executeRoleChange(user.id, newRole);
};
const executeRoleChange = async (userId: string, newRole: UserRole) => {
try {
await changeRole.mutateAsync({ userId, newRole });
} catch (err) {
if (err instanceof ProblemDetailsError) {
if (err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSelfRoleChangeConfirm = async () => {
if (!selfRoleChangeTarget) return;
try {
await changeRole.mutateAsync(selfRoleChangeTarget);
setSelfRoleChangeTarget(null);
await logout();
} catch (err) {
setSelfRoleChangeTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleOwnerAssignConfirm = async () => {
if (!ownerAssignTarget) return;
try {
await changeRole.mutateAsync({ userId: ownerAssignTarget.userId, newRole: 'Owner' });
setOwnerAssignTarget(null);
} catch (err) {
setOwnerAssignTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err instanceof ProblemDetailsError && err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSetActive = async (user: UserListItem, isActive: boolean) => {
try {
await setUserActive.mutateAsync({ userId: user.id, isActive });
toast.success(
isActive
? t('users.active.successActivated', { name: user.name })
: t('users.active.successDeactivated', { name: user.name }),
);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await deleteUser.mutateAsync(deleteTarget.id);
toast.success(t('users.delete.success', { name: deleteTarget.name }));
setDeleteTarget(null);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
setDeleteTarget(null);
} else {
toast.error(t('errors.generic'));
}
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-4" data-testid="users-page">
<h1 className="text-2xl font-semibold" data-testid="users-title"> <div className="flex items-center justify-between">
{t('nav.users')} <h1 className="text-2xl font-semibold" data-testid="users-title">
</h1> {t('users.title')}
<p className="text-muted-foreground">Coming soon.</p> </h1>
<Button onClick={() => setInviteDialogOpen(true)} data-testid="users-invite-button">
{t('users.inviteButton')}
</Button>
</div>
{isPending && (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
)}
{isError && (
<p className="text-sm text-destructive">{t('errors.generic')}</p>
)}
{users && (
<Table data-testid="users-table">
<TableHeader>
<TableRow>
<TableHead>{t('users.table.name')}</TableHead>
<TableHead>{t('users.table.email')}</TableHead>
<TableHead>{t('users.table.role')}</TableHead>
<TableHead>{t('users.table.status')}</TableHead>
<TableHead>{t('users.table.createdAt')}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const roles = availableRolesFor(user);
const toggleActive = canToggleActive(user);
const deletable = canDelete(user);
const showActions = hasAnyActions(user);
return (
<TableRow key={user.id} data-testid={`user-row-${user.id}`}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={roleBadgeVariant(user.role)}>
{t(`users.roles.${user.role}`)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(user)}>
{statusLabel(user, t)}
</Badge>
</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell>
{showActions && <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('users.table.actions')}
data-testid={`user-actions-${user.id}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t('users.table.actions')}
</DropdownMenuLabel>
{user.invitationPending && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleCopyInviteLink(user)}
data-testid={`user-copy-link-${user.id}`}
>
<LinkIcon className="mr-2 h-4 w-4" />
{t('users.actions.copyInviteLink')}
</DropdownMenuItem>
</>
)}
{roles.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={`user-change-role-${user.id}`}
>
{t('users.actions.changeRole')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{roles.map((role) => (
<DropdownMenuItem
key={role}
disabled={role === user.role}
onClick={() =>
handleRoleChange(user, role)
}
>
{t(`users.roles.${role}`)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{toggleActive && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleSetActive(user, !user.isActive)
}
data-testid={`user-toggle-active-${user.id}`}
>
{user.isActive ? (
<>
<UserX className="mr-2 h-4 w-4" />
{t('users.actions.deactivate')}
</>
) : (
<>
<UserCheck className="mr-2 h-4 w-4" />
{t('users.actions.activate')}
</>
)}
</DropdownMenuItem>
</>
)}
{deletable && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(user)}
data-testid={`user-delete-${user.id}`}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('users.actions.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<InviteUserDialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen} />
<Dialog
open={selfRoleChangeTarget !== null}
onOpenChange={(open) => { if (!open) setSelfRoleChangeTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.selfRoleChange.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.selfRoleChange.description"
values={{
role: selfRoleChangeTarget
? t(`users.roles.${selfRoleChangeTarget.newRole}`)
: '',
}}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSelfRoleChangeTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSelfRoleChangeConfirm}
disabled={changeRole.isPending}
>
{t('users.selfRoleChange.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={ownerAssignTarget !== null}
onOpenChange={(open) => { if (!open) setOwnerAssignTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.ownerAssign.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.ownerAssign.description"
values={{ name: ownerAssignTarget?.userName ?? '' }}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOwnerAssignTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleOwnerAssignConfirm}
disabled={changeRole.isPending}
>
{t('users.ownerAssign.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteUserDialog
user={deleteTarget}
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm}
isPending={deleteUser.isPending}
/>
</div> </div>
); );
} }
+15
View File
@@ -32,6 +32,21 @@ if (typeof globalAny.ResizeObserver === 'undefined') {
}; };
} }
// Radix UI uses pointer capture APIs not present in jsdom.
if (typeof Element.prototype.hasPointerCapture === 'undefined') {
Element.prototype.hasPointerCapture = () => false;
}
if (typeof Element.prototype.setPointerCapture === 'undefined') {
Element.prototype.setPointerCapture = () => {};
}
if (typeof Element.prototype.releasePointerCapture === 'undefined') {
Element.prototype.releasePointerCapture = () => {};
}
// Radix scroll-into-view
if (typeof Element.prototype.scrollIntoView === 'undefined') {
Element.prototype.scrollIntoView = () => {};
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { afterEach(() => {
@@ -4,3 +4,15 @@ public record CreateOwnerRequest(string Name, string Email, string Password);
public record LoginRequest(string Email, string Password); public record LoginRequest(string Email, string Password);
public record InviteUserRequest(string Email, string Role); public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password); public record CompleteSetupRequest(string Token, string Password);
public record ChangeRoleRequest(string NewRole);
public record SetUserActiveRequest(bool IsActive);
public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
public record UserDto(
Guid Id,
string Email,
string Name,
string Role,
bool IsActive,
DateTimeOffset CreatedAt,
bool InvitationPending,
string? InviteLink);
@@ -1,3 +1,5 @@
using SlpModularCms.Core.Identity.Models;
namespace SlpModularCms.Core.Identity.Services; namespace SlpModularCms.Core.Identity.Services;
/// <summary> /// <summary>
@@ -19,4 +21,29 @@ public interface IInvitationService
/// Voltooit de uitnodiging door een gebruiker aan te maken met het opgegeven wachtwoord. /// Voltooit de uitnodiging door een gebruiker aan te maken met het opgegeven wachtwoord.
/// </summary> /// </summary>
Task CompleteInvitationAsync(string token, string password); Task CompleteInvitationAsync(string token, string password);
/// <summary>
/// Geeft de pending uitnodiging voor een e-mailadres terug, of (false, null) als er geen is.
/// </summary>
Task<(bool IsPending, string? Token)> GetPendingInvitationByEmailAsync(string email);
/// <summary>
/// Verwijdert alle pending (niet-geaccepteerde) uitnodigingen voor een e-mailadres.
/// </summary>
Task DeletePendingInvitationsByEmailAsync(string email);
/// <summary>
/// Geeft alle actieve (niet-geaccepteerde, niet-verlopen) uitnodigingen terug.
/// </summary>
Task<IReadOnlyList<PendingInvitationInfo>> GetAllPendingInvitationsAsync();
/// <summary>
/// Verwijdert een pending uitnodiging op basis van het ID. Retourneert false als niet gevonden.
/// </summary>
Task<bool> DeleteInvitationByIdAsync(Guid invitationId);
/// <summary>
/// Wijzigt de rol van een pending uitnodiging. Retourneert false als niet gevonden.
/// </summary>
Task<bool> UpdateInvitationRoleAsync(Guid invitationId, string newRole);
} }
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Data; using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions; using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities; using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
namespace SlpModularCms.Core.Identity.Services; namespace SlpModularCms.Core.Identity.Services;
@@ -77,6 +78,59 @@ public class InvitationService : IInvitationService
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task<(bool IsPending, string? Token)> GetPendingInvitationByEmailAsync(string email)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Email == email && !i.IsAccepted && i.ExpiryDate > DateTimeOffset.UtcNow);
return invitation is null ? (false, null) : (true, invitation.Token);
}
public async Task DeletePendingInvitationsByEmailAsync(string email)
{
var invitations = await _context.Invitations
.Where(i => i.Email == email && !i.IsAccepted)
.ToListAsync();
if (invitations.Count > 0)
{
_context.Invitations.RemoveRange(invitations);
await _context.SaveChangesAsync();
}
}
public async Task<IReadOnlyList<PendingInvitationInfo>> GetAllPendingInvitationsAsync()
{
var invitations = await _context.Invitations
.Where(i => !i.IsAccepted && i.ExpiryDate > DateTimeOffset.UtcNow)
.OrderBy(i => i.CreatedAt)
.ToListAsync();
return invitations
.Select(i => new PendingInvitationInfo(i.Id, i.Email, i.Role, i.Token, i.CreatedAt))
.ToList();
}
public async Task<bool> DeleteInvitationByIdAsync(Guid invitationId)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Id == invitationId && !i.IsAccepted);
if (invitation is null) return false;
_context.Invitations.Remove(invitation);
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> UpdateInvitationRoleAsync(Guid invitationId, string newRole)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Id == invitationId && !i.IsAccepted);
if (invitation is null) return false;
invitation.Role = newRole;
await _context.SaveChangesAsync();
return true;
}
private static string GenerateSecureToken() private static string GenerateSecureToken()
{ {
var bytes = new byte[32]; var bytes = new byte[32];
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
namespace SlpModularCms.Modules.Identity.Controllers;
[ApiController]
[Route("[controller]")]
[AllowAnonymous]
public class InvitationController : ControllerBase
{
private readonly IInvitationService _invitationService;
private readonly ApplicationDbContext _context;
public InvitationController(IInvitationService invitationService, ApplicationDbContext context)
{
_invitationService = invitationService;
_context = context;
}
[HttpGet("validate")]
public async Task<IActionResult> Validate([FromQuery] string token)
{
if (string.IsNullOrWhiteSpace(token))
return Ok(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Token == token);
if (invitation is null)
return Ok(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
if (invitation.IsAccepted)
return Ok(new { isValid = false, email = invitation.Email, name = (string?)null, errorCode = "USED" });
if (invitation.IsExpired)
return Ok(new { isValid = false, email = invitation.Email, name = (string?)null, errorCode = "EXPIRED" });
return Ok(new { isValid = true, email = invitation.Email, name = (string?)null, errorCode = (string?)null });
}
[HttpPost("complete")]
public async Task<IActionResult> Complete([FromBody] CompleteSetupRequest request)
{
await _invitationService.CompleteInvitationAsync(request.Token, request.Password);
return Ok(new { message = "Account succesvol ingesteld. Je kunt nu inloggen." });
}
}
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services; using SlpModularCms.Core.Identity.Services;
@@ -7,38 +10,189 @@ namespace SlpModularCms.Modules.Identity.Controllers;
[ApiController] [ApiController]
[Route("[controller]")] [Route("[controller]")]
[Authorize(Policy = "AdminOnly")]
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly IInvitationService _invitationService; private readonly IInvitationService _invitationService;
private readonly UserManager<ApplicationUser> _userManager;
public UsersController(IInvitationService invitationService) public UsersController(IInvitationService invitationService, UserManager<ApplicationUser> userManager)
{ {
_invitationService = invitationService; _invitationService = invitationService;
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> GetUsers()
{
var users = await _userManager.Users.OrderBy(u => u.CreatedAt).ToListAsync();
var userEmails = users
.Select(u => u.Email?.ToLowerInvariant())
.Where(e => e is not null)
.ToHashSet();
var result = new List<UserDto>(users.Count);
foreach (var user in users)
{
var roles = await _userManager.GetRolesAsync(user);
var role = roles.FirstOrDefault() ?? "User";
var (isPending, token) = await _invitationService.GetPendingInvitationByEmailAsync(user.Email ?? string.Empty);
var inviteLink = isPending && token is not null
? $"/invite/complete?token={Uri.EscapeDataString(token)}"
: null;
result.Add(new UserDto(
user.Id,
user.Email ?? string.Empty,
user.DisplayName ?? user.Email ?? string.Empty,
role,
user.IsActive,
user.CreatedAt,
isPending,
inviteLink));
}
// Include pending invitations that have no user account yet
var pendingInvitations = await _invitationService.GetAllPendingInvitationsAsync();
foreach (var inv in pendingInvitations)
{
if (!userEmails.Contains(inv.Email.ToLowerInvariant()))
{
result.Add(new UserDto(
inv.Id,
inv.Email,
inv.Email,
inv.Role,
true,
inv.CreatedAt,
true,
$"/invite/complete?token={Uri.EscapeDataString(inv.Token)}"));
}
}
return Ok(result.OrderBy(u => u.CreatedAt));
} }
[HttpPost("invite")] [HttpPost("invite")]
[Authorize(Policy = "AdminOnly")]
public async Task<IActionResult> Invite([FromBody] InviteUserRequest request) public async Task<IActionResult> Invite([FromBody] InviteUserRequest request)
{ {
var token = await _invitationService.CreateInvitationAsync(request.Email, request.Role); var token = await _invitationService.CreateInvitationAsync(request.Email, request.Role);
// In een echte app zou je hier een email sturen. Voor nu geven we de link terug. var inviteLink = $"/invite/complete?token={Uri.EscapeDataString(token)}";
var inviteLink = $"/setup/complete?token={Uri.EscapeDataString(token)}"; return Ok(new { inviteLink });
return Ok(new { InviteLink = inviteLink });
} }
[HttpPost("complete-setup")] [HttpPut("{userId:guid}/role")]
[AllowAnonymous] public async Task<IActionResult> ChangeRole(Guid userId, [FromBody] ChangeRoleRequest request)
public async Task<IActionResult> CompleteSetup([FromBody] CompleteSetupRequest request)
{ {
await _invitationService.CompleteInvitationAsync(request.Token, request.Password); var target = await _userManager.FindByIdAsync(userId.ToString());
return Ok(new { Message = "Account succesvol ingesteld. Je kunt nu inloggen." });
// Pending invite: no user account yet — update the invitation's role directly
if (target is null)
{
var requesterEmailForInvite = User.Identity?.Name;
var requesterForInvite = requesterEmailForInvite is not null
? await _userManager.FindByNameAsync(requesterEmailForInvite)
: null;
var requesterRolesForInvite = requesterForInvite is not null
? await _userManager.GetRolesAsync(requesterForInvite)
: [];
var requesterRoleForInvite = requesterRolesForInvite.FirstOrDefault() ?? "User";
// Admins cannot assign Owner role via invitation either
if (requesterRoleForInvite == "Administrator" && request.NewRole == "Owner")
return Forbid();
var updated = await _invitationService.UpdateInvitationRoleAsync(userId, request.NewRole);
return updated ? Ok() : NotFound();
}
var requesterEmail = User.Identity?.Name;
var requester = requesterEmail is not null ? await _userManager.FindByNameAsync(requesterEmail) : null;
var requesterRoles = requester is not null ? await _userManager.GetRolesAsync(requester) : [];
var requesterRole = requesterRoles.FirstOrDefault() ?? "User";
var targetRoles = await _userManager.GetRolesAsync(target);
var targetRole = targetRoles.FirstOrDefault() ?? "User";
if (requesterRole == "Administrator" && targetRole != "User")
return Forbid();
if (targetRole == "Owner" && request.NewRole != "Owner")
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
if (targetRoles.Count > 0)
await _userManager.RemoveFromRolesAsync(target, targetRoles);
await _userManager.AddToRoleAsync(target, request.NewRole);
return Ok();
} }
[HttpGet("validate-invitation")] [HttpPut("{userId:guid}/active")]
[AllowAnonymous] public async Task<IActionResult> SetUserActive(Guid userId, [FromBody] SetUserActiveRequest request)
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
{ {
var isValid = await _invitationService.ValidateInvitationAsync(token); var target = await _userManager.FindByIdAsync(userId.ToString());
return Ok(new { Valid = isValid }); if (target is null) return NotFound();
var requesterEmail = User.Identity?.Name;
if (string.Equals(target.Email, requesterEmail, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { detail = "You cannot change your own active status." });
if (!request.IsActive)
{
var targetRoles = await _userManager.GetRolesAsync(target);
if (targetRoles.Contains("Owner"))
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
}
target.IsActive = request.IsActive;
var result = await _userManager.UpdateAsync(target);
if (!result.Succeeded)
return StatusCode(500, new { detail = "Failed to update user." });
return Ok();
} }
[HttpDelete("{userId:guid}")]
public async Task<IActionResult> DeleteUser(Guid userId)
{
var target = await _userManager.FindByIdAsync(userId.ToString());
// If no user account found, try to cancel a pending invitation with this ID
if (target is null)
{
var deleted = await _invitationService.DeleteInvitationByIdAsync(userId);
return deleted ? NoContent() : NotFound();
}
var requesterEmail = User.Identity?.Name;
if (string.Equals(target.Email, requesterEmail, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { detail = "You cannot delete your own account." });
var targetRoles = await _userManager.GetRolesAsync(target);
if (targetRoles.Contains("Owner"))
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
await _invitationService.DeletePendingInvitationsByEmailAsync(target.Email ?? string.Empty);
var result = await _userManager.DeleteAsync(target);
if (!result.Succeeded)
return StatusCode(500, new { detail = "Failed to delete user." });
return NoContent();
}
} }