From d37fd89166aa44762c2aae630dc0b2e0ca4ac9a1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:01:02 -0700 Subject: [PATCH 1/2] Show and mint organization API keys in the console --- apps/cloud/src/routes/app/api-keys.tsx | 12 +- packages/react/src/api/account-atoms.tsx | 13 + packages/react/src/api/analytics.tsx | 3 + packages/react/src/api/reactivity-keys.tsx | 6 + packages/react/src/pages/api-keys.tsx | 362 ++++++++++++++++----- 5 files changed, 316 insertions(+), 80 deletions(-) diff --git a/apps/cloud/src/routes/app/api-keys.tsx b/apps/cloud/src/routes/app/api-keys.tsx index ddc9807929..b686410175 100644 --- a/apps/cloud/src/routes/app/api-keys.tsx +++ b/apps/cloud/src/routes/app/api-keys.tsx @@ -1,8 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ApiKeysPage } from "@executor-js/react/pages/api-keys"; +import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys"; // Cloud renders the SHARED API-keys page over the provider-neutral -// `/account/api-keys` surface — identical UI to self-host. +// `/account/api-keys` surface — identical UI to self-host, plus the +// cloud-only Organization keys section (self-host's provider refuses +// org keys; its admin plane is session-gated instead). export const Route = createFileRoute("/{-$orgSlug}/api-keys")({ - component: ApiKeysPage, + component: CloudApiKeysPage, }); + +function CloudApiKeysPage() { + return } />; +} diff --git a/packages/react/src/api/account-atoms.tsx b/packages/react/src/api/account-atoms.tsx index 7f4f88f836..bbae9384ca 100644 --- a/packages/react/src/api/account-atoms.tsx +++ b/packages/react/src/api/account-atoms.tsx @@ -26,6 +26,19 @@ export const apiKeysAtom = AccountApiClient.query("account", "listApiKeys", { export const createApiKey = AccountApiClient.mutation("account", "createApiKey"); export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey"); +// ── Org API keys ──────────────────────────────────────────────────────────── + +// Admin-gated on the server (403 for a plain member) and refused entirely on +// self-host. The page only reads this atom for an admin on a host that mints +// org keys, so a failure here renders as the section's error state, not as a +// role probe. +export const orgApiKeysAtom = AccountApiClient.query("account", "listOrgApiKeys", { + reactivityKeys: [ReactivityKey.orgApiKeys], +}); + +export const createOrgApiKey = AccountApiClient.mutation("account", "createOrgApiKey"); +export const revokeOrgApiKey = AccountApiClient.mutation("account", "revokeOrgApiKey"); + // ── Organization members ───────────────────────────────────────────────────── // `refreshOnWindowFocus` is BROWSER-ONLY: its signal atom subscribes to diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 6fbe46a840..2b68f57ad8 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -129,6 +129,9 @@ export interface AnalyticsEvents { api_key_created: { success: boolean }; api_key_revoked: { success: boolean }; api_key_copied: { kind: "value" | "bearer_header" }; + org_api_key_created: { success: boolean }; + org_api_key_revoked: { success: boolean }; + org_api_key_copied: { kind: "value" | "bearer_header" }; // ── Organization ───────────────────────────────────────────────────────── org_renamed: { success: boolean }; diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index a6a06da41b..7f9bc27407 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -37,6 +37,9 @@ export const ReactivityKey = { orgDomains: "org:domains", orgInfo: "org:info", apiKeys: "api-keys", + /** Org-owned keys (the platform credential) — separate from `apiKeys` so + * minting one does not refetch every member's personal-key list. */ + orgApiKeys: "org:api-keys", auth: "auth", /** The tenant-wide admin users view. Read-only today (the admin plane has no * writes), so nothing invalidates it — it exists so the pages refresh @@ -89,6 +92,9 @@ export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as c /** Cloud-only: user API key mutations. */ export const apiKeyWriteKeys = [ReactivityKey.apiKeys] as const; +/** Cloud-only: org API key mutations. */ +export const orgApiKeyWriteKeys = [ReactivityKey.orgApiKeys] as const; + /** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */ export const authWriteKeys = [ ReactivityKey.auth, diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx index 37459ab34b..61fb04b178 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -1,11 +1,19 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Exit } from "effect"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { toast } from "sonner"; -import { apiKeyWriteKeys } from "../api/reactivity-keys"; +import { apiKeyWriteKeys, orgApiKeyWriteKeys } from "../api/reactivity-keys"; import { trackEvent } from "../api/analytics"; -import { apiKeysAtom, createApiKey, revokeApiKey } from "../api/account-atoms"; +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"; @@ -29,6 +37,10 @@ import { isAsyncResultLoading } from "../lib/async-result"; // 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 OrgPage pattern): cloud passes +// `` below; self-host passes nothing because its provider +// refuses org keys (`/admin/*` there is gated on an owner/admin session). // --------------------------------------------------------------------------- type ApiKeySummary = { @@ -60,7 +72,88 @@ const defaultApiKeyName = (): string => year: "numeric", }).format(new Date())}`; -export function ApiKeysPage() { +/** 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. +

