// @vitest-environment jsdom // // The admin lifeline for a sign-up that is stuck outside (roadmap UX-25). Approve an address // BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests // screen was empty while they sat locked out and the only working move — re-typing the address into // the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly, // what it does NOT show: the section is a discovery surface, and a person the queue above already // covers must not appear twice. // // i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton 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: () => ({ t: (key: string, vars?: Record) => vars?.email ? `${key}:${String(vars.email)}` : key, i18n: { language: "en" }, }), }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true), })); 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 renders into an app-level toolbar slot that does not exist here; pin the tab. vi.mock("./ui/Tabs", () => ({ default: () => null })); vi.mock("../lib/storage", async (importOriginal) => ({ ...(await importOriginal()), useTabPersistedState: () => ["access", () => {}], })); const pendingMock = vi.fn(); const activateMock = vi.fn(); const resendMock = vi.fn(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, adminInvites: () => Promise.resolve([]), adminPendingAccess: () => pendingMock(), activateUser: (id: number) => activateMock(id), resendUserVerification: (id: number) => resendMock(id), }, }; }); import AdminUsers from "./AdminUsers"; /** A row as /api/admin/pending-access serves it. */ const row = (over: Record = {}) => ({ id: 7, email: "later@example.com", display_name: null, role: "user", is_active: false, is_suspended: false, email_verified: true, has_password: true, has_google: false, created_at: "2026-09-01T10:00:00Z", can_activate: true, ...over, }); function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); } beforeEach(() => { pendingMock.mockReset(); activateMock.mockReset().mockResolvedValue({}); resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" }); notifyMock.mockReset(); }); afterEach(cleanup); describe("the stuck sign-ups section", () => { it("offers Activate for a registrant whose address was already approved", async () => { pendingMock.mockResolvedValue([row()]); mount(); const button = await screen.findByText("users.pendingAccess.activate"); fireEvent.click(button); await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7)); expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }), ); }); it("shows no Activate when the address is not allowed, since that would be a different decision", async () => { // Activating an address with no approved invite makes an account that signs in with a password // but is turned away by Google — the whitelist box is the honest move for that. pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]); mount(); await screen.findByText("users.pendingAccess.emailUnconfirmed"); expect(screen.queryByText("users.pendingAccess.activate")).toBeNull(); }); it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => { pendingMock.mockResolvedValue([row({ email_verified: false })]); mount(); // On a no-SMTP instance this is the difference between the owner and whoever registered the // address first (roadmap SEC-15), so it belongs on the row, not in a tooltip. expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy(); expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy(); }); it("re-sends the confirmation link for an unconfirmed account", async () => { pendingMock.mockResolvedValue([row({ email_verified: false })]); mount(); fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend")); await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7)); }); it("offers no resend once the address is confirmed", async () => { pendingMock.mockResolvedValue([row()]); mount(); await screen.findByText("users.pendingAccess.activate"); expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull(); }); it("says the mail was never sent when the instance has no SMTP", async () => { const { HttpError } = await import("../lib/api"); pendingMock.mockResolvedValue([row({ email_verified: false })]); resendMock.mockRejectedValue( new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }), ); mount(); fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend")); // Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall // back on. A button the admin pressed must not answer with a success toast. `req` has already // localized the refusal into `detail` (lib/api/core); the toast carries it. await waitFor(() => expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ title: "users.pendingAccess.resendFailed", message: "errors.admin.email_off", }), ), ); }); it("renders nothing at all when nobody is stuck", async () => { pendingMock.mockResolvedValue([]); mount(); // The section withdraws itself rather than leaving an empty box to explain — so wait for it to // GO, not merely for the request to have been made. await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull()); expect(pendingMock).toHaveBeenCalled(); }); });