13 KiB
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.tsxis a placeholder — full replacementuseInvitation.tsuses raw state hooks — migrate to TanStack Query + align types- Backend
UsersControllerneeds 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)
sonnertoast is already installed;lucide-reacticons 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 badgeinfrontend/ - Installs Radix UI dependencies and creates
src/components/ui/dialog.tsx,select.tsx,table.tsx,badge.tsx
- Run
-
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
- File:
-
Step 3: Add
GetPendingInvitationByEmailAsynctoIInvitationService- 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
- File:
-
Step 4: Implement
GetPendingInvitationByEmailAsyncinInvitationService- File:
src/SlpModularCms.Core/Identity/Services/InvitationService.cs - Query
InvitationswhereEmail == email && !IsAccepted && !IsExpired - Return
(true, invitation.Token)if found; otherwise(false, null)
- File:
-
Step 5: Add backend models
UserDtoandChangeRoleRequest- 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);
- File:
-
Step 6: Update
UsersController— addGET /andPUT /{userId}/role- File:
src/SlpModularCms.Modules.Identity/Controllers/UsersController.cs - Inject
UserManager<ApplicationUser>(add constructor parameter) - Add
[HttpGet]actionGetUsers():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; setInviteLink = $"/invite/complete?token={Uri.EscapeDataString(token)}"when pending - Authorize:
[Authorize(Policy = "AdminOnly")]
- Add
[HttpPut("{userId}/role")]actionChangeRole(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")]
- File:
-
Step 6b: Verify no database migration needed
GET /UsersandPUT /{userId}/roleonly 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 listshows no pending model changes
PART 2 — Frontend Types
- Step 7: Extend
frontend/src/api/types.ts- Add after
AvailabilityResponse: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(notInviteUserRequest) to avoid collision with backend model name in future shared type scenarios
- Add after
PART 3 — Migrate useInvitation.ts to TanStack Query
-
Step 8: Migrate
frontend/src/api/useInvitation.tsto TanStack Query- Replace
useState/useEffectimplementation withuseQueryanduseMutation - Use
InvitationValidationandInviteCompleteRequestfrom./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) })
- Replace
-
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' }
- Change mock response format from
-
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?.emailfield is unchanged (same name inInvitationValidation)
- Change
PART 4 — New Frontend Hooks
- Step 11: Create
frontend/src/api/useUsers.tsuseUsers()—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 setinviteLink+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>: callsonOpenChange(false) - data-testid:
invite-dialog-link-input,invite-dialog-copy,invite-dialog-close
- Show invite link in read-only
- Use shadcn
<Dialog>,<DialogContent>,<DialogHeader>,<DialogTitle>
- Props:
-
Step 13: Replace
frontend/src/pages/UsersPage.tsx(full implementation)- Use
useUsers(),useInviteUser(),useChangeRole()fromapi/useUsers - Use
useAuth()fromcontexts/auth-contextforcurrentUser(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, callsnavigator.clipboard.writeText(user.inviteLink!)+ toast - "Change role" sub-items — role options filtered by currentUser.role (Owner: all 3 roles; Admin: User only)
- "Copy invite link" — visible if
<InviteUserDialog open={dialogOpen} onOpenChange={setDialogOpen} />
- Header:
- data-testid:
users-page,users-invite-button,users-table,user-row-{id},user-copy-link-{id},user-change-role-{id}
- Use
PART 6 — Mocks + i18n
-
Step 14: Expand
frontend/src/mocks/users/handlers.ts- Replace placeholder
GET /Userswith full mock returning 3 users: 1 Owner (active), 1 Admin (active), 1 User (invitationPending=true, inviteLink='/invite/complete?token=pending-token') - Add
POST /Users/invitehandler: return{ inviteLink: '/invite/complete?token=new-mock-token' } - Add
PUT /Users/:userId/rolehandler: return 200 OK
- Replace placeholder
-
Step 15: Add
users.*keys tofrontend/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
- Add
-
Step 16: Add
users.*keys tofrontend/src/i18n/locales/nl/translation.json- Dutch translations for all
users.*keys
- Dutch translations for all
PART 7 — Tests
-
Step 17: Create
frontend/src/api/useUsers.test.tsuseUsers: loading state, success with user list, error stateuseInviteUser: success (returns inviteLink), API error (400)useChangeRole: success (200), forbidden (403)- Use
renderHook+QueryClientProviderfromtest/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
renderAppfromtest/utils.tsxwith 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
FormErrorBannermessage - Use
renderWithProvidersfromtest/utils.tsx
-
Step 20: Update
frontend/src/pages/InviteCompletePage.test.tsx- Update any assertions using
valid→isValidin mock responses (via updated invitation mock handlers) - Verify existing 29 tests still pass after the
isValidmigration
- Update any assertions using
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 |