+
+ ); +} + +export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {}) { useExecutorDocumentTitle("API keys"); const result = useAtomValue(apiKeysAtom); const refreshApiKeys = useAtomRefresh(apiKeysAtom); @@ -133,7 +226,8 @@ export function ApiKeysPage() {

- API keys work like personal access tokens and have full access to your account. + API keys work like personal access tokens: they act as you, in this organization, with + full access to your own account.

@@ -160,47 +254,13 @@ export function ApiKeysPage() {

) : ( -
-
- Name - Created - Last used - Actions -
- {value.apiKeys.map((key: ApiKeySummary) => ( -
-
-

{key.name}

-

- {key.obfuscatedValue} -

-
-

- {formatDate(key.createdAt)} -

-

- {formatDate(key.lastUsedAt)} -

- -
- ))} -
+ ), }) )} + {props.orgKeysSection} + @@ -211,41 +271,10 @@ export function ApiKeysPage() { {createdKey ? ( -
-
- -
- - trackEvent("api_key_copied", { kind: "value" })} - /> -
-
-
- -
- - trackEvent("api_key_copied", { kind: "bearer_header" })} - /> -
-
-

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

-
+ trackEvent("api_key_copied", { kind })} + /> ) : (
@@ -279,3 +308,182 @@ export function ApiKeysPage() { ); } + +// --------------------------------------------------------------------------- +// 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. +// +// Rendered via the page's `orgKeysSection` slot by hosts that mint org keys +// (cloud). The admin gate here only HIDES the section — the server enforces +// the real one (403 for a plain member) and the section renders that refusal +// as an error state if it arrives anyway. +// --------------------------------------------------------------------------- + +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 doRevoke = useAtomSet(revokeOrgApiKey, { mode: "promiseExit" }); + const [createOpen, setCreateOpen] = useState(false); + // Remount the dialog body per open (self-contained modal): its form and + // created-key state are destroyed on close instead of hand-reset, while the + // Dialog shell stays mounted for the exit animation. + const [openCount, setOpenCount] = useState(0); + const [revokingId, setRevokingId] = useState(null); + + const handleRevoke = async (key: ApiKeySummary) => { + 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: () => ( + + ), + onSuccess: ({ value }) => + value.apiKeys.length === 0 ? ( +
+

No organization keys

+

+ Create one to call the admin API from your backend. +

