import { useState, type ReactNode } from "react"; import { Cause, Exit, Option, Predicate } from "effect"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { toast } from "sonner"; import type { ApiKeySummary as ApiKeySummarySchema } from "@executor-js/api"; import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys"; import { trackEvent } from "../api/analytics"; import { apiKeysAtom, createApiKey, createOrgApiKey, orgApiKeysAtom, revokeApiKey, revokeOrgApiKey, } from "../api/account-atoms"; import { useIsTenantAdmin } from "../multiplayer/use-admin-nav"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; import { CopyButton } from "../components/copy-button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/dialog"; import { Input } from "../components/input"; import { Label } from "../components/label"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; // --------------------------------------------------------------------------- // Shared API-keys page. Reads/writes the provider-neutral `/account/api-keys` // surface, so it works identically on cloud (WorkOS) and self-host (Better // Auth). API keys are how a user authenticates the Executor API + MCP endpoint // from scripts/agents (Authorization: Bearer ). // // `orgKeysSection` is a host slot: the section component lives in this file // (its contract is the shared account API), but only hosts that mint org keys // mount it — cloud passes ``, self-host passes nothing // because its provider refuses org keys (`/admin/*` there is gated on an // owner/admin session instead). // --------------------------------------------------------------------------- type ApiKeySummary = typeof ApiKeySummarySchema.Type; type CreatedKey = ApiKeySummary & { readonly value: string }; const formatDate = (value: string | null): string => { if (!value) return "Never"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", }).format(date); }; const defaultKeyName = (kind: string): string => `${kind} ${new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", }).format(new Date())}`; /** The shared list markup — both key sections render the same columns. */ function KeyTable(props: { readonly keys: readonly ApiKeySummary[]; readonly revokingId: string | null; readonly onRevoke: (key: ApiKeySummary) => void; }) { return (
Name Created Last used Actions
{props.keys.map((key) => (

{key.name}

{key.obfuscatedValue}

{formatDate(key.createdAt)}

{formatDate(key.lastUsedAt)}

))}
); } /** The one-time reveal of a freshly minted key's value + Bearer header. */ function CreatedKeyReveal(props: { readonly value: string; readonly onCopy: (kind: "value" | "bearer_header") => void; }) { return (
props.onCopy("value")} />
props.onCopy("bearer_header")} />

Send this value as a Bearer token. It is only shown once.

); } /** * The create dialog's state-bearing body, shared by both key kinds. Remounted * per open via a key bump (self-contained modal): form and in-flight state are * destroyed on close instead of hand-reset, while the Dialog shell stays * mounted so Radix's exit animation keeps its last frame. A create resolving * after close therefore lands on an unmounted component instead of wedging the * next open on the previous key's reveal. */ function CreateKeyDialogBody(props: { readonly title: string; readonly description: string; readonly defaultName: string; readonly placeholder: string; readonly onCreate: (name: string) => Promise; readonly onCopy: (kind: "value" | "bearer_header") => void; }) { const [name, setName] = useState(props.defaultName); const [createdKey, setCreatedKey] = useState(null); const [creating, setCreating] = useState(false); const handleCreate = async () => { const trimmed = name.trim(); if (!trimmed) return; setCreating(true); const created = await props.onCreate(trimmed); setCreating(false); if (created) setCreatedKey(created); }; return ( <> {props.title} {props.description} {createdKey ? ( ) : (
setName(event.target.value)} placeholder={props.placeholder} maxLength={80} autoFocus />
)} {!createdKey && ( )} ); } export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { useExecutorDocumentTitle("API keys"); const result = useAtomValue(apiKeysAtom); const refreshApiKeys = useAtomRefresh(apiKeysAtom); const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" }); const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" }); const [createOpen, setCreateOpen] = useState(false); // Bumped per open so the dialog body remounts with fresh state; the shell // stays mounted for Radix's exit animation (see CreateKeyDialogBody). const [openCount, setOpenCount] = useState(0); const [revokingId, setRevokingId] = useState(null); const [confirmRevoke, setConfirmRevoke] = useState(null); const handleCreate = async (name: string): Promise => { const exit = await doCreate({ payload: { name }, reactivityKeys: apiKeyWriteKeys }); trackEvent("api_key_created", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success("API key created"); return exit.value; } toast.error("Failed to create API key"); return null; }; const handleRevoke = async (key: ApiKeySummary) => { setRevokingId(key.id); const exit = await doRevoke({ params: { apiKeyId: key.id }, reactivityKeys: apiKeyWriteKeys }); setRevokingId(null); trackEvent("api_key_revoked", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success(`Revoked ${key.name}`); return; } toast.error("Failed to revoke API key"); }; return ( { setOpenCount((count) => count + 1); setCreateOpen(true); }} > New key } >
Authorization: Bearer <api-key>

