# 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(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 ``` - **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 ``` - **onSuccess**: invalidates `['users']` query ### useChangeRole — `api/useUsers.ts` ```typescript // PUT /api/v1/Users/{userId}/role export function useChangeRole(): UseMutationResult ``` - **onSuccess**: invalidates `['users']` query ### useValidateInvitation (migrated) — `api/useInvitation.ts` ```typescript // GET /api/v1/Invitation/validate?token=... export function useValidateInvitation(token: string | null): UseQueryResult ``` - 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 ``` - 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 |