diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx
index 260405a0e..f796c5865 100644
--- a/apps/cloud/src/web/shell.tsx
+++ b/apps/cloud/src/web/shell.tsx
@@ -13,12 +13,14 @@ import { SupportSlot } from "./components/support-slot";
// - nav items defaults + Organization + Billing (cloud-only sections)
// - org menu slot multi-org switcher + create-org dialog (cloud-only)
// - support slot the "Get support" dialog button (cloud-only)
-// The shared shell already renders the account dropdown frame, API-keys link,
-// and sign-out; `orgMenuSlot` is injected above the API-keys link.
+// API keys live in the main sidebar nav (via `defaultShellNavItems`); the
+// shared shell renders the account dropdown frame and sign-out, with
+// `orgMenuSlot` injected at the top of the dropdown.
// ---------------------------------------------------------------------------
const navItems = [
...defaultShellNavItems.filter((item) => item.to !== "/secrets"),
+ { to: "/api-keys", label: "API keys" },
{ to: "/org", label: "Organization" },
{ to: "/billing", label: "Billing" },
];
@@ -34,7 +36,6 @@ export function Shell(props: { readonly content?: React.ReactNode }) {
}
supportSlot={}
content={props.content}
diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx
index c3bbda707..eaf4e356a 100644
--- a/apps/host-cloudflare/web/routes/__root.tsx
+++ b/apps/host-cloudflare/web/routes/__root.tsx
@@ -20,8 +20,8 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client";
// resolves to authenticated; the unauthenticated branch can only happen when
// Access isn't in front yet (or a JWT expired) — we bounce to the Access login.
//
-// API keys + members are managed in Cloudflare Access, not in-app, so the
-// API-keys footer is hidden (`apiKeysTo={null}`) and the nav is the default set.
+// API keys + members are managed in Cloudflare Access, not in-app, so this host
+// omits the API-keys nav item and just uses the default set.
// ---------------------------------------------------------------------------
export const Route = createRootRoute({
@@ -66,7 +66,7 @@ function AuthenticatedApp() {
// slug. There's only ever one org, so no other slug is reachable.
const gated = (
<>
-
+
>
);
diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx
index c20d4bb93..dbd77f875 100644
--- a/apps/host-selfhost/web/routes/__root.tsx
+++ b/apps/host-selfhost/web/routes/__root.tsx
@@ -27,10 +27,14 @@ export const Route = createRootRoute({
component: RootComponent,
});
-// Self-host adds the instance Admin page (members + invite links) to the shared
-// nav. The page and its API gate to owner/admin, so a non-admin who opens it
-// just sees the access notice.
-const selfHostNavItems = [...defaultShellNavItems, { to: "/admin", label: "Admin" }];
+// Self-host adds the account's API keys and the instance Admin page (members +
+// invite links) to the shared nav. The Admin page and its API gate to
+// owner/admin, so a non-admin who opens it just sees the access notice.
+const selfHostNavItems = [
+ ...defaultShellNavItems,
+ { to: "/api-keys", label: "API keys" },
+ { to: "/admin", label: "Admin" },
+];
const signOut = async () => {
await authClient.signOut();
diff --git a/e2e/selfhost/api-keys-feedback.test.ts b/e2e/selfhost/api-keys-feedback.test.ts
new file mode 100644
index 000000000..eee5fde72
--- /dev/null
+++ b/e2e/selfhost/api-keys-feedback.test.ts
@@ -0,0 +1,200 @@
+// Selfhost-only (browser): guards two pieces of user feedback about the
+// API-keys experience on a self-hosted instance —
+//
+// 1. the copy-API-key buttons work even on a plain-HTTP (non-secure) origin,
+// and
+// 2. the API keys page is reachable from the main sidebar.
+//
+// Selfhost is the right target for (1): a self-hosted console is typically
+// served over plain HTTP on a LAN host/IP — a NON-secure origin, where the
+// browser does not expose `navigator.clipboard`. There the copy buttons fall
+// back to `document.execCommand("copy")` (see @executor-js/react `lib/clipboard`).
+// The harness runs on http://localhost, which IS a secure context, so the copy
+// test drops `navigator.clipboard` to reproduce the real deployment and records
+// what the page copies through the fallback.
+import { randomBytes } from "node:crypto";
+
+import { expect } from "@effect/vitest";
+import { Effect } from "effect";
+import { AccountHttpApi } from "@executor-js/api";
+
+import { scenario } from "../src/scenario";
+import { Api, Browser, Target } from "../src/services";
+
+declare global {
+ interface Window {
+ // The copy test stashes the text the page copied (via the execCommand
+ // fallback) here, so it can be read back out of the browser context.
+ __e2eCopied?: Array;
+ }
+}
+
+scenario(
+ "API keys · the page is reachable from the main sidebar",
+ {},
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const browser = yield* Browser;
+ const identity = yield* target.newIdentity();
+
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Land on the dashboard", async () => {
+ await page.goto("/", { waitUntil: "networkidle" });
+ // The shared shell's main nav lists the workspace sections.
+ await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor();
+ });
+
+ await step("The main sidebar links straight to API keys", async () => {
+ // Feedback: "the api keys link should be in the main sidebar." Today the
+ // link lives only in the account dropdown at the bottom of the sidebar
+ // (a closed popover that isn't even mounted), so there is no API-keys
+ // link in the main