diff --git a/.env.example b/.env.example index 646eadd..61bf857 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,27 @@ PYMTHOUSE_ALLOW_INSECURE_HTTP= # MCP_PUBLIC_ORIGIN=https://dashboard.livepeer.org # DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw # PYMTHOUSE_SIGNER_URL=https://signer.pymthouse.com -# Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660 +# Preview credential operations require BOTH staging issuer and app: +# PYMTHOUSE_ISSUER_URL=https://staging.pymthouse.com/api/v1/oidc +# PYMTHOUSE_PUBLIC_CLIENT_ID=app_088f2082a8f1161d60179431 -# Invite-only Console HTML (/home, /usage, /keys, /calls, /settings). -# Empty = no gate. Comma-separated emails, matched case-insensitively. -# CONSOLE_EMAIL_ALLOWLIST=alice@livepeer.org,bob@studio.com +# Console admission is database-authoritative; no environment email allowlist. + +# Waitlist + identity/access + single-use OAuth receipts (migrations 0000-0008). +# Production must use the existing waitlist database. Previews and tests must +# use an isolated database branch; never point them at the production branch. +DATABASE_URL=postgresql://user:password@host/database?sslmode=require +ATTRIBUTION_HASH_SECRET=replace-with-at-least-32-random-bytes +RESEND_API_KEY=re_replace_me +RESEND_NEWSLETTER_SEGMENT_ID=replace-with-resend-segment-id +EMAIL_FROM=Livepeer Waitlist +EMAIL_REPLY_TO=help@example.com +INTERNAL_OUTBOX_SECRET=replace-with-at-least-32-random-bytes +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Publishable token; analytics safely no-ops when unset. +NEXT_PUBLIC_POSTHOG_KEY= + +# Mandatory on Vercel Preview. Capture only; never write production contacts. +# EMAIL_DELIVERY_MODE=capture +# Disposable automated tests additionally require independently supplied +# TEST_DATABASE_URL, TEST_DATABASE_HOST, TEST_DATABASE_BRANCH_ID and DB marker. diff --git a/.github/workflows/validate-console.yml b/.github/workflows/validate-console.yml new file mode 100644 index 0000000..1900a64 --- /dev/null +++ b/.github/workflows/validate-console.yml @@ -0,0 +1,25 @@ +name: Validate Console +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm test:console + - run: pnpm lint + - run: pnpm build + - run: pnpm typecheck + # No cloud/database/email credentials in untrusted PR jobs. Disposable DB + # integration and protected preview evidence are coordinator-run gates. diff --git a/.gitignore b/.gitignore index 219d8bd..2036520 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ # next.js /.next/ +/.next-cutover/ +/.agent-worktrees/ /out/ # production diff --git a/.prettierignore b/.prettierignore index bd5535a..145aa65 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,4 @@ pnpm-lock.yaml +.next/ +.next-cutover/ +.agent-worktrees/ diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 0000000..989757e --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from "next/navigation"; +import ConsolePageHeader from "@/components/console/ConsolePageHeader"; +import AccessManager from "@/components/admin/AccessManager"; +import { getAdminWaitlistSummary } from "@/lib/waitlist/admin"; +import { getAdminPrincipal } from "@/lib/admin/auth"; + +export const dynamic = "force-dynamic"; + +export default async function AdminPage() { + const admin = await getAdminPrincipal(); + if (!admin) redirect("/waitlist"); + const summary = await getAdminWaitlistSummary(); + return ( +
+ + Export CSV + + } + /> +
+

+ Waitlist administration +

