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/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/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..eb15084cb7 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -1,11 +1,20 @@ -import { useState } from "react"; -import { Exit } from "effect"; +import { useState, type ReactNode } from "react"; +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 { apiKeyWriteKeys } from "../api/reactivity-keys"; +import type { ApiKeySummary as ApiKeySummarySchema } from "@executor-js/api"; +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,15 +38,15 @@ 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 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 }; @@ -53,39 +62,187 @@ 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", }).format(new Date())}`; -export function ApiKeysPage() { - 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 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. +

+
+ ); +} + +/** + * 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) => { @@ -100,24 +257,15 @@ export function ApiKeysPage() { 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); }} > @@ -132,150 +280,250 @@ export function ApiKeysPage() { -

- API keys work like personal access tokens and have full access to your account. -

+
+

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} + + + + trackEvent("api_key_copied", { kind })} + /> + + +
+ ); +} + +// --------------------------------------------------------------------------- +// 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. +// +// 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 + // 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 doCreate = useAtomSet(createOrgApiKey, { mode: "promiseExit" }); + const doRevoke = useAtomSet(revokeOrgApiKey, { mode: "promiseExit" }); + const [createOpen, setCreateOpen] = useState(false); + // 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 }, + 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 API keys... + Loading organization keys...
) : ( AsyncResult.match(result, { onInitial: () => (
- Loading API keys... + 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 ? (
-

No API keys

+

No organization keys

- Create a key and send it in the Authorization Bearer header. + Create one to call the admin API from your backend.

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

{key.name}

-

- {key.obfuscatedValue} -

-
-

- {formatDate(key.createdAt)} -

-

- {formatDate(key.lastUsedAt)} -

- -
- ))} -
+ setConfirmRevoke(key)} + /> ), }) )} - + + + trackEvent("org_api_key_copied", { kind })} + /> + + + + {/* 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); + }} + > - Create API key + Revoke organization key - The key will act as your user in the current organization. + {confirmRevoke + ? `Revoke ${confirmRevoke.name}? Anything authenticating with it loses admin API access immediately. This cannot be undone.` + : ""} - - {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. -

-
- ) : ( -
-
- - setName(event.target.value)} - placeholder="Local CLI" - maxLength={80} - autoFocus - /> -
-
- )} - - + - {!createdKey && ( - - )} +
- +
); }