feat(admin): consolidate user actions into keyboard menus

This commit is contained in:
cesnimda
2026-11-10 17:37:37 +01:00
parent fd524a4b9d
commit 171a69089f
4 changed files with 92 additions and 50 deletions
@@ -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<Array<{ id: string; roles: string[] }>>(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);
+15 -11
View File
@@ -55,6 +55,11 @@ beforeEach(() => {
function held<T>() { let resolve!: (value: T) => void; const promise=new Promise<T>(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();
+2
View File
@@ -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.",
+72 -37
View File
@@ -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<void>;
sendReset: (user: UserDto) => Promise<void>;
remove: (user: UserDto) => Promise<void>;
}) {
const { t } = useI18n();
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(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<void>) => {
// 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 (
<>
<Button
id={`${id}-trigger`}
size="small"
variant="outlined"
disabled={busy}
tabIndex={tabIndex}
aria-label={label}
aria-haspopup="menu"
aria-expanded={Boolean(anchor)}
aria-controls={anchor ? id : undefined}
onClick={event => setAnchor(event.currentTarget)}
>
{t("adminUsersActions")}
</Button>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
slotProps={{ list: { id, "aria-labelledby": `${id}-trigger` } }}
>
<MenuItem disabled={busy || lastAdmin} onClick={() => run(() => setAdminRole(user, !isAdmin))}>
{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}
</MenuItem>
<MenuItem disabled={busy} onClick={() => run(() => sendReset(user))}>{t("adminUsersSendReset")}</MenuItem>
<MenuItem disabled={busy || lastAdmin} onClick={() => run(() => remove(user))} sx={{ color: "error.main" }}>{t("adminUsersDelete")}</MenuItem>
{lastAdmin && <Typography component="li" role="presentation" variant="body2" sx={{ px: 2, py: 1, maxWidth: 240, color: "text.secondary" }}>{t("adminUsersLastAdminHelp")}</Typography>}
</Menu>
</>
);
}
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 (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", py: 0.5 }}>
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
<span>
<Button size="small" variant={isAdmin ? "contained" : "outlined"} disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)}>
{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}
</Button>
</span>
</Tooltip>
<Button size="small" variant="outlined" disabled={busy} onClick={() => void sendReset(user)}>
{t("adminUsersSendReset")}
</Button>
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
<span><Button size="small" color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)}>{t("adminUsersDelete")}</Button></span>
</Tooltip>
</Box>
);
},
renderCell: (params) => <UserActions user={params.row.raw as UserDto} busy={busy} tabIndex={params.hasFocus ? 0 : -1} setAdminRole={setAdminRole} sendReset={sendReset} remove={remove} />,
},
], [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 (
<Paper key={row.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
<Stack spacing={1.25}>
@@ -285,17 +330,7 @@ export default function AdminUsersPage() {
</Box>
<UserStatus user={user} />
<Stack spacing={1}>
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
<span><Button variant={isAdmin ? "contained" : "outlined"} disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}</Button></span>
</Tooltip>
<Button variant="outlined" disabled={busy} onClick={() => void sendReset(user)} fullWidth>
{t("adminUsersSendReset")}
</Button>
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
<span><Button color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)} fullWidth>{t("adminUsersDelete")}</Button></span>
</Tooltip>
</Stack>
<UserActions user={user} busy={busy} setAdminRole={setAdminRole} sendReset={sendReset} remove={remove} />
</Stack>
</Paper>
);