// @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(); return { ...actual, useTranslation: () => ({ // Echo the interpolation too, so a message that forgets its {{email}} is visible in the test. t: (key: string, vars?: Record) => vars?.email ? `${key}:${String(vars.email)}` : key, i18n: { language: "en" }, }), }; }); type ConfirmOpts = { title?: string; message?: React.ReactNode; 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(); return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) }; }); const meMock = { pending_invites: 0, pending_activations: 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("./ui/Tabs", () => ({ default: () => null, usePersistedTab: () => ["access", () => {}], })); const listMock = vi.fn(); const deleteMock = vi.fn(); const inviteMock = vi.fn(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, adminInvites: () => listMock(), deleteInvite: (id: number) => deleteMock(id), sendInvitation: (id: number, lang: string) => inviteMock(id, lang), // The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it // renders nothing and this file stays about the delete affordance. adminPendingAccess: () => Promise.resolve([]), }, }; }); import { HttpError } from "../lib/api"; import AdminUsers from "./AdminUsers"; const invite = (over: Partial> = {}) => ({ 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, invited_at: null, lang: null, ...over, }); const renderPanel = () => { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); }; beforeEach(() => { confirmMock.mockClear().mockResolvedValue(true); notifyMock.mockClear(); deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false }); listMock.mockReset().mockResolvedValue([invite()]); inviteMock .mockReset() .mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" })); 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("keeps the same control on the previously-decided list, where the case is commonest", async () => { // The affordance exists twice — the pending rows and the collapsed "decided" list — and an // approved address that has since registered lands in the second one. Both must behave alike. listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); const btn = await screen.findByLabelText("settings.invites.deleteHasAccount"); expect(btn.getAttribute("aria-disabled")).toBe("true"); expect((btn as HTMLButtonElement).disabled).toBe(false); }); it("offers a working delete on a decided entry with no account", async () => { listMock.mockResolvedValue([invite({ status: "denied" })]); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1)); }); 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(); }); }); // Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has // usually already agreed it with the person, and a row can sit approved for weeks. So adding stays // silent and telling them is its own act, on its own button (E9.5 S6). describe("inviting someone the admin whitelisted", () => { // The decided rows live behind a
; await the summary rather than the section title, or // the click races the invite query that populates it. const open = async () => { renderPanel(); fireEvent.click(await screen.findByText(/settings.invites.decided/)); }; it("offers a send button on an approved row", async () => { listMock.mockResolvedValue([invite({ status: "approved" })]); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); // Falls back to the admin's own UI language: nobody ever told us anything about this mailbox. await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en")); expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }), ); }); it("asks first, and writes to nobody when the admin backs out", async () => { confirmMock.mockResolvedValue(false); listMock.mockResolvedValue([invite({ status: "approved" })]); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1)); expect(inviteMock).not.toHaveBeenCalled(); }); it("defaults to the language this person asked us in", async () => { listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu")); }); it("sends in the language picked in the dialog, not the default", async () => { // The dialog is the only place this decision can be made — and the pick has to survive back to // the caller, because the confirmation resolves to a plain boolean. let release: (ok: boolean) => void = () => {}; confirmMock.mockImplementation(() => new Promise((r) => (release = r))); listMock.mockResolvedValue([invite({ status: "approved" })]); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); await waitFor(() => expect(confirmMock).toHaveBeenCalled()); const dialog = render(<>{confirmMock.mock.calls[0]![0].message}); fireEvent.click(dialog.getByText("Magyar")); release(true); await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu")); }); it("warns in the dialog when this person has already been written to", async () => { // The warning used to live only in a tooltip, which the admin may never hover. It belongs next // to the button that would mail a real person a second time. listMock.mockResolvedValue([ invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }), ]); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); await waitFor(() => expect(confirmMock).toHaveBeenCalled()); const dialog = render(<>{confirmMock.mock.calls[0]![0].message}); expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain"); }); it("offers none on a denied row, which would contradict the decision", async () => { listMock.mockResolvedValue([invite({ status: "denied" })]); await open(); expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull(); }); it("offers none once somebody is already behind that address", async () => { // "The door is open, come in" is meaningless to someone already inside, and most of a mature // whitelist is existing users. An account that exists but is stuck has its own row above. listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]); await open(); expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull(); }); it("says the mail was never sent when the instance has no SMTP", async () => { listMock.mockResolvedValue([invite({ status: "approved" })]); inviteMock.mockRejectedValue( new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }), ); await open(); fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); // A button the admin pressed must not answer with a success toast for a mail nobody received. await waitFor(() => expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ message: "settings.invites.inviteEmailOff" }), ), ); }); });