diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 00000000..5cc7565a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,7 @@ +tmp/ +.next/ +.next-cutover/ +node_modules/ +output/ +.agent-worktrees/ +.pnpm-store/ diff --git a/app/(app)/admin-preview/page.tsx b/app/(app)/admin-preview/page.tsx new file mode 100644 index 00000000..f3c580c2 --- /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 920cb14e..1a03b0b2 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 00000000..dc5301a9 --- /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 3919e787..1ad55443 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 (
@@ -402,7 +408,10 @@ export default function AccessManager() { )}
- - - {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/components/admin/AdminWorkspace.tsx b/components/admin/AdminWorkspace.tsx index db3ab04f..c4031eb2 100644 --- a/components/admin/AdminWorkspace.tsx +++ b/components/admin/AdminWorkspace.tsx @@ -3,9 +3,15 @@ import { useRef, useState, type ReactNode } from "react"; import RunsPreview from "./RunsPreview"; -const sections = ["Waitlist", "History"] as const; +const sections = ["Waitlist", "History", "Team"] as const; -export default function AdminWorkspace({ children }: { children: ReactNode }) { +export default function AdminWorkspace({ + children, + team, +}: { + children: ReactNode; + team?: ReactNode; +}) { const [active, setActive] = useState<(typeof sections)[number]>("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/components/ui/dialog.tsx b/components/ui/dialog.tsx index 8487ec2b..67817827 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -43,9 +43,11 @@ function DialogContent({ className, children, showCloseButton = true, + closeButtonClassName, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; + closeButtonClassName?: string; }) { return ( @@ -65,7 +67,10 @@ function DialogContent({ render={