# 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` (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; } 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({ queryKey: ['invitation', 'validate', token], queryFn: () => api.get(...), enabled: !!token })` - URL: `/api/v1/Invitation/validate?token=${encodeURIComponent(token)}` - `useCompleteInvitation()`: - `useMutation({ 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({ queryKey: ['users'], queryFn: () => api.get('/api/v1/Users'), staleTime: 30_000 })` - `useInviteUser()` — `useMutation({ mutationFn: (data) => api.post('/api/v1/Users/invite', data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }) })` - `useChangeRole()` — `useMutation({ 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: `` for email, `` - "Copy to clipboard" `` - `` with columns: Name, Email, Role, Status, Created At, Actions - Per row: role ``, status ``, 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) - `` - 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 |