Files
2026-06-22 21:14:22 +02:00

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.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:
      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?.validvalidationQuery.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 validisValid 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