From 785148a70a2e143f36206f62960e2268cb64e9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:49:51 +0000 Subject: [PATCH] feat: localize user mutation confirmations (#2878) --- .../action-confirm-popover.test.tsx | 15 + .../confirmation/action-confirm-popover.tsx | 7 +- .../settings/system/users-table.test.tsx | 215 ++++++++++++ .../settings/system/users-table.tsx | 329 +++++++++++++----- .../auth/mobile-users-self-actions.spec.ts | 45 +++ .../e2e/tests/auth/users-self-actions.spec.ts | 34 ++ 6 files changed, 554 insertions(+), 91 deletions(-) create mode 100644 apps/web/components/settings/system/users-table.test.tsx diff --git a/apps/web/components/confirmation/action-confirm-popover.test.tsx b/apps/web/components/confirmation/action-confirm-popover.test.tsx index c2813d2724..d6831b33a1 100644 --- a/apps/web/components/confirmation/action-confirm-popover.test.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.test.tsx @@ -85,6 +85,21 @@ describe("ActionConfirmPopover", () => { expect(shellClosed).toBe(true); }); + it("does not restore focus to the confirmed action anchor", async () => { + const onConfirm = vi.fn(); + render(); + + const anchor = screen.getByTestId("anchor"); + fireEvent.click( + within(screen.getByRole("dialog", { name: deleteTitle })).getByRole("button", { + name: "Delete", + }), + ); + + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + expect(document.activeElement).not.toBe(anchor); + }); + it("keeps a disabled confirmation inert", () => { const onConfirm = vi.fn(); render(); diff --git a/apps/web/components/confirmation/action-confirm-popover.tsx b/apps/web/components/confirmation/action-confirm-popover.tsx index 8b4c1e2522..c7f8fd160c 100644 --- a/apps/web/components/confirmation/action-confirm-popover.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.tsx @@ -65,10 +65,9 @@ export function ActionConfirmPopover({ // data without changing the confirmation's open state, so each render must // re-check the guard before the shell can invoke a stale action. useLayoutEffect(() => { - if (!open) { - confirmedRef.current = false; - return; - } + // Keep a confirmed close marked until Radix finishes close-autofocus. An + // early reset can refocus this anchor while a following popover is opening. + if (!open) return; if (isConnected(anchorRef.current)) return; onCancel?.(); onOpenChange(false); diff --git a/apps/web/components/settings/system/users-table.test.tsx b/apps/web/components/settings/system/users-table.test.tsx new file mode 100644 index 0000000000..10fa75a08a --- /dev/null +++ b/apps/web/components/settings/system/users-table.test.tsx @@ -0,0 +1,215 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AuthUser } from "@/lib/state/slices/auth/types"; + +const mocks = vi.hoisted(() => ({ + listUsers: vi.fn(), + updateUser: vi.fn(), + toast: vi.fn(), + responsive: { isFinePointer: true, isMobile: false }, +})); + +vi.mock("@/lib/api/domains/auth-api", () => ({ + listUsers: mocks.listUsers, + updateUser: mocks.updateUser, +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => mocks.responsive, +})); + +vi.mock("./create-user-dialog", () => ({ + CreateUserDialog: () => null, +})); + +vi.mock("./invite-dialog", () => ({ + InviteDialog: () => null, +})); + +import { UsersTable } from "./users-table"; + +const ADMIN = makeUser({ id: "admin-id", email: "admin@example.com", role: "admin" }); +const TARGET_EMAIL = "target@example.com"; +const TARGET = makeUser({ id: "target-id", email: TARGET_EMAIL, role: "member" }); +const CONFIRM_TEST_ID = "users-table-confirm"; +const POPOVER_TEST_ID = "users-table-confirm-popover"; +const ROLE_TOGGLE_TEST_ID = "users-table-toggle-role"; +const STATUS_TOGGLE_TEST_ID = "users-table-toggle-status"; + +function makeUser(overrides: Partial): AuthUser { + return { + id: "user-id", + email: "user@example.com", + display_name: "User", + role: "member", + status: "active", + ...overrides, + }; +} + +function targetRow() { + const row = screen + .getAllByTestId("users-table-row") + .find((candidate) => candidate.getAttribute("data-user-id") === TARGET.id); + if (!row) throw new Error("target user row not found"); + return row; +} + +async function renderUsersTable() { + render(); + await screen.findAllByTestId("users-table-row"); + await waitFor(() => expect(mocks.listUsers).toHaveBeenCalledTimes(1)); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function resetMocks() { + mocks.responsive.isFinePointer = true; + mocks.responsive.isMobile = false; + vi.clearAllMocks(); + mocks.listUsers.mockResolvedValue({ users: [ADMIN, TARGET] }); + mocks.updateUser.mockResolvedValue({ user: TARGET }); +} + +describe("UsersTable confirmation presentation", () => { + beforeEach(resetMocks); + + afterEach(cleanup); + + it("confirms another user's role change at its row action and refreshes the list", async () => { + await renderUsersTable(); + const row = targetRow(); + + fireEvent.click(within(row).getByTestId(ROLE_TOGGLE_TEST_ID)); + + const confirmation = await screen.findByTestId(POPOVER_TEST_ID); + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(confirmation.textContent).toContain(`Change ${TARGET_EMAIL} to admin?`); + expect(confirmation.textContent).toContain(`${TARGET_EMAIL}; this takes effect immediately.`); + + fireEvent.click(within(confirmation).getByTestId(CONFIRM_TEST_ID)); + + await waitFor(() => { + expect(mocks.updateUser).toHaveBeenCalledWith("target-id", { role: "admin" }); + }); + await waitFor(() => expect(mocks.listUsers).toHaveBeenCalledTimes(2)); + expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull(); + }); + + it("morphs a phone row action into visible touch-sized status confirmation", async () => { + mocks.responsive.isFinePointer = false; + mocks.responsive.isMobile = true; + await renderUsersTable(); + const row = targetRow(); + + expect(screen.getByTestId("users-table-mobile-list")).toBeTruthy(); + expect(within(row).getByTestId("users-table-email").textContent).toContain(TARGET.email); + fireEvent.click(within(row).getByTestId(STATUS_TOGGLE_TEST_ID)); + + const confirmation = await screen.findByTestId("users-table-inline-confirmation"); + expect(confirmation.textContent).toContain( + `Disable ${TARGET_EMAIL}? They will be signed out everywhere.`, + ); + expect(screen.queryByRole("alertdialog")).toBeNull(); + + fireEvent.click(within(confirmation).getByTestId(CONFIRM_TEST_ID)); + + await waitFor(() => { + expect(mocks.updateUser).toHaveBeenCalledWith("target-id", { status: "disabled" }); + }); + }); + + it("chooses row composition by width and confirmation behavior by pointer precision", async () => { + mocks.responsive.isFinePointer = false; + await renderUsersTable(); + + expect(screen.getByTestId("users-table")).toBeTruthy(); + expect(screen.queryByTestId("users-table-mobile-list")).toBeNull(); + + fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID)); + expect(await screen.findByTestId("users-table-inline-confirmation")).toBeTruthy(); + }); + + it("keeps phone cards on a narrow fine-pointer viewport", async () => { + mocks.responsive.isMobile = true; + await renderUsersTable(); + + expect(screen.getByTestId("users-table-mobile-list")).toBeTruthy(); + expect(screen.queryByTestId("users-table")).toBeNull(); + + fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID)); + expect(await screen.findByTestId(POPOVER_TEST_ID)).toBeTruthy(); + }); +}); + +describe("UsersTable mutation lifecycle", () => { + beforeEach(resetMocks); + + afterEach(cleanup); + + it("blocks every row mutation until the active update and refresh finish", async () => { + const update = deferred<{ user: AuthUser }>(); + mocks.updateUser.mockReturnValueOnce(update.promise); + await renderUsersTable(); + + fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID)); + const confirmation = await screen.findByTestId(POPOVER_TEST_ID); + fireEvent.click(within(confirmation).getByTestId(CONFIRM_TEST_ID)); + + await waitFor(() => expect(mocks.updateUser).toHaveBeenCalledTimes(1)); + const statusToggle = within(targetRow()).getByTestId(STATUS_TOGGLE_TEST_ID); + expect((statusToggle as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(statusToggle); + expect(mocks.updateUser).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull(); + + update.resolve({ user: TARGET }); + await waitFor(() => expect(mocks.listUsers).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect( + (within(targetRow()).getByTestId(STATUS_TOGGLE_TEST_ID) as HTMLButtonElement).disabled, + ).toBe(false), + ); + }); + + it("cancels the row confirmation without mutating", async () => { + await renderUsersTable(); + + fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID)); + const confirmation = await screen.findByTestId(POPOVER_TEST_ID); + fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull()); + expect(mocks.updateUser).not.toHaveBeenCalled(); + expect( + (within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("keeps existing request error feedback without refreshing after failure", async () => { + mocks.updateUser.mockRejectedValue(new Error("permission denied")); + await renderUsersTable(); + + const row = targetRow(); + fireEvent.click(within(row).getByTestId(STATUS_TOGGLE_TEST_ID)); + const confirmation = await screen.findByTestId(POPOVER_TEST_ID); + fireEvent.click(within(confirmation).getByTestId(CONFIRM_TEST_ID)); + + await waitFor(() => expect(mocks.toast).toHaveBeenCalledTimes(1)); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ variant: "error", title: "Could not update user" }), + ); + expect(mocks.listUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/settings/system/users-table.tsx b/apps/web/components/settings/system/users-table.tsx index 200feaa3d2..ad3c0b2420 100644 --- a/apps/web/components/settings/system/users-table.tsx +++ b/apps/web/components/settings/system/users-table.tsx @@ -1,25 +1,18 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@kandev/ui/alert-dialog"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react"; +import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; +import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; import { useToast } from "@/components/toast-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { ApiError } from "@/lib/api/client"; import { listUsers, updateUser, type AuthUser } from "@/lib/api/domains/auth-api"; import { CreateUserDialog } from "./create-user-dialog"; @@ -80,25 +73,30 @@ function useUsersList(t: TFunction) { function StatusBadge({ status }: { status: string }) { const { t } = useTranslation(); return ( - + {STATUS_LABEL_KEYS[status] ? t(STATUS_LABEL_KEYS[status]) : status} ); } -function UserRow({ - user, - isLastActiveAdmin, - onToggleRole, - onToggleStatus, -}: { +type UserRowProps = { user: AuthUser; isLastActiveAdmin: boolean; + isFinePointer: boolean; + isMutating: boolean; + pending: PendingAction | null; onToggleRole: (user: AuthUser) => void; onToggleStatus: (user: AuthUser) => void; -}) { + onCancel: () => void; + onConfirm: (action: PendingAction) => void; +}; + +function UserRow({ user, ...props }: UserRowProps) { const { t } = useTranslation(); - const isDisabled = user.status === "disabled"; return ( {/* Email and display name are user data. */} @@ -107,7 +105,11 @@ function UserRow({ {user.display_name} - + {roleLabel(user.role, t)} @@ -115,63 +117,188 @@ function UserRow({ -
- - -
+
); } -function UsersConfirmDialog({ +function MobileUserRow({ user, ...props }: UserRowProps) { + const { t } = useTranslation(); + return ( +
+ {/* Identity and current state stay above the row-owned action region on phones. */} +
+

+ {user.email} +

+

{user.display_name}

+
+
+ {t("system:usersColumnRole")}: + + {roleLabel(user.role, t)} + + {t("system:usersColumnStatus")}: + +
+ +
+ ); +} + +function UserActionRegion({ + user, + isLastActiveAdmin, + isFinePointer, + isMutating, pending, + onToggleRole, + onToggleStatus, + onCancel, + onConfirm, +}: UserRowProps) { + const { t } = useTranslation(); + const roleAnchorRef = useRef(null); + const statusAnchorRef = useRef(null); + const action = pending?.user.id === user.id ? pending : null; + const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef; + + if (!isFinePointer && action) { + return ( + onConfirm(action)} + /> + ); + } + + return ( + <> + + {isFinePointer && action ? ( + + ) : null} + + ); +} + +function UserActionButtons({ + user, + isLastActiveAdmin, + isFinePointer, + isMutating, + roleAnchorRef, + statusAnchorRef, + onToggleRole, + onToggleStatus, +}: { + user: AuthUser; + isLastActiveAdmin: boolean; + isFinePointer: boolean; + isMutating: boolean; + roleAnchorRef: RefObject; + statusAnchorRef: RefObject; + onToggleRole: (user: AuthUser) => void; + onToggleStatus: (user: AuthUser) => void; +}) { + const { t } = useTranslation(); + const roleButtonClassName = isFinePointer ? "cursor-pointer" : "min-h-11 cursor-pointer"; + const statusButtonClassName = isFinePointer + ? "cursor-pointer text-destructive" + : "min-h-11 cursor-pointer text-destructive"; + + return ( +
+ + +
+ ); +} + +function UserConfirmPopover({ + action, + anchorRef, onCancel, onConfirm, }: { - pending: PendingAction | null; + action: PendingAction; + anchorRef: RefObject; onCancel: () => void; - onConfirm: () => void; + onConfirm: (action: PendingAction) => void; }) { const { t } = useTranslation(); return ( - !open && onCancel()}> - - - {pending?.label} - - {t("system:usersTakesEffectImmediately", { email: pending?.user.email ?? "" })} - - - - {t("common:cancel")} - - {t("system:usersConfirm")} - - - - + !open && onCancel()} + onConfirm={() => onConfirm(action)} + /> ); } @@ -204,16 +331,48 @@ function UsersTableList({ users, onToggleRole, onToggleStatus, + pending, + isMutating, + onCancel, + onConfirm, }: { users: AuthUser[]; onToggleRole: (u: AuthUser) => void; onToggleStatus: (u: AuthUser) => void; + pending: PendingAction | null; + isMutating: boolean; + onCancel: () => void; + onConfirm: (action: PendingAction) => void; }) { const { t } = useTranslation(); + const { isFinePointer, isMobile } = useResponsiveBreakpoint(); // Mirrors the backend last-admin guard (ensureAnotherAdmin): an active // admin cannot be demoted or disabled when no other active admin exists, // so those toggles are greyed out on that row. const activeAdminCount = users.filter((u) => u.role === "admin" && u.status === "active").length; + + const rowProps = (user: AuthUser): UserRowProps => ({ + user, + isLastActiveAdmin: user.role === "admin" && user.status === "active" && activeAdminCount === 1, + isFinePointer, + isMutating, + pending, + onToggleRole, + onToggleStatus, + onCancel, + onConfirm, + }); + + if (isMobile) { + return ( +
+ {users.map((user) => ( + + ))} +
+ ); + } + return ( @@ -226,19 +385,9 @@ function UsersTableList({ - {users.map((user) => { - const isLastActiveAdmin = - user.role === "admin" && user.status === "active" && activeAdminCount === 1; - return ( - - ); - })} + {users.map((user) => ( + + ))}
); @@ -250,12 +399,18 @@ export function UsersTable() { const [createOpen, setCreateOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [pending, setPending] = useState(null); + const [activeMutation, setActiveMutation] = useState(null); const { toast } = useToast(); - const onConfirm = async () => { - if (!pending) return; + useEffect(() => { + if (pending && !users.some((user) => user.id === pending.user.id)) setPending(null); + }, [pending, users]); + + const onConfirm = async (action: PendingAction) => { + if (activeMutation) return; + setActiveMutation(action); try { - await updateUser(pending.user.id, pending.next); + await updateUser(action.user.id, action.next); await reload(); } catch (err) { toast({ @@ -264,7 +419,8 @@ export function UsersTable() { description: err instanceof ApiError ? err.message : t("system:usersLastAdminGuard"), }); } finally { - setPending(null); + setPending((current) => (current === action ? null : current)); + setActiveMutation((current) => (current === action ? null : current)); } }; @@ -310,6 +466,10 @@ export function UsersTable() { users={users} onToggleRole={(u) => setPending(roleTogglePending(u, t))} onToggleStatus={(u) => setPending(statusTogglePending(u, t))} + pending={pending} + isMutating={activeMutation !== null} + onCancel={() => setPending(null)} + onConfirm={(action) => void onConfirm(action)} /> )} {loaded && users.length === 0 && !error && ( @@ -328,11 +488,6 @@ export function UsersTable() { onOpenChange={setInviteOpen} onCreated={() => void reload()} /> - setPending(null)} - onConfirm={() => void onConfirm()} - /> ); } diff --git a/apps/web/e2e/tests/auth/mobile-users-self-actions.spec.ts b/apps/web/e2e/tests/auth/mobile-users-self-actions.spec.ts index 856d7eb603..b712dc414a 100644 --- a/apps/web/e2e/tests/auth/mobile-users-self-actions.spec.ts +++ b/apps/web/e2e/tests/auth/mobile-users-self-actions.spec.ts @@ -2,6 +2,7 @@ import { devices, expect } from "@playwright/test"; import path from "node:path"; import { backendFixture as test } from "../../fixtures/backend"; import { login, setupAdmin } from "../../helpers/auth"; +import { waitForHttp } from "../../helpers/causal-waits"; import { responseUserId } from "./users-auth-helpers"; /** @@ -119,13 +120,57 @@ test.describe.serial("users self-actions guard (mobile)", () => { expect(secondRes.status(), await secondRes.text()).toBe(201); const secondAdminId = responseUserId(await secondRes.json()); + const secondUsersRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); await page.reload(); + await secondUsersRefresh; const secondAdminRow = page.locator(`[data-user-id="${secondAdminId}"]`); + await expect(ownRow).toBeVisible({ timeout: 15_000 }); await expect(ownRow.getByTestId("users-table-toggle-role")).toBeEnabled(); await expect(ownRow.getByTestId("users-table-toggle-status")).toBeEnabled(); await expect(secondAdminRow.getByTestId("users-table-toggle-role")).toBeEnabled(); await expect(secondAdminRow.getByTestId("users-table-toggle-status")).toBeEnabled(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + + // Phone action remains in target row. Confirmation morphs only action + // region, leaving identity and current state visible. + const rolePatch = waitForHttp(page, "PATCH", new RegExp(`/api/v1/users/${memberId}$`)); + const roleRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); + await memberRow.getByTestId("users-table-toggle-role").tap(); + const roleConfirmation = memberRow.getByTestId("users-table-inline-confirmation"); + await expect(roleConfirmation).toBeVisible(); + await expect(memberRow.getByTestId("users-table-email")).toBeVisible(); + await expect(memberRow.getByTestId("users-table-role")).toContainText("member"); + await expect(roleConfirmation).toContainText(`Change ${MEMBER.email} to admin?`); + const roleConfirmButton = roleConfirmation.getByTestId("users-table-confirm"); + await expect(roleConfirmButton).toBeVisible(); + const roleConfirmBox = await roleConfirmButton.boundingBox(); + expect(roleConfirmBox).not.toBeNull(); + expect(roleConfirmBox!.height).toBeGreaterThanOrEqual(44); + await roleConfirmButton.tap(); + await rolePatch; + await roleRefresh; + await expect(memberRow.getByTestId("users-table-role")).toContainText("admin"); + + const statusPatch = waitForHttp(page, "PATCH", new RegExp(`/api/v1/users/${disabledAdminId}$`)); + const statusRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); + await disabledAdminRow.getByTestId("users-table-toggle-status").tap(); + const statusConfirmation = disabledAdminRow.getByTestId("users-table-inline-confirmation"); + await expect(statusConfirmation).toBeVisible(); + await expect(statusConfirmation).toContainText(`Re-enable ${DISABLED_ADMIN.email}?`); + const statusConfirmButton = statusConfirmation.getByTestId("users-table-confirm"); + const statusConfirmBox = await statusConfirmButton.boundingBox(); + expect(statusConfirmBox).not.toBeNull(); + expect(statusConfirmBox!.height).toBeGreaterThanOrEqual(44); + await statusConfirmButton.tap(); + await statusPatch; + await statusRefresh; + await expect(disabledAdminRow.getByTestId("users-table-status")).toContainText("active"); + await ctx.close(); }); }); diff --git a/apps/web/e2e/tests/auth/users-self-actions.spec.ts b/apps/web/e2e/tests/auth/users-self-actions.spec.ts index 9ac0c0db9a..2d37b0fdf6 100644 --- a/apps/web/e2e/tests/auth/users-self-actions.spec.ts +++ b/apps/web/e2e/tests/auth/users-self-actions.spec.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import path from "node:path"; import { backendFixture as test } from "../../fixtures/backend"; import { login, setupAdmin } from "../../helpers/auth"; +import { waitForHttp } from "../../helpers/causal-waits"; import { responseUserId } from "./users-auth-helpers"; /** @@ -112,8 +113,11 @@ test.describe.serial("users self-actions guard", () => { }); expect(disable.status(), await disable.text()).toBe(200); + const disabledUsersRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); await page.reload(); + await disabledUsersRefresh; const disabledAdminRow = page.locator(`[data-user-id="${disabledAdminId}"]`); + await expect(ownRow).toBeVisible({ timeout: 15_000 }); await expect(ownRow.getByTestId("users-table-toggle-role")).toBeDisabled(); await expect(ownRow.getByTestId("users-table-toggle-status")).toBeDisabled(); await expect(disabledAdminRow.getByTestId("users-table-toggle-role")).toBeEnabled(); @@ -131,13 +135,43 @@ test.describe.serial("users self-actions guard", () => { expect(secondRes.status(), await secondRes.text()).toBe(201); const secondAdminId = responseUserId(await secondRes.json()); + const secondUsersRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); await page.reload(); + await secondUsersRefresh; const secondAdminRow = page.locator(`[data-user-id="${secondAdminId}"]`); + await expect(ownRow).toBeVisible({ timeout: 15_000 }); await expect(ownRow.getByTestId("users-table-toggle-role")).toBeEnabled(); await expect(ownRow.getByTestId("users-table-toggle-status")).toBeEnabled(); await expect(secondAdminRow.getByTestId("users-table-toggle-role")).toBeEnabled(); await expect(secondAdminRow.getByTestId("users-table-toggle-status")).toBeEnabled(); + // Another user's role change stays anchored to that user's action and + // refreshes the existing list after confirmation. + const rolePatch = waitForHttp(page, "PATCH", new RegExp(`/api/v1/users/${memberId}$`)); + const roleRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); + await memberRow.getByTestId("users-table-toggle-role").click(); + const roleConfirmation = page.getByTestId("users-table-confirm-popover"); + await expect(roleConfirmation).toBeVisible(); + await expect(roleConfirmation).toContainText(`Change ${MEMBER.email} to admin?`); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await roleConfirmation.getByTestId("users-table-confirm").click(); + await rolePatch; + await roleRefresh; + await expect(memberRow.getByTestId("users-table-role")).toContainText("admin"); + + // Enabled-state changes use the same row-local confirmation and keep the + // existing target transition. + const statusPatch = waitForHttp(page, "PATCH", new RegExp(`/api/v1/users/${disabledAdminId}$`)); + const statusRefresh = waitForHttp(page, "GET", /^\/api\/v1\/users$/); + await disabledAdminRow.getByTestId("users-table-toggle-status").click(); + const statusConfirmation = page.getByTestId("users-table-confirm-popover"); + await expect(statusConfirmation).toBeVisible(); + await expect(statusConfirmation).toContainText(`Re-enable ${DISABLED_ADMIN.email}?`); + await statusConfirmation.getByTestId("users-table-confirm").click(); + await statusPatch; + await statusRefresh; + await expect(disabledAdminRow.getByTestId("users-table-status")).toContainText("active"); + // No new backend behavior: self-demotion is allowed again once another // active admin exists. const selfDemoteAfter = await ctx.request.patch(`${backend.baseUrl}/api/v1/users/${adminId}`, {