From 95be04d50becebd6aeade4bcaead9b00a853ebd3 Mon Sep 17 00:00:00 2001 From: peace node Date: Tue, 8 Sep 2026 11:31:54 -0400 Subject: [PATCH 1/5] feat(admin): manage console admin team --- app/(app)/admin-preview/page.tsx | 46 +++ app/(app)/admin/page.tsx | 3 +- app/api/admin/team/route.ts | 61 +++ components/admin/AccessManager.tsx | 5 +- components/admin/AdminWorkspace.tsx | 25 +- components/admin/TeamManager.tsx | 407 ++++++++++++++++++++ lib/admin/http.ts | 2 +- lib/admin/team-routes.test.ts | 80 ++++ lib/admin/team.ts | 262 +++++++++++++ lib/console/dev-mock.ts | 78 +++- lib/platform/contracts.ts | 12 + tests/contracts/admin-access-table.test.tsx | 21 +- tests/contracts/admin-team.test.tsx | 99 +++++ 13 files changed, 1087 insertions(+), 14 deletions(-) create mode 100644 app/(app)/admin-preview/page.tsx create mode 100644 app/api/admin/team/route.ts create mode 100644 components/admin/TeamManager.tsx create mode 100644 lib/admin/team-routes.test.ts create mode 100644 lib/admin/team.ts create mode 100644 tests/contracts/admin-team.test.tsx diff --git a/app/(app)/admin-preview/page.tsx b/app/(app)/admin-preview/page.tsx new file mode 100644 index 0000000..f3c580c --- /dev/null +++ b/app/(app)/admin-preview/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from "next/navigation"; +import AccessManager from "@/components/admin/AccessManager"; +import AdminWorkspace from "@/components/admin/AdminWorkspace"; +import TeamManager from "@/components/admin/TeamManager"; + +export const dynamic = "force-dynamic"; + +export default function AdminPreviewPage() { + if ( + process.env.NODE_ENV === "production" || + process.env.CONSOLE_DEV_MOCK !== "1" + ) + notFound(); + + const summary = [ + ["Total signups", 1842], + ["Verified signups", 1376], + ["Total verified referrals", 492], + ["Newsletter opt-ins", 918], + ] as const; + + return ( +
+
+ }> +
+ {summary.map(([label, value]) => ( +
+
+ {label} +
+
+ {value.toLocaleString()} +
+
+ ))} +
+ +
+
+
+ ); +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx index 920cb14..1a03b0b 100644 --- a/app/(app)/admin/page.tsx +++ b/app/(app)/admin/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import AccessManager from "@/components/admin/AccessManager"; import AdminWorkspace from "@/components/admin/AdminWorkspace"; +import TeamManager from "@/components/admin/TeamManager"; import { getAdminWaitlistSummary } from "@/lib/waitlist/admin"; import { getAdminPrincipal } from "@/lib/admin/auth"; import { getAuthenticatedIdentity } from "@/lib/authentication/session"; @@ -22,7 +23,7 @@ export default async function AdminPage() { className="flex min-h-full flex-1 flex-col bg-dark text-fg" >
- + }>
{[ ["Total signups", summary.totalSignups], diff --git a/app/api/admin/team/route.ts b/app/api/admin/team/route.ts new file mode 100644 index 0000000..dc5301a --- /dev/null +++ b/app/api/admin/team/route.ts @@ -0,0 +1,61 @@ +import { getAdminPrincipal } from "@/lib/admin/auth"; +import { apiError, requireSameOrigin } from "@/lib/admin/http"; +import { + addAdmin, + addAdminSchema, + listAdminTeam, + revokeAdmin, + revokeAdminSchema, +} from "@/lib/admin/team"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const actor = await getAdminPrincipal(); + if (!actor) + return Response.json({ error: "admin_required" }, { status: 403 }); + return Response.json(await listAdminTeam(actor), { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return apiError(error); + } +} + +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const actor = await getAdminPrincipal(); + if (!actor) + return Response.json({ error: "admin_required" }, { status: 403 }); + if (Number(request.headers.get("content-length") ?? 0) > 1_000) + return Response.json({ error: "invalid_admin_email" }, { status: 400 }); + const parsed = addAdminSchema.safeParse(await request.json()); + if (!parsed.success) + return Response.json({ error: "invalid_admin_email" }, { status: 400 }); + return Response.json(await addAdmin(actor, parsed.data), { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return apiError(error); + } +} + +export async function DELETE(request: Request) { + try { + requireSameOrigin(request); + const actor = await getAdminPrincipal(); + if (!actor) + return Response.json({ error: "admin_required" }, { status: 403 }); + const parsed = revokeAdminSchema.safeParse(await request.json()); + if (!parsed.success) + return Response.json({ error: "invalid_admin_grant" }, { status: 400 }); + return Response.json(await revokeAdmin(actor, parsed.data.grantId), { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return apiError(error); + } +} diff --git a/components/admin/AccessManager.tsx b/components/admin/AccessManager.tsx index 3919e78..6077a7c 100644 --- a/components/admin/AccessManager.tsx +++ b/components/admin/AccessManager.tsx @@ -402,7 +402,10 @@ export default function AccessManager() { )}
("Waitlist"); const tabs = useRef<(HTMLButtonElement | null)[]>([]); return ( @@ -42,8 +48,11 @@ export default function AdminWorkspace({ children }: { children: ReactNode }) { event.key === "Home" ? 0 : event.key === "End" - ? 1 - : 1 - index; + ? sections.length - 1 + : (index + + (event.key === "ArrowRight" ? 1 : -1) + + sections.length) % + sections.length; setActive(sections[next]); tabs.current[next]?.focus(); }} @@ -54,6 +63,14 @@ export default function AdminWorkspace({ children }: { children: ReactNode }) { ))} +
= { + admin_account_not_eligible: + "That email must belong to an active, verified Console account before it can be added as an admin.", + admin_access_revoked: + "That account’s Console access is revoked. Restore Console access before adding it as an admin.", + cannot_revoke_self: "You can’t revoke your own administrator access.", + admin_member_not_found: "That team member is no longer an active admin.", + admin_required: + "Your administrator session is unavailable. Sign in to Console again.", +}; + +async function responseError(response: Response, fallback: string) { + try { + const body = (await response.json()) as { error?: string }; + return (body.error && ERROR_MESSAGES[body.error]) || fallback; + } catch { + return fallback; + } +} + +export default function TeamManager({ + embedded = false, +}: { + embedded?: boolean; +}) { + 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); + const [email, setEmail] = useState(""); + const [addError, setAddError] = useState(""); + const [revokeTarget, setRevokeTarget] = useState( + null + ); + const [revokeError, setRevokeError] = useState(""); + const [working, setWorking] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void fetch("/api/admin/team", { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) + throw new Error( + await responseError(response, "Could not load the admin team.") + ); + const result = (await response.json()) as AdminTeamList; + if (!Array.isArray(result.members)) + throw new Error("Could not load the admin team."); + if (!controller.signal.aborted) { + setList(result); + setSelected((current) => + result.members.some((item) => item.grantId === current) + ? current + : null + ); + } + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setError( + cause instanceof Error + ? cause.message + : "Could not load the admin team." + ); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [reload]); + + const selectedMember = + list?.members.find((item) => item.grantId === selected) ?? null; + + async function submitAdmin(event: React.FormEvent) { + event.preventDefault(); + if (working || !email.trim()) return; + setWorking(true); + setAddError(""); + setNotice(""); + try { + const response = await fetch("/api/admin/team", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim() }), + }); + if (!response.ok) + throw new Error( + await responseError(response, "Could not add this administrator.") + ); + const result = (await response.json()) as { + member: AdminTeamMember; + outcome: "added" | "restored" | "unchanged"; + }; + setAddOpen(false); + setEmail(""); + setAddError(""); + setNotice( + 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( + cause instanceof Error + ? cause.message + : "Could not add this administrator." + ); + } finally { + setWorking(false); + } + } + + async function confirmRevoke() { + if (working || !revokeTarget) return; + setWorking(true); + setRevokeError(""); + setNotice(""); + try { + const response = await fetch("/api/admin/team", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grantId: revokeTarget.grantId }), + }); + if (!response.ok) + throw new Error( + await responseError( + response, + "Could not revoke administrator access." + ) + ); + setNotice(`${revokeTarget.email} no longer has administrator access.`); + setRevokeTarget(null); + setRevokeError(""); + setSelected(null); + setReload((value) => value + 1); + } catch (cause) { + setRevokeError( + cause instanceof Error + ? cause.message + : "Could not revoke administrator access." + ); + } finally { + setWorking(false); + } + } + + return ( +
+ { + setError(""); + setNotice(""); + setAddError(""); + setAddOpen(true); + }} + > +
+ + + + + + + + + + + + + + {list?.members.map((item) => ( + + + + + + ))} + {(!list || !list.members.length) && ( + + + + )} + +
SelectionEmailAdded
+ + setSelected(event.target.checked ? item.grantId : null) + } + /> + + {item.email} + {item.isCurrentUser && ( + You + )} + + {new Date(item.grantedAt).toLocaleDateString()} +
+ {loading + ? "Loading team…" + : error + ? "Team unavailable." + : "No administrators found."} +
+
+ + { + if (!working) { + setAddOpen(open); + if (!open) setAddError(""); + } + }} + > + +
+ + Add an admin + + Enter the email for an active, verified Console account. This + person will be able to manage Console access and the admin team. + + +
+ + setEmail(event.target.value)} + /> + {addError && ( +

+ {addError} +

+ )} +
+ + + + +
+
+
+ + { + if (!open && !working) { + setRevokeTarget(null); + setRevokeError(""); + } + }} + > + + + Revoke administrator access? + + {revokeTarget?.email} will immediately lose access to this admin + area. Their regular Console access will not be changed. + + + {revokeError && ( +

+ {revokeError} +

+ )} + + + + +
+
+
+ ); +} diff --git a/lib/admin/http.ts b/lib/admin/http.ts index 3f4a202..556d333 100644 --- a/lib/admin/http.ts +++ b/lib/admin/http.ts @@ -6,7 +6,7 @@ export function requireSameOrigin(request: Request) { } export function apiError(error: unknown) { const typed = error as { status?: number; code?: string }; - const status = [400, 401, 403, 409, 503].includes(typed?.status ?? 0) + const status = [400, 401, 403, 404, 409, 503].includes(typed?.status ?? 0) ? typed.status! : 503; return Response.json( diff --git a/lib/admin/team-routes.test.ts b/lib/admin/team-routes.test.ts new file mode 100644 index 0000000..92f24bc --- /dev/null +++ b/lib/admin/team-routes.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/admin/auth", () => ({ getAdminPrincipal: vi.fn() })); +vi.mock("@/lib/admin/team", async (importOriginal) => ({ + ...(await importOriginal()), + listAdminTeam: vi.fn(), + addAdmin: vi.fn(), + revokeAdmin: vi.fn(), +})); + +import { getAdminPrincipal } from "@/lib/admin/auth"; +import { addAdmin, listAdminTeam, revokeAdmin } from "@/lib/admin/team"; +import { DELETE, GET, POST } from "@/app/api/admin/team/route"; + +const actor = { + adminGrantId: "00000000-0000-4000-8000-000000000001", + signupId: "00000000-0000-4000-8000-000000000002", + userId: "00000000-0000-4000-8000-000000000003", +}; +const origin = "https://console.example.invalid"; + +function mutation(method: "POST" | "DELETE", body: unknown, from = origin) { + return new Request(`${origin}/api/admin/team`, { + method, + headers: { origin: from, "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("admin team route", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getAdminPrincipal).mockResolvedValue(actor); + }); + + it("requires an administrator for the team list", async () => { + vi.mocked(getAdminPrincipal).mockResolvedValue(null); + expect((await GET()).status).toBe(403); + expect(listAdminTeam).not.toHaveBeenCalled(); + }); + + it("validates email and derives the adding actor from the session", async () => { + expect( + (await POST(mutation("POST", { email: "not-an-email" }))).status + ).toBe(400); + expect(addAdmin).not.toHaveBeenCalled(); + + vi.mocked(addAdmin).mockResolvedValue({ + member: { + grantId: "00000000-0000-4000-8000-000000000004", + signupId: "00000000-0000-4000-8000-000000000005", + email: "new@example.com", + grantedAt: "2026-09-08T00:00:00.000Z", + isCurrentUser: false, + }, + outcome: "added", + }); + const response = await POST( + mutation("POST", { email: " new@example.com " }) + ); + expect(response.status).toBe(200); + expect(addAdmin).toHaveBeenCalledWith(actor, { email: "new@example.com" }); + }); + + it("rejects cross-site revocation and prevents body-supplied authority", async () => { + const grantId = "00000000-0000-4000-8000-000000000004"; + expect( + (await DELETE(mutation("DELETE", { grantId }, "https://evil.invalid"))) + .status + ).toBe(403); + expect(getAdminPrincipal).not.toHaveBeenCalled(); + + expect( + (await DELETE(mutation("DELETE", { grantId, actor: "body-admin" }))) + .status + ).toBe(400); + expect(revokeAdmin).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/admin/team.ts b/lib/admin/team.ts new file mode 100644 index 0000000..689ebe3 --- /dev/null +++ b/lib/admin/team.ts @@ -0,0 +1,262 @@ +import "server-only"; + +import { and, asc, eq, isNotNull, isNull, or } from "drizzle-orm"; +import { z } from "zod"; +import { getDb } from "@/lib/db"; +import { + accessGrants, + adminRoleGrants, + userEmails, + users, + waitlistSignups, +} from "@/lib/db/schema"; +import type { + AdminPrincipal, + AdminTeamList, + AdminTeamMember, +} from "@/lib/platform/contracts"; +import { normalizeEmail } from "@/lib/waitlist/security"; +import { getAdminPrincipalForUser } from "./permissions"; + +export const addAdminSchema = z + .object({ email: z.string().trim().email().max(320) }) + .strict(); + +export const revokeAdminSchema = z + .object({ grantId: z.string().uuid() }) + .strict(); + +export class AdminTeamError extends Error { + constructor( + readonly status: number, + readonly code: string + ) { + super(code); + this.name = "AdminTeamError"; + } +} + +type TeamDb = Parameters< + Parameters["transaction"]>[0] +>[0]; + +async function requireActiveActor(actor: AdminPrincipal, db: TeamDb) { + const [row] = await db + .select({ userId: waitlistSignups.userId }) + .from(adminRoleGrants) + .innerJoin( + waitlistSignups, + eq(waitlistSignups.id, adminRoleGrants.signupId) + ) + .where( + and( + eq(adminRoleGrants.id, actor.adminGrantId), + eq(adminRoleGrants.signupId, actor.signupId), + isNull(adminRoleGrants.revokedAt) + ) + ) + .limit(1); + if (!row?.userId) throw new AdminTeamError(403, "admin_required"); + const principal = await getAdminPrincipalForUser(row.userId, db); + if ( + !principal || + principal.adminGrantId !== actor.adminGrantId || + principal.signupId !== actor.signupId + ) + throw new AdminTeamError(403, "admin_required"); +} + +function member( + row: { + grantId: string; + signupId: string; + email: string; + grantedAt: Date; + }, + actor: AdminPrincipal +): AdminTeamMember { + return { + ...row, + grantedAt: row.grantedAt.toISOString(), + isCurrentUser: row.grantId === actor.adminGrantId, + }; +} + +export async function listAdminTeam( + actor: AdminPrincipal +): Promise { + const rows = await getDb() + .select({ + grantId: adminRoleGrants.id, + signupId: waitlistSignups.id, + email: waitlistSignups.email, + grantedAt: adminRoleGrants.grantedAt, + }) + .from(adminRoleGrants) + .innerJoin( + waitlistSignups, + eq(waitlistSignups.id, adminRoleGrants.signupId) + ) + .innerJoin(users, eq(users.id, waitlistSignups.userId)) + .innerJoin( + userEmails, + and( + eq(userEmails.userId, users.id), + eq(userEmails.normalizedEmail, waitlistSignups.normalizedEmail), + isNotNull(userEmails.verifiedAt) + ) + ) + .where( + and( + eq(adminRoleGrants.role, "admin"), + isNull(adminRoleGrants.revokedAt), + eq(waitlistSignups.status, "confirmed"), + isNotNull(waitlistSignups.confirmedAt), + eq(users.status, "active") + ) + ) + .orderBy(asc(waitlistSignups.normalizedEmail)); + return { members: rows.map((row) => member(row, actor)) }; +} + +export async function addAdmin( + actor: AdminPrincipal, + input: z.infer +): Promise<{ + member: AdminTeamMember; + outcome: "added" | "restored" | "unchanged"; +}> { + const parsed = addAdminSchema.safeParse(input); + if (!parsed.success) throw new AdminTeamError(400, "invalid_admin_email"); + const normalizedEmail = normalizeEmail(parsed.data.email); + + return getDb().transaction(async (tx) => { + await requireActiveActor(actor, tx); + const [target] = await tx + .select({ + signupId: waitlistSignups.id, + userId: users.id, + email: waitlistSignups.email, + }) + .from(waitlistSignups) + .innerJoin(users, eq(users.id, waitlistSignups.userId)) + .innerJoin( + userEmails, + and( + eq(userEmails.userId, users.id), + eq(userEmails.normalizedEmail, waitlistSignups.normalizedEmail), + isNotNull(userEmails.verifiedAt) + ) + ) + .where( + and( + eq(waitlistSignups.normalizedEmail, normalizedEmail), + eq(waitlistSignups.status, "confirmed"), + isNotNull(waitlistSignups.confirmedAt), + eq(users.status, "active") + ) + ) + .limit(1); + if (!target) throw new AdminTeamError(409, "admin_account_not_eligible"); + + const [revokedAccess] = await tx + .select({ id: accessGrants.id }) + .from(accessGrants) + .where( + and( + eq(accessGrants.status, "revoked"), + or( + eq(accessGrants.signupId, target.signupId), + eq(accessGrants.userId, target.userId) + ) + ) + ) + .limit(1); + if (revokedAccess) throw new AdminTeamError(409, "admin_access_revoked"); + + const [existing] = await tx + .select() + .from(adminRoleGrants) + .where( + and( + eq(adminRoleGrants.signupId, target.signupId), + eq(adminRoleGrants.role, "admin") + ) + ) + .limit(1) + .for("update"); + const now = new Date(); + let grant = existing; + let outcome: "added" | "restored" | "unchanged" = "unchanged"; + if (!grant) { + [grant] = await tx + .insert(adminRoleGrants) + .values({ signupId: target.signupId, source: "admin_team" }) + .onConflictDoNothing() + .returning(); + if (grant) outcome = "added"; + else + [grant] = await tx + .select() + .from(adminRoleGrants) + .where( + and( + eq(adminRoleGrants.signupId, target.signupId), + eq(adminRoleGrants.role, "admin") + ) + ) + .limit(1) + .for("update"); + } + if (!grant) throw new AdminTeamError(503, "admin_team_update_unavailable"); + if (grant.revokedAt) { + [grant] = await tx + .update(adminRoleGrants) + .set({ revokedAt: null, source: "admin_team" }) + .where(eq(adminRoleGrants.id, grant.id)) + .returning(); + outcome = "restored"; + } + + return { + member: member( + { + grantId: grant.id, + signupId: target.signupId, + email: target.email, + grantedAt: grant.grantedAt ?? now, + }, + actor + ), + outcome, + }; + }); +} + +export async function revokeAdmin(actor: AdminPrincipal, grantId: string) { + const parsed = revokeAdminSchema.safeParse({ grantId }); + if (!parsed.success) throw new AdminTeamError(400, "invalid_admin_grant"); + if (grantId === actor.adminGrantId) + throw new AdminTeamError(409, "cannot_revoke_self"); + + return getDb().transaction(async (tx) => { + await requireActiveActor(actor, tx); + const [grant] = await tx + .select({ id: adminRoleGrants.id }) + .from(adminRoleGrants) + .where( + and( + eq(adminRoleGrants.id, grantId), + eq(adminRoleGrants.role, "admin"), + isNull(adminRoleGrants.revokedAt) + ) + ) + .limit(1); + if (!grant) throw new AdminTeamError(404, "admin_member_not_found"); + await tx + .update(adminRoleGrants) + .set({ revokedAt: new Date() }) + .where(eq(adminRoleGrants.id, grant.id)); + return { grantId: grant.id, outcome: "revoked" as const }; + }); +} diff --git a/lib/console/dev-mock.ts b/lib/console/dev-mock.ts index 2d34626..c1deaa6 100644 --- a/lib/console/dev-mock.ts +++ b/lib/console/dev-mock.ts @@ -491,7 +491,7 @@ export function devMockResponse( name: "Design Preview", email: MOCK_EMAIL, provider: "google", - isAdmin: false, + isAdmin: true, }); } // Auth0's client `useUser()` reads this; a body here makes the app "signed in". @@ -512,6 +512,82 @@ export function devMockResponse( return devRedirect(search.get("returnTo"), requestUrl); } + if (pathname === "/api/admin/access") { + const state = search.get("state") ?? "waiting"; + const fixtures = { + approved: [ + ["alex@livepeer.org", "2026-07-18T14:22:00.000Z"], + ["samira@daydream.live", "2026-08-02T09:14:00.000Z"], + ], + waiting: [ + ["jordan@studio.example", "2026-09-08T13:41:00.000Z"], + ["maya@video.example", "2026-09-07T18:09:00.000Z"], + ["devon@creative.example", "2026-09-06T11:32:00.000Z"], + ], + subscribed: [["newsletter@stream.example", "2026-09-04T17:04:00.000Z"]], + unverified: [["pending@creator.example", "2026-09-08T15:18:00.000Z"]], + } as const; + const rows = fixtures[state as keyof typeof fixtures] ?? fixtures.waiting; + return json({ + rows: rows.map(([email, joinedAt], index) => ({ + id: `00000000-0000-4000-8000-${String(index + 100).padStart(12, "0")}`, + email, + waitlistStatus: state === "unverified" ? "pending" : "confirmed", + accessState: state === "approved" ? "approved" : "pending", + joinedAt, + userId: null, + newsletterSubscribed: state === "subscribed", + })), + total: rows.length, + page: 1, + pageSize: 50, + }); + } + + if (pathname === "/api/admin/team") { + return json({ + members: [ + { + grantId: "00000000-0000-4000-8000-000000000201", + signupId: "00000000-0000-4000-8000-000000000301", + email: MOCK_EMAIL, + grantedAt: "2026-06-12T12:00:00.000Z", + isCurrentUser: true, + }, + { + grantId: "00000000-0000-4000-8000-000000000202", + signupId: "00000000-0000-4000-8000-000000000302", + email: "operations@livepeer.org", + grantedAt: "2026-07-03T15:30:00.000Z", + isCurrentUser: false, + }, + { + grantId: "00000000-0000-4000-8000-000000000203", + signupId: "00000000-0000-4000-8000-000000000303", + email: "studio@livepeer.org", + grantedAt: "2026-08-21T09:10:00.000Z", + isCurrentUser: false, + }, + ], + }); + } + + if (pathname === "/api/admin/runs") { + return json({ + items: [], + nextCursor: null, + counts: { + total: 0, + succeeded: 0, + failed: 0, + queued: 0, + running: 0, + unknown: 0, + cancelled: 0, + }, + }); + } + if (pathname === "/api/pymthouse/account-usage") { const rawDays = Number.parseInt(search.get("days") ?? "", 10); const periodDays = diff --git a/lib/platform/contracts.ts b/lib/platform/contracts.ts index 43a52e7..eaef315 100644 --- a/lib/platform/contracts.ts +++ b/lib/platform/contracts.ts @@ -85,6 +85,18 @@ export type AdminAccessList = { pageSize: number; }; +export type AdminTeamMember = { + grantId: string; + signupId: string; + email: string; + grantedAt: string; + isCurrentUser: boolean; +}; + +export type AdminTeamList = { + members: AdminTeamMember[]; +}; + /** Backend-resolved profile; clients must never derive external account IDs. */ export type ConsoleSessionProfile = { userId: string; diff --git a/tests/contracts/admin-access-table.test.tsx b/tests/contracts/admin-access-table.test.tsx index 2a0a283..17308c1 100644 --- a/tests/contracts/admin-access-table.test.tsx +++ b/tests/contracts/admin-access-table.test.tsx @@ -36,6 +36,7 @@ it("scopes actions and selections to the selected status section", async () => { ); vi.stubGlobal("fetch", fetch); render(); + fireEvent.click(screen.getByRole("button", { name: "Waitlist" })); const selected = await screen.findByRole("checkbox", { name: "Select alex@example.com", }); @@ -53,9 +54,7 @@ it("scopes actions and selections to the selected status section", async () => { expect( screen.getByRole("group", { name: "Selection actions" }).className ).toContain("h-12"); - expect( - screen.queryByRole("button", { name: "Refresh list" }) - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Refresh list" })).toBeNull(); expect(screen.queryByRole("button", { name: "Allow" })).toBeNull(); expect(screen.queryByRole("button", { name: "Clear selection" })).toBeNull(); expect(screen.queryByRole("button", { name: "Export CSV" })).toBeNull(); @@ -95,7 +94,10 @@ it("scopes actions and selections to the selected status section", async () => { expect(selected.className).toContain("checked:bg-black"); const csvButton = screen.getByRole("button", { name: "Export CSV" }); expect(csvButton.textContent).toBe(".csv"); - expect(screen.getByRole("group", { name: "Selection actions" }).lastElementChild?.textContent).toBe("Allow"); + expect( + screen.getByRole("group", { name: "Selection actions" }).lastElementChild + ?.textContent + ).toBe("Allow"); expect( csvButton.querySelector("svg.lucide-arrow-down-to-line") ).not.toBeNull(); @@ -118,8 +120,13 @@ it("scopes actions and selections to the selected status section", async () => { await screen.findByRole("checkbox", { name: "Select alex@example.com" }) ); expect(screen.getByRole("button", { name: "Revoke selected" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Revoke selected" }).className).toContain("border-border"); - expect(screen.getByRole("group", { name: "Selection actions" }).lastElementChild?.textContent).toBe("Revoke selected"); + expect( + screen.getByRole("button", { name: "Revoke selected" }).className + ).toContain("border-border"); + expect( + screen.getByRole("group", { name: "Selection actions" }).lastElementChild + ?.textContent + ).toBe("Revoke selected"); fireEvent.click(screen.getByRole("button", { name: "Waitlist" })); await waitFor(() => expect(fetch).toHaveBeenLastCalledWith( @@ -167,6 +174,7 @@ it("lets vertical scrolling pass through the horizontally scrollable table", asy ) ); render(); + fireEvent.click(screen.getByRole("button", { name: "Waitlist" })); await screen.findByText("No matching entries."); const wrapper = screen.getByRole("table", { name: "Access entries", @@ -226,6 +234,7 @@ it("selects across pages and exports only the frozen IDs in bounded chunks", asy vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); vi.stubGlobal("fetch", fetch); render(); + fireEvent.click(screen.getByRole("button", { name: "Waitlist" })); await screen.findByRole("checkbox", { name: "Select alex@example.com" }); fireEvent.click(screen.getByRole("checkbox", { name: "Select all" })); await waitFor(() => diff --git a/tests/contracts/admin-team.test.tsx b/tests/contracts/admin-team.test.tsx new file mode 100644 index 0000000..5c38a18 --- /dev/null +++ b/tests/contracts/admin-team.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import TeamManager from "@/components/admin/TeamManager"; +import type { AdminTeamMember } from "@/lib/platform/contracts"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const members: AdminTeamMember[] = [ + { + grantId: "00000000-0000-4000-8000-000000000001", + signupId: "00000000-0000-4000-8000-000000000011", + email: "me@example.com", + grantedAt: "2026-09-01T00:00:00.000Z", + isCurrentUser: true, + }, + { + grantId: "00000000-0000-4000-8000-000000000002", + signupId: "00000000-0000-4000-8000-000000000012", + email: "teammate@example.com", + grantedAt: "2026-09-02T00:00:00.000Z", + isCurrentUser: false, + }, +]; + +it("adds and revokes administrators from the team section", async () => { + let active = [...members]; + const fetch = vi.fn(async (input: string, init?: RequestInit) => { + if (init?.method === "POST") { + const added: AdminTeamMember = { + grantId: "00000000-0000-4000-8000-000000000003", + signupId: "00000000-0000-4000-8000-000000000013", + email: JSON.parse(String(init.body)).email, + grantedAt: "2026-09-08T00:00:00.000Z", + isCurrentUser: false, + }; + active.push(added); + return Response.json({ member: added, outcome: "added" }); + } + if (init?.method === "DELETE") { + const { grantId } = JSON.parse(String(init.body)); + active = active.filter((item) => item.grantId !== grantId); + return Response.json({ grantId, outcome: "revoked" }); + } + return Response.json({ members: active }); + }); + vi.stubGlobal("fetch", fetch); + render(); + + await screen.findByRole("table", { name: "Admin team members" }); + expect(await screen.findByText("teammate@example.com")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("checkbox", { name: "Select me@example.com" }) + ); + expect(screen.queryByRole("button", { name: "Revoke access" })).toBeNull(); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select teammate@example.com" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Revoke access" })); + const revokeDialog = await screen.findByRole("dialog"); + expect(revokeDialog.textContent).toContain("teammate@example.com"); + fireEvent.click( + within(revokeDialog).getByRole("button", { name: "Revoke access" }) + ); + await waitFor(() => + expect( + fetch.mock.calls.some( + ([url, init]) => url === "/api/admin/team" && init?.method === "DELETE" + ) + ).toBe(true) + ); + + fireEvent.click(screen.getByRole("button", { name: "Add admin" })); + const addDialog = await screen.findByRole("dialog"); + fireEvent.change(within(addDialog).getByLabelText("Email address"), { + target: { value: "new@example.com" }, + }); + fireEvent.click(within(addDialog).getByRole("button", { name: "Add admin" })); + await waitFor(() => + expect( + fetch.mock.calls.some( + ([url, init]) => url === "/api/admin/team" && init?.method === "POST" + ) + ).toBe(true) + ); + expect(await screen.findByText("new@example.com")).toBeTruthy(); +}); From 3f55432a91a0e48fabfd8d76ababfe7290aede5a Mon Sep 17 00:00:00 2001 From: peace node Date: Tue, 8 Sep 2026 11:34:07 -0400 Subject: [PATCH 2/5] feat(admin): redesign MCP access confirmations --- components/admin/AccessManager.tsx | 95 ++++++++++----------- tests/contracts/admin-access-table.test.tsx | 62 ++++++++++++++ 2 files changed, 109 insertions(+), 48 deletions(-) diff --git a/components/admin/AccessManager.tsx b/components/admin/AccessManager.tsx index 6077a7c..570a396 100644 --- a/components/admin/AccessManager.tsx +++ b/components/admin/AccessManager.tsx @@ -242,6 +242,12 @@ export default function AccessManager() { selected.size > 0 && selectionScope === JSON.stringify([filter, query]); const failed = outcomes.filter((item) => item.outcome === "failed").length; const pages = Math.max(1, Math.ceil((list?.total ?? 0) / 50)); + const confirmationCount = + confirmation?.reduce( + (total, request) => total + request.signupIds.length, + 0 + ) ?? 0; + const isApproving = confirmation?.[0]?.action === "approve"; return (
@@ -552,54 +558,47 @@ export default function AccessManager() { if (!open) setConfirmation(null); }} > - - - {confirmation?.[0]?.action === "approve" ? "Approve" : "Revoke"}{" "} - {confirmation?.reduce( - (total, request) => total + request.signupIds.length, - 0 - )}{" "} - selected entries? - - - This is a frozen selection of record IDs, not a live filter. - Approval invitations are transactional. Revocation blocks subsequent - protected requests; it does not cancel running external jobs. - -
- - Review exact selected records - -
    - {confirmation - ?.flatMap((request) => request.signupIds) - .map((id) => ( -
  • - {labels.current.get(id) - ? `${labels.current.get(id)} · ` - : ""} - {id} -
  • - ))} -
-
-
- - + +
+ +
+
+
+ + {isApproving ? "Approve MCP access?" : "Revoke MCP access?"} + + + {isApproving + ? `Give ${confirmationCount} selected ${confirmationCount === 1 ? "person" : "people"} access to Livepeer through MCP.` + : `Remove MCP access for ${confirmationCount} selected ${confirmationCount === 1 ? "person" : "people"}. Running jobs won’t be stopped.`} + +
+
+ + +
diff --git a/tests/contracts/admin-access-table.test.tsx b/tests/contracts/admin-access-table.test.tsx index 17308c1..9836db1 100644 --- a/tests/contracts/admin-access-table.test.tsx +++ b/tests/contracts/admin-access-table.test.tsx @@ -5,6 +5,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import AccessManager from "@/components/admin/AccessManager"; @@ -15,6 +16,67 @@ afterEach(() => { vi.restoreAllMocks(); }); +it("uses distinct, simplified MCP approval and revocation dialogs", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + rows: [ + { + id: "00000000-0000-4000-8000-000000000001", + email: "alex@example.com", + waitlistStatus: "confirmed", + accessState: "pending", + joinedAt: "2026-09-04T00:00:00Z", + userId: null, + newsletterSubscribed: false, + }, + ], + total: 1, + page: 1, + pageSize: 50, + }) + ) + ); + render(); + fireEvent.click( + await screen.findByRole("checkbox", { name: "Select alex@example.com" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Allow" })); + + let dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Approve MCP access?")).toBeTruthy(); + expect(dialog.textContent).toContain( + "Give 1 selected person access to Livepeer through MCP." + ); + expect( + within(dialog).getByRole("button", { name: "Approve MCP access" }) + ).toBeTruthy(); + expect(dialog.querySelector("img")?.getAttribute("src")).toBe( + "/images/console/explore/flux-schnell.webp" + ); + expect(dialog.querySelector("details")).toBeNull(); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + fireEvent.click(screen.getByRole("button", { name: "Approved" })); + fireEvent.click( + await screen.findByRole("checkbox", { name: "Select alex@example.com" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Revoke selected" })); + dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Revoke MCP access?")).toBeTruthy(); + expect(dialog.textContent).toContain( + "Remove MCP access for 1 selected person. Running jobs won’t be stopped." + ); + expect( + within(dialog).getByRole("button", { name: "Revoke MCP access" }) + ).toBeTruthy(); + expect(dialog.querySelector("img")?.getAttribute("src")).toBe( + "/images/console/explore/stable-video-diffusion.webp" + ); +}); + it("scopes actions and selections to the selected status section", async () => { const fetch = vi.fn(async () => Response.json({ From 0cd93ef8625201ea665bfee46ae01ee340f27c86 Mon Sep 17 00:00:00 2001 From: peace node Date: Tue, 8 Sep 2026 11:41:14 -0400 Subject: [PATCH 3/5] style(admin): refine access dialog proportions --- components/admin/AccessManager.tsx | 13 ++++++++++--- components/ui/dialog.tsx | 7 ++++++- tests/contracts/admin-access-table.test.tsx | 10 ++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/components/admin/AccessManager.tsx b/components/admin/AccessManager.tsx index 570a396..1ad5544 100644 --- a/components/admin/AccessManager.tsx +++ b/components/admin/AccessManager.tsx @@ -558,8 +558,11 @@ export default function AccessManager() { if (!open) setConfirmation(null); }} > - -
+ +
-
+