+
+ ) : ( + + ), + }) + )} + + + + {createOpen ? : null} + + +
+ ); +} + +function CreateOrgKeyDialogBody() { + const doCreate = useAtomSet(createOrgApiKey, { mode: "promiseExit" }); + const [name, setName] = useState(defaultApiKeyName()); + const [createdKey, setCreatedKey] = useState(null); + const [creating, setCreating] = useState(false); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setCreating(true); + const exit = await doCreate({ + payload: { name: trimmed }, + reactivityKeys: orgApiKeyWriteKeys, + }); + setCreating(false); + trackEvent("org_api_key_created", { success: Exit.isSuccess(exit) }); + if (Exit.isSuccess(exit)) { + setCreatedKey(exit.value); + toast.success("Organization key created"); + return; + } + toast.error("Failed to create organization key"); + }; + + return ( + <> + + Create organization key + + The key will belong to the organization itself — not to you — with read-only access to the + admin API across the whole organization. + + + + {createdKey ? ( + trackEvent("org_api_key_copied", { kind })} + /> + ) : ( +
+
+ + setName(event.target.value)} + placeholder="Backend admin reader" + maxLength={80} + autoFocus + /> +
+
+ )} + + + + + + {!createdKey && ( + + )} + + + ); +} From bb85445e257068ae589edc3504ca2b08ecdd7781 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:56 -0700 Subject: [PATCH 2/2] Address review: denial states, self-contained dialogs, org-key mint e2e --- bun.lock | 4 +- e2e/cloud/org-api-keys-console.test.ts | 141 +++++++++ e2e/package.json | 2 +- packages/react/src/pages/api-keys.tsx | 422 ++++++++++++++----------- 4 files changed, 375 insertions(+), 194 deletions(-) create mode 100644 e2e/cloud/org-api-keys-console.test.ts diff --git a/bun.lock b/bun.lock index e71f755e57..5ae1546bf6 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ "version": "0.0.36", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.13.9", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1774,7 +1774,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.6", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-FwR2RvO5DnwYgGO2w3JKPCpUx8o+AzSPJeei/oYMIrb4PYg2bQceucxiw52698t9SDEDwAOHkdC/Vv1Tv1+MJQ=="], + "@executor-js/emulate": ["@executor-js/emulate@0.13.9", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-GXuooRKtJPrWp5AEdcE6w0DeIt+TF/bonV1bzuZHX0khx2bsUrE4z7gKK/5IMxYEr+ifgVk7HWnxLnhx8BHNGg=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts new file mode 100644 index 0000000000..fae9f61e63 --- /dev/null +++ b/e2e/cloud/org-api-keys-console.test.ts @@ -0,0 +1,141 @@ +// Cloud-only: the Organization keys SECTION of the API keys page — the console +// surface over `/api/account/org-api-keys`, minting the machine credential for +// the tenant-wide admin plane. +// +// Two members are built through the real flows. The guarantees pinned here: +// +// 1. an ADMIN sees the section, mints an org key through the dialog, gets the +// one-time reveal, and the minted value ACTUALLY authenticates the admin +// API (the whole point of the credential); +// 2. the listing shows the key afterward and revoke asks for confirmation +// before killing it — after which the value stops authenticating; +// 3. a PLAIN MEMBER never sees the section at all. +// +// Runs against emulate >= 0.13.9, whose WorkOS emulator serves the org-key +// routes (list/mint via /organizations/:id/api_keys, org-owner validation) — +// the gap that previously kept this scenario impossible. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { forBrowser, joinOrg } from "./support/session"; + +declare global { + interface Window { + __e2eCopied?: Array; + } +} + +scenario( + "Admin · organization keys are minted in the console and authenticate the admin API", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + + let mintedValue = ""; + + // ── The admin's view: mint, verify, revoke ────────────────────────────── + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + let slug = ""; + + await step("Land in the workspace and open the API keys page", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; + await page.getByRole("link", { name: "API keys" }).click(); + await page.waitForURL((url) => url.pathname === `/${slug}/api-keys`, { timeout: 30_000 }); + }); + + await step("The admin is offered the Organization keys section", async () => { + await page + .getByRole("heading", { name: "Organization keys", exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); + + await step("Mint an org key through the dialog and capture the reveal", async () => { + await page.getByRole("button", { name: "New org key" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Name").fill("e2e backend reader"); + await dialog.getByRole("button", { name: "Create key" }).click(); + + // The one-time secret panel renders once the key exists; its first + // input holds the plaintext value (same shape the personal-key + // feedback scenario reads). + await dialog.getByText("It is only shown once").waitFor({ timeout: 30_000 }); + mintedValue = await dialog.locator("input").first().inputValue(); + expect(mintedValue, "a real key value is revealed once").toMatch(/^sk_/); + await dialog.getByRole("button", { name: "Close", exact: true }).first().click(); + }); + + await step("The minted key appears in the org listing", async () => { + await page + .getByText("e2e backend reader") + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + }); + + await step("The minted value authenticates the tenant-wide admin API", async () => { + // The credential's purpose, proven from outside the browser: a backend + // holding ONLY this value can read the admin plane. + const response = await fetch(new URL("/api/admin/users", target.baseUrl), { + headers: { authorization: `Bearer ${mintedValue}` }, + }); + expect(response.status, "the org key reads the admin plane").toBe(200); + const body = (await response.json()) as { + users: ReadonlyArray<{ email: string | null }>; + }; + expect( + body.users.map((user) => user.email), + "and sees the workspace's members", + ).toContain(admin.credentials?.email); + }); + + await step("Revoke asks for confirmation, then the key stops working", async () => { + await page.getByRole("button", { name: "Revoke e2e backend reader" }).click(); + // The confirm dialog names the key and warns; nothing is revoked yet. + await page + .getByRole("heading", { name: "Revoke organization key" }) + .waitFor({ state: "visible", timeout: 30_000 }); + await page.getByRole("button", { name: "Revoke key" }).click(); + await page + .getByRole("heading", { name: "Revoke organization key" }) + .waitFor({ state: "hidden", timeout: 30_000 }); + + // The revoked value no longer authenticates. + const after = await fetch(new URL("/api/admin/users", target.baseUrl), { + headers: { authorization: `Bearer ${mintedValue}` }, + }); + expect(after.status, "the revoked key is refused").toBe(401); + }); + }); + + // ── The plain member's view ─────────────────────────────────────────── + yield* browser.session(forBrowser(member), async ({ page, step }) => { + await step("A plain member is not shown the Organization keys section", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; + await page.goto(`/${slug}/api-keys`, { waitUntil: "networkidle" }); + // The personal half renders for everyone… + await page + .getByRole("heading", { name: "Personal keys" }) + .waitFor({ state: "visible", timeout: 30_000 }); + // …the org section does not exist for a non-admin. + expect( + await page.getByRole("heading", { name: "Organization keys", exact: true }).count(), + "a plain member is not shown a section that would only refuse them", + ).toBe(0); + }); + }); + }), +); diff --git a/e2e/package.json b/e2e/package.json index 246b174c9b..7e901b09df 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.13.9", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx index 61fb04b178..eb15084cb7 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -1,8 +1,9 @@ import { useState, type ReactNode } from "react"; -import { Exit } from "effect"; +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 { @@ -38,18 +39,14 @@ import { isAsyncResultLoading } from "../lib/async-result"; // Auth). API keys are how a user authenticates the Executor API + MCP endpoint // from scripts/agents (Authorization: Bearer ). // -// `orgKeysSection` is a host slot (the OrgPage pattern): cloud passes -// `` below; self-host passes nothing because its provider -// refuses org keys (`/admin/*` there is gated on an owner/admin session). +// `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 = { - readonly id: string; - readonly name: string; - readonly obfuscatedValue: string; - readonly createdAt: string; - readonly lastUsedAt: string | null; -}; +type ApiKeySummary = typeof ApiKeySummarySchema.Type; type CreatedKey = ApiKeySummary & { readonly value: string }; @@ -65,8 +62,8 @@ const formatDate = (value: string | null): string => { }).format(date); }; -const defaultApiKeyName = (): string => - `API key ${new Intl.DateTimeFormat(undefined, { +const defaultKeyName = (kind: string): string => + `${kind} ${new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", @@ -153,32 +150,99 @@ function CreatedKeyReveal(props: { ); } -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); - const [name, setName] = useState(""); +/** + * 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 [revokingId, setRevokingId] = useState(null); const handleCreate = async () => { const trimmed = name.trim(); if (!trimmed) return; setCreating(true); - const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: apiKeyWriteKeys }); + 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 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)) { - setCreatedKey(exit.value); - setName(""); toast.success("API key created"); - return; + return exit.value; } toast.error("Failed to create API key"); + return null; }; const handleRevoke = async (key: ApiKeySummary) => { @@ -193,24 +257,15 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {}) toast.error("Failed to revoke API key"); }; - const closeCreate = (open: boolean) => { - setCreateOpen(open); - if (!open) { - setName(""); - setCreatedKey(null); - setCreating(false); - } - }; - return ( { - setName(defaultApiKeyName()); + setOpenCount((count) => count + 1); setCreateOpen(true); }} > @@ -225,84 +280,57 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {})
-

