| @@ -1,11 +1,19 @@ |
| 1 | | −import { useState } from "react"; |
| 1 | +import { useState, type ReactNode } from "react"; |
| 2 | 2 | import { Exit } from "effect"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 5 | 5 | import { toast } from "sonner"; |
| 6 | | −import { apiKeyWriteKeys } from "../api/reactivity-keys"; |
| 6 | +import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys"; |
| 7 | 7 | 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"; |
| 9 | 17 | import { Button } from "../components/button"; |
| 10 | 18 | import { PageContainer, PageHeader } from "../components/page"; |
| 11 | 19 | import { CopyButton } from "../components/copy-button"; |
| @@ -29,6 +37,10 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 17 unmodified lines |
| 29 | 37 | // surface, so it works identically on cloud (WorkOS) and self-host (Better |
| 30 | 38 | // Auth). API keys are how a user authenticates the Executor API + MCP endpoint |
| 31 | 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 | |
| 34 | 46 | type ApiKeySummary = { |
| @@ -60,7 +72,88 @@ const defaultApiKeyName = (): string => · 25 unmodified lines |
| 60 | 72 | year: "numeric", |
| 61 | 73 | }).format(new Date())}`; |
| 62 | 74 | |
| 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 } = {}) { |
| 64 | 157 | useExecutorDocumentTitle("API keys"); |
| 65 | 158 | const result = useAtomValue(apiKeysAtom); |
| 66 | 159 | const refreshApiKeys = useAtomRefresh(apiKeysAtom); |
| @@ -133,7 +226,8 @@ export function ApiKeysPage() { · 66 unmodified lines |
| 133 | 226 | <CopyButton value="Authorization: Bearer <api-key>" /> |
| 134 | 227 | </div> |
| 135 | 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 | 231 | </p> |
| 138 | 232 | </PageHeader> |
| 139 | 233 | |
| @@ -160,47 +254,13 @@ export function ApiKeysPage() { · 20 unmodified lines |
| 160 | 254 | </p> |
| 161 | 255 | </div> |
| 162 | 256 | ) : ( |
| 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} /> |
| 200 | 258 | ), |
| 201 | 259 | }) |
| 202 | 260 | )} |
| 203 | 261 | |
| 262 | + {props.orgKeysSection} |
| 263 | + |
| 204 | 264 | <Dialog open={createOpen} onOpenChange={closeCreate}> |
| 205 | 265 | <DialogContent className="sm:max-w-[480px]"> |
| 206 | 266 | <DialogHeader> |
| @@ -211,41 +271,10 @@ export function ApiKeysPage() { · 4 unmodified lines |
| 211 | 271 | </DialogHeader> |
| 212 | 272 | |
| 213 | 273 | {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 | + /> |
| 249 | 278 | ) : ( |
| 250 | 279 | <div className="grid gap-4 py-3"> |
| 251 | 280 | <div className="grid gap-1.5"> |
| @@ -279,3 +308,182 @@ export function ApiKeysPage() { · 27 unmodified lines |
| 279 | 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 | + |
| 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 | +} |