From 171a69089f7d16490b8b12939e0372c423b216cb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 17:37:37 +0100 Subject: [PATCH] feat(admin): consolidate user actions into keyboard menus --- job-tracker-ui/e2e/phase7-admin-users.spec.ts | 5 +- job-tracker-ui/src/admin-users-page.test.tsx | 26 +++-- job-tracker-ui/src/i18n/translations.ts | 2 + job-tracker-ui/src/views/AdminUsersPage.tsx | 109 ++++++++++++------ 4 files changed, 92 insertions(+), 50 deletions(-) diff --git a/job-tracker-ui/e2e/phase7-admin-users.spec.ts b/job-tracker-ui/e2e/phase7-admin-users.spec.ts index 41ebfc30..65fa17ba 100644 --- a/job-tracker-ui/e2e/phase7-admin-users.spec.ts +++ b/job-tracker-ui/e2e/phase7-admin-users.spec.ts @@ -91,7 +91,8 @@ test('[P7-10 Users A01 E09] real admin creation/role persistence revoke sessions expect((await call(session.requests, other, 'PUT', `/users/${administrator.id}/roles`, before.headers, { roles: [] })).status()).toBe(403); const card=page.locator('.MuiPaper-root').filter({ has: page.getByText(email, { exact: true }).first() }).last(); const changed=response(page, 'PUT', `/users/${created.id}/roles`); - await keyboard(card.getByRole('button', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204); + await keyboard(card.getByRole('button', { name: copy('adminUsersActionsFor').replace('{name}', email), exact: true })); + await keyboard(page.getByRole('menuitem', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204); await expect.poll(async () => (await json>(await call(session.requests, page.request, 'GET', '/users'))).find(value => value.id===created.id)?.roles).toEqual(['Admin']); expect((await call(session.requests, other, 'GET', '/auth/me')).status()).toBe(401); expect((await page.request.delete(`${apiUrl}/users/${created.id}`, { headers: { ...administrator.headers, 'X-Confirm-Account-Deletion': email } })).status()).toBe(503); @@ -145,7 +146,7 @@ test('[P7-10 Users A01] finite bilingual light/dark widths retain long identitie await expect(page.getByText(email, { exact: true }).first()).toBeVisible(); if (sample.width>=768) { const region=page.getByRole('region', { name: copy('adminUsersTitle', sample.language), exact: true }); await expect(region).toHaveAttribute('tabindex', '0'); - const action=region.getByRole('button', { name: copy('adminUsersSendReset', sample.language), exact: true }).first(); await action.focus(); await expect(action).toBeFocused(); + const action=region.getByRole('button', { name: copy('adminUsersActionsFor', sample.language).replace('{name}', email), exact: true }); await action.focus(); await expect(action).toBeFocused(); const box=await action.boundingBox(); expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x+box!.width).toBeLessThanOrEqual(sample.width); const clipped=await region.locator('.MuiDataGrid-row').evaluateAll(rows => rows.flatMap(row => { const bounds=row.getBoundingClientRect(); return Array.from(row.querySelectorAll('button')).filter(button => { const rect=button.getBoundingClientRect(); return rect.top < bounds.top-1 || rect.bottom > bounds.bottom+1; }).map(button => button.textContent); diff --git a/job-tracker-ui/src/admin-users-page.test.tsx b/job-tracker-ui/src/admin-users-page.test.tsx index e1358f48..16111253 100644 --- a/job-tracker-ui/src/admin-users-page.test.tsx +++ b/job-tracker-ui/src/admin-users-page.test.tsx @@ -55,6 +55,11 @@ beforeEach(() => { function held() { let resolve!: (value: T) => void; const promise=new Promise(yes => { resolve=yes; }); return { promise, resolve }; } function createDraft(email='new@example.test') { fireEvent.change(screen.getByLabelText('Email'), { target: { value: email } }); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'TestPassword123!' } }); } +async function openUserActions() { + fireEvent.click(await screen.findByRole("button", { name: /^Actions for / })); + return screen.findByRole("menu"); +} + test.each([['en', 'Rows per page:'], ['nb', 'Rader per side:']])('desktop user-grid pagination follows %s UI language', async (language, label) => { window.localStorage.setItem('uiLanguage', language); (window.matchMedia as jest.Mock).mockImplementation(() => ({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn() })); @@ -113,14 +118,14 @@ test("requires explicit confirmation before removing your own admin role", async expect(screen.getByRole("heading", { name: "Create user", level: 2 })).toBeInTheDocument(); - fireEvent.click(await screen.findByRole("button", { name: "Remove admin" })); + fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" })); expect(await screen.findByText(/you will immediately lose access/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(mockedApi.put).not.toHaveBeenCalled(); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); - fireEvent.click(screen.getByRole("button", { name: "Remove admin" })); + fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" })); fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove admin" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/users/me/roles", { roles: [] })); @@ -129,7 +134,7 @@ test("requires explicit confirmation before removing your own admin role", async test("warns before demoting another administrator", async () => { renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: ["Admin"], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); - fireEvent.click(await screen.findByRole("button", { name: "Remove admin" })); + fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" })); expect(await screen.findByText(/they will immediately lose access/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); @@ -139,18 +144,17 @@ test("warns before demoting another administrator", async () => { test("disables demotion and deletion for the final administrator", async () => { renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: false }]); - const demote = await screen.findByRole("button", { name: "Remove admin" }); - expect(demote).toBeDisabled(); - expect(demote.parentElement).not.toHaveAttribute("aria-label"); - fireEvent.mouseOver(demote.parentElement!); - expect(await screen.findByRole("tooltip")).toHaveTextContent("The final administrator cannot be demoted or deleted."); - expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + const menu = within(await openUserActions()); + expect(menu.getByRole("menuitem", { name: "Remove admin" })).toHaveAttribute("aria-disabled", "true"); + expect(menu.getByRole("menuitem", { name: "Delete" })).toHaveAttribute("aria-disabled", "true"); + expect(menu.getByText(/The final administrator cannot be demoted or deleted/)).toBeVisible(); + expect(menu.getByRole("menuitem", { name: "Send reset" })).not.toHaveAttribute("aria-disabled"); }); test("sends the exact account email after confirming deletion", async () => { renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); - fireEvent.click(await screen.findByRole("button", { name: "Delete" })); + fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Delete" })); fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", { @@ -161,7 +165,7 @@ test("sends the exact account email after confirming deletion", async () => { test('accepted deletion is reported as queued rather than completed', async () => { renderPage([{ id: 'other', email: 'other@example.com', userName: 'other', roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); mockedApi.delete.mockResolvedValueOnce({ status: 202, data: { status: 'pending' } } as any); - fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(within(await openUserActions()).getByRole('menuitem', { name: 'Delete' })); fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); expect(await screen.findByText('Account deletion request queued. Deletion has not completed yet.')).toBeInTheDocument(); expect(screen.queryByText('User deleted.')).not.toBeInTheDocument(); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 7083e834..e0429ba8 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2231,6 +2231,7 @@ export const translations = { adminUsers2faDisabled: "2FA disabled", adminUsers2faUnavailable: "2FA status unavailable", adminUsersActions: "Actions", + adminUsersActionsFor: "Actions for {name}", adminUsersNoUsers: "No users.", adminUsersRolesUpdated: "Roles updated.", adminUsersRolesUpdateFailed: "Failed to update roles.", @@ -5472,6 +5473,7 @@ export const translations = { adminUsers2faDisabled: "2FA deaktivert", adminUsers2faUnavailable: "2FA-status utilgjengelig", adminUsersActions: "Handlinger", + adminUsersActionsFor: "Handlinger for {name}", adminUsersNoUsers: "Ingen brukere.", adminUsersRolesUpdated: "Roller oppdatert.", adminUsersRolesUpdateFailed: "Kunne ikke oppdatere roller.", diff --git a/job-tracker-ui/src/views/AdminUsersPage.tsx b/job-tracker-ui/src/views/AdminUsersPage.tsx index a3d084ac..591d0878 100644 --- a/job-tracker-ui/src/views/AdminUsersPage.tsx +++ b/job-tracker-ui/src/views/AdminUsersPage.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { Alert, @@ -7,10 +8,11 @@ import { Checkbox, Chip, FormControlLabel, + Menu, + MenuItem, Paper, Stack, TextField, - Tooltip, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -49,6 +51,71 @@ function UserStatus({ user }: { user: UserDto }) { ); } +function UserActions({ user, busy, tabIndex, setAdminRole, sendReset, remove }: { + user: UserDto; + busy: boolean; + tabIndex?: number; + setAdminRole: (user: UserDto, grant: boolean) => Promise; + sendReset: (user: UserDto) => Promise; + remove: (user: UserDto) => Promise; +}) { + const { t } = useI18n(); + const [anchor, setAnchor] = useState(null); + const id = useId(); + const isAdmin = user.roles?.includes("Admin") ?? false; + const lastAdmin = isAdmin && !user.canRemoveAdmin; + const label = t("adminUsersActionsFor", { name: user.email || user.userName || user.id }); + const run = (action: () => Promise) => { + // Close the menu before an action moves focus or opens its confirmation dialog. + flushSync(() => setAnchor(null)); + anchor?.focus(); + const group = anchor?.closest('[role="group"][aria-busy]'); + void action().finally(() => { + const closingDialog = document.querySelector('[role="dialog"]'); + const restore = (frames: number) => { + if (!anchor?.isConnected) return; + if (closingDialog?.isConnected && frames > 0) { + requestAnimationFrame(() => restore(frames - 1)); + } else if (document.activeElement === document.body || document.activeElement === group) { + anchor.focus({ preventScroll: true }); + } + }; + requestAnimationFrame(() => restore(60)); + }); + }; + return ( + <> + + setAnchor(null)} + slotProps={{ list: { id, "aria-labelledby": `${id}-trigger` } }} + > + run(() => setAdminRole(user, !isAdmin))}> + {isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")} + + run(() => sendReset(user))}>{t("adminUsersSendReset")} + run(() => remove(user))} sx={{ color: "error.main" }}>{t("adminUsersDelete")} + {lastAdmin && {t("adminUsersLastAdminHelp")}} + + + ); +} + export default function AdminUsersPage() { const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); @@ -203,31 +270,10 @@ export default function AdminUsersPage() { { field: "actions", headerName: t("adminUsersActions"), - minWidth: 300, - flex: 1.4, + minWidth: 125, sortable: false, filterable: false, - renderCell: (params) => { - const user = params.row.raw as UserDto; - const isAdmin = (user.roles || []).includes("Admin"); - return ( - - - - - - - - - - - - ); - }, + renderCell: (params) => , }, ], [busy, remove, sendReset, setAdminRole, t]); @@ -264,7 +310,6 @@ export default function AdminUsersPage() { ) : null} {rows.map((row) => { const user = row.raw as UserDto; - const isAdmin = user.roles.includes("Admin"); return ( @@ -285,17 +330,7 @@ export default function AdminUsersPage() { - - - - - - - - - + );