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
7 changes: 7 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
tmp/
.next/
.next-cutover/
node_modules/
output/
.agent-worktrees/
.pnpm-store/
46 changes: 46 additions & 0 deletions app/(app)/admin-preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main
id="main-content"
className="flex min-h-full flex-1 flex-col bg-dark text-fg"
>
<section className="w-full px-5 py-8 sm:px-7">
<AdminWorkspace team={<TeamManager />}>
<dl className="mt-8 grid grid-cols-1 gap-x-6 gap-y-8 min-[480px]:grid-cols-2 xl:grid-cols-4">
{summary.map(([label, value]) => (
<div key={label}>
<dt className="whitespace-nowrap text-xs text-fg-muted">
{label}
</dt>
<dd className="mt-2 text-3xl font-light tabular-nums">
{value.toLocaleString()}
</dd>
</div>
))}
</dl>
<AccessManager />
</AdminWorkspace>
</section>
</main>
);
}
3 changes: 2 additions & 1 deletion app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,7 +23,7 @@ export default async function AdminPage() {
className="flex min-h-full flex-1 flex-col bg-dark text-fg"
>
<section className="w-full px-5 py-8 sm:px-7">
<AdminWorkspace>
<AdminWorkspace team={<TeamManager />}>
<dl className="mt-8 grid grid-cols-1 gap-x-6 gap-y-8 min-[480px]:grid-cols-2 xl:grid-cols-4">
{[
["Total signups", summary.totalSignups],
Expand Down
61 changes: 61 additions & 0 deletions app/api/admin/team/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
107 changes: 58 additions & 49 deletions components/admin/AccessManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="mt-10" aria-label="Console access">
Expand Down Expand Up @@ -402,7 +408,10 @@ export default function AccessManager() {
)}
<div
className="-mx-5 mt-4 overflow-x-auto sm:-mx-7"
style={{ overscrollBehaviorY: "auto", overscrollBehaviorX: "contain" }}
style={{
overscrollBehaviorY: "auto",
overscrollBehaviorX: "contain",
}}
aria-busy={loading}
>
<table
Expand Down Expand Up @@ -549,54 +558,54 @@ export default function AccessManager() {
if (!open) setConfirmation(null);
}}
>
<DialogContent className="max-h-[85vh] overflow-auto">
<DialogTitle>
{confirmation?.[0]?.action === "approve" ? "Approve" : "Revoke"}{" "}
{confirmation?.reduce(
(total, request) => total + request.signupIds.length,
0
)}{" "}
selected entries?
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
<details>
<summary className="cursor-pointer">
Review exact selected records
</summary>
<ul className="mt-3 max-h-52 overflow-auto text-xs">
{confirmation
?.flatMap((request) => request.signupIds)
.map((id) => (
<li key={id} className="py-1 break-all">
{labels.current.get(id)
? `${labels.current.get(id)} · `
: ""}
{id}
</li>
))}
</ul>
</details>
<div className="flex gap-3">
<button
type="button"
className={control}
onClick={() => setConfirmation(null)}
>
Cancel
</button>
<button
type="button"
className={control}
onClick={() => {
if (confirmation) void execute(confirmation);
}}
>
Confirm {confirmation?.[0]?.action}
</button>
<DialogContent
className="max-h-[90vh] gap-0 overflow-hidden p-0 sm:max-w-sm"
closeButtonClassName="bg-transparent text-white hover:bg-black/25 hover:text-white"
>
<div className="aspect-[4/3] w-full overflow-hidden bg-muted">
<img
src={
isApproving
? "/images/console/explore/flux-schnell.webp"
: "/images/console/explore/stable-video-diffusion.webp"
}
alt=""
className="h-full w-full object-cover"
/>
</div>
<div className="grid gap-5 p-6">
<div className="grid gap-2">
<DialogTitle>
{isApproving ? "Approve MCP access?" : "Revoke MCP access?"}
</DialogTitle>
<DialogDescription>
{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.`}
</DialogDescription>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="lg"
className="h-12 w-full rounded-sm"
onClick={() => setConfirmation(null)}
>
Cancel
</Button>
<Button
type="button"
variant={isApproving ? "default" : "destructive"}
size="lg"
className="h-12 w-full rounded-sm"
onClick={() => {
if (confirmation) void execute(confirmation);
}}
>
{isApproving ? "Approve MCP access" : "Revoke MCP access"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
Expand Down
25 changes: 21 additions & 4 deletions components/admin/AdminWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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();
}}
Expand All @@ -54,6 +63,14 @@ export default function AdminWorkspace({ children }: { children: ReactNode }) {
))}
</div>
</div>
<div
role="tabpanel"
id="admin-panel-team"
aria-labelledby="admin-tab-team"
hidden={active !== "Team"}
>
{team}
</div>
<div
role="tabpanel"
id="admin-panel-waitlist"
Expand Down
Loading
Loading