Personal keys

Personal keys work like personal access tokens: they act as you, in this organization, with full access to your own account.

{isAsyncResultLoading(result) ? (
Loading API keys...
) : ( AsyncResult.match(result, { onInitial: () => (
Loading API keys...
), onFailure: () => ( ), onSuccess: ({ value }) => value.apiKeys.length === 0 ? (

No API keys

Create a key and send it in the Authorization Bearer header.

) : ( setConfirmRevoke(key)} /> ), }) )}
{props.orgKeysSection} trackEvent("api_key_copied", { kind })} /> {/* A personal key revoke breaks any script or tool using it, so it asks first — same as the org-key revoke. */} { if (!open) setConfirmRevoke(null); }} > Revoke API key {confirmRevoke ? `Revoke ${confirmRevoke.name}? Any script or tool authenticating with it loses access immediately. This cannot be undone.` : ""}
); } // --------------------------------------------------------------------------- // Organization keys — the admin-only, org-owned credentials for the read-only // admin API (`/api/admin/*`). A separate section rather than rows in the table // above because the two key kinds answer different questions: a personal key // acts AS the member who minted it on the product plane; an org key has no // member behind it and reads the whole tenant. // // The admin gate here only HIDES the section — the server enforces the real // one (403 for a plain member), and that refusal renders as an explicit // denial below, never as a retryable failure. // --------------------------------------------------------------------------- /** 403 (or no-org) from the org-key surface: the caller is not an active admin * of this org. A denial, not a transient failure — the client-side role gate * can disagree with the server (role change inside the member list's TTL; * `owner` accepted client-side where cloud requires `admin`), and offering * Retry on a refusal would only re-fail forever. */ const isOrgKeysAccessDenied = (cause: Cause.Cause): boolean => Option.match(Cause.findErrorOption(cause), { onNone: () => false, onSome: (error) => Predicate.isTagged(error, "AccountForbidden") || Predicate.isTagged(error, "AccountNoOrganization"), }); export function OrgApiKeysSection() { // Gate BEFORE mounting the body: `orgApiKeysAtom` starts its fetch when the // reading component mounts, and for a plain member that request is a // guaranteed 403. Fail-closed like the admin nav — while the member list is // loading, show nothing rather than a section that will refuse. const isAdmin = useIsTenantAdmin(); return isAdmin ? : null; } function OrgApiKeysSectionBody() { const result = useAtomValue(orgApiKeysAtom); const refresh = useAtomRefresh(orgApiKeysAtom); const doCreate = useAtomSet(createOrgApiKey, { mode: "promiseExit" }); const doRevoke = useAtomSet(revokeOrgApiKey, { mode: "promiseExit" }); const [createOpen, setCreateOpen] = useState(false); // Same remount-per-open discipline as the personal dialog above. const [openCount, setOpenCount] = useState(0); const [confirmRevoke, setConfirmRevoke] = useState(null); const [revokingId, setRevokingId] = useState(null); const handleCreate = async (name: string): Promise => { const exit = await doCreate({ payload: { name }, reactivityKeys: orgApiKeyWriteKeys }); trackEvent("org_api_key_created", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success("Organization key created"); return exit.value; } toast.error("Failed to create organization key"); return null; }; const handleRevoke = async (key: ApiKeySummary) => { setConfirmRevoke(null); setRevokingId(key.id); const exit = await doRevoke({ params: { apiKeyId: key.id }, reactivityKeys: orgApiKeyWriteKeys, }); setRevokingId(null); trackEvent("org_api_key_revoked", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success(`Revoked ${key.name}`); return; } toast.error("Failed to revoke organization key"); }; return (

Organization keys

Read-only keys owned by the organization, not a member. They authenticate the admin API (who are my users, what have they connected) and cannot act as anyone or write anything. Admins only.

{isAsyncResultLoading(result) ? (
Loading organization keys...
) : ( AsyncResult.match(result, { onInitial: () => (
Loading organization keys...
), onFailure: ({ cause }) => isOrgKeysAccessDenied(cause) ? (

Admin only

You don't have access to this organization's keys

Managing organization keys requires an active admin role. Ask an admin of this organization if you need one.

) : ( ), onSuccess: ({ value }) => value.apiKeys.length === 0 ? (

No organization keys

Create one to call the admin API from your backend.

) : ( setConfirmRevoke(key)} /> ), }) )} trackEvent("org_api_key_copied", { kind })} /> {/* Revoking an org key breaks every backend using it, so it asks first. */} { if (!open) setConfirmRevoke(null); }} > {/* A confirmation with nothing to lose: clicking away cancels it. */} Revoke organization key {confirmRevoke ? `Revoke ${confirmRevoke.name}? Anything authenticating with it loses admin API access immediately. This cannot be undone.` : ""}
); }