History

Show and mint organization API keys in the console

Rhys Sullivan committed 3 months ago

Commit d37fd891 parent 676af1a5 files · +316 −80Browse files

Unified · Split

5 files
  1. apps/cloud/src/routes/app/api-keys.tsx +9 −3
  2. packages/react/src/api/account-atoms.tsx +13 −0
  3. packages/react/src/api/analytics.tsx +3 −0
  4. packages/react/src/api/reactivity-keys.tsx +6 −0
  5. packages/react/src/pages/api-keys.tsx +285 −77
apps/cloud/src/routes/app/api-keys.tsx+9−3
@@ -1,8 +1,14 @@
11 import { createFileRoute } from "@tanstack/react-router";
2−import { ApiKeysPage } from "@executor-js/react/pages/api-keys";
2+import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys";
33
44 // Cloud renders the SHARED API-keys page over the provider-neutral
5−// `/account/api-keys` surface — identical UI to self-host.
5+// `/account/api-keys` surface — identical UI to self-host, plus the
6+// cloud-only Organization keys section (self-host's provider refuses
7+// org keys; its admin plane is session-gated instead).
68 export const Route = createFileRoute("/{-$orgSlug}/api-keys")({
7− component: ApiKeysPage,
9+ component: CloudApiKeysPage,
810 });
11+
12+function CloudApiKeysPage() {
13+ return <ApiKeysPage orgKeysSection={<OrgApiKeysSection />} />;
14+}
packages/react/src/api/account-atoms.tsx+13−0
@@ -26,6 +26,19 @@ export const apiKeysAtom = AccountApiClient.query("account", "listApiKeys", { · 25 unmodified lines
2626 export const createApiKey = AccountApiClient.mutation("account", "createApiKey");
2727 export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey");
2828
29+// ── Org API keys ────────────────────────────────────────────────────────────
30+
31+// Admin-gated on the server (403 for a plain member) and refused entirely on
32+// self-host. The page only reads this atom for an admin on a host that mints
33+// org keys, so a failure here renders as the section's error state, not as a
34+// role probe.
35+export const orgApiKeysAtom = AccountApiClient.query("account", "listOrgApiKeys", {
36+ reactivityKeys: [ReactivityKey.orgApiKeys],
37+});
38+
39+export const createOrgApiKey = AccountApiClient.mutation("account", "createOrgApiKey");
40+export const revokeOrgApiKey = AccountApiClient.mutation("account", "revokeOrgApiKey");
41+
2942 // ── Organization members ─────────────────────────────────────────────────────
3043
3144 // `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to
packages/react/src/api/analytics.tsx+3−0
@@ -129,6 +129,9 @@ export interface AnalyticsEvents { · 128 unmodified lines
129129 api_key_created: { success: boolean };
130130 api_key_revoked: { success: boolean };
131131 api_key_copied: { kind: "value" | "bearer_header" };
132+ org_api_key_created: { success: boolean };
133+ org_api_key_revoked: { success: boolean };
134+ org_api_key_copied: { kind: "value" | "bearer_header" };
132135
133136 // ── Organization ─────────────────────────────────────────────────────────
134137 org_renamed: { success: boolean };
packages/react/src/api/reactivity-keys.tsx+6−0
@@ -37,6 +37,9 @@ export const ReactivityKey = { · 36 unmodified lines
3737 orgDomains: "org:domains",
3838 orgInfo: "org:info",
3939 apiKeys: "api-keys",
40+ /** Org-owned keys (the platform credential) — separate from `apiKeys` so
41+ * minting one does not refetch every member's personal-key list. */
42+ orgApiKeys: "org:api-keys",
4043 auth: "auth",
4144 /** The tenant-wide admin users view. Read-only today (the admin plane has no
4245 * writes), so nothing invalidates it — it exists so the pages refresh
@@ -89,6 +92,9 @@ export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as c · 46 unmodified lines
8992 /** Cloud-only: user API key mutations. */
9093 export const apiKeyWriteKeys = [ReactivityKey.apiKeys] as const;
9194
95+/** Cloud-only: org API key mutations. */
96+export const orgApiKeyWriteKeys = [ReactivityKey.orgApiKeys] as const;
97+
9298 /** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */
9399 export const authWriteKeys = [
94100 ReactivityKey.auth,
packages/react/src/pages/api-keys.tsx+285−77
@@ -1,11 +1,19 @@
1−import { useState } from "react";
1+import { useState, type ReactNode } from "react";
22 import { Exit } from "effect";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
55 import { toast } from "sonner";
6−import { apiKeyWriteKeys } from "../api/reactivity-keys";
6+import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys";
77 import { trackEvent } from "../api/analytics";
8−import { apiKeysAtom, createApiKey, revokeApiKey } from "../api/account-atoms";
8+import {
9+ apiKeysAtom,
10+ createApiKey,
11+ createOrgApiKey,
12+ orgApiKeysAtom,
13+ revokeApiKey,
14+ revokeOrgApiKey,
15+} from "../api/account-atoms";
16+import { useIsTenantAdmin } from "../multiplayer/use-admin-nav";
917 import { Button } from "../components/button";
1018 import { PageContainer, PageHeader } from "../components/page";
1119 import { CopyButton } from "../components/copy-button";
@@ -29,6 +37,10 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 17 unmodified lines
2937 // surface, so it works identically on cloud (WorkOS) and self-host (Better
3038 // Auth). API keys are how a user authenticates the Executor API + MCP endpoint
3139 // from scripts/agents (Authorization: Bearer <key>).
40+//
41+// `orgKeysSection` is a host slot (the OrgPage pattern): cloud passes
42+// `<OrgApiKeysSection />` below; self-host passes nothing because its provider
43+// refuses org keys (`/admin/*` there is gated on an owner/admin session).
3244 // ---------------------------------------------------------------------------
3345
3446 type ApiKeySummary = {
@@ -60,7 +72,88 @@ const defaultApiKeyName = (): string => · 25 unmodified lines
6072 year: "numeric",
6173 }).format(new Date())}`;
6274
63−export function ApiKeysPage() {
75+/** The shared list markup — both key sections render the same columns. */
76+function KeyTable(props: {
77+ readonly keys: readonly ApiKeySummary[];
78+ readonly revokingId: string | null;
79+ readonly onRevoke: (key: ApiKeySummary) => void;
80+}) {
81+ return (
82+ <div className="overflow-hidden rounded-md border border-border bg-card">
83+ <div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground md:grid-cols-[1.4fr_1fr_1fr_auto]">
84+ <span>Name</span>
85+ <span className="hidden md:block">Created</span>
86+ <span className="hidden md:block">Last used</span>
87+ <span className="text-right">Actions</span>
88+ </div>
89+ {props.keys.map((key) => (
90+ <div
91+ key={key.id}
92+ className="grid grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 last:border-b-0 md:grid-cols-[1.4fr_1fr_1fr_auto]"
93+ >
94+ <div className="min-w-0">
95+ <p className="truncate text-sm font-medium text-foreground">{key.name}</p>
96+ <p className="mt-1 font-mono text-xs text-muted-foreground">{key.obfuscatedValue}</p>
97+ </div>
98+ <p className="hidden text-sm text-muted-foreground md:block">
99+ {formatDate(key.createdAt)}
100+ </p>
101+ <p className="hidden text-sm text-muted-foreground md:block">
102+ {formatDate(key.lastUsedAt)}
103+ </p>
104+ <Button
105+ variant="ghost"
106+ size="icon-sm"
107+ onClick={() => props.onRevoke(key)}
108+ disabled={props.revokingId === key.id}
109+ title={`Revoke ${key.name}`}
110+ className="text-muted-foreground hover:text-destructive"
111+ >
112+ <span aria-hidden="true">×</span>
113+ </Button>
114+ </div>
115+ ))}
116+ </div>
117+ );
118+}
119+
120+/** The one-time reveal of a freshly minted key's value + Bearer header. */
121+function CreatedKeyReveal(props: {
122+ readonly value: string;
123+ readonly onCopy: (kind: "value" | "bearer_header") => void;
124+}) {
125+ return (
126+ <div className="grid gap-4 py-3">
127+ <div className="grid gap-1.5">
128+ <Label className="text-sm font-medium text-foreground">New key</Label>
129+ <div className="flex items-center gap-2">
130+ <Input value={props.value} readOnly className="font-mono text-xs" data-ph-mask />
131+ <CopyButton value={props.value} onCopy={() => props.onCopy("value")} />
132+ </div>
133+ </div>
134+ <div className="grid gap-1.5">
135+ <Label className="text-sm font-medium text-foreground">Bearer header</Label>
136+ <div className="flex items-center gap-2">
137+ <Input
138+ value={`Authorization: Bearer ${props.value}`}
139+ readOnly
140+ className="font-mono text-xs"
141+ data-ph-mask
142+ />
143+ <CopyButton
144+ value={`Authorization: Bearer ${props.value}`}
145+ onCopy={() => props.onCopy("bearer_header")}
146+ />
147+ </div>
148+ </div>
149+ <p className="text-sm leading-6 text-muted-foreground">
150+ Send this value as a Bearer token. It is only shown once.
151+ </p>
152+ </div>
153+ );
154+}
155+
156+export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {}) {
64157 useExecutorDocumentTitle("API keys");
65158 const result = useAtomValue(apiKeysAtom);
66159 const refreshApiKeys = useAtomRefresh(apiKeysAtom);
@@ -133,7 +226,8 @@ export function ApiKeysPage() { · 66 unmodified lines
133226 <CopyButton value="Authorization: Bearer <api-key>" />
134227 </div>
135228 <p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
136− API keys work like personal access tokens and have full access to your account.
229+ API keys work like personal access tokens: they act as you, in this organization, with
230+ full access to your own account.
137231 </p>
138232 </PageHeader>
139233
@@ -160,47 +254,13 @@ export function ApiKeysPage() { · 20 unmodified lines
160254 </p>
161255 </div>
162256 ) : (
163− <div className="overflow-hidden rounded-md border border-border bg-card">
164− <div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground md:grid-cols-[1.4fr_1fr_1fr_auto]">
165− <span>Name</span>
166− <span className="hidden md:block">Created</span>
167− <span className="hidden md:block">Last used</span>
168− <span className="text-right">Actions</span>
169− </div>
170− {value.apiKeys.map((key: ApiKeySummary) => (
171− <div
172− key={key.id}
173− className="grid grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 last:border-b-0 md:grid-cols-[1.4fr_1fr_1fr_auto]"
174− >
175− <div className="min-w-0">
176− <p className="truncate text-sm font-medium text-foreground">{key.name}</p>
177− <p className="mt-1 font-mono text-xs text-muted-foreground">
178− {key.obfuscatedValue}
179− </p>
180− </div>
181− <p className="hidden text-sm text-muted-foreground md:block">
182− {formatDate(key.createdAt)}
183− </p>
184− <p className="hidden text-sm text-muted-foreground md:block">
185− {formatDate(key.lastUsedAt)}
186− </p>
187− <Button
188− variant="ghost"
189− size="icon-sm"
190− onClick={() => handleRevoke(key)}
191− disabled={revokingId === key.id}
192− title={`Revoke ${key.name}`}
193− className="text-muted-foreground hover:text-destructive"
194− >
195− <span aria-hidden="true">×</span>
196− </Button>
197− </div>
198− ))}
199− </div>
257+ <KeyTable keys={value.apiKeys} revokingId={revokingId} onRevoke={handleRevoke} />
200258 ),
201259 })
202260 )}
203261
262+ {props.orgKeysSection}
263+
204264 <Dialog open={createOpen} onOpenChange={closeCreate}>
205265 <DialogContent className="sm:max-w-[480px]">
206266 <DialogHeader>
@@ -211,41 +271,10 @@ export function ApiKeysPage() { · 4 unmodified lines
211271 </DialogHeader>
212272
213273 {createdKey ? (
214− <div className="grid gap-4 py-3">
215− <div className="grid gap-1.5">
216− <Label className="text-sm font-medium text-foreground">New key</Label>
217− <div className="flex items-center gap-2">
218− <Input
219− value={createdKey.value}
220− readOnly
221− className="font-mono text-xs"
222− data-ph-mask
223− />
224− <CopyButton
225− value={createdKey.value}
226− onCopy={() => trackEvent("api_key_copied", { kind: "value" })}
227− />
228− </div>
229− </div>
230− <div className="grid gap-1.5">
231− <Label className="text-sm font-medium text-foreground">Bearer header</Label>
232− <div className="flex items-center gap-2">
233− <Input
234− value={`Authorization: Bearer ${createdKey.value}`}
235− readOnly
236− className="font-mono text-xs"
237− data-ph-mask
238− />
239− <CopyButton
240− value={`Authorization: Bearer ${createdKey.value}`}
241− onCopy={() => trackEvent("api_key_copied", { kind: "bearer_header" })}
242− />
243− </div>
244− </div>
245− <p className="text-sm leading-6 text-muted-foreground">
246− Send this value as a Bearer token. It is only shown once.
247− </p>
248− </div>
274+ <CreatedKeyReveal
275+ value={createdKey.value}
276+ onCopy={(kind) => trackEvent("api_key_copied", { kind })}
277+ />
249278 ) : (
250279 <div className="grid gap-4 py-3">
251280 <div className="grid gap-1.5">
@@ -279,3 +308,182 @@ export function ApiKeysPage() { · 27 unmodified lines
279308 </PageContainer>
280309 );
281310 }
311+
312+// ---------------------------------------------------------------------------
313+// Organization keys — the admin-only, org-owned credentials for the read-only
314+// admin API (`/api/admin/*`). A separate section rather than rows in the table
315+// above because the two key kinds answer different questions: a personal key
316+// acts AS the member who minted it on the product plane; an org key has no
317+// member behind it and reads the whole tenant.
318+//
319+// Rendered via the page's `orgKeysSection` slot by hosts that mint org keys
320+// (cloud). The admin gate here only HIDES the section — the server enforces
321+// the real one (403 for a plain member) and the section renders that refusal
322+// as an error state if it arrives anyway.
323+// ---------------------------------------------------------------------------
324+
325+export function OrgApiKeysSection() {
326+ // Gate BEFORE mounting the body: `orgApiKeysAtom` starts its fetch when the
327+ // reading component mounts, and for a plain member that request is a
328+ // guaranteed 403. Fail-closed like the admin nav — while the member list is
329+ // loading, show nothing rather than a section that will refuse.
330+ const isAdmin = useIsTenantAdmin();
331+ return isAdmin ? <OrgApiKeysSectionBody /> : null;
332+}
333+
334+function OrgApiKeysSectionBody() {
335+ const result = useAtomValue(orgApiKeysAtom);
336+ const refresh = useAtomRefresh(orgApiKeysAtom);
337+ const doRevoke = useAtomSet(revokeOrgApiKey, { mode: "promiseExit" });
338+ const [createOpen, setCreateOpen] = useState(false);
339+ // Remount the dialog body per open (self-contained modal): its form and
340+ // created-key state are destroyed on close instead of hand-reset, while the
341+ // Dialog shell stays mounted for the exit animation.
342+ const [openCount, setOpenCount] = useState(0);
343+ const [revokingId, setRevokingId] = useState<string | null>(null);
344+
345+ const handleRevoke = async (key: ApiKeySummary) => {
346+ setRevokingId(key.id);
347+ const exit = await doRevoke({
348+ params: { apiKeyId: key.id },
349+ reactivityKeys: orgApiKeyWriteKeys,
350+ });
351+ setRevokingId(null);
352+ trackEvent("org_api_key_revoked", { success: Exit.isSuccess(exit) });
353+ if (Exit.isSuccess(exit)) {
354+ toast.success(`Revoked ${key.name}`);
355+ return;
356+ }
357+ toast.error("Failed to revoke organization key");
358+ };
359+
360+ return (
361+ <section className="mt-10">
362+ <div className="mb-4 flex items-center justify-between gap-4">
363+ <div>
364+ <h2 className="text-sm font-medium text-foreground">Organization keys</h2>
365+ <p className="mt-0.5 max-w-2xl text-sm text-muted-foreground">
366+ Read-only keys owned by the organization, not a member. They authenticate the admin API
367+ (who are my users, what have they connected) and cannot act as anyone or write anything.
368+ Admins only.
369+ </p>
370+ </div>
371+ <Button
372+ size="sm"
373+ onClick={() => {
374+ setOpenCount((count) => count + 1);
375+ setCreateOpen(true);
376+ }}
377+ >
378+ <span aria-hidden="true">+</span>
379+ New org key
380+ </Button>
381+ </div>
382+
383+ {isAsyncResultLoading(result) ? (
384+ <div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
385+ Loading organization keys...
386+ </div>
387+ ) : (
388+ AsyncResult.match(result, {
389+ onInitial: () => (
390+ <div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
391+ Loading organization keys...
392+ </div>
393+ ),
394+ onFailure: () => (
395+ <ErrorState message="Failed to load organization keys" onRetry={refresh} />
396+ ),
397+ onSuccess: ({ value }) =>
398+ value.apiKeys.length === 0 ? (
399+ <div className="rounded-md border border-dashed border-border bg-card p-8">
400+ <h3 className="text-base font-semibold text-foreground">No organization keys</h3>
401+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
402+ Create one to call the admin API from your backend.
403+ </p>
404+ </div>
405+ ) : (
406+ <KeyTable keys={value.apiKeys} revokingId={revokingId} onRevoke={handleRevoke} />
407+ ),
408+ })
409+ )}
410+
411+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
412+ <DialogContent className="sm:max-w-[480px]">
413+ {createOpen ? <CreateOrgKeyDialogBody key={openCount} /> : null}
414+ </DialogContent>
415+ </Dialog>
416+ </section>
417+ );
418+}
419+
420+function CreateOrgKeyDialogBody() {
421+ const doCreate = useAtomSet(createOrgApiKey, { mode: "promiseExit" });
422+ const [name, setName] = useState(defaultApiKeyName());
423+ const [createdKey, setCreatedKey] = useState<CreatedKey | null>(null);
424+ const [creating, setCreating] = useState(false);
425+
426+ const handleCreate = async () => {
427+ const trimmed = name.trim();
428+ if (!trimmed) return;
429+ setCreating(true);
430+ const exit = await doCreate({
431+ payload: { name: trimmed },
432+ reactivityKeys: orgApiKeyWriteKeys,
433+ });
434+ setCreating(false);
435+ trackEvent("org_api_key_created", { success: Exit.isSuccess(exit) });
436+ if (Exit.isSuccess(exit)) {
437+ setCreatedKey(exit.value);
438+ toast.success("Organization key created");
439+ return;
440+ }
441+ toast.error("Failed to create organization key");
442+ };
443+
444+ return (
445+ <>
446+ <DialogHeader>
447+ <DialogTitle className="font-display text-xl">Create organization key</DialogTitle>
448+ <DialogDescription className="text-sm leading-relaxed">
449+ The key will belong to the organization itself — not to you — with read-only access to the
450+ admin API across the whole organization.
451+ </DialogDescription>
452+ </DialogHeader>
453+
454+ {createdKey ? (
455+ <CreatedKeyReveal
456+ value={createdKey.value}
457+ onCopy={(kind) => trackEvent("org_api_key_copied", { kind })}
458+ />
459+ ) : (
460+ <div className="grid gap-4 py-3">
461+ <div className="grid gap-1.5">
462+ <Label htmlFor="org-api-key-name" className="text-sm font-medium text-foreground">
463+ Name
464+ </Label>
465+ <Input
466+ id="org-api-key-name"
467+ value={name}
468+ onChange={(event) => setName(event.target.value)}
469+ placeholder="Backend admin reader"
470+ maxLength={80}
471+ autoFocus
472+ />
473+ </div>
474+ </div>
475+ )}
476+
477+ <DialogFooter>
478+ <DialogClose asChild>
479+ <Button variant="ghost">Close</Button>
480+ </DialogClose>
481+ {!createdKey && (
482+ <Button onClick={handleCreate} disabled={creating || !name.trim()}>
483+ {creating ? "Creating..." : "Create key"}
484+ </Button>
485+ )}
486+ </DialogFooter>
487+ </>
488+ );
489+}