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 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { ApiKeysPage } from "@executor-js/react/pages/api-keys";2import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys";
3 3
4// Cloud renders the SHARED API-keys page over the provider-neutral4// 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).
6export const Route = createFileRoute("/{-$orgSlug}/api-keys")({8export const Route = createFileRoute("/{-$orgSlug}/api-keys")({
7 component: ApiKeysPage,9 component: CloudApiKeysPage,
8});10});
11
12function 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
26export const createApiKey = AccountApiClient.mutation("account", "createApiKey");26export const createApiKey = AccountApiClient.mutation("account", "createApiKey");
27export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey");27export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey");
28 28
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.
35export const orgApiKeysAtom = AccountApiClient.query("account", "listOrgApiKeys", {
36 reactivityKeys: [ReactivityKey.orgApiKeys],
37});
38
39export const createOrgApiKey = AccountApiClient.mutation("account", "createOrgApiKey");
40export const revokeOrgApiKey = AccountApiClient.mutation("account", "revokeOrgApiKey");
41
29// ── Organization members ─────────────────────────────────────────────────────42// ── Organization members ─────────────────────────────────────────────────────
30 43
31// `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to44// `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
129 api_key_created: { success: boolean };129 api_key_created: { success: boolean };
130 api_key_revoked: { success: boolean };130 api_key_revoked: { success: boolean };
131 api_key_copied: { kind: "value" | "bearer_header" };131 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" };
132 135
133 // ── Organization ─────────────────────────────────────────────────────────136 // ── Organization ─────────────────────────────────────────────────────────
134 org_renamed: { success: boolean };137 org_renamed: { success: boolean };
packages/react/src/api/reactivity-keys.tsx+6−0
@@ -37,6 +37,9 @@ export const ReactivityKey = { · 36 unmodified lines
37 orgDomains: "org:domains",37 orgDomains: "org:domains",
38 orgInfo: "org:info",38 orgInfo: "org:info",
39 apiKeys: "api-keys",39 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",
40 auth: "auth",43 auth: "auth",
41 /** The tenant-wide admin users view. Read-only today (the admin plane has no44 /** The tenant-wide admin users view. Read-only today (the admin plane has no
42 * writes), so nothing invalidates it — it exists so the pages refresh45 * 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
89/** Cloud-only: user API key mutations. */92/** Cloud-only: user API key mutations. */
90export const apiKeyWriteKeys = [ReactivityKey.apiKeys] as const;93export const apiKeyWriteKeys = [ReactivityKey.apiKeys] as const;
91 94
95/** Cloud-only: org API key mutations. */
96export const orgApiKeyWriteKeys = [ReactivityKey.orgApiKeys] as const;
97
92/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */98/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */
93export const authWriteKeys = [99export const authWriteKeys = [
94 ReactivityKey.auth,100 ReactivityKey.auth,
packages/react/src/pages/api-keys.tsx+285−77
@@ -1,11 +1,19 @@
1import { useState } from "react";1import { useState, type ReactNode } from "react";
2import { Exit } from "effect";2import { Exit } from "effect";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";4import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
5import { toast } from "sonner";5import { toast } from "sonner";
6import { apiKeyWriteKeys } from "../api/reactivity-keys";6import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys";
7import { trackEvent } from "../api/analytics";7import { trackEvent } from "../api/analytics";
8import { apiKeysAtom, createApiKey, revokeApiKey } from "../api/account-atoms";8import {
9 apiKeysAtom,
10 createApiKey,
11 createOrgApiKey,
12 orgApiKeysAtom,
13 revokeApiKey,
14 revokeOrgApiKey,
15} from "../api/account-atoms";
16import { useIsTenantAdmin } from "../multiplayer/use-admin-nav";
9import { Button } from "../components/button";17import { Button } from "../components/button";
10import { PageContainer, PageHeader } from "../components/page";18import { PageContainer, PageHeader } from "../components/page";
11import { CopyButton } from "../components/copy-button";19import { CopyButton } from "../components/copy-button";
@@ -29,6 +37,10 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 17 unmodified lines
29// surface, so it works identically on cloud (WorkOS) and self-host (Better37// surface, so it works identically on cloud (WorkOS) and self-host (Better
30// Auth). API keys are how a user authenticates the Executor API + MCP endpoint38// Auth). API keys are how a user authenticates the Executor API + MCP endpoint
31// from scripts/agents (Authorization: Bearer <key>).39// 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).
32// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
33 45
34type ApiKeySummary = {46type ApiKeySummary = {
@@ -60,7 +72,88 @@ const defaultApiKeyName = (): string => · 25 unmodified lines
60 year: "numeric",72 year: "numeric",
61 }).format(new Date())}`;73 }).format(new Date())}`;
62 74
63export function ApiKeysPage() {75/** The shared list markup — both key sections render the same columns. */
76function 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. */
121function 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
156export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {}) {
64 useExecutorDocumentTitle("API keys");157 useExecutorDocumentTitle("API keys");
65 const result = useAtomValue(apiKeysAtom);158 const result = useAtomValue(apiKeysAtom);
66 const refreshApiKeys = useAtomRefresh(apiKeysAtom);159 const refreshApiKeys = useAtomRefresh(apiKeysAtom);
@@ -133,7 +226,8 @@ export function ApiKeysPage() { · 66 unmodified lines
133 <CopyButton value="Authorization: Bearer <api-key>" />226 <CopyButton value="Authorization: Bearer <api-key>" />
134 </div>227 </div>
135 <p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">228 <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.
137 </p>231 </p>
138 </PageHeader>232 </PageHeader>
139 233
@@ -160,47 +254,13 @@ export function ApiKeysPage() { · 20 unmodified lines
160 </p>254 </p>
161 </div>255 </div>
162 ) : (256 ) : (
163 <div className="overflow-hidden rounded-md border border-border bg-card">257 <KeyTable keys={value.apiKeys} revokingId={revokingId} onRevoke={handleRevoke} />
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>
200 ),258 ),
201 })259 })
202 )}260 )}
203 261
262 {props.orgKeysSection}
263
204 <Dialog open={createOpen} onOpenChange={closeCreate}>264 <Dialog open={createOpen} onOpenChange={closeCreate}>
205 <DialogContent className="sm:max-w-[480px]">265 <DialogContent className="sm:max-w-[480px]">
206 <DialogHeader>266 <DialogHeader>
@@ -211,41 +271,10 @@ export function ApiKeysPage() { · 4 unmodified lines
211 </DialogHeader>271 </DialogHeader>
212 272
213 {createdKey ? (273 {createdKey ? (
214 <div className="grid gap-4 py-3">274 <CreatedKeyReveal
215 <div className="grid gap-1.5">275 value={createdKey.value}
216 <Label className="text-sm font-medium text-foreground">New key</Label>276 onCopy={(kind) => trackEvent("api_key_copied", { kind })}
217 <div className="flex items-center gap-2">277 />
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>
249 ) : (278 ) : (
250 <div className="grid gap-4 py-3">279 <div className="grid gap-4 py-3">
251 <div className="grid gap-1.5">280 <div className="grid gap-1.5">
@@ -279,3 +308,182 @@ export function ApiKeysPage() { · 27 unmodified lines
279 </PageContainer>308 </PageContainer>
280 );309 );
281}310}
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
325export 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
334function 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
420function 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}