fix: warn before the delete, not after — plus round-2 copy and a11y corrections
Round 2 on the feature diff (15 → 12): - still_allowed_by_env now rides the invite LIST as well as the delete response, so the confirmation warns while the admin can still change their mind. It mattered most for a DENIED row on an env-allow-listed address: deleting it silently converted a refusal into a grant, and the only warning arrived after the irreversible click. - Both inbox bodies claimed to name "the most recent" dropped message. The throttle records the FIRST of each hour and never updates it, so they now say "one of them", and the not-configured body finally names where to fix it. - settings.invites.emailOff told the admin they "get no notice when someone signs up" — rendered directly above the queue that shows exactly that. Only the email is lost, and it now says so. - The disabled delete buttons put their reason out of reach: `disabled` drops a control from the tab order and suppresses its title. Switched to aria-disabled + preventDefault, the pattern Playlists.tsx already established for the same reason. - deleteInvite is quiet, so the 409 the UI handles with a translated toast no longer also raises the server's English detail as a modal. - After a completed password reset, Back landed on the reset form again with a consumed token — the history change made that state reachable. The token is cleared on success so the reset view stops being the card's root. - The hu deleteHasAccount named a tab that renders under a different label. Tests: a dom test for the Access-requests panel covering both has_account variants and all three delete outcomes, /api/me flag cases, an ADMIN_EMAILS case, and the env-allowlist test now patches the raw setting so the parsing stays under test.
This commit is contained in:
@@ -322,10 +322,17 @@ function AdminInvites() {
|
||||
if (conflict) refresh();
|
||||
},
|
||||
});
|
||||
const onDelete = async (i: { id: number; email: string }) => {
|
||||
const onDelete = async (i: Invite) => {
|
||||
const ok = await confirm({
|
||||
title: t("settings.invites.deleteTitle"),
|
||||
message: t("settings.invites.deleteConfirm", { email: i.email }),
|
||||
// Removing the row is not a revoke when the address is also in the server's env allow-lists —
|
||||
// say so while the admin can still change their mind, not in a toast after the fact.
|
||||
message: t(
|
||||
i.still_allowed_by_env
|
||||
? "settings.invites.deleteConfirmStillAllowed"
|
||||
: "settings.invites.deleteConfirm",
|
||||
{ email: i.email },
|
||||
),
|
||||
confirmLabel: t("settings.invites.delete"),
|
||||
danger: true,
|
||||
});
|
||||
@@ -410,9 +417,12 @@ function AdminInvites() {
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
user's access, since is_allowed reads this row. Delete the user instead. */}
|
||||
{i.has_account ? (
|
||||
// `disabled` would drop it from the tab order AND suppress the title, so the
|
||||
// reason reached nobody — same call as the Playlists drag handle.
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
|
||||
@@ -615,9 +625,11 @@ function InviteRow({
|
||||
instead — shown as a disabled control rather than omitted, or the admin sees a row that is
|
||||
silently missing a button their other rows have. */}
|
||||
{inv.has_account ? (
|
||||
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
|
||||
|
||||
@@ -309,7 +309,9 @@ function AuthCard() {
|
||||
// stay in the query string.
|
||||
const [params] = useState(() => new URLSearchParams(window.location.search));
|
||||
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
|
||||
const resetToken = hashParams.get("reset");
|
||||
// State, not a const: once the token has been spent the reset view must stop being the card's
|
||||
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => hashParams.get("reset"));
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
@@ -423,6 +425,7 @@ function AuthCard() {
|
||||
setDone(t("welcome.auth.resendDone"));
|
||||
} else if (mode === "reset") {
|
||||
await api.confirmPasswordReset(resetToken ?? "", password);
|
||||
setResetToken(null); // single-use: the reset view is no longer a place to come back to
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Access-requests panel's delete affordance and its three outcomes. The control's whole point is
|
||||
// that it explains itself: an entry backed by an account keeps a focusable, labelled control instead
|
||||
// of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the
|
||||
// irreversible click, not a correction after it.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify
|
||||
// singletons are stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type ConfirmOpts = { title?: string; message?: string; confirmLabel?: string; danger?: boolean };
|
||||
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => confirmMock,
|
||||
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = { pending_invites: 0, email_configured: true, id: 1, role: "admin" };
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
|
||||
vi.mock("./Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const listMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => listMock(),
|
||||
deleteInvite: (id: number) => deleteMock(id),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { HttpError } from "../lib/api";
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 1,
|
||||
email: "asker@example.com",
|
||||
status: "pending",
|
||||
note: null,
|
||||
requested_at: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
has_account: false,
|
||||
still_allowed_by_env: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const renderPanel = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
confirmMock.mockClear().mockResolvedValue(true);
|
||||
notifyMock.mockClear();
|
||||
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
|
||||
listMock.mockReset().mockResolvedValue([invite()]);
|
||||
meMock.email_configured = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const openAccessTab = async () => {
|
||||
await screen.findByText("settings.invites.title");
|
||||
};
|
||||
|
||||
describe("Access requests — the delete affordance", () => {
|
||||
it("offers a labelled delete on an entry with no account behind it", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com");
|
||||
expect(btn.getAttribute("aria-disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the control present and focusable — not missing — when the address has an account", async () => {
|
||||
listMock.mockResolvedValue([invite({ has_account: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
|
||||
// aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its
|
||||
// explanation with it, which is the whole reason the control is shown at all.
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount");
|
||||
expect(screen.queryByLabelText(/deleteLabel/)).toBeNull();
|
||||
});
|
||||
|
||||
it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => {
|
||||
listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirmStillAllowed:asker@example.com",
|
||||
danger: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the plain confirmation otherwise", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirm:asker@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the entry kept its access when the server reports the env allow-list", async () => {
|
||||
deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true });
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "warning",
|
||||
message: "settings.invites.deletedStillAllowed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns a 409 into the actionable reason rather than a generic failure", async () => {
|
||||
// The stale-list case: someone registered on the address after the page loaded.
|
||||
deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account"));
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: "settings.invites.deleteHasAccount",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the mail-is-off warning only while email is unconfigured", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(screen.queryByText("settings.invites.emailOff")).toBeNull();
|
||||
cleanup();
|
||||
|
||||
meMock.email_configured = false;
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user