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)}
+
+
props.onRevoke(key)}
+ disabled={props.revokingId === key.id}
+ title={`Revoke ${key.name}`}
+ className="text-muted-foreground hover:text-destructive"
+ >
+ ×
+
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+
+
New key
+
+
+ props.onCopy("value")} />
+
+
+
+
Bearer header
+
+
+ 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)}
-
-
handleRevoke(key)}
- disabled={revokingId === key.id}
- title={`Revoke ${key.name}`}
- className="text-muted-foreground hover:text-destructive"
- >
- ×
-
-
- ))}
-
+
),
})
)}
+ {props.orgKeysSection}
+
@@ -211,41 +271,10 @@ export function ApiKeysPage() {
{createdKey ? (
-
-
-
New key
-
-
- trackEvent("api_key_copied", { kind: "value" })}
- />
-
-
-
-
Bearer header
-
-
- 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.
+
+
+
{
+ setOpenCount((count) => count + 1);
+ setCreateOpen(true);
+ }}
+ >
+ +
+ New org key
+
+
+
+ {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 })}
+ />
+ ) : (
+
+
+
+ Name
+
+ setName(event.target.value)}
+ placeholder="Backend admin reader"
+ maxLength={80}
+ autoFocus
+ />
+
+
+ )}
+
+
+
+ Close
+
+ {!createdKey && (
+
+ {creating ? "Creating..." : "Create key"}
+
+ )}
+
+ >
+ );
+}
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 ? (
+
+ ) : (
+
+
+
+ Name
+
+ setName(event.target.value)}
+ placeholder={props.placeholder}
+ maxLength={80}
+ autoFocus
+ />
+
+
+ )}
+
+
+
+ Close
+
+ {!createdKey && (
+
+ {creating ? "Creating..." : "Create key"}
+
+ )}
+
+ >
+ );
+}
+
+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 })}
- />
- ) : (
-
-
-
- Name
-
- setName(event.target.value)}
- placeholder="Local CLI"
- maxLength={80}
- autoFocus
- />
-
-
- )}
-
-
-
- Close
-
- {!createdKey && (
-
- {creating ? "Creating..." : "Create key"}
-
- )}
-
+ 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 })}
- />
- ) : (
-
-
-
- Name
-
- setName(event.target.value)}
- placeholder="Backend admin reader"
- maxLength={80}
- autoFocus
- />
-
-
- )}
-
-
-
- Close
-
- {!createdKey && (
-
- {creating ? "Creating..." : "Create key"}
-
- )}
-
- >
+ {/* 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.`
+ : ""}
+
+
+
+
+ Cancel
+
+ {
+ if (confirmRevoke) void handleRevoke(confirmRevoke);
+ }}
+ >
+ Revoke key
+
+
+
+
+
);
}