+
+ {[ + ["Total signups", summary.totalSignups], + ["Verified signups", summary.confirmedSignups], + ["Total verified referrals", summary.totalVerifiedReferrals], + ["Newsletter opt-ins", summary.newsletterSubscribers], + ].map(([label, value]) => ( +
+
{label}
+
+ {Number(value).toLocaleString()} +
+
+ ))} +
+ +
+
+ ); +} diff --git a/app/(app)/calls/page.tsx b/app/(app)/calls/page.tsx index de668fb..8f22ae1 100644 --- a/app/(app)/calls/page.tsx +++ b/app/(app)/calls/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { requireConsolePage } from "@/lib/access/page"; /** * `/calls` folded into `/home` for the creator pilot — the call log now @@ -17,5 +18,8 @@ export default async function CallsPage({ const params = await searchParams; const request = params.request; const id = Array.isArray(request) ? request[0] : request; + await requireConsolePage( + id ? `/calls?request=${encodeURIComponent(id)}` : "/calls" + ); redirect(id ? `/home?request=${encodeURIComponent(id)}` : "/home"); } diff --git a/app/(app)/device/DeviceApproveForm.tsx b/app/(app)/device/DeviceApproveForm.tsx index 1249ef3..9c768de 100644 --- a/app/(app)/device/DeviceApproveForm.tsx +++ b/app/(app)/device/DeviceApproveForm.tsx @@ -32,24 +32,29 @@ export default function DeviceApproveForm({ async function approve() { setError(""); setPhase("submitting"); - const response = await fetch("/api/v1/auth/device/approve", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - iss, - target_link_uri: targetLinkUri, - }), - }); - const json = (await response.json()) as { - ok?: boolean; - error?: string; - }; - if (!response.ok || !json.ok) { + try { + const response = await fetch("/api/v1/auth/device/approve", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + iss, + target_link_uri: targetLinkUri, + }), + }); + const json = (await response.json()) as { + ok?: boolean; + error?: string; + }; + if (!response.ok || !json.ok) { + setPhase("error"); + setError(json.error ?? "Approval failed"); + return; + } + setPhase("ok"); + } catch { setPhase("error"); - setError(json.error ?? "Approval failed"); - return; + setError("We couldn’t confirm device approval. Please try again."); } - setPhase("ok"); } if (phase === "ok") { @@ -78,7 +83,11 @@ export default function DeviceApproveForm({ > {phase === "submitting" ? "Approving…" : "Approve device"} - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} ); } diff --git a/app/(app)/device/page.tsx b/app/(app)/device/page.tsx index 68b8817..5123571 100644 --- a/app/(app)/device/page.tsx +++ b/app/(app)/device/page.tsx @@ -1,6 +1,4 @@ -import { redirect } from "next/navigation"; - -import { auth0 } from "@/lib/auth0"; +import { requireConsolePage } from "@/lib/access/page"; import { parseDeviceInitiateParams } from "@/lib/console/device-approval"; import DeviceApproveForm, { DevicePageChrome } from "./DeviceApproveForm"; @@ -18,20 +16,19 @@ export default async function DevicePage({ const params = await searchParams; const query = new URLSearchParams(); if (params.iss) query.set("iss", params.iss); - if (params.target_link_uri) query.set("target_link_uri", params.target_link_uri); + if (params.target_link_uri) + query.set("target_link_uri", params.target_link_uri); if (params.login_hint) query.set("login_hint", params.login_hint); const returnTo = `/device${query.size ? `?${query.toString()}` : ""}`; - const session = await auth0.getSession(); - if (!session?.user?.sub) { - redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`); - } + await requireConsolePage(returnTo); let parsed; try { parsed = parseDeviceInitiateParams(query); } catch (error) { - const message = error instanceof Error ? error.message : "Invalid device request"; + const message = + error instanceof Error ? error.message : "Invalid device request"; return (

{message}

diff --git a/app/(app)/home/page.tsx b/app/(app)/home/page.tsx index 7e4f899..dde1f38 100644 --- a/app/(app)/home/page.tsx +++ b/app/(app)/home/page.tsx @@ -1,34 +1,18 @@ -"use client"; - import { Suspense } from "react"; -import { useAuth } from "@/components/console/AuthContext"; +import { requireConsolePage } from "@/lib/access/page"; import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton"; -import SignInWall from "@/components/console/SignInWall"; import UsageView from "@/components/console/UsageView"; -export default function HomePage() { +export const dynamic = "force-dynamic"; +export default async function HomePage() { + await requireConsolePage("/home"); return ( }> - +
+
+ +
+
); } - -function HomeContent() { - const { isConnected, isLoading } = useAuth(); - - // Avoid flashing the wall while auth hydrates. - if (isLoading) return null; - - // Organization-only route — logged-out users see the in-shell sign-in wall - // instead of a hard redirect. - if (!isConnected) return ; - - return ( -
-
- -
-
- ); -} diff --git a/app/(app)/install/InstallContent.tsx b/app/(app)/install/InstallContent.tsx new file mode 100644 index 0000000..bcd51df --- /dev/null +++ b/app/(app)/install/InstallContent.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { useAuth } from "@/components/console/AuthContext"; +import { AUTH_SIGNIN_HREF } from "@/lib/console/auth-login"; +import CopyButton from "@/components/console/CopyButton"; +import HarnessLogo from "@/components/console/HarnessLogo"; +import SectionHeader from "@/components/console/SectionHeader"; +import { MCP_SERVER_URL } from "@/lib/constants"; + +type HarnessKey = "claude" | "claude-code" | "chatgpt" | "hermes"; + +type CopyValue = { + value: string; + ariaLabel: string; +}; + +type InstallStepConfig = { + title: string; + body: string; + copy?: CopyValue; +}; + +type InstallTarget = { + key: HarnessKey; + label: string; + steps: InstallStepConfig[]; +}; + +type WheelImage = { + src: string; + alt: string; +}; + +const CLAUDE_CODE_COMMAND = `claude mcp add --transport http livepeer ${MCP_SERVER_URL}`; +const CHATGPT_CODEX_PROMPT = `Codex, add the Livepeer MCP connector to ChatGPT desktop. Name it Livepeer and use this server URL: ${MCP_SERVER_URL}`; +const HERMES_CODEX_PROMPT = `Codex, add the Livepeer MCP connector to Hermes. Name it Livepeer and use this server URL: ${MCP_SERVER_URL}`; +const WHEEL_IMAGE_REPEAT = 1; +const WHEEL_CARD_WIDTH = 118; +const WHEEL_CARD_HEIGHT = WHEEL_CARD_WIDTH * (4 / 3); +const WHEEL_RADIUS_X = 226; +const WHEEL_RADIUS_Y = 54; +const WHEEL_SPIN_SPEED = 9; +const WHEEL_SCROLL_BOOST_FACTOR = 0.8; +const WHEEL_SCROLL_BOOST_MAX = 180; +const WHEEL_SCROLL_BOOST_DECAY = 0.92; +const WHEEL_CARD_TILT_DEGREES = 5; + +const WHEEL_IMAGES: WheelImage[] = [ + { + src: "/images/console/explore/stable-video-diffusion.webp", + alt: "Stable Video Diffusion preview", + }, + { + src: "/images/console/explore/img2img-sdxl.webp", + alt: "Image editing preview", + }, + { + src: "/images/console/explore/live-video-to-video.webp", + alt: "Live video preview", + }, + { + src: "/images/console/explore/flux-schnell.webp", + alt: "Flux Schnell preview", + }, + { + src: "/images/console/daydream.png", + alt: "Daydream preview", + }, + { + src: "/images/console/explore/sdxl-turbo.webp", + alt: "SDXL Turbo preview", + }, + { + src: "/images/console/explore/real-esrgan-4x.webp", + alt: "Image upscale preview", + }, +]; + +const TARGETS: InstallTarget[] = [ + { + key: "claude", + label: "Claude", + steps: [ + { + title: "Add the Livepeer MCP URL", + body: "Open Claude connector settings, name the server Livepeer, and paste this URL.", + copy: { + value: MCP_SERVER_URL, + ariaLabel: "Copy Claude MCP server URL", + }, + }, + { + title: "Connect and start", + body: "Sign in when the browser opens, then ask Claude to create or edit production media.", + }, + ], + }, + { + key: "claude-code", + label: "Claude Code", + steps: [ + { + title: "Run the Claude Code command", + body: "Paste this into your terminal once to add Livepeer as an MCP server. Use /mcp inside Claude Code if it asks you to finish sign-in.", + copy: { + value: CLAUDE_CODE_COMMAND, + ariaLabel: "Copy Claude Code install command", + }, + }, + { + title: "Connect and start", + body: "Start a Claude Code session and ask Livepeer for image, video, audio, or rendering work.", + }, + ], + }, + { + key: "chatgpt", + label: "ChatGPT", + steps: [ + { + title: "Copy and run the Codex prompt", + body: "ChatGPT MCP connector setup works in the desktop app only. Open ChatGPT desktop, start Codex, paste the prompt, and approve the connector changes.", + copy: { + value: CHATGPT_CODEX_PROMPT, + ariaLabel: "Copy ChatGPT Codex connector prompt", + }, + }, + { + title: "Connect and start", + body: "Sign in, then bring Livepeer production tools into your ChatGPT workflows.", + }, + ], + }, + { + key: "hermes", + label: "Hermes", + steps: [ + { + title: "Copy and run the Codex prompt", + body: "This asks Codex to add the Livepeer MCP server for you. Open Hermes, start Codex, paste the prompt, and approve the connector changes.", + copy: { + value: HERMES_CODEX_PROMPT, + ariaLabel: "Copy Hermes Codex connector prompt", + }, + }, + { + title: "Connect and start", + body: "Sign in, then start generating production assets from Hermes.", + }, + ], + }, +]; + +function TargetIcon({ target }: { target: HarnessKey }) { + if (target === "claude" || target === "claude-code") { + return ; + } + if (target === "chatgpt") { + return ; + } + return ; +} + +function InstallImageWheel() { + const rootRef = useRef(null); + const itemRefs = useRef>([]); + const rotationRef = useRef(0); + const momentumRef = useRef(0); + const scrollBoostRef = useRef(0); + const previousScrollYRef = useRef(0); + const isDraggingRef = useRef(false); + const dragPointerIdRef = useRef(-1); + const previousPointerXRef = useRef(0); + const inViewRef = useRef(true); + const wheelItems = useMemo( + () => + Array.from({ length: WHEEL_IMAGE_REPEAT }).flatMap(() => WHEEL_IMAGES), + [] + ); + + const renderWheel = useCallback( + (rotation: number) => { + const root = rootRef.current; + const itemCount = wheelItems.length; + if (!root || itemCount === 0) return; + + const viewportScale = Math.min(1, Math.max(0.66, root.clientWidth / 760)); + const cardWidth = WHEEL_CARD_WIDTH * viewportScale; + const cardHeight = WHEEL_CARD_HEIGHT * viewportScale; + const radiusX = WHEEL_RADIUS_X * viewportScale; + const radiusY = WHEEL_RADIUS_Y * viewportScale; + + itemRefs.current.forEach((node, index) => { + if (!node) return; + const radians = + (index / itemCount) * Math.PI * 2 + (rotation * Math.PI) / 180; + const depth = (Math.sin(radians) + 1) / 2; + const x = Math.cos(radians) * radiusX * (0.74 + depth * 0.26); + const y = Math.sin(radians) * radiusY * (0.88 + depth * 0.12); + const scale = 0.72 + depth * 0.28; + const shadowY = 4 + depth * 8; + const shadowBlur = 14 + depth * 18; + + node.style.width = `${cardWidth}px`; + node.style.height = `${cardHeight}px`; + node.style.marginLeft = `${-cardWidth / 2}px`; + node.style.marginTop = `${-cardHeight / 2}px`; + node.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale}) rotate(${WHEEL_CARD_TILT_DEGREES}deg)`; + node.style.removeProperty("opacity"); + node.style.zIndex = `${Math.round(depth * 1000)}`; + node.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0,0,0,${0.05 + depth * 0.08})`; + }); + }, + [wheelItems.length] + ); + + useEffect(() => { + itemRefs.current.length = wheelItems.length; + renderWheel(rotationRef.current); + }, [renderWheel, wheelItems.length]); + + useEffect(() => { + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + let animationFrame = 0; + let lastTime = 0; + let running = false; + + const tick = (now: number) => { + if (!lastTime) lastTime = now; + const delta = (now - lastTime) / 1000; + lastTime = now; + + if (!isDraggingRef.current && inViewRef.current && !reducedMotion) { + rotationRef.current += WHEEL_SPIN_SPEED * delta; + rotationRef.current += scrollBoostRef.current * delta; + rotationRef.current += momentumRef.current; + momentumRef.current *= 0.95; + scrollBoostRef.current *= WHEEL_SCROLL_BOOST_DECAY; + } + + renderWheel(rotationRef.current); + if (inViewRef.current) { + animationFrame = window.requestAnimationFrame(tick); + } else { + animationFrame = 0; + running = false; + } + }; + + const start = () => { + if (reducedMotion || running || !inViewRef.current) return; + window.cancelAnimationFrame(animationFrame); + lastTime = 0; + running = true; + animationFrame = window.requestAnimationFrame(tick); + }; + + const stop = () => { + window.cancelAnimationFrame(animationFrame); + animationFrame = 0; + running = false; + }; + + const handleScroll = () => { + if (!inViewRef.current || reducedMotion) return; + const scrollY = window.scrollY; + const delta = Math.abs(scrollY - previousScrollYRef.current); + previousScrollYRef.current = scrollY; + if (delta > 0) { + scrollBoostRef.current = Math.min( + scrollBoostRef.current + delta * WHEEL_SCROLL_BOOST_FACTOR, + WHEEL_SCROLL_BOOST_MAX + ); + start(); + } + }; + + const handleResize = () => renderWheel(rotationRef.current); + + previousScrollYRef.current = window.scrollY; + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleResize); + + const observer = + rootRef.current && "IntersectionObserver" in window + ? new IntersectionObserver( + ([entry]) => { + inViewRef.current = entry.isIntersecting; + if (entry.isIntersecting) { + start(); + } else { + stop(); + } + }, + { threshold: 0.1 } + ) + : null; + + if (observer && rootRef.current) observer.observe(rootRef.current); + + renderWheel(rotationRef.current); + start(); + + return () => { + stop(); + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleResize); + observer?.disconnect(); + }; + }, [renderWheel]); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType === "touch") return; + isDraggingRef.current = true; + dragPointerIdRef.current = event.pointerId; + previousPointerXRef.current = event.clientX; + momentumRef.current = 0; + event.currentTarget.setPointerCapture(event.pointerId); + event.currentTarget.style.cursor = "grabbing"; + }, + [] + ); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + if ( + !isDraggingRef.current || + dragPointerIdRef.current !== event.pointerId + ) { + return; + } + + const delta = event.clientX - previousPointerXRef.current; + previousPointerXRef.current = event.clientX; + rotationRef.current += delta * 0.28; + momentumRef.current = delta * 0.03; + renderWheel(rotationRef.current); + }, + [renderWheel] + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (dragPointerIdRef.current !== event.pointerId) return; + isDraggingRef.current = false; + dragPointerIdRef.current = -1; + event.currentTarget.style.cursor = "grab"; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, + [] + ); + + return ( + + ); +} + +function InstallStep({ + n, + title, + body, + children, +}: { + n: number; + title: string; + body: string; + children?: ReactNode; +}) { + return ( +
+ + {n} + +

+ {title} +

+

+ {body} +

+ {children &&
{children}
} +
+ ); +} + +function CopyValueBlock({ copy }: { copy: CopyValue }) { + return ( +
+
+ + {copy.value} + + +
+
+ ); +} + +function InstallGuide() { + const [activeKey, setActiveKey] = useState("claude"); + const active = TARGETS.find((target) => target.key === activeKey)!; + const gridCols = + active.steps.length === 2 ? "md:grid-cols-2" : "md:grid-cols-3"; + + return ( +
+
+
+ {TARGETS.map((target) => { + const selected = target.key === activeKey; + return ( + + ); + })} +
+
+ +
+ {active.steps.map((step, index) => ( + + {step.copy && } + + ))} +
+
+ ); +} + +function McpServerUrl() { + return ( +
+
+ + {MCP_SERVER_URL} + + +
+
+ ); +} + +export default function InstallPage() { + const { isConnected, isLoading } = useAuth(); + + // Middleware already sends signed-out requests to /login before this page + // is served (see middleware.ts). This client-side fallback only fires if + // the session lapses while the console is open. + useEffect(() => { + if (!isLoading && !isConnected) { + window.location.replace(AUTH_SIGNIN_HREF); + } + }, [isLoading, isConnected]); + + if (isLoading) return null; + + // Redirect is in flight; render nothing while it takes effect. + if (!isConnected) return null; + + return ( +
+
+ +

+ Turn your agent into a full suite production studio. +

+

+ Bring image, video, audio, 3D, editing, rendering, and production + tools into your agent’s workflows with Livepeer. +

+ + +
+ + +
+
+
+ ); +} diff --git a/app/(app)/install/page.tsx b/app/(app)/install/page.tsx index 811c1d7..140c2a6 100644 --- a/app/(app)/install/page.tsx +++ b/app/(app)/install/page.tsx @@ -1,590 +1,8 @@ -"use client"; +import { requireConsolePage } from "@/lib/access/page"; +import InstallContent from "./InstallContent"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type PointerEvent as ReactPointerEvent, - type ReactNode, -} from "react"; -import { useAuth } from "@/components/console/AuthContext"; -import { AUTH_SIGNIN_HREF } from "@/lib/console/auth-login"; -import CopyButton from "@/components/console/CopyButton"; -import HarnessLogo from "@/components/console/HarnessLogo"; -import SectionHeader from "@/components/console/SectionHeader"; -import { MCP_SERVER_URL } from "@/lib/constants"; - -type HarnessKey = "claude" | "claude-code" | "chatgpt" | "hermes"; - -type CopyValue = { - value: string; - ariaLabel: string; -}; - -type InstallStepConfig = { - title: string; - body: string; - copy?: CopyValue; -}; - -type InstallTarget = { - key: HarnessKey; - label: string; - steps: InstallStepConfig[]; -}; - -type WheelImage = { - src: string; - alt: string; -}; - -const CLAUDE_CODE_COMMAND = `claude mcp add --transport http livepeer ${MCP_SERVER_URL}`; -const CHATGPT_CODEX_PROMPT = `Codex, add the Livepeer MCP connector to ChatGPT desktop. Name it Livepeer and use this server URL: ${MCP_SERVER_URL}`; -const HERMES_CODEX_PROMPT = `Codex, add the Livepeer MCP connector to Hermes. Name it Livepeer and use this server URL: ${MCP_SERVER_URL}`; -const WHEEL_IMAGE_REPEAT = 1; -const WHEEL_CARD_WIDTH = 118; -const WHEEL_CARD_HEIGHT = WHEEL_CARD_WIDTH * (4 / 3); -const WHEEL_RADIUS_X = 226; -const WHEEL_RADIUS_Y = 54; -const WHEEL_SPIN_SPEED = 9; -const WHEEL_SCROLL_BOOST_FACTOR = 0.8; -const WHEEL_SCROLL_BOOST_MAX = 180; -const WHEEL_SCROLL_BOOST_DECAY = 0.92; -const WHEEL_CARD_TILT_DEGREES = 5; - -const WHEEL_IMAGES: WheelImage[] = [ - { - src: "/images/console/explore/stable-video-diffusion.webp", - alt: "Stable Video Diffusion preview", - }, - { - src: "/images/console/explore/img2img-sdxl.webp", - alt: "Image editing preview", - }, - { - src: "/images/console/explore/live-video-to-video.webp", - alt: "Live video preview", - }, - { - src: "/images/console/explore/flux-schnell.webp", - alt: "Flux Schnell preview", - }, - { - src: "/images/console/daydream.png", - alt: "Daydream preview", - }, - { - src: "/images/console/explore/sdxl-turbo.webp", - alt: "SDXL Turbo preview", - }, - { - src: "/images/console/explore/real-esrgan-4x.webp", - alt: "Image upscale preview", - }, -]; - -const TARGETS: InstallTarget[] = [ - { - key: "claude", - label: "Claude", - steps: [ - { - title: "Add the Livepeer MCP URL", - body: "Open Claude connector settings, name the server Livepeer, and paste this URL.", - copy: { - value: MCP_SERVER_URL, - ariaLabel: "Copy Claude MCP server URL", - }, - }, - { - title: "Connect and start", - body: "Sign in when the browser opens, then ask Claude to create or edit production media.", - }, - ], - }, - { - key: "claude-code", - label: "Claude Code", - steps: [ - { - title: "Run the Claude Code command", - body: "Paste this into your terminal once to add Livepeer as an MCP server. Use /mcp inside Claude Code if it asks you to finish sign-in.", - copy: { - value: CLAUDE_CODE_COMMAND, - ariaLabel: "Copy Claude Code install command", - }, - }, - { - title: "Connect and start", - body: "Start a Claude Code session and ask Livepeer for image, video, audio, or rendering work.", - }, - ], - }, - { - key: "chatgpt", - label: "ChatGPT", - steps: [ - { - title: "Copy and run the Codex prompt", - body: "ChatGPT MCP connector setup works in the desktop app only. Open ChatGPT desktop, start Codex, paste the prompt, and approve the connector changes.", - copy: { - value: CHATGPT_CODEX_PROMPT, - ariaLabel: "Copy ChatGPT Codex connector prompt", - }, - }, - { - title: "Connect and start", - body: "Sign in, then bring Livepeer production tools into your ChatGPT workflows.", - }, - ], - }, - { - key: "hermes", - label: "Hermes", - steps: [ - { - title: "Copy and run the Codex prompt", - body: "This asks Codex to add the Livepeer MCP server for you. Open Hermes, start Codex, paste the prompt, and approve the connector changes.", - copy: { - value: HERMES_CODEX_PROMPT, - ariaLabel: "Copy Hermes Codex connector prompt", - }, - }, - { - title: "Connect and start", - body: "Sign in, then start generating production assets from Hermes.", - }, - ], - }, -]; - -function TargetIcon({ target }: { target: HarnessKey }) { - if (target === "claude" || target === "claude-code") { - return ; - } - if (target === "chatgpt") { - return ; - } - return ; -} - -function InstallImageWheel() { - const rootRef = useRef(null); - const itemRefs = useRef>([]); - const rotationRef = useRef(0); - const momentumRef = useRef(0); - const scrollBoostRef = useRef(0); - const previousScrollYRef = useRef(0); - const isDraggingRef = useRef(false); - const dragPointerIdRef = useRef(-1); - const previousPointerXRef = useRef(0); - const inViewRef = useRef(true); - const wheelItems = useMemo( - () => Array.from({ length: WHEEL_IMAGE_REPEAT }).flatMap(() => WHEEL_IMAGES), - [] - ); - - const renderWheel = useCallback( - (rotation: number) => { - const root = rootRef.current; - const itemCount = wheelItems.length; - if (!root || itemCount === 0) return; - - const viewportScale = Math.min( - 1, - Math.max(0.66, root.clientWidth / 760) - ); - const cardWidth = WHEEL_CARD_WIDTH * viewportScale; - const cardHeight = WHEEL_CARD_HEIGHT * viewportScale; - const radiusX = WHEEL_RADIUS_X * viewportScale; - const radiusY = WHEEL_RADIUS_Y * viewportScale; - - itemRefs.current.forEach((node, index) => { - if (!node) return; - const radians = - (index / itemCount) * Math.PI * 2 + (rotation * Math.PI) / 180; - const depth = (Math.sin(radians) + 1) / 2; - const x = Math.cos(radians) * radiusX * (0.74 + depth * 0.26); - const y = Math.sin(radians) * radiusY * (0.88 + depth * 0.12); - const scale = 0.72 + depth * 0.28; - const shadowY = 4 + depth * 8; - const shadowBlur = 14 + depth * 18; - - node.style.width = `${cardWidth}px`; - node.style.height = `${cardHeight}px`; - node.style.marginLeft = `${-cardWidth / 2}px`; - node.style.marginTop = `${-cardHeight / 2}px`; - node.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale}) rotate(${WHEEL_CARD_TILT_DEGREES}deg)`; - node.style.removeProperty("opacity"); - node.style.zIndex = `${Math.round(depth * 1000)}`; - node.style.boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0,0,0,${0.05 + depth * 0.08})`; - }); - }, - [wheelItems.length] - ); - - useEffect(() => { - itemRefs.current.length = wheelItems.length; - renderWheel(rotationRef.current); - }, [renderWheel, wheelItems.length]); - - useEffect(() => { - const reducedMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)" - ).matches; - let animationFrame = 0; - let lastTime = 0; - let running = false; - - const tick = (now: number) => { - if (!lastTime) lastTime = now; - const delta = (now - lastTime) / 1000; - lastTime = now; - - if (!isDraggingRef.current && inViewRef.current && !reducedMotion) { - rotationRef.current += WHEEL_SPIN_SPEED * delta; - rotationRef.current += scrollBoostRef.current * delta; - rotationRef.current += momentumRef.current; - momentumRef.current *= 0.95; - scrollBoostRef.current *= WHEEL_SCROLL_BOOST_DECAY; - } - - renderWheel(rotationRef.current); - if (inViewRef.current) { - animationFrame = window.requestAnimationFrame(tick); - } else { - animationFrame = 0; - running = false; - } - }; - - const start = () => { - if (reducedMotion || running || !inViewRef.current) return; - window.cancelAnimationFrame(animationFrame); - lastTime = 0; - running = true; - animationFrame = window.requestAnimationFrame(tick); - }; - - const stop = () => { - window.cancelAnimationFrame(animationFrame); - animationFrame = 0; - running = false; - }; - - const handleScroll = () => { - if (!inViewRef.current || reducedMotion) return; - const scrollY = window.scrollY; - const delta = Math.abs(scrollY - previousScrollYRef.current); - previousScrollYRef.current = scrollY; - if (delta > 0) { - scrollBoostRef.current = Math.min( - scrollBoostRef.current + delta * WHEEL_SCROLL_BOOST_FACTOR, - WHEEL_SCROLL_BOOST_MAX - ); - start(); - } - }; - - const handleResize = () => renderWheel(rotationRef.current); - - previousScrollYRef.current = window.scrollY; - window.addEventListener("scroll", handleScroll, { passive: true }); - window.addEventListener("resize", handleResize); - - const observer = - rootRef.current && "IntersectionObserver" in window - ? new IntersectionObserver( - ([entry]) => { - inViewRef.current = entry.isIntersecting; - if (entry.isIntersecting) { - start(); - } else { - stop(); - } - }, - { threshold: 0.1 } - ) - : null; - - if (observer && rootRef.current) observer.observe(rootRef.current); - - renderWheel(rotationRef.current); - start(); - - return () => { - stop(); - window.removeEventListener("scroll", handleScroll); - window.removeEventListener("resize", handleResize); - observer?.disconnect(); - }; - }, [renderWheel]); - - const handlePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.pointerType === "touch") return; - isDraggingRef.current = true; - dragPointerIdRef.current = event.pointerId; - previousPointerXRef.current = event.clientX; - momentumRef.current = 0; - event.currentTarget.setPointerCapture(event.pointerId); - event.currentTarget.style.cursor = "grabbing"; - }, - [] - ); - - const handlePointerMove = useCallback( - (event: ReactPointerEvent) => { - if ( - !isDraggingRef.current || - dragPointerIdRef.current !== event.pointerId - ) { - return; - } - - const delta = event.clientX - previousPointerXRef.current; - previousPointerXRef.current = event.clientX; - rotationRef.current += delta * 0.28; - momentumRef.current = delta * 0.03; - renderWheel(rotationRef.current); - }, - [renderWheel] - ); - - const handlePointerUp = useCallback( - (event: ReactPointerEvent) => { - if (dragPointerIdRef.current !== event.pointerId) return; - isDraggingRef.current = false; - dragPointerIdRef.current = -1; - event.currentTarget.style.cursor = "grab"; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }, - [] - ); - - return ( - - ); -} - -function InstallStep({ - n, - title, - body, - children, -}: { - n: number; - title: string; - body: string; - children?: ReactNode; -}) { - return ( -
- - {n} - -

- {title} -

-

- {body} -

- {children &&
{children}
} -
- ); -} - -function CopyValueBlock({ copy }: { copy: CopyValue }) { - return ( -
-
- - {copy.value} - - -
-
- ); -} - -function InstallGuide() { - const [activeKey, setActiveKey] = useState("claude"); - const active = TARGETS.find((target) => target.key === activeKey)!; - const gridCols = - active.steps.length === 2 ? "md:grid-cols-2" : "md:grid-cols-3"; - - return ( -
-
-
- {TARGETS.map((target) => { - const selected = target.key === activeKey; - return ( - - ); - })} -
-
- -
- {active.steps.map((step, index) => ( - - {step.copy && } - - ))} -
-
- ); -} - -function McpServerUrl() { - return ( -
-
- - {MCP_SERVER_URL} - - -
-
- ); -} - -export default function InstallPage() { - const { isConnected, isLoading } = useAuth(); - - // Middleware already sends signed-out requests to /login before this page - // is served (see middleware.ts). This client-side fallback only fires if - // the session lapses while the console is open. - useEffect(() => { - if (!isLoading && !isConnected) { - window.location.replace(AUTH_SIGNIN_HREF); - } - }, [isLoading, isConnected]); - - if (isLoading) return null; - - // Redirect is in flight; render nothing while it takes effect. - if (!isConnected) return null; - - return ( -
-
- -

- Turn your agent into a full suite production studio. -

-

- Bring image, video, audio, 3D, editing, rendering, and production - tools into your agent’s workflows with Livepeer. -

- - -
- - -
-
-
- ); +export const dynamic = "force-dynamic"; +export default async function InstallPage() { + await requireConsolePage("/install"); + return ; } diff --git a/app/(app)/keys/page.tsx b/app/(app)/keys/page.tsx index cd3b146..d261c7e 100644 --- a/app/(app)/keys/page.tsx +++ b/app/(app)/keys/page.tsx @@ -1,20 +1,8 @@ -"use client"; - +import { requireConsolePage } from "@/lib/access/page"; import KeysView from "@/components/console/KeysView"; -import SignInWall from "@/components/console/SignInWall"; -import { useAuth } from "@/components/console/AuthContext"; - -// Note: the previous server-component metadata moves out with the auth gate. -// Title/description for /keys now come from the layout's defaults. - -export default function KeysPage() { - const { isConnected, isLoading } = useAuth(); - - if (isLoading) return null; - - // Organization-only — logged-out users see the route-specific sign-in wall - // ("API keys are scoped to an organization…") instead of the keys table. - if (!isConnected) return ; +export const dynamic = "force-dynamic"; +export default async function KeysPage() { + await requireConsolePage("/keys"); return ; } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index b9d3374..0612d65 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,25 +1,15 @@ -"use client"; - -import { useEffect } from "react"; -import { useAuth } from "@/components/console/AuthContext"; -import { AUTH_SIGNIN_HREF } from "@/lib/console/auth-login"; - -// Root `/`: -// - signed in → redirect to /home (the console default) -// - signed out → /login -export default function RootPage() { - const { isConnected, isLoading, user } = useAuth(); - - const signedIn = isConnected && !!user; - - useEffect(() => { - if (isLoading) return; - if (signedIn) { - window.location.replace("/home"); - return; - } - window.location.replace(AUTH_SIGNIN_HREF); - }, [isLoading, signedIn]); - - return null; +import { redirect } from "next/navigation"; +import { identitySyncPath } from "@/lib/identity/sync-return"; + +export const dynamic = "force-dynamic"; + +export default async function RootPage({ + searchParams, +}: { + searchParams: Promise<{ ref?: string }>; +}) { + const params = await searchParams; + if (params.ref?.trim()) + redirect(`/waitlist?ref=${encodeURIComponent(params.ref.trim())}`); + redirect(identitySyncPath("/home")); } diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 7fb4b3a..7e1ca54 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; +import { requireConsolePage } from "@/lib/access/page"; -export default function LegacySettingsPage() { +export default async function LegacySettingsPage() { + await requireConsolePage("/settings"); redirect("/home"); } diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx index 49a2852..a826dd0 100644 --- a/app/(app)/usage/page.tsx +++ b/app/(app)/usage/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; +import { requireConsolePage } from "@/lib/access/page"; -export default function LegacyUsagePage() { +export default async function LegacyUsagePage() { + await requireConsolePage("/usage"); redirect("/home"); } diff --git a/app/(app)/waitlist/page.tsx b/app/(app)/waitlist/page.tsx deleted file mode 100644 index b02a543..0000000 --- a/app/(app)/waitlist/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { auth0 } from "@/lib/auth0"; -import ConsolePageHeader from "@/components/console/ConsolePageHeader"; -import SectionHeader from "@/components/console/SectionHeader"; -import { consoleSignInHref } from "@/lib/console/auth-login"; -import { isEmailAllowlisted } from "@/lib/console/email-allowlist"; - -export const dynamic = "force-dynamic"; - -export default async function WaitlistPage() { - const session = await auth0.getSession(); - const email = session?.user?.email; - const listed = isEmailAllowlisted(email); - - return ( - <> - -
- - {email ? ( -

{email}

- ) : ( -

- - Sign in - {" "} - to join the waitlist with your account email. -

- )} - {listed ? ( -

- - Enter Console - -

- ) : null} -
- - ); -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 9e07de7..5eb98f8 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; -import { auth0 } from "@/lib/auth0"; +import { getAuthenticatedIdentity } from "@/lib/authentication/session"; import { authLoginHref, safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; +import { identitySyncPath } from "@/lib/identity/sync-return"; import type { Metadata } from "next"; @@ -23,10 +24,9 @@ export default async function LoginRoute({ const mcpOauth = params.mcp_oauth === "1"; const returnTo = safeReturnTo(params.returnTo); - const session = await auth0.getSession(); - if (session) { - redirect(mcpOauth ? MCP_CALLBACK_PATH : returnTo); - } + const identity = await getAuthenticatedIdentity(); + if (identity) + redirect(identitySyncPath(mcpOauth ? MCP_CALLBACK_PATH : returnTo)); // MCP flow must go directly to Auth0 — no interactive UI step. if (mcpOauth) { diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index b1dc73f..27c4117 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { auth0 } from "@/lib/auth0"; +import { getAuthenticatedIdentity } from "@/lib/authentication/session"; import { safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; +import { identitySyncPath } from "@/lib/identity/sync-return"; export const metadata: Metadata = { title: "Sign up — Livepeer Early Access", @@ -15,7 +16,7 @@ export default async function SignupRoute({ }) { const params = await searchParams; const returnTo = safeReturnTo(params.returnTo); - const session = await auth0.getSession(); - if (session) redirect(returnTo); + const identity = await getAuthenticatedIdentity(); + if (identity) redirect(identitySyncPath(returnTo)); return ; } diff --git a/app/(waitlist)/layout.tsx b/app/(waitlist)/layout.tsx new file mode 100644 index 0000000..42a9621 --- /dev/null +++ b/app/(waitlist)/layout.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; + +const waitlistInter = localFont({ + src: [ + { + path: "../../assets/fonts/waitlist/InterVariable.woff2", + weight: "100 900", + style: "normal", + }, + { + path: "../../assets/fonts/waitlist/InterVariable-Italic.woff2", + weight: "100 900", + style: "italic", + }, + ], + variable: "--font-inter", +}); + +const waitlistFavorit = localFont({ + src: [ + { + path: "../../assets/fonts/waitlist/FavoritPro-Light.woff2", + weight: "300", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Book.woff2", + weight: "350", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Regular.woff2", + weight: "400", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Medium.woff2", + weight: "500", + }, + { + path: "../../assets/fonts/waitlist/FavoritPro-Bold.woff2", + weight: "700", + }, + ], + variable: "--font-favorit", +}); + +const waitlistMono = localFont({ + src: "../../assets/fonts/waitlist/FavoritMono-Regular.woff2", + weight: "400", + variable: "--font-mono", +}); + +export const metadata: Metadata = { + title: "Join the waitlist — Livepeer Early Access", + description: + "Request early access to a faster way to build, run, and scale live video products.", +}; + +export default function WaitlistLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} diff --git a/app/(waitlist)/waitlist/page.tsx b/app/(waitlist)/waitlist/page.tsx new file mode 100644 index 0000000..ae06dc2 --- /dev/null +++ b/app/(waitlist)/waitlist/page.tsx @@ -0,0 +1,37 @@ +import { AgentScrollerPage } from "@/components/livepeer-ui/agent-scroller-page"; +import { + capabilities, + networkImages, +} from "@/components/livepeer-ui/frozen-content"; +import { WaitlistSessionProvider } from "@/components/livepeer-ui/waitlist-session"; +import { getCurrentWaitlistSession } from "@/lib/waitlist/current-session"; +import { buildWaitlistJoinHref } from "@/components/livepeer-ui/waitlist-auth-navigation"; +import { LegacyVerificationNotice } from "@/components/livepeer-ui/waitlist-header-auth"; + +export default async function WaitlistPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(await searchParams)) { + if (typeof value === "string") params.set(key, value); + } + const initialSession = await getCurrentWaitlistSession(); + const verification = params.get("verification"); + + return ( + + {(verification === "confirmed" || verification === "invalid") && ( + + )} + + + ); +} diff --git a/app/access-pending/content.tsx b/app/access-pending/content.tsx new file mode 100644 index 0000000..4b7e526 --- /dev/null +++ b/app/access-pending/content.tsx @@ -0,0 +1,107 @@ +export type WaitingState = + | "pending" + | "verify-email" + | "revoked" + | "disabled" + | "enrollment-attention" + | "unavailable"; + +export const waitingCopy: Record< + WaitingState, + { title: string; description: string } +> = { + "enrollment-attention": { + title: "We couldn’t finish connecting your waitlist entry.", + description: + "Your sign-in worked, but we can’t confirm waitlist enrollment for this account. Please contact the Livepeer team for help. We haven’t changed your email preferences.", + }, + pending: { + title: "You’re on the waitlist.", + description: + "We’re welcoming people to Livepeer in stages. We’ll email you when your Console access is ready. You don’t need to sign up again.", + }, + "verify-email": { + title: "Verify your email to continue.", + description: + "Your sign-in provider hasn’t confirmed your email address yet. Verify it with your provider, then sign out and sign in again so we can safely connect your waitlist entry.", + }, + revoked: { + title: "Your Console access is paused.", + description: + "Your account no longer has early access. Signing in again won’t change that. Please contact the Livepeer team if you think this is a mistake.", + }, + disabled: { + title: "Your account is disabled.", + description: + "Console access is unavailable for this account. Please contact the Livepeer team for help.", + }, + unavailable: { + title: "We can’t check your access right now.", + description: + "This doesn’t mean your access was removed. Please try again in a moment. Your sign-in and waitlist membership are separate from this temporary check.", + }, +}; + +export function WaitingContent({ + state, + retryHref, + fromMcp = false, +}: { + state: WaitingState; + retryHref: string; + fromMcp?: boolean; +}) { + const copy = waitingCopy[state]; + return ( +
+
+ + Livepeer · Early access + +

+ {copy.title} +

+

+ {copy.description} +

+ {fromMcp ? ( +

+ Your agent connection has not been authorized. Once your access is + ready, restart the connection from your agent. +

+ ) : null} + + {state === "pending" ? ( + + Manage waitlist & email preferences + + ) : null} +

+ Waitlist membership does not subscribe you to marketing emails. +

+
+
+ ); +} diff --git a/app/access-pending/page.tsx b/app/access-pending/page.tsx new file mode 100644 index 0000000..336635d --- /dev/null +++ b/app/access-pending/page.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { getAuthenticatedIdentity } from "@/lib/authentication/session"; +import { consoleSignInHref, safeReturnTo } from "@/lib/console/auth-login"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { WaitingContent, type WaitingState } from "./content"; + +export const dynamic = "force-dynamic"; + +export default async function AccessPendingPage({ + searchParams, +}: { + searchParams: Promise<{ returnTo?: string; from?: string }>; +}) { + const params = await searchParams; + const requested = safeReturnTo(params.returnTo); + const returnTo = requested.startsWith("/access-pending") + ? "/home" + : requested; + let approved = false; + let unauthenticated = false; + let state: WaitingState = "unavailable"; + try { + await requireConsoleSession(); + approved = true; + } catch (error) { + const failure = error as { status?: number; code?: string } | null; + unauthenticated = failure?.status === 401; + if (failure?.code === "access_pending") { + state = "pending"; + try { + const identity = await getAuthenticatedIdentity(); + if (identity && (!identity.emailVerified || !identity.email)) + state = "verify-email"; + } catch { + state = "unavailable"; + } + } else if (failure?.code === "enrollment_attention_required") + state = "enrollment-attention"; + else if (failure?.code === "access_revoked") state = "revoked"; + else if ( + failure?.code === "access_disabled" || + failure?.code === "canonical_user_disabled" + ) + state = "disabled"; + } + if (unauthenticated) redirect(consoleSignInHref({ returnTo })); + if (approved) redirect(returnTo); + return ( + + ); +} diff --git a/app/api/access/status/route.ts b/app/api/access/status/route.ts new file mode 100644 index 0000000..eb8df20 --- /dev/null +++ b/app/api/access/status/route.ts @@ -0,0 +1,23 @@ +import { getAuthenticatedIdentity } from "@/lib/authentication/session"; +import { resolveProviderIdentity } from "@/lib/identity/provider-user"; +import { enrollAuthenticatedUser } from "@/lib/access/enrollment"; +import { getAccessDecision } from "@/lib/access/service"; +import { apiError } from "@/lib/admin/http"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET() { + try { + const identity = await getAuthenticatedIdentity(); + if (!identity) + return Response.json({ error: "unauthorized" }, { status: 401 }); + const canonical = await resolveProviderIdentity(identity); + await enrollAuthenticatedUser(identity, canonical); + const decision = await getAccessDecision(canonical.userId); + return Response.json(decision, { + status: decision.state === "unavailable" ? 503 : 200, + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/admin/access/route.ts b/app/api/admin/access/route.ts new file mode 100644 index 0000000..e16ac20 --- /dev/null +++ b/app/api/admin/access/route.ts @@ -0,0 +1,50 @@ +import { getAdminPrincipal } from "@/lib/admin/auth"; +import { apiError, requireSameOrigin } from "@/lib/admin/http"; +import { + bulkAccessSchema, + dispatchSelectionInvitations, + listAccessEntries, + mutateAccessSelection, + parseAccessFilters, +} from "@/lib/admin/access"; +import { after } from "next/server"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET(request: Request) { + try { + if (!(await getAdminPrincipal())) + return Response.json({ error: "admin_required" }, { status: 403 }); + return Response.json( + await listAccessEntries( + parseAccessFilters(new URL(request.url).searchParams) + ), + { 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) > 20_000) + return Response.json({ error: "selection_too_large" }, { status: 400 }); + const parsed = bulkAccessSchema.safeParse(await request.json()); + if (!parsed.success) + return Response.json({ error: "invalid_selection" }, { status: 400 }); + const result = await mutateAccessSelection(actor, parsed.data); + after(async () => { + try { + await dispatchSelectionInvitations(actor, result.requestId); + } catch { + console.error("approval_invitation_dispatch_deferred"); + } + }); + return Response.json(result, { headers: { "cache-control": "no-store" } }); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/admin/access/selection/route.ts b/app/api/admin/access/selection/route.ts new file mode 100644 index 0000000..1633f50 --- /dev/null +++ b/app/api/admin/access/selection/route.ts @@ -0,0 +1,19 @@ +import { getAdminPrincipal } from "@/lib/admin/auth"; +import { apiError } from "@/lib/admin/http"; +import { freezeAccessSelection, parseAccessFilters } from "@/lib/admin/access"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET(request: Request) { + try { + if (!(await getAdminPrincipal())) + return Response.json({ error: "admin_required" }, { status: 403 }); + return Response.json( + await freezeAccessSelection( + parseAccessFilters(new URL(request.url).searchParams) + ), + { headers: { "cache-control": "no-store" } } + ); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/admin/emails/route.ts b/app/api/admin/emails/route.ts new file mode 100644 index 0000000..6c9f900 --- /dev/null +++ b/app/api/admin/emails/route.ts @@ -0,0 +1,40 @@ +import { and, desc, eq, isNotNull } from "drizzle-orm"; +import { getAdminPrincipal } from "@/lib/admin/auth"; +import { apiError } from "@/lib/admin/http"; +import { getDb } from "@/lib/db"; +import { emailOutbox } from "@/lib/db/schema"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET() { + try { + if ( + process.env.VERCEL_ENV !== "preview" || + process.env.EMAIL_DELIVERY_MODE !== "capture" + ) + return new Response(null, { status: 404 }); + if (!(await getAdminPrincipal())) + return Response.json({ error: "admin_required" }, { status: 403 }); + const events = await getDb() + .select({ + id: emailOutbox.id, + eventType: emailOutbox.eventType, + payload: emailOutbox.payload, + createdAt: emailOutbox.createdAt, + }) + .from(emailOutbox) + .where( + and( + isNotNull(emailOutbox.processedAt), + eq(emailOutbox.lastErrorCode, "captured") + ) + ) + .orderBy(desc(emailOutbox.createdAt)) + .limit(100); + return Response.json( + { events }, + { headers: { "cache-control": "no-store" } } + ); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/admin/signups.csv/route.ts b/app/api/admin/signups.csv/route.ts new file mode 100644 index 0000000..d3c8399 --- /dev/null +++ b/app/api/admin/signups.csv/route.ts @@ -0,0 +1,57 @@ +import { getAdminWaitlistRows } from "@/lib/waitlist/admin"; +import { getAdminSession } from "@/lib/waitlist/admin-auth"; + +export const runtime = "nodejs"; + +function csvCell(value: string | number | boolean | null) { + let text = value === null ? "" : String(value); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +export async function GET() { + if (!(await getAdminSession())) { + return Response.json({ message: "Not found." }, { status: 404 }); + } + + const rows = await getAdminWaitlistRows(5000); + const header = [ + "email", + "status", + "newsletter_subscribed", + "referral_code", + "referred_by_email", + "verified_referrals", + "pending_referrals", + "points", + "first_seen_at", + "confirmed_at", + ]; + const lines = [ + header.map(csvCell).join(","), + ...rows.map((row) => + [ + row.email, + row.status, + row.marketingConsent, + row.referralCode, + row.referredByEmail, + row.verifiedReferrals, + row.pendingReferrals, + row.points, + row.firstSeenAt.toISOString(), + row.confirmedAt?.toISOString() ?? null, + ] + .map(csvCell) + .join(",") + ), + ]; + + return new Response(`\uFEFF${lines.join("\r\n")}\r\n`, { + headers: { + "cache-control": "private, no-store", + "content-disposition": 'attachment; filename="livepeer-waitlist.csv"', + "content-type": "text/csv; charset=utf-8", + }, + }); +} diff --git a/app/api/console/session/route.ts b/app/api/console/session/route.ts new file mode 100644 index 0000000..ec95c4e --- /dev/null +++ b/app/api/console/session/route.ts @@ -0,0 +1,28 @@ +import { requireConsoleSession } from "@/lib/console/session-user"; +import { apiError } from "@/lib/admin/http"; +import type { ConsoleSessionProfile } from "@/lib/platform/contracts"; +import { getAdminPrincipalForUser } from "@/lib/admin/permissions"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET() { + try { + const session = await requireConsoleSession(); + const strategy = session.identity.strategy; + const profile: ConsoleSessionProfile = { + userId: session.canonicalUserId, + externalUserId: session.externalUserId, + name: session.email?.split("@")[0] ?? "Member", + email: session.email ?? "", + isAdmin: !!(await getAdminPrincipalForUser(session.canonicalUserId)), + provider: + strategy === "github" || strategy === "google-oauth2" + ? strategy === "github" + ? "github" + : "google" + : "email", + }; + return Response.json(profile, { headers: { "cache-control": "no-store" } }); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/identity/sync/route.ts b/app/api/identity/sync/route.ts new file mode 100644 index 0000000..b52e7a2 --- /dev/null +++ b/app/api/identity/sync/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedIdentity } from "@/lib/authentication/session"; +import { resolveProviderIdentity } from "@/lib/identity/provider-user"; +import { enrollAuthenticatedUser } from "@/lib/access/enrollment"; +import { safeIdentityReturnTo } from "@/lib/identity/sync-return"; +import { getAccessDecision } from "@/lib/access/service"; +import { getAdminPrincipalForUser } from "@/lib/admin/permissions"; +import { + isProtocolReturnPath, + waitlistAuthLoginPath, + waitlistEnrollmentContext, +} from "@/lib/waitlist/auth-join"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest) { + const returnTo = safeIdentityReturnTo( + request.nextUrl.searchParams.get("returnTo") + ); + const identity = await getAuthenticatedIdentity(); + if (!identity) { + if (request.nextUrl.searchParams.get("from") === "waitlist") + return NextResponse.redirect( + new URL( + waitlistAuthLoginPath(request.nextUrl.searchParams), + request.url + ) + ); + const login = new URL("/login", request.url); + login.searchParams.set("returnTo", returnTo); + return NextResponse.redirect(login); + } + let destination = "/access-pending"; + try { + const canonical = await resolveProviderIdentity(identity); + await enrollAuthenticatedUser( + identity, + canonical, + request.nextUrl.searchParams.get("from") === "waitlist" + ? waitlistEnrollmentContext(request.nextUrl.searchParams) + : undefined + ); + const decision = await getAccessDecision(canonical.userId); + if (decision.state === "approved") + destination = (await getAdminPrincipalForUser(canonical.userId)) + ? "/admin" + : "/home"; + if (returnTo === "/waitlist") destination = "/waitlist"; + } catch (error) { + console.error("identity_sync_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + } + return NextResponse.redirect( + new URL( + isProtocolReturnPath(returnTo) ? returnTo : destination, + request.url + ) + ); +} diff --git a/app/api/internal/outbox/route.ts b/app/api/internal/outbox/route.ts new file mode 100644 index 0000000..d5afc32 --- /dev/null +++ b/app/api/internal/outbox/route.ts @@ -0,0 +1,23 @@ +import { dispatchPendingOutbox } from "@/lib/email/outbox"; +import { isAuthorizedOutboxRequest } from "@/lib/email/internal-auth"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + if (!isAuthorizedOutboxRequest(request)) { + return Response.json({ message: "Unauthorized." }, { status: 401 }); + } + + try { + const result = await dispatchPendingOutbox(); + return Response.json(result); + } catch (error) { + console.error("email_outbox_dispatch_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + return Response.json( + { message: "Outbox dispatch failed." }, + { status: 503 } + ); + } +} diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..2b4de4b --- /dev/null +++ b/app/api/logout/route.ts @@ -0,0 +1,11 @@ +import { apiError, requireSameOrigin } from "@/lib/admin/http"; +export const runtime = "nodejs"; +/** Compatibility redirect only; Auth0 is responsible for clearing authentication. */ +export function POST(request: Request) { + try { + requireSameOrigin(request); + return Response.redirect(new URL("/auth/logout", request.url), 303); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/mcp/oauth/callback/route.ts b/app/api/mcp/oauth/callback/route.ts index 6967d17..94e5e4c 100644 --- a/app/api/mcp/oauth/callback/route.ts +++ b/app/api/mcp/oauth/callback/route.ts @@ -1,12 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth0 } from "@/lib/auth0"; -import { externalUserIdFromSub } from "@/lib/console/external-user-id"; +import { + requireConsoleSession, + SessionRequiredError, +} from "@/lib/console/session-user"; import { issueAuthCode, parsePending, PKCE_COOKIE, - pkceCookieOptions + pkceCookieOptions, } from "@/lib/mcp/as"; export const runtime = "nodejs"; @@ -22,24 +24,36 @@ export async function GET(req: NextRequest) { return clear; } - const session = await auth0.getSession(); - const sub = session?.user?.sub?.trim(); - if (!session || !sub) { - const login = new URL("/auth/login", origin); - login.searchParams.set("returnTo", "/api/mcp/oauth/callback"); - return NextResponse.redirect(login); + let session; + try { + session = await requireConsoleSession(); + } catch (error) { + if (error instanceof SessionRequiredError) { + const login = new URL("/auth/login", origin); + login.searchParams.set("returnTo", "/api/mcp/oauth/callback"); + return NextResponse.redirect(login); + } + // This endpoint is a browser handoff, not a token API. Explain admission + // failure on the waiting page, and terminate rather than issue a code. + const response = NextResponse.redirect( + new URL("/access-pending?from=mcp", origin), + 302 + ); + response.headers.set("Cache-Control", "no-store"); + response.cookies.set(PKCE_COOKIE, "", { + ...pkceCookieOptions(), + maxAge: 0, + }); + return response; } - - const externalUserId = await externalUserIdFromSub(sub); - const email = session.user.email?.trim(); let code: string; try { code = issueAuthCode({ redirectUri: pending.redirectUri, codeChallenge: pending.codeChallenge, clientId: pending.clientId, - externalUserId, - email: email || undefined + externalUserId: session.externalUserId, + email: session.email, }); } catch { return clear; diff --git a/app/api/newsletter-consent/route.ts b/app/api/newsletter-consent/route.ts new file mode 100644 index 0000000..749c914 --- /dev/null +++ b/app/api/newsletter-consent/route.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { dispatchOutboxEvent } from "@/lib/email/outbox"; +import { changeNewsletterConsent } from "@/lib/subscriptions/service"; +import { getAuthenticatedWaitlistSignup } from "@/lib/waitlist/current-session"; +import { apiError, requireSameOrigin } from "@/lib/admin/http"; +export const runtime = "nodejs"; +const consentSchema = z.object({ newsletterOptIn: z.boolean() }); +export async function PUT(request: Request) { + try { + requireSameOrigin(request); + const current = await getAuthenticatedWaitlistSignup(); + if (!current) + return Response.json({ error: "unauthorized" }, { status: 401 }); + const parsed = consentSchema.safeParse(await request.json()); + if (!parsed.success) + return Response.json({ error: "invalid_preference" }, { status: 400 }); + const outboxId = await changeNewsletterConsent( + current.signup.id, + parsed.data.newsletterOptIn, + "home_panel" + ); + if (outboxId) { + try { + await dispatchOutboxEvent(outboxId); + } catch { + /* Durable retry retains the committed preference. */ + } + } + return Response.json(parsed.data); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/pymthouse/keys/exchange/route.ts b/app/api/pymthouse/keys/exchange/route.ts index 25a24bb..a5251b2 100644 --- a/app/api/pymthouse/keys/exchange/route.ts +++ b/app/api/pymthouse/keys/exchange/route.ts @@ -8,6 +8,10 @@ import { readPymthouseM2mConfig, readPublicClientId, } from "@/lib/console/pymthouse-http"; +import { verifyMcpUserJwt } from "@/lib/mcp/jwt"; +import { requireApprovedMcpAccount } from "@/lib/mcp/access"; +import { AccessError } from "@/lib/access/service"; +import { billingAppMismatch } from "@/lib/console/mcp-oauth-login-bridge"; const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; @@ -110,6 +114,23 @@ async function exchangeApiKeyViaOidcToken(input: { }); } + // The issuer is the only authority for opaque API-key ownership. Never expose + // its exchanged credentials before verifying their signed app-bound owner. + let principal; + try { + principal = await verifyMcpUserJwt(accessToken); + } catch (error) { + if (error instanceof AccessError) throw error; + throw new PmtHouseError( + "Token exchange did not establish a verified account", + { + status: 401, + code: "invalid_exchange_identity", + } + ); + } + await requireApprovedMcpAccount(principal.externalUserId); + // signer_url comes from the issuer exchange response (app signer routing). const signerUrl = readStringField(parsed, "signer_url"); @@ -137,6 +158,15 @@ async function exchangeApiKeyViaOidcToken(input: { } function errorResponse(error: unknown): Response { + if (error instanceof AccessError) { + return Response.json( + { error: error.code, error_description: error.message }, + { + status: error.status, + headers: { "Cache-Control": "no-store" }, + } + ); + } if (error instanceof PmtHouseError) { return Response.json( { @@ -155,6 +185,13 @@ function errorResponse(error: unknown): Response { } export async function POST(request: Request) { + const mismatch = billingAppMismatch(); + if (mismatch) { + return Response.json(mismatch, { + status: 503, + headers: { "Cache-Control": "no-store" }, + }); + } const config = readApiKeyExchangeConfig(); if (!config) { return Response.json( diff --git a/app/api/pymthouse/route-helpers.ts b/app/api/pymthouse/route-helpers.ts index c001888..48b88ea 100644 --- a/app/api/pymthouse/route-helpers.ts +++ b/app/api/pymthouse/route-helpers.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { PmtHouseError } from "@pymthouse/builder-sdk"; import { SessionRequiredError } from "@/lib/console/session-user"; +import { AccessError } from "@/lib/access/service"; export const PYMTHOUSE_NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0", @@ -10,7 +11,7 @@ export function pymthouseErrorResponse( error: unknown, fallback: string ): NextResponse { - if (error instanceof SessionRequiredError) { + if (error instanceof SessionRequiredError || error instanceof AccessError) { return NextResponse.json( { error: error.message, code: error.code }, { status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS } diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 0000000..2a819eb --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,19 @@ +import { getCurrentWaitlistSession } from "@/lib/waitlist/current-session"; +import { apiError } from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const session = await getCurrentWaitlistSession(); + if (!session) { + return Response.json( + { message: "Authentication required." }, + { status: 401 } + ); + } + return Response.json(session, { headers: { "cache-control": "no-store" } }); + } catch (error) { + return apiError(error); + } +} diff --git a/app/api/v1/auth/device/approve/route.ts b/app/api/v1/auth/device/approve/route.ts index 95c763d..f45f163 100644 --- a/app/api/v1/auth/device/approve/route.ts +++ b/app/api/v1/auth/device/approve/route.ts @@ -6,12 +6,14 @@ import { parseDeviceInitiateParams, } from "@/lib/console/device-approval"; import { requireConsoleSession } from "@/lib/console/session-user"; +import { requireSameOrigin } from "@/lib/admin/http"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest): Promise { try { + requireSameOrigin(request); const session = await requireConsoleSession(); const body = (await request.json()) as { iss?: unknown; diff --git a/app/api/waitlist/join/route.ts b/app/api/waitlist/join/route.ts new file mode 100644 index 0000000..37941f5 --- /dev/null +++ b/app/api/waitlist/join/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { waitlistAuthLoginPath } from "@/lib/waitlist/auth-join"; +export const runtime = "nodejs"; +export function GET(request: Request) { + return NextResponse.redirect( + new URL( + waitlistAuthLoginPath(new URL(request.url).searchParams), + request.url + ) + ); +} diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts new file mode 100644 index 0000000..13c7da2 --- /dev/null +++ b/app/api/waitlist/route.ts @@ -0,0 +1,12 @@ +export const runtime = "nodejs"; +/** Legacy anonymous enrollment/sign-in links are retired. This performs no writes. */ +export function POST() { + return Response.json( + { + error: "auth0_signin_required", + message: "Join or sign in with your Livepeer account.", + signInUrl: "/api/waitlist/join", + }, + { status: 410, headers: { "cache-control": "no-store" } } + ); +} diff --git a/app/globals.css b/app/globals.css index bda6a79..1daf275 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "./waitlist.css"; /* Custom variant: targets descendants of html[data-theme="light"]. Use as `light:bg-foo` to override values when the page theme is light. */ @@ -38,7 +40,8 @@ ); --font-inter: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; - --font-display: "Favorit", "Favorit Pro", var(--font-inter), sans-serif; + --font-display: + var(--font-favorit, "Favorit"), "Favorit Pro", var(--font-inter), sans-serif; --font-heading: var(--font-inter), Inter, sans-serif; --font-sans: var(--font-inter), Inter, ui-sans-serif, system-ui, sans-serif; --font-mono: "Favorit Mono", ui-monospace, monospace; @@ -572,7 +575,8 @@ textarea:-webkit-autofill { @theme inline { --font-inter: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; - --font-display: "Favorit", "Favorit Pro", var(--font-inter), sans-serif; + --font-display: + var(--font-favorit, "Favorit"), "Favorit Pro", var(--font-inter), sans-serif; --font-heading: var(--font-inter), Inter, sans-serif; --font-sans: var(--font-inter), Inter, ui-sans-serif, system-ui, sans-serif; --font-mono: "Favorit Mono", ui-monospace, monospace; diff --git a/app/token/route.ts b/app/token/route.ts index 8b8dbd5..99601de 100644 --- a/app/token/route.ts +++ b/app/token/route.ts @@ -6,6 +6,9 @@ import { BillingAppMismatchError, } from "@/lib/console/mcp-internal-mint"; import { redeemMcpRefreshToken } from "@/lib/console/mcp-oauth-login-bridge"; +import { AccessError } from "@/lib/access/service"; +import { requireApprovedMcpAccount } from "@/lib/mcp/access"; +import { consumeAuthorizationCode } from "@/lib/mcp/code-redemption"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,61 +19,64 @@ function json(req: Request, status: number, body: Record) { headers: { ...corsHeaders(req), "Cache-Control": "no-store" }, }); } - export function OPTIONS(req: NextRequest) { return new NextResponse(null, { status: 204, headers: corsHeaders(req) }); } - async function readParams(req: NextRequest): Promise { - const ctype = req.headers.get("content-type") ?? ""; - if (ctype.includes("application/json")) { + if ((req.headers.get("content-type") ?? "").includes("application/json")) { const parsed = (await req.json()) as Record; const params = new URLSearchParams(); - for (const [k, v] of Object.entries(parsed)) { - if (v == null) continue; - params.set(k, String(v)); - } + for (const [k, v] of Object.entries(parsed)) + if (v != null) params.set(k, String(v)); return params; } return new URLSearchParams(await req.text()); } - -/** Upstream shape only — never the whole error, whose `details` carry the raw PymtHouse body. */ -function describeMintError(error: unknown): string { - if (!(error instanceof Error)) return "unknown error"; - const { status, code } = error as { status?: number; code?: string }; - const tags = [error.name, code, status].filter(Boolean).join(" "); - return `${tags}: ${error.message}`; -} - async function mintTokens( req: NextRequest, - input: { externalUserId: string; email?: string } + input: { externalUserId: string; email?: string }, + authorizationCode?: { code: string; expiresAt: number } ) { try { + // Both code and refresh redemption use fresh application-owned admission. + await requireApprovedMcpAccount(input.externalUserId); + if ( + authorizationCode && + !(await consumeAuthorizationCode( + authorizationCode.code, + authorizationCode.expiresAt + )) + ) { + return json(req, 400, { error: "invalid_grant" }); + } const minted = await mintMcpUserTokens(input); return json(req, 200, { access_token: minted.access_token, refresh_token: minted.refresh_token, - token_type: minted.token_type, + token_type: minted.token_type ?? "Bearer", expires_in: minted.expires_in, ...(minted.scope ? { scope: minted.scope } : {}), }); } catch (error) { - if (error instanceof BillingAppMismatchError) { - return json(req, 503, { + if ( + error instanceof AccessError || + error instanceof BillingAppMismatchError + ) { + return json(req, error instanceof AccessError ? error.status : 503, { error: error.code, error_description: error.message, }); } - console.error(`mcp token mint failed — ${describeMintError(error)}`); + // Never print provider bodies, tokens, or upstream error messages. + console.error("mcp_token_mint_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); return json(req, 503, { error: "temporarily_unavailable", error_description: "failed to mint an access token", }); } } - export async function POST(req: NextRequest) { let params: URLSearchParams; try { @@ -78,56 +84,48 @@ export async function POST(req: NextRequest) { } catch { return json(req, 400, { error: "invalid_request" }); } - if (!isAllowedMcpResource(req, params.get("resource"))) { return json(req, 400, { error: "invalid_target", error_description: "resource does not match this MCP", }); } - const grantType = params.get("grant_type") ?? ""; - if (grantType === "refresh_token") { const refreshToken = params.get("refresh_token") ?? ""; - if (!refreshToken) { + if (!refreshToken) return json(req, 400, { error: "invalid_request", error_description: "refresh_token required", }); - } const externalUserId = redeemMcpRefreshToken(refreshToken); - if (!externalUserId) { - return json(req, 400, { error: "invalid_grant" }); - } + if (!externalUserId) return json(req, 400, { error: "invalid_grant" }); return mintTokens(req, { externalUserId }); } - - if (grantType !== "authorization_code") { + if (grantType !== "authorization_code") return json(req, 400, { error: "unsupported_grant_type" }); - } - + const code = params.get("code")?.trim() ?? ""; const outcome = validateAuthorizationCodeGrant({ - code: params.get("code")?.trim() ?? "", + code, redirectUri: params.get("redirect_uri")?.trim() ?? "", codeVerifier: params.get("code_verifier")?.trim() ?? "", clientId: params.get("client_id")?.trim() ?? "", }); if (!outcome.ok) { - // A malformed request may say why; a rejected grant may not — the reason - // would let a caller probe which half of the credential it got wrong. - if (outcome.error === "invalid_request") { + if (outcome.error === "invalid_request") return json(req, 400, { error: outcome.error, error_description: outcome.reason, }); - } console.warn(`mcp token ${outcome.error} — ${outcome.reason}`); return json(req, 400, { error: outcome.error }); } - - return mintTokens(req, { - externalUserId: outcome.grant.externalUserId, - email: outcome.grant.email, - }); + return mintTokens( + req, + { + externalUserId: outcome.grant.externalUserId, + email: outcome.grant.email, + }, + { code, expiresAt: outcome.grant.exp } + ); } diff --git a/app/verify/route.ts b/app/verify/route.ts new file mode 100644 index 0000000..3b775f5 --- /dev/null +++ b/app/verify/route.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { after } from "next/server"; + +import { captureEmailVerified } from "@/lib/analytics-server"; +import { getDb } from "@/lib/db"; +import { + pointEvents, + verificationTokens, + waitlistSignups, +} from "@/lib/db/schema"; +import { analyticsMemberId, hashToken } from "@/lib/waitlist/security"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const token = new URL(request.url).searchParams.get("token"); + if (!token) redirect("/waitlist?verification=invalid"); + + const db = getDb(); + const now = new Date(); + + const result = await db.transaction(async (tx) => { + const [verification] = await tx + .select({ + id: verificationTokens.id, + signupId: verificationTokens.signupId, + requestedMarketingConsent: verificationTokens.requestedMarketingConsent, + }) + .from(verificationTokens) + .where( + and( + eq(verificationTokens.tokenHash, hashToken(token)), + isNull(verificationTokens.consumedAt), + gt(verificationTokens.expiresAt, now) + ) + ) + .limit(1); + if (!verification) return null; + + const [signup] = await tx + .select() + .from(waitlistSignups) + .where(eq(waitlistSignups.id, verification.signupId)) + .for("update") + .limit(1); + if (!signup || !["pending", "confirmed"].includes(signup.status)) + return null; + // Signup-first lock order matches token issuance and preference mutations. + const [validToken] = await tx + .select() + .from(verificationTokens) + .where( + and( + eq(verificationTokens.id, verification.id), + isNull(verificationTokens.consumedAt), + gt(verificationTokens.expiresAt, now) + ) + ) + .for("update") + .limit(1); + if (!validToken) return null; + + await tx + .update(verificationTokens) + .set({ consumedAt: now }) + .where(eq(verificationTokens.id, verification.id)); + await tx + .update(waitlistSignups) + .set({ + status: "confirmed", + confirmedAt: now, + }) + .where( + and( + eq(waitlistSignups.id, signup.id), + eq(waitlistSignups.status, "pending") + ) + ); + // Legacy links confirm only their original record; Auth0 now owns sessions + // and authenticated preference changes. Never replay old requested consent. + + if (signup.referredBy && signup.referredBy !== signup.id) { + await tx + .insert(pointEvents) + .values({ + id: randomUUID(), + signupId: signup.referredBy, + points: 1, + reason: "verified_referral", + referralSignupId: signup.id, + }) + .onConflictDoNothing({ + target: [pointEvents.reason, pointEvents.referralSignupId], + }); + } + + return { + analyticsId: analyticsMemberId(signup.id), + verificationId: verification.id, + }; + }); + + if (!result) redirect("/waitlist?verification=invalid"); + after(async () => { + try { + await captureEmailVerified(result); + } catch (error) { + console.error("waitlist_verification_analytics_failed", { + errorType: error instanceof Error ? error.name : "unknown", + }); + } + }); + redirect("/waitlist?verification=confirmed"); +} diff --git a/app/waitlist.css b/app/waitlist.css new file mode 100644 index 0000000..946ebfe --- /dev/null +++ b/app/waitlist.css @@ -0,0 +1,325 @@ +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-inverse-foreground: var(--inverse-foreground); + --color-inverse-background: var(--inverse-background); + --text-ui-caption: 0.75rem; + --text-ui-caption--line-height: 1rem; + --text-ui-caption--font-weight: 500; + --text-ui-body: 0.875rem; + --text-ui-body--line-height: 1.25rem; + --text-ui-body--font-weight: 400; + --text-display-sm: 2.25rem; + --text-display-sm--line-height: 0.98; + --text-display-sm--letter-spacing: -0.045em; + --text-display-sm--font-weight: 300; + --text-display-md: 3rem; + --text-display-md--line-height: 0.98; + --text-display-md--letter-spacing: -0.045em; + --text-display-md--font-weight: 300; + --text-display-lg: 3.75rem; + --text-display-lg--line-height: 0.98; + --text-display-lg--letter-spacing: -0.045em; + --text-display-lg--font-weight: 300; + --text-display-fluid: clamp(2.5rem, 4.5vw, 4rem); + --text-display-fluid--line-height: 0.98; + --text-display-fluid--letter-spacing: -0.045em; + --text-display-fluid--font-weight: 300; + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); +} + +.waitlist-surface { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --inverse-background: oklch(0 0 0); + --inverse-foreground: oklch(1 0 0); + --radius: 0.625rem; + min-height: 100dvh; + background: var(--background); + color: var(--foreground); +} + +.waitlist-surface .dark, +.waitlist-surface.dark { + --background: oklch(0 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.16 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --inverse-background: oklch(1 0 0); + --inverse-foreground: oklch(0 0 0); +} + +.input-size-xs::placeholder { + font-size: 0.75rem; +} + +.animated-emerald-radial-frame { + background-color: #059669; +} + +.animated-emerald-radial-frame::before { + position: absolute; + inset: 0; + z-index: 0; + content: ""; + background-image: + radial-gradient(circle at 18% 18%, #a7f3d0 0%, transparent 34%), + radial-gradient(circle at 82% 28%, #34d399 0%, transparent 42%), + linear-gradient(135deg, #047857 0%, #10b981 38%, #6ee7b7 66%, #059669 100%); + background-position: 0% 0%; + background-size: 180% 180%; + filter: saturate(1.18) contrast(1.08); + animation: emerald-radial-drift 5.5s ease-in-out infinite alternate; +} + +@keyframes emerald-radial-drift { + from { + background-position: 0% 0%; + } + 50% { + background-position: 100% 45%; + } + to { + background-position: 20% 100%; + } +} + +@keyframes input-bump { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-4px); + } + 50% { + transform: translateX(4px); + } + 75% { + transform: translateX(-2px); + } +} + +.animate-input-bump { + animation: input-bump 280ms ease-out; +} + +@layer utilities { + @keyframes scene-frame-exit-up { + 0%, + 8% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + 48%, + 100% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-exit-down { + 0%, + 8% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + 48%, + 100% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-up { + 0%, + 12% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-down { + 0%, + 12% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-up-synced { + 0%, + 48% { + clip-path: inset(100% 0 0 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-frame-reveal-down-synced { + 0%, + 48% { + clip-path: inset(0 0 100% 0 round var(--scene-frame-radius)); + } + 92%, + 100% { + clip-path: inset(0 0 0 0 round var(--scene-frame-radius)); + } + } + @keyframes scene-content-exit-up { + 0%, + 8% { + opacity: 1; + transform: translateY(0); + } + 48%, + 100% { + opacity: 0; + transform: translateY(clamp(-5rem, -7svh, -3rem)); + } + } + @keyframes scene-content-enter-down { + 0%, + 48% { + opacity: 0; + transform: translateY(clamp(3rem, 7svh, 5rem)); + } + 92%, + 100% { + opacity: 1; + transform: translateY(0); + } + } + @keyframes scene-content-exit-down { + 0%, + 8% { + opacity: 1; + transform: translateY(0); + } + 48%, + 100% { + opacity: 0; + transform: translateY(clamp(3rem, 7svh, 5rem)); + } + } + @keyframes scene-content-enter-up { + 0%, + 48% { + opacity: 0; + transform: translateY(clamp(-5rem, -7svh, -3rem)); + } + 92%, + 100% { + opacity: 1; + transform: translateY(0); + } + } + @keyframes lock-submitted-email { + from { + width: min(60vw, 22rem); + } + to { + width: var(--submitted-email-width); + } + } + + .animate-lock-submitted-email { + width: var(--submitted-email-width); + animation: lock-submitted-email 360ms cubic-bezier(0.32, 0.72, 0, 1) both; + } + + .animate-scene-frame-exit-up, + .animate-scene-frame-exit-down, + .animate-scene-frame-reveal-up, + .animate-scene-frame-reveal-down, + .animate-scene-frame-reveal-up-synced, + .animate-scene-frame-reveal-down-synced, + .animate-scene-content-exit-up, + .animate-scene-content-enter-down, + .animate-scene-content-exit-down, + .animate-scene-content-enter-up { + animation-duration: 760ms; + animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); + animation-fill-mode: both; + } + + .animate-scene-frame-exit-up { + animation-name: scene-frame-exit-up; + } + .animate-scene-frame-exit-down { + animation-name: scene-frame-exit-down; + } + .animate-scene-frame-reveal-up { + animation-name: scene-frame-reveal-up; + } + .animate-scene-frame-reveal-down { + animation-name: scene-frame-reveal-down; + } + .animate-scene-frame-reveal-up-synced { + animation-name: scene-frame-reveal-up-synced; + } + .animate-scene-frame-reveal-down-synced { + animation-name: scene-frame-reveal-down-synced; + } + .animate-scene-content-exit-up { + animation-name: scene-content-exit-up; + } + .animate-scene-content-enter-down { + animation-name: scene-content-enter-down; + } + .animate-scene-content-exit-down { + animation-name: scene-content-exit-down; + } + .animate-scene-content-enter-up { + animation-name: scene-content-enter-up; + } + + .font-identifier { + font-feature-settings: + "liga" 0, + "calt" 0; + font-variant-ligatures: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .animated-emerald-radial-frame::before, + .animate-input-bump, + .animate-lock-submitted-email, + [class*="animate-scene-"] { + animation: none; + } +} diff --git a/assets/fonts/waitlist/FavoritMono-Regular.woff2 b/assets/fonts/waitlist/FavoritMono-Regular.woff2 new file mode 100644 index 0000000..6c1b5b1 Binary files /dev/null and b/assets/fonts/waitlist/FavoritMono-Regular.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Bold.woff2 b/assets/fonts/waitlist/FavoritPro-Bold.woff2 new file mode 100644 index 0000000..b2f1fe9 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Bold.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Book.woff2 b/assets/fonts/waitlist/FavoritPro-Book.woff2 new file mode 100644 index 0000000..6f24cb4 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Book.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Light.woff2 b/assets/fonts/waitlist/FavoritPro-Light.woff2 new file mode 100644 index 0000000..f8e2088 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Light.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Medium.woff2 b/assets/fonts/waitlist/FavoritPro-Medium.woff2 new file mode 100644 index 0000000..9385ee2 Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Medium.woff2 differ diff --git a/assets/fonts/waitlist/FavoritPro-Regular.woff2 b/assets/fonts/waitlist/FavoritPro-Regular.woff2 new file mode 100644 index 0000000..340d81c Binary files /dev/null and b/assets/fonts/waitlist/FavoritPro-Regular.woff2 differ diff --git a/assets/fonts/waitlist/InterVariable-Italic.woff2 b/assets/fonts/waitlist/InterVariable-Italic.woff2 new file mode 100644 index 0000000..b3530f3 Binary files /dev/null and b/assets/fonts/waitlist/InterVariable-Italic.woff2 differ diff --git a/assets/fonts/waitlist/InterVariable.woff2 b/assets/fonts/waitlist/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/assets/fonts/waitlist/InterVariable.woff2 differ diff --git a/components/admin/AccessManager.tsx b/components/admin/AccessManager.tsx new file mode 100644 index 0000000..6e51401 --- /dev/null +++ b/components/admin/AccessManager.tsx @@ -0,0 +1,506 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import type { + AccessAction, + AdminAccessList, + BulkAccessOutcome, + BulkAccessRequest, +} from "@/lib/platform/contracts"; +import { + freezeAccessRequests, + normalizeOutcomes, + retryableRequests, + toggleSelection, +} from "./access-selection"; + +const control = + "rounded border border-hairline bg-transparent px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40"; +type Filter = "waiting" | "approved" | "revoked" | "all"; + +export default function AccessManager() { + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("waiting"); + const [page, setPage] = useState(1); + const [list, setList] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [selecting, setSelecting] = useState(false); + const [error, setError] = useState(""); + const [reload, setReload] = useState(0); + const [confirmation, setConfirmation] = useState( + null + ); + const [batch, setBatch] = useState(null); + const [outcomes, setOutcomes] = useState([]); + const [working, setWorking] = useState(false); + const mutationLock = useRef(false); + const selectionLock = useRef(false); + const labels = useRef(new Map()); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setList(null); + setError(""); + const params = new URLSearchParams({ + search: query, + state: filter, + page: String(page), + pageSize: "50", + }); + void fetch(`/api/admin/access?${params}`, { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) + throw new Error( + response.status === 401 || response.status === 403 + ? "Your administrator session is unavailable. Sign in through the waitlist again." + : "Could not load entries. Try refreshing the list." + ); + const result = (await response.json()) as AdminAccessList; + if (controller.signal.aborted) return; + result.rows.forEach((row) => labels.current.set(row.id, row.email)); + setList(result); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setError( + cause instanceof Error ? cause.message : "Could not load entries." + ); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [filter, page, query, reload]); + + async function selectMatching() { + if (selectionLock.current || mutationLock.current) return; + selectionLock.current = true; + setSelecting(true); + setError(""); + // Capture filters now. The returned IDs remain fixed as filters/pages change. + const params = new URLSearchParams({ search: query, state: filter }); + try { + const response = await fetch(`/api/admin/access/selection?${params}`, { + cache: "no-store", + }); + if (!response.ok) + throw new Error( + "Could not freeze this selection. Nothing was changed." + ); + const result = (await response.json()) as { + signupIds: string[]; + total: number; + }; + if ( + !Array.isArray(result.signupIds) || + result.signupIds.some((id) => typeof id !== "string") || + result.signupIds.length !== result.total + ) + throw new Error( + "The selection response was incomplete. Nothing was changed." + ); + setSelected(new Set(result.signupIds)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Selection failed."); + } finally { + selectionLock.current = false; + setSelecting(false); + } + } + + function propose(action: AccessAction) { + if (selected.size && !mutationLock.current) + setConfirmation(freezeAccessRequests(selected, action)); + } + + async function execute( + requests: BulkAccessRequest[], + previous: BulkAccessOutcome[] = [] + ) { + if (mutationLock.current) return; + mutationLock.current = true; + setWorking(true); + setConfirmation(null); + setBatch(requests); + setError(""); + const merged = new Map(previous.map((item) => [item.signupId, item])); + try { + for (const request of retryableRequests(requests, previous)) { + let next: BulkAccessOutcome[]; + try { + const response = await fetch("/api/admin/access", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + next = response.ok + ? normalizeOutcomes(request, await response.json()) + : request.signupIds.map((signupId) => ({ + signupId, + outcome: "failed", + code: `http_${response.status}`, + })); + } catch { + next = request.signupIds.map((signupId) => ({ + signupId, + outcome: "failed", + code: "network_error", + })); + } + // A failed retry must not obscure a previously committed per-record success. + for (const item of next) { + const old = merged.get(item.signupId); + if (!old || old.outcome === "failed" || item.outcome !== "failed") + merged.set(item.signupId, item); + } + setOutcomes([...merged.values()]); + } + } finally { + mutationLock.current = false; + setWorking(false); + setReload((value) => value + 1); + } + } + + const locked = working || selecting || !!batch || !!confirmation; + const pageIds = list?.rows.map((row) => row.id) ?? []; + const allPageSelected = + pageIds.length > 0 && pageIds.every((id) => selected.has(id)); + const failed = outcomes.filter((item) => item.outcome === "failed").length; + const pages = Math.max(1, Math.ceil((list?.total ?? 0) / 50)); + + return ( +
+

+ Console access +

+

+ Approval unlocks Console and MCP. It does not grant administrator + permissions or marketing consent. +

+
{ + event.preventDefault(); + setQuery(search.trim()); + setPage(1); + }} + > + + + + +
+
+ + {selected.size} selected across all pages + + + + + +
+

+ Selections remain fixed when you change filters or pages. Review the + exact selection before confirming. +

+ {error && ( +

+ {error} +

+ )} +
+ + + + + {[ + "Email", + "Waitlist", + "Console access", + "Newsletter", + "Joined", + ].map((label) => ( + + ))} + + + + {list?.rows.map((row) => ( + + + + + + + + + ))} + {(!list || !list.rows.length) && ( + + + + )} + +
+ + setSelected((old) => + toggleSelection(old, pageIds, event.target.checked) + ) + } + /> + + {label} +
+ + setSelected((old) => + toggleSelection(old, [row.id], event.target.checked) + ) + } + /> + {row.email}{row.waitlistStatus} + {row.accessState === "pending" ? "Waiting" : row.accessState} + + {row.newsletterSubscribed ? "Subscribed" : "Not subscribed"} + + {new Date(row.joinedAt).toLocaleDateString()} +
+ {loading + ? "Loading entries…" + : error + ? "Entries unavailable." + : "No matching entries."} +
+
+
+ + + Page {page} of {pages} · {list?.total ?? 0} entries + + +
+ {batch && ( +
+

+ {working ? "Processing selection…" : "Selection processed."}{" "} + {outcomes.length} of{" "} + {batch.reduce( + (total, request) => total + request.signupIds.length, + 0 + )}{" "} + outcomes recorded; {failed} need retry. +

+

+ Retries reuse the original request IDs. Already completed approvals + do not send another invitation. +

+
+ + +
+
+ + Per-record outcomes and request IDs + +
    + {outcomes.map((item) => ( +
  • + {labels.current.get(item.signupId) ?? item.signupId}:{" "} + {item.outcome} + {item.code ? ` (${item.code})` : ""} +
  • + ))} +
+

+ Requests: {batch.map((request) => request.requestId).join(", ")} +

+
+
+ )} + { + 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} +
  • + ))} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/components/admin/access-selection.ts b/components/admin/access-selection.ts new file mode 100644 index 0000000..46785ee --- /dev/null +++ b/components/admin/access-selection.ts @@ -0,0 +1,75 @@ +import type { + AccessAction, + BulkAccessOutcome, + BulkAccessRequest, +} from "@/lib/platform/contracts"; + +/** Snapshot IDs, never a live filter. A retry must reuse these exact requests. */ +export function freezeAccessRequests( + ids: Iterable, + action: AccessAction, + nextId: () => string = () => crypto.randomUUID() +): BulkAccessRequest[] { + const frozen = [...new Set(ids)].sort(); + return Array.from({ length: Math.ceil(frozen.length / 100) }, (_, index) => ({ + requestId: nextId(), + action, + signupIds: frozen.slice(index * 100, (index + 1) * 100), + })); +} + +export function toggleSelection( + selected: ReadonlySet, + ids: string[], + checked: boolean +) { + const next = new Set(selected); + for (const id of ids) { + if (checked) next.add(id); + else next.delete(id); + } + return next; +} + +/** Missing or malformed server outcomes are retryable failures, not successes. */ +export function normalizeOutcomes( + request: BulkAccessRequest, + value: unknown +): BulkAccessOutcome[] { + const body = value as { requestId?: unknown; outcomes?: unknown } | null; + const outcomes = + body?.requestId === request.requestId && Array.isArray(body.outcomes) + ? (body.outcomes as BulkAccessOutcome[]) + : []; + return request.signupIds.map((signupId) => { + const matches = outcomes.filter( + (item) => item && item.signupId === signupId + ); + const outcome = matches[0]; + return matches.length === 1 && + ["approved", "revoked", "unchanged", "ineligible", "failed"].includes( + outcome?.outcome + ) + ? { + signupId, + outcome: outcome.outcome, + ...(typeof outcome.code === "string" ? { code: outcome.code } : {}), + } + : { signupId, outcome: "failed", code: "invalid_response" }; + }); +} + +export function retryableRequests( + requests: BulkAccessRequest[], + outcomes: BulkAccessOutcome[] +) { + const results = new Map( + outcomes.map((item) => [item.signupId, item.outcome]) + ); + // Never shrink a chunk: the server binds its idempotency key to its full payload. + return requests.filter((request) => + request.signupIds.some( + (id) => !results.has(id) || results.get(id) === "failed" + ) + ); +} diff --git a/components/brand.tsx b/components/brand.tsx new file mode 100644 index 0000000..84202bb --- /dev/null +++ b/components/brand.tsx @@ -0,0 +1,247 @@ +import * as React from "react"; + +const symbolPaths = ( + <> + + + + + + + +); + +const wordmarkPaths = ( + <> + + + + + + + + + +); + +function LivepeerSymbol(props: React.SVGProps) { + return ( + + {symbolPaths} + + ); +} + +function LivepeerGradientSymbol(props: React.SVGProps) { + const gradientId = React.useId().replaceAll(":", ""); + + return ( + + + + + + + + + {symbolPaths} + + ); +} + +function LivepeerWordmark(props: React.SVGProps) { + return ( + + {wordmarkPaths} + + ); +} + +function LivepeerForegroundGradientWordmark( + props: React.SVGProps +) { + const gradientId = React.useId().replaceAll(":", ""); + + return ( + + + + + + + + {wordmarkPaths} + + ); +} + +function LivepeerLogo({ className, ...props }: React.ComponentProps<"span">) { + return ( + + {/* + ); +} + +function LivepeerLockup(props: React.SVGProps) { + return ( + + {symbolPaths} + {wordmarkPaths} + + ); +} + +function LivepeerGradientLockup({ + metallic = false, + ...props +}: React.SVGProps & { metallic?: boolean }) { + const gradientId = React.useId().replaceAll(":", ""); + const wordmarkGradientId = `${gradientId}-wordmark`; + const metallicSymbolGradientId = `${gradientId}-metallic-symbol`; + + return ( + + + + + + + + + + + + + + + + + + + + + + {symbolPaths} + + + {wordmarkPaths} + + + ); +} + +function AgentWordmark(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +function RegistryUiMark(props: React.SVGProps) { + return ( + + + + + ); +} + +export { + LivepeerSymbol, + LivepeerGradientSymbol, + LivepeerWordmark, + LivepeerForegroundGradientWordmark, + LivepeerLogo, + LivepeerLockup, + LivepeerGradientLockup, + AgentWordmark, + RegistryUiMark, +}; diff --git a/components/console/AuthContext.tsx b/components/console/AuthContext.tsx index 059f845..c73a30f 100644 --- a/components/console/AuthContext.tsx +++ b/components/console/AuthContext.tsx @@ -9,108 +9,88 @@ import { useState, type ReactNode, } from "react"; -import { useUser } from "@auth0/nextjs-auth0/client"; -import { externalUserIdFromSub } from "@/lib/console/external-user-id"; +import type { ConsoleSessionProfile } from "@/lib/platform/contracts"; export type AuthProvider = "github" | "google" | "email"; - export interface ConsoleUser { - /** PymtHouse externalUserId — `eu_`. */ + /** Persisted app-scoped PymtHouse external account ID, supplied by the server. */ id: string; + canonicalUserId: string; + isAdmin: boolean; name: string; email: string; initials: string; provider: AuthProvider; avatarUrl?: string; } - interface AuthContextValue { isConnected: boolean; isLoading: boolean; user: ConsoleUser | null; disconnect: () => void; } - const AuthContext = createContext({ isConnected: false, isLoading: true, user: null, disconnect: () => {}, }); - export function useAuth() { return useContext(AuthContext); } -function getInitials(name: string): string { - return name - .split(/\s+/) - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2); -} - -function displayNameFrom(email: string, preferredName?: string): string { - const trimmed = preferredName?.trim(); - if (trimmed) return trimmed; - return email.split("@")[0] || "User"; -} - -function providerFromSub(sub?: string): AuthProvider { - if (sub?.startsWith("github|")) return "github"; - if (sub?.startsWith("google-oauth2|")) return "google"; - return "email"; -} - export function AuthProvider({ children }: { children: ReactNode }) { - const { user: auth0User, isLoading: auth0Loading } = useUser(); - const [externalUserId, setExternalUserId] = useState(null); - + const [profile, setProfile] = useState(null); + const [isLoading, setLoading] = useState(true); useEffect(() => { - const sub = auth0User?.sub; - if (!sub) { - setExternalUserId(null); - return; - } - let cancelled = false; - void externalUserIdFromSub(sub).then((id) => { - if (!cancelled) setExternalUserId(id); - }); - return () => { - cancelled = true; - }; - }, [auth0User?.sub]); - - const user = useMemo(() => { - if (!auth0User || !externalUserId) return null; - const email = auth0User.email?.trim() || ""; - const name = displayNameFrom( - email, - auth0User.name?.trim() || auth0User.nickname?.trim() - ); - return { - id: externalUserId, - name, - email, - initials: getInitials(name) || "U", - provider: providerFromSub(auth0User.sub), - avatarUrl: auth0User.picture, - }; - }, [auth0User, externalUserId]); - + const controller = new AbortController(); + void fetch("/api/console/session", { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) return null; + return (await response.json()) as ConsoleSessionProfile; + }) + .then((value) => { + if (!controller.signal.aborted) setProfile(value); + }) + .catch(() => { + if (!controller.signal.aborted) setProfile(null); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, []); + const user = useMemo( + () => + profile + ? { + id: profile.externalUserId, + canonicalUserId: profile.userId, + isAdmin: profile.isAdmin === true, + name: profile.name, + email: profile.email, + provider: profile.provider, + avatarUrl: profile.avatarUrl, + initials: + profile.name + .split(/\s+/) + .map((part) => part[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "U", + } + : null, + [profile] + ); const disconnect = useCallback(() => { window.location.assign("/auth/logout"); }, []); - return ( {children} diff --git a/components/console/ConsoleSidebar.tsx b/components/console/ConsoleSidebar.tsx index 6a66a36..c7b6111 100644 --- a/components/console/ConsoleSidebar.tsx +++ b/components/console/ConsoleSidebar.tsx @@ -12,7 +12,17 @@ import { useAuth, type ConsoleUser } from "@/components/console/AuthContext"; import Drawer from "@/components/design-system/Drawer"; import NavLink from "@/components/console/NavLink"; -type PortalNavItem = (typeof PORTAL_NAV_ITEMS)[number]; +type PortalNavItem = { + href: string; + label: string; + zone: "network" | "organization"; +}; + +const ADMIN_NAV_ITEM: PortalNavItem = { + label: "Admin", + href: "/admin", + zone: "organization", +}; function getNavActive(itemHref: string, pathname: string): boolean { if (itemHref === "/home") return pathname === "/home"; @@ -91,10 +101,7 @@ function MobileBrandLink({ className="inline-flex h-10 items-center rounded-sm p-1.5 text-foreground" onClick={onNavigate} > -