feat: localize user mutation confirmations (#2878)

This commit is contained in:
José Almeida
2026-08-23 03:49:51 +00:00
committed by GitHub
parent ba0afd8e2b
commit 785148a70a
6 changed files with 554 additions and 91 deletions
@@ -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(<Harness onConfirm={onConfirm} />);
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(<Harness onConfirm={onConfirm} confirmDisabled />);
@@ -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);
@@ -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>): 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(<UsersTable />);
await screen.findAllByTestId("users-table-row");
await waitFor(() => expect(mocks.listUsers).toHaveBeenCalledTimes(1));
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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);
});
});
@@ -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 (
<Badge variant={status === "active" ? "default" : "secondary"} className="text-[10px]">
<Badge
variant={status === "active" ? "default" : "secondary"}
className="text-[10px]"
data-testid="users-table-status"
>
{STATUS_LABEL_KEYS[status] ? t(STATUS_LABEL_KEYS[status]) : status}
</Badge>
);
}
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 (
<TableRow data-testid="users-table-row" data-user-id={user.id}>
{/* Email and display name are user data. */}
@@ -107,7 +105,11 @@ function UserRow({
</TableCell>
<TableCell className="text-xs">{user.display_name}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="text-[10px]">
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
className="text-[10px]"
data-testid="users-table-role"
>
{roleLabel(user.role, t)}
</Badge>
</TableCell>
@@ -115,63 +117,188 @@ function UserRow({
<StatusBadge status={user.status} />
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
onClick={() => onToggleRole(user)}
disabled={isLastActiveAdmin}
data-testid="users-table-toggle-role"
>
{/* Two whole-word variants get their own keys rather than
interpolating a role name into "Make {x}", which does not
survive languages that inflect the object. */}
{user.role === "admin" ? t("system:usersMakeMember") : t("system:usersMakeAdmin")}
</Button>
<Button
size="sm"
variant="ghost"
className="cursor-pointer text-destructive"
onClick={() => onToggleStatus(user)}
disabled={isLastActiveAdmin}
data-testid="users-table-toggle-status"
>
{isDisabled ? t("system:usersEnable") : t("system:usersDisable")}
</Button>
</div>
<UserActionRegion user={user} {...props} />
</TableCell>
</TableRow>
);
}
function UsersConfirmDialog({
function MobileUserRow({ user, ...props }: UserRowProps) {
const { t } = useTranslation();
return (
<article
className="space-y-3 rounded-md border p-3"
data-testid="users-table-row"
data-user-id={user.id}
>
{/* Identity and current state stay above the row-owned action region on phones. */}
<div className="min-w-0 space-y-1">
<p className="break-words text-xs font-medium" data-testid="users-table-email">
{user.email}
</p>
<p className="break-words text-xs text-muted-foreground">{user.display_name}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-muted-foreground">{t("system:usersColumnRole")}:</span>
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
className="text-[10px]"
data-testid="users-table-role"
>
{roleLabel(user.role, t)}
</Badge>
<span className="ml-1 text-muted-foreground">{t("system:usersColumnStatus")}:</span>
<StatusBadge status={user.status} />
</div>
<UserActionRegion user={user} {...props} />
</article>
);
}
function UserActionRegion({
user,
isLastActiveAdmin,
isFinePointer,
isMutating,
pending,
onToggleRole,
onToggleStatus,
onCancel,
onConfirm,
}: UserRowProps) {
const { t } = useTranslation();
const roleAnchorRef = useRef<HTMLButtonElement>(null);
const statusAnchorRef = useRef<HTMLButtonElement>(null);
const action = pending?.user.id === user.id ? pending : null;
const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef;
if (!isFinePointer && action) {
return (
<InlineConfirmActions
density="touch"
testId="users-table-inline-confirmation"
ariaLabel={action.label}
description={action.label}
cancelLabel={t("common:cancel")}
confirmLabel={t("system:usersConfirm")}
confirmAriaLabel={action.label}
confirmTestId="users-table-confirm"
onCancel={onCancel}
onClose={onCancel}
onConfirm={() => onConfirm(action)}
/>
);
}
return (
<>
<UserActionButtons
user={user}
isLastActiveAdmin={isLastActiveAdmin}
isFinePointer={isFinePointer}
roleAnchorRef={roleAnchorRef}
statusAnchorRef={statusAnchorRef}
onToggleRole={onToggleRole}
onToggleStatus={onToggleStatus}
isMutating={isMutating}
/>
{isFinePointer && action ? (
<UserConfirmPopover
action={action}
anchorRef={anchorRef}
onCancel={onCancel}
onConfirm={onConfirm}
/>
) : null}
</>
);
}
function UserActionButtons({
user,
isLastActiveAdmin,
isFinePointer,
isMutating,
roleAnchorRef,
statusAnchorRef,
onToggleRole,
onToggleStatus,
}: {
user: AuthUser;
isLastActiveAdmin: boolean;
isFinePointer: boolean;
isMutating: boolean;
roleAnchorRef: RefObject<HTMLButtonElement | null>;
statusAnchorRef: RefObject<HTMLButtonElement | null>;
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 (
<div
className={isFinePointer ? "flex items-center justify-end gap-1" : "grid grid-cols-2 gap-2"}
data-testid="users-table-action-region"
>
<Button
ref={roleAnchorRef}
size={isFinePointer ? "sm" : "default"}
variant="ghost"
className={roleButtonClassName}
onClick={() => onToggleRole(user)}
disabled={isLastActiveAdmin || isMutating}
data-testid="users-table-toggle-role"
>
{/* Two whole-word variants get their own keys rather than
interpolating a role name into "Make {x}", which does not
survive languages that inflect the object. */}
{user.role === "admin" ? t("system:usersMakeMember") : t("system:usersMakeAdmin")}
</Button>
<Button
ref={statusAnchorRef}
size={isFinePointer ? "sm" : "default"}
variant="ghost"
className={statusButtonClassName}
onClick={() => onToggleStatus(user)}
disabled={isLastActiveAdmin || isMutating}
data-testid="users-table-toggle-status"
>
{user.status === "disabled" ? t("system:usersEnable") : t("system:usersDisable")}
</Button>
</div>
);
}
function UserConfirmPopover({
action,
anchorRef,
onCancel,
onConfirm,
}: {
pending: PendingAction | null;
action: PendingAction;
anchorRef: RefObject<HTMLElement | null>;
onCancel: () => void;
onConfirm: () => void;
onConfirm: (action: PendingAction) => void;
}) {
const { t } = useTranslation();
return (
<AlertDialog open={pending !== null} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pending?.label}</AlertDialogTitle>
<AlertDialogDescription>
{t("system:usersTakesEffectImmediately", { email: pending?.user.email ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="cursor-pointer">
{t("system:usersConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ActionConfirmPopover
open
anchorRef={anchorRef}
title={action.label}
description={t("system:usersTakesEffectImmediately", { email: action.user.email })}
cancelLabel={t("common:cancel")}
confirmLabel={t("system:usersConfirm")}
confirmAriaLabel={action.label}
confirmTestId="users-table-confirm"
testId="users-table-confirm-popover"
onOpenChange={(open) => !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 (
<div className="space-y-3" data-testid="users-table-mobile-list">
{users.map((user) => (
<MobileUserRow key={user.id} {...rowProps(user)} />
))}
</div>
);
}
return (
<Table data-testid="users-table">
<TableHeader>
@@ -226,19 +385,9 @@ function UsersTableList({
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const isLastActiveAdmin =
user.role === "admin" && user.status === "active" && activeAdminCount === 1;
return (
<UserRow
key={user.id}
user={user}
isLastActiveAdmin={isLastActiveAdmin}
onToggleRole={onToggleRole}
onToggleStatus={onToggleStatus}
/>
);
})}
{users.map((user) => (
<UserRow key={user.id} {...rowProps(user)} />
))}
</TableBody>
</Table>
);
@@ -250,12 +399,18 @@ export function UsersTable() {
const [createOpen, setCreateOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [pending, setPending] = useState<PendingAction | null>(null);
const [activeMutation, setActiveMutation] = useState<PendingAction | null>(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()}
/>
<UsersConfirmDialog
pending={pending}
onCancel={() => setPending(null)}
onConfirm={() => void onConfirm()}
/>
</Card>
);
}
@@ -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();
});
});
@@ -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}`, {