| @@ -1,11 +1,19 @@ |
| 1 | import { useState } from "react"; | 1 | import { useState, type ReactNode } from "react"; |
| 2 | import { Exit } from "effect"; | 2 | import { Exit } from "effect"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 5 | import { toast } from "sonner"; | 5 | import { toast } from "sonner"; |
| 6 | import { apiKeyWriteKeys } from "../api/reactivity-keys"; | 6 | import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys"; |
| 7 | import { trackEvent } from "../api/analytics"; | 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 | import { Button } from "../components/button"; | 17 | import { Button } from "../components/button"; |
| 10 | import { PageContainer, PageHeader } from "../components/page"; | 18 | import { PageContainer, PageHeader } from "../components/page"; |
| 11 | import { CopyButton } from "../components/copy-button"; | 19 | import { 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 (Better | 37 | // 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 endpoint | 38 | // 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 | |
| 34 | type ApiKeySummary = { | 46 | type 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 | |
| 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 | 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 | |
| | 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 | } |