- API 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. -

+
+

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. +

+
+ ) : ( + + ), + }) + )} +
{props.orgKeysSection} - + - - Create API key - - The key will act as your user in the current organization. - - - - {createdKey ? ( - trackEvent("api_key_copied", { kind })} - /> - ) : ( -
-
- - setName(event.target.value)} - placeholder="Local CLI" - maxLength={80} - autoFocus - /> -
-
- )} - - - - - - {!createdKey && ( - - )} - + trackEvent("api_key_copied", { kind })} + />
@@ -316,12 +344,24 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode } = {}) // acts AS the member who minted it on the product plane; an org key has no // member behind it and reads the whole tenant. // -// Rendered via the page's `orgKeysSection` slot by hosts that mint org keys -// (cloud). The admin gate here only HIDES the section — the server enforces -// the real one (403 for a plain member) and the section renders that refusal -// as an error state if it arrives anyway. +// 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 @@ -334,15 +374,27 @@ export function OrgApiKeysSection() { 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); - // Remount the dialog body per open (self-contained modal): its form and - // created-key state are destroyed on close instead of hand-reset, while the - // Dialog shell stays mounted for the exit animation. + // 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 }, @@ -359,7 +411,7 @@ function OrgApiKeysSectionBody() { return (
-
+

Organization keys

@@ -391,9 +443,23 @@ function OrgApiKeysSectionBody() { Loading organization keys...

), - onFailure: () => ( - - ), + 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 ? (
@@ -403,87 +469,61 @@ function OrgApiKeysSectionBody() {

) : ( - + setConfirmRevoke(key)} + /> ), }) )} - {createOpen ? : null} + trackEvent("org_api_key_copied", { kind })} + /> -
- ); -} - -function CreateOrgKeyDialogBody() { - const doCreate = useAtomSet(createOrgApiKey, { mode: "promiseExit" }); - const [name, setName] = useState(defaultApiKeyName()); - const [createdKey, setCreatedKey] = useState(null); - const [creating, setCreating] = useState(false); - const handleCreate = async () => { - const trimmed = name.trim(); - if (!trimmed) return; - setCreating(true); - const exit = await doCreate({ - payload: { name: trimmed }, - reactivityKeys: orgApiKeyWriteKeys, - }); - setCreating(false); - trackEvent("org_api_key_created", { success: Exit.isSuccess(exit) }); - if (Exit.isSuccess(exit)) { - setCreatedKey(exit.value); - toast.success("Organization key created"); - return; - } - toast.error("Failed to create organization key"); - }; - - return ( - <> - - Create organization key - - The key will belong to the organization itself — not to you — with read-only access to the - admin API across the whole organization. - - - - {createdKey ? ( - trackEvent("org_api_key_copied", { kind })} - /> - ) : ( -
-
- - setName(event.target.value)} - placeholder="Backend admin reader" - maxLength={80} - autoFocus - /> -
-
- )} - - - - - - {!createdKey && ( - - )} - - + {/* Revoking an org key breaks every backend using it, so it is the one + revoke on this page that asks first. */} + { + if (!open) setConfirmRevoke(null); + }} + > + + + Revoke organization key + + {confirmRevoke + ? `Revoke ${confirmRevoke.name}? Anything authenticating with it loses admin API access immediately. This cannot be undone.` + : ""} + + + + + + + + + + + ); }