Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion components/admin/AccessManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,7 +66,6 @@ export default function AccessManager() {
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setList(null);
setError("");
const params = new URLSearchParams({
search: query,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 44 additions & 21 deletions components/admin/TeamManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -44,7 +45,6 @@ export default function TeamManager({
const [list, setList] = useState<AdminTeamList | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [reload, setReload] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
Expand Down Expand Up @@ -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",
Expand All @@ -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);
}
Expand All @@ -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",
Expand All @@ -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);
}
Expand All @@ -177,16 +200,16 @@ export default function TeamManager({
<SectionHeader
variant="default"
title={embedded ? "Administrators" : "Team"}
description="People with administrator access to Console."
className="mb-4 flex flex-wrap items-end justify-between gap-3"
description="Admins can grant or revoke platform access, as well as add or remove other admins."
descriptionClassName="max-w-md"
className="flex items-start justify-between gap-3 border-b border-hairline pb-4"
action={
<Button
type="button"
size="sm"
className="rounded-sm"
onClick={() => {
setError("");
setNotice("");
setAddError("");
setAddOpen(true);
}}
Expand All @@ -196,7 +219,10 @@ export default function TeamManager({
</Button>
}
/>
<div className="flex min-h-12 items-center justify-end gap-2 border-b border-hairline">
<div
className="mt-3 flex h-12 items-center justify-end gap-2"
data-testid="team-selection-toolbar"
>
{selectedMember && (
<button
type="button"
Expand Down Expand Up @@ -224,12 +250,9 @@ export default function TeamManager({
</Button>
)}
</div>
{(error || notice) && (
<p
className={`mt-4 text-sm ${error ? "text-red-500" : "text-fg-muted"}`}
role={error ? "alert" : "status"}
>
{error || notice}
{error && (
<p className="mt-4 text-sm text-red-500" role="alert">
{error}
</p>
)}
<div
Expand Down
11 changes: 9 additions & 2 deletions components/console/SectionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface SectionHeaderProps {
title: string;
/** Optional one-line description below the title. Only shown when `variant="default"`. */
description?: string;
/** Optional width or typography treatment for the description. */
descriptionClassName?: string;
/** Optional content rendered to the right of the title — typically a "View all →" link or a control. */
action?: ReactNode;
/** Optional count rendered as a mono pill next to the title (mono variant only). */
Expand Down Expand Up @@ -32,6 +34,7 @@ interface SectionHeaderProps {
export default function SectionHeader({
title,
description,
descriptionClassName,
action,
count,
variant = "mono",
Expand Down Expand Up @@ -74,10 +77,14 @@ export default function SectionHeader({
className ?? "mt-7 mb-3 flex items-end justify-between gap-3 first:mt-0"
}
>
<div>
<div className="min-w-0">
<h2 className={titleClass}>{title}</h2>
{description && (
<p className="mt-[3px] text-[12.5px] text-fg-faint">{description}</p>
<p
className={`mt-[3px] text-[12.5px] text-fg-faint ${descriptionClassName ?? ""}`}
>
{description}
</p>
)}
</div>
{action && (
Expand Down
64 changes: 64 additions & 0 deletions tests/contracts/admin-access-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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(<AccessManager />);

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({
Expand Down
Loading
Loading