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