feat: localize user mutation confirmations (#2878)
This commit is contained in:
@@ -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}`, {
|
||||
|
||||
Reference in New Issue
Block a user