diff --git a/components/admin/AccessManager.tsx b/components/admin/AccessManager.tsx index 1ad5544..84bd6e5 100644 --- a/components/admin/AccessManager.tsx +++ b/components/admin/AccessManager.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Search, X, ArrowDownToLine } from "lucide-react"; +import { toast } from "sonner"; import { selectionCsv, type SelectionExportRow, @@ -65,7 +66,6 @@ export default function AccessManager() { useEffect(() => { const controller = new AbortController(); setLoading(true); - setList(null); setError(""); const params = new URLSearchParams({ search: query, @@ -153,6 +153,9 @@ export default function AccessManager() { setConfirmation(null); setBatch(requests); setError(""); + const previousById = new Map( + previous.map((item) => [item.signupId, item.outcome]) + ); const merged = new Map(previous.map((item) => [item.signupId, item])); try { for (const request of retryableRequests(requests, previous)) { @@ -185,6 +188,58 @@ export default function AccessManager() { } setOutcomes([...merged.values()]); } + + const completed = [...merged.values()]; + const failedCount = completed.filter( + (item) => item.outcome === "failed" + ).length; + const ineligibleCount = completed.filter( + (item) => item.outcome === "ineligible" + ).length; + const action = requests[0]?.action; + + if (failedCount) { + setOutcomes(completed); + toast.error( + `${failedCount} ${failedCount === 1 ? "change needs" : "changes need"} another try.` + ); + } else { + const removableIds = new Set( + completed + .filter( + (item) => + item.outcome !== "ineligible" && + previousById.get(item.signupId) !== item.outcome + ) + .map((item) => item.signupId) + ); + setList((current) => + current + ? { + ...current, + rows: current.rows.filter((row) => !removableIds.has(row.id)), + total: Math.max(0, current.total - removableIds.size), + } + : current + ); + setBatch(null); + setOutcomes([]); + setSelected(new Set()); + setSelectionScope(null); + + if (ineligibleCount) { + toast.error( + `${ineligibleCount} selected ${ineligibleCount === 1 ? "person was" : "people were"} not eligible for this change.` + ); + } else if (action) { + const count = completed.length; + toast.success( + action === "approve" + ? `MCP access approved for ${count} ${count === 1 ? "person" : "people"}.` + : `MCP access revoked for ${count} ${count === 1 ? "person" : "people"}.` + ); + } + } } finally { mutationLock.current = false; setWorking(false); diff --git a/components/admin/TeamManager.tsx b/components/admin/TeamManager.tsx index 4dfb9d3..4bccff8 100644 --- a/components/admin/TeamManager.tsx +++ b/components/admin/TeamManager.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Plus, X } from "lucide-react"; +import { toast } from "sonner"; import SectionHeader from "@/components/console/SectionHeader"; import { Button } from "@/components/ui/button"; import { @@ -44,7 +45,6 @@ export default function TeamManager({ const [list, setList] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); const [reload, setReload] = useState(0); const [selected, setSelected] = useState(null); const [addOpen, setAddOpen] = useState(false); @@ -103,7 +103,6 @@ export default function TeamManager({ if (working || !email.trim()) return; setWorking(true); setAddError(""); - setNotice(""); try { const response = await fetch("/api/admin/team", { method: "POST", @@ -121,18 +120,31 @@ export default function TeamManager({ setAddOpen(false); setEmail(""); setAddError(""); - setNotice( + setList((current) => + current + ? { + members: [ + result.member, + ...current.members.filter( + (member) => member.grantId !== result.member.grantId + ), + ], + } + : current + ); + toast.success( result.outcome === "unchanged" ? `${result.member.email} is already an admin.` : `${result.member.email} was added as an admin.` ); setReload((value) => value + 1); } catch (cause) { - setAddError( + const message = cause instanceof Error ? cause.message - : "Could not add this administrator." - ); + : "Could not add this administrator."; + setAddError(message); + toast.error(message); } finally { setWorking(false); } @@ -142,7 +154,6 @@ export default function TeamManager({ if (working || !revokeTarget) return; setWorking(true); setRevokeError(""); - setNotice(""); try { const response = await fetch("/api/admin/team", { method: "DELETE", @@ -156,17 +167,29 @@ export default function TeamManager({ "Could not revoke administrator access." ) ); - setNotice(`${revokeTarget.email} no longer has administrator access.`); + setList((current) => + current + ? { + members: current.members.filter( + (member) => member.grantId !== revokeTarget.grantId + ), + } + : current + ); + toast.success( + `${revokeTarget.email} no longer has administrator access.` + ); setRevokeTarget(null); setRevokeError(""); setSelected(null); setReload((value) => value + 1); } catch (cause) { - setRevokeError( + const message = cause instanceof Error ? cause.message - : "Could not revoke administrator access." - ); + : "Could not revoke administrator access."; + setRevokeError(message); + toast.error(message); } finally { setWorking(false); } @@ -177,8 +200,9 @@ export default function TeamManager({ { setError(""); - setNotice(""); setAddError(""); setAddOpen(true); }} @@ -196,7 +219,10 @@ export default function TeamManager({ } /> -
+
{selectedMember && (
- {(error || notice) && ( -

- {error || notice} + {error && ( +

+ {error}

)}
-
+

{title}

{description && ( -

{description}

+

+ {description} +

)}
{action && ( diff --git a/tests/contracts/admin-access-table.test.tsx b/tests/contracts/admin-access-table.test.tsx index a7d6412..6b702d7 100644 --- a/tests/contracts/admin-access-table.test.tsx +++ b/tests/contracts/admin-access-table.test.tsx @@ -10,8 +10,13 @@ import { import { afterEach, expect, it, vi } from "vitest"; import AccessManager from "@/components/admin/AccessManager"; +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("sonner", () => ({ toast })); + afterEach(() => { cleanup(); + toast.success.mockReset(); + toast.error.mockReset(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -87,6 +92,65 @@ it("uses distinct, simplified MCP approval and revocation dialogs", async () => ); }); +it("settles a successful access change, refreshes the table, and shows a toast", async () => { + const id = "00000000-0000-4000-8000-000000000001"; + let approved = false; + const fetch = vi.fn(async (input: string, options?: RequestInit) => { + if (input === "/api/admin/access" && options?.method === "POST") { + const request = JSON.parse(String(options.body)); + approved = true; + return Response.json({ + requestId: request.requestId, + outcomes: [{ signupId: id, outcome: "approved" }], + }); + } + return Response.json({ + rows: approved + ? [] + : [ + { + id, + email: "alex@example.com", + waitlistStatus: "confirmed", + accessState: "pending", + joinedAt: "2026-09-04T00:00:00Z", + userId: null, + newsletterSubscribed: false, + }, + ], + total: approved ? 0 : 1, + page: 1, + pageSize: 50, + }); + }); + vi.stubGlobal("fetch", fetch); + render(); + + fireEvent.click( + await screen.findByRole("checkbox", { name: "Select alex@example.com" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Allow" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "Approve MCP access" }) + ); + + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith( + "MCP access approved for 1 person." + ) + ); + await screen.findByText("No matching entries."); + expect( + screen.queryByRole("region", { name: "Bulk action results" }) + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Allow" })).toBeNull(); + expect( + (screen.getByRole("button", { name: "Approved" }) as HTMLButtonElement) + .disabled + ).toBe(false); +}); + it("scopes actions and selections to the selected status section", async () => { const fetch = vi.fn(async () => Response.json({ diff --git a/tests/contracts/admin-team.test.tsx b/tests/contracts/admin-team.test.tsx index 5c38a18..27100ee 100644 --- a/tests/contracts/admin-team.test.tsx +++ b/tests/contracts/admin-team.test.tsx @@ -11,8 +11,13 @@ import { afterEach, expect, it, vi } from "vitest"; import TeamManager from "@/components/admin/TeamManager"; import type { AdminTeamMember } from "@/lib/platform/contracts"; +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("sonner", () => ({ toast })); + afterEach(() => { cleanup(); + toast.success.mockReset(); + toast.error.mockReset(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -60,6 +65,20 @@ it("adds and revokes administrators from the team section", async () => { await screen.findByRole("table", { name: "Admin team members" }); expect(await screen.findByText("teammate@example.com")).toBeTruthy(); + const description = screen.getByText( + "Admins can grant or revoke platform access, as well as add or remove other admins." + ); + expect(description.className).toContain("max-w-md"); + expect(description.parentElement?.className).toContain("min-w-0"); + const header = description.parentElement?.parentElement; + expect(header?.className).toContain("items-start"); + expect(header?.className).toContain("border-b"); + expect(header?.className).toContain("border-hairline"); + expect(header?.className).not.toContain("flex-wrap"); + const selectionToolbar = screen.getByTestId("team-selection-toolbar"); + expect(selectionToolbar.className).toContain("h-12"); + expect(selectionToolbar.className).toContain("mt-3"); + expect(selectionToolbar.className).not.toContain("border-b"); fireEvent.click( screen.getByRole("checkbox", { name: "Select me@example.com" }) @@ -81,6 +100,12 @@ it("adds and revokes administrators from the team section", async () => { ) ).toBe(true) ); + expect(toast.success).toHaveBeenCalledWith( + "teammate@example.com no longer has administrator access." + ); + await waitFor(() => + expect(screen.queryByText("teammate@example.com")).toBeNull() + ); fireEvent.click(screen.getByRole("button", { name: "Add admin" })); const addDialog = await screen.findByRole("dialog"); @@ -96,4 +121,7 @@ it("adds and revokes administrators from the team section", async () => { ).toBe(true) ); expect(await screen.findByText("new@example.com")).toBeTruthy(); + expect(toast.success).toHaveBeenCalledWith( + "new@example.com was added as an admin." + ); });