History

Fix copy buttons on non-secure origins; move API keys into the sidebar

The copy buttons (API key, MCP install command, tool IDs, …) called navigator.clipboard.writeText with no fallback. navigator.clipboard only exists in a secure context (HTTPS or localhost), so on a self-hosted console served over plain HTTP on a LAN host/IP the property is undefined and the click threw and was swallowed — copy silently did nothing. Add a shared copyToClipboard helper (@executor-js/react lib/clipboard) that tries the async Clipboard API and falls back to selecting a hidden node via a document Range + execCommand("copy"). The Range path does NOT move focus, so it still works inside a focus trap such as the dialog that shows a freshly created API key (focusing a throwaway <textarea> there gets yanked straight back, copying nothing). Route CopyButton, the code blocks, and the app update card through it. Move the API keys link out of the account dropdown and into the main sidebar nav for the hosts that serve keys in-app (self-host + cloud). Cloudflare manages API keys in Access, so it keeps the default nav without the item. Add selfhost browser e2e guards for both: API keys is a first-class sidebar item, and the copy button copies a new key on a simulated plain-HTTP origin.

Rhys Sullivan committed 4 months ago

Commit 3fbfe301 parent 1e1567e10 files · +248 −34Browse files

Unified · Split

10 files
  1. apps/cloud/src/web/shell.tsx +4 −3
  2. apps/host-cloudflare/web/routes/__root.tsx +3 −3
  3. apps/host-selfhost/web/routes/__root.tsx +8 −4
  4. e2e/selfhost/api-keys-feedback.test.ts +148 −0
  5. packages/app/src/web/shell.tsx +3 −1
  6. packages/react/src/components/code-block.tsx +3 −1
  7. packages/react/src/components/copy-button.tsx +3 −1
  8. packages/react/src/components/expandable-code-block.tsx +3 −1
  9. packages/react/src/lib/clipboard.ts +68 −0
  10. packages/react/src/multiplayer/shell.tsx +5 −20
apps/cloud/src/web/shell.tsx+4−3
@@ -13,12 +13,14 @@ import { SupportSlot } from "./components/support-slot"; · 12 unmodified lines
1313 // - nav items defaults + Organization + Billing (cloud-only sections)
1414 // - org menu slot multi-org switcher + create-org dialog (cloud-only)
1515 // - support slot the "Get support" dialog button (cloud-only)
16−// The shared shell already renders the account dropdown frame, API-keys link,
17−// and sign-out; `orgMenuSlot` is injected above the API-keys link.
16+// API keys live in the main sidebar nav (via `defaultShellNavItems`); the
17+// shared shell renders the account dropdown frame and sign-out, with
18+// `orgMenuSlot` injected at the top of the dropdown.
1819 // ---------------------------------------------------------------------------
1920
2021 const navItems = [
2122 ...defaultShellNavItems.filter((item) => item.to !== "/secrets"),
23+ { to: "/api-keys", label: "API keys" },
2224 { to: "/org", label: "Organization" },
2325 { to: "/billing", label: "Billing" },
2426 ];
@@ -34,7 +36,6 @@ export function Shell(props: { readonly content?: React.ReactNode }) { · 9 unmodified lines
3436 <SharedShell
3537 onSignOut={signOut}
3638 navItems={navItems}
37− apiKeysTo="/api-keys"
3839 orgMenuSlot={<OrgMenuSlot />}
3940 supportSlot={<SupportSlot />}
4041 content={props.content}
apps/host-cloudflare/web/routes/__root.tsx+3−3
@@ -20,8 +20,8 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; · 19 unmodified lines
2020 // resolves to authenticated; the unauthenticated branch can only happen when
2121 // Access isn't in front yet (or a JWT expired) — we bounce to the Access login.
2222 //
23−// API keys + members are managed in Cloudflare Access, not in-app, so the
24−// API-keys footer is hidden (`apiKeysTo={null}`) and the nav is the default set.
23+// API keys + members are managed in Cloudflare Access, not in-app, so this host
24+// omits the API-keys nav item and just uses the default set.
2525 // ---------------------------------------------------------------------------
2626
2727 export const Route = createRootRoute({
@@ -66,7 +66,7 @@ function AuthenticatedApp() { · 38 unmodified lines
6666 // slug. There's only ever one org, so no other slug is reachable.
6767 const gated = (
6868 <>
69− <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />
69+ <Shell onSignOut={signOut} navItems={defaultShellNavItems} />
7070 <Toaster />
7171 </>
7272 );
apps/host-selfhost/web/routes/__root.tsx+8−4
@@ -27,10 +27,14 @@ export const Route = createRootRoute({ · 26 unmodified lines
2727 component: RootComponent,
2828 });
2929
30−// Self-host adds the instance Admin page (members + invite links) to the shared
31−// nav. The page and its API gate to owner/admin, so a non-admin who opens it
32−// just sees the access notice.
33−const selfHostNavItems = [...defaultShellNavItems, { to: "/admin", label: "Admin" }];
30+// Self-host adds the account's API keys and the instance Admin page (members +
31+// invite links) to the shared nav. The Admin page and its API gate to
32+// owner/admin, so a non-admin who opens it just sees the access notice.
33+const selfHostNavItems = [
34+ ...defaultShellNavItems,
35+ { to: "/api-keys", label: "API keys" },
36+ { to: "/admin", label: "Admin" },
37+];
3438
3539 const signOut = async () => {
3640 await authClient.signOut();
e2e/selfhost/api-keys-feedback.test.ts+148−0
@@ -0,0 +1,148 @@
1+// Selfhost-only (browser): guards two pieces of user feedback about the
2+// API-keys experience on a self-hosted instance —
3+//
4+// 1. the copy-API-key buttons work even on a plain-HTTP (non-secure) origin,
5+// and
6+// 2. the API keys page is reachable from the main sidebar.
7+//
8+// Selfhost is the right target for (1): a self-hosted console is typically
9+// served over plain HTTP on a LAN host/IP — a NON-secure origin, where the
10+// browser does not expose `navigator.clipboard`. There the copy buttons fall
11+// back to `document.execCommand("copy")` (see @executor-js/react `lib/clipboard`).
12+// The harness runs on http://localhost, which IS a secure context, so the copy
13+// test drops `navigator.clipboard` to reproduce the real deployment and records
14+// what the page copies through the fallback.
15+import { randomBytes } from "node:crypto";
16+
17+import { expect } from "@effect/vitest";
18+import { Effect } from "effect";
19+import { AccountHttpApi } from "@executor-js/api";
20+
21+import { scenario } from "../src/scenario";
22+import { Api, Browser, Target } from "../src/services";
23+
24+declare global {
25+ interface Window {
26+ // The copy test stashes the text the page copied (via the execCommand
27+ // fallback) here, so it can be read back out of the browser context.
28+ __e2eCopied?: Array<string>;
29+ }
30+}
31+
32+scenario(
33+ "API keys · the page is reachable from the main sidebar",
34+ {},
35+ Effect.gen(function* () {
36+ const target = yield* Target;
37+ const browser = yield* Browser;
38+ const identity = yield* target.newIdentity();
39+
40+ yield* browser.session(identity, async ({ page, step }) => {
41+ await step("Land on the dashboard", async () => {
42+ await page.goto("/", { waitUntil: "networkidle" });
43+ // The shared shell's main nav lists the workspace sections.
44+ await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor();
45+ });
46+
47+ await step("The main sidebar links straight to API keys", async () => {
48+ // Feedback: "the api keys link should be in the main sidebar." Today the
49+ // link lives only in the account dropdown at the bottom of the sidebar
50+ // (a closed popover that isn't even mounted), so there is no API-keys
51+ // link in the main <nav>. Scoping to <nav> is what makes this the repro:
52+ // it ignores the dropdown and asserts a first-class sidebar item. The
53+ // failure message lists the items the sidebar actually has today.
54+ const navLinks = await page.locator("nav").getByRole("link").allInnerTexts();
55+ expect(navLinks, "API keys should be a first-class item in the main sidebar nav").toContain(
56+ "API keys",
57+ );
58+ });
59+ });
60+ }),
61+);
62+
63+scenario(
64+ "API keys · the copy button copies a new key on a plain-HTTP self-host",
65+ {},
66+ Effect.gen(function* () {
67+ const target = yield* Target;
68+ const browser = yield* Browser;
69+ const { client: apiClient } = yield* Api;
70+ const identity = yield* target.newIdentity();
71+ const client = yield* apiClient(AccountHttpApi, identity);
72+
73+ // Selfhost is single-tenant, so name the key uniquely and revoke it after.
74+ const keyName = `copy-repro-${randomBytes(3).toString("hex")}`;
75+
76+ yield* browser
77+ .session(identity, async ({ page, step }) => {
78+ // Recreate the real self-host deployment: a plain-HTTP, non-secure
79+ // origin. There the browser does not expose `navigator.clipboard`, so
80+ // the page must fall back to `document.execCommand("copy")` (the
81+ // universal insecure-context copy path). We drop the Clipboard API
82+ // exactly as a non-secure origin does, and record what the page copies
83+ // through the fallback so we can assert the key actually reached the
84+ // clipboard. (The harness itself runs on http://localhost, which IS a
85+ // secure context — without this it would mask the bug entirely.)
86+ await page.addInitScript(() => {
87+ Object.defineProperty(navigator, "clipboard", {
88+ configurable: true,
89+ get: () => undefined,
90+ });
91+ const copied: Array<string> = [];
92+ window.__e2eCopied = copied;
93+ const exec = document.execCommand?.bind(document);
94+ document.execCommand = (command, ...rest) => {
95+ if (String(command).toLowerCase() === "copy") {
96+ // execCommand("copy") copies the current selection — capture it.
97+ const selected = window.getSelection ? String(window.getSelection()) : "";
98+ const active = document.activeElement;
99+ const fromField =
100+ active && "value" in active && typeof active.value === "string" ? active.value : "";
101+ copied.push(selected || fromField);
102+ }
103+ return exec ? exec(command, ...rest) : false;
104+ };
105+ });
106+
107+ await step("Open the API keys page", async () => {
108+ await page.goto("/api-keys", { waitUntil: "networkidle" });
109+ await page.getByRole("heading", { name: "API keys", exact: true }).waitFor();
110+ });
111+
112+ await step("Create a new key", async () => {
113+ await page.getByRole("button", { name: "New key" }).click();
114+ const dialog = page.getByRole("dialog");
115+ await dialog.getByLabel("Name").fill(keyName);
116+ await dialog.getByRole("button", { name: "Create key" }).click();
117+ // The one-time secret panel renders once the key exists.
118+ await dialog.getByText("It is only shown once").waitFor();
119+ });
120+
121+ await step("Copy the key with the copy button", async () => {
122+ const dialog = page.getByRole("dialog");
123+ // The exact secret the copy button should place on the clipboard.
124+ const keyValue = await dialog.locator("input[readonly]").first().inputValue();
125+ expect(keyValue, "the one-time secret is shown to copy").not.toBe("");
126+
127+ // The copy button must place the key on the clipboard even here, where
128+ // navigator.clipboard is unavailable — it falls back to
129+ // execCommand("copy"), which has to survive the dialog's focus trap.
130+ await dialog.getByRole("button", { name: "Copy" }).first().click();
131+
132+ const copied = await page.evaluate(() => window.__e2eCopied ?? []);
133+ expect(copied, "clicking copy should put the key on the clipboard").toContain(keyValue);
134+ });
135+ })
136+ .pipe(
137+ // Revoke the key whether the copy assertion passed or failed, so the
138+ // shared single-tenant instance is left clean.
139+ Effect.ensuring(
140+ Effect.gen(function* () {
141+ const list = yield* client.account.listApiKeys();
142+ const mine = list.apiKeys.find((key) => key.name === keyName);
143+ if (mine) yield* client.account.revokeApiKey({ params: { apiKeyId: mine.id } });
144+ }).pipe(Effect.ignore),
145+ ),
146+ );
147+ }),
148+);
packages/app/src/web/shell.tsx+3−1
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 toolsAllAtom,
1212 } from "@executor-js/react/api/atoms";
1313 import { Button } from "@executor-js/react/components/button";
14+import { copyToClipboard } from "@executor-js/react/lib/clipboard";
1415 import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon";
1516 import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account";
1617 import { CommandPalette } from "@executor-js/react/components/command-palette";
@@ -134,7 +135,8 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { · 117 unmodified lines
134135 const [copied, setCopied] = useState(false);
135136
136137 const handleCopy = useCallback(() => {
137− void navigator.clipboard.writeText(command).then(() => {
138+ void copyToClipboard(command).then((ok) => {
139+ if (!ok) return;
138140 setCopied(true);
139141 setTimeout(() => setCopied(false), 1500);
140142 });
packages/react/src/components/code-block.tsx+3−1
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 type ShikiThemeProp,
1010 } from "../lib/shiki";
1111 import { cn } from "../lib/utils";
12+import { copyToClipboard } from "../lib/clipboard";
1213 import { Button } from "./button";
1314
1415 // ---------------------------------------------------------------------------
@@ -102,7 +103,8 @@ export function CodeBlock(props: { · 87 unmodified lines
102103 const maxH = !expanded && isLong ? (props.maxHeight ?? "24rem") : undefined;
103104
104105 const handleCopy = useCallback(() => {
105− void navigator.clipboard.writeText(code).then(() => {
106+ void copyToClipboard(code).then((ok) => {
107+ if (!ok) return;
106108 setCopied(true);
107109 onCopy?.();
108110 setTimeout(() => setCopied(false), 1500);
packages/react/src/components/copy-button.tsx+3−1
@@ -2,6 +2,7 @@ import { useState } from "react"; · 1 unmodified line
22 import { Copy, Check } from "lucide-react";
33 import { Button } from "./button";
44 import { cn } from "../lib/utils";
5+import { copyToClipboard } from "../lib/clipboard";
56
67 function CopyButton({
78 value,
@@ -18,7 +19,8 @@ function CopyButton({ · 10 unmodified lines
1819 const [copied, setCopied] = useState(false);
1920
2021 const handleCopy = () => {
21− void navigator.clipboard.writeText(value).then(() => {
22+ void copyToClipboard(value).then((ok) => {
23+ if (!ok) return;
2224 setCopied(true);
2325 onCopy?.();
2426 setTimeout(() => setCopied(false), 1500);
packages/react/src/components/expandable-code-block.tsx+3−1
@@ -1,6 +1,7 @@
11 import { useCallback, useMemo, useState, type CSSProperties } from "react";
22 import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki";
33 import { cn } from "../lib/utils";
4+import { copyToClipboard } from "../lib/clipboard";
45 import { Button } from "./button";
56 import type { ThemedToken } from "shiki/core";
67
@@ -358,7 +359,8 @@ export function ExpandableCodeBlock(props: { · 351 unmodified lines
358359 }, []);
359360
360361 const handleCopy = useCallback(() => {
361− void navigator.clipboard.writeText(displayCode).then(() => {
362+ void copyToClipboard(displayCode).then((ok) => {
363+ if (!ok) return;
362364 setCopied(true);
363365 setTimeout(() => setCopied(false), 1500);
364366 });
packages/react/src/lib/clipboard.ts+68−0
@@ -0,0 +1,68 @@
1+// Copy text to the clipboard, working in BOTH secure and non-secure origins.
2+//
3+// `navigator.clipboard` only exists in a secure context (HTTPS, or localhost).
4+// A self-hosted console is commonly served over plain HTTP on a LAN host/IP —
5+// a non-secure origin — where `navigator.clipboard` is `undefined`, so a bare
6+// `navigator.clipboard.writeText(...)` throws and the copy silently does
7+// nothing. Fall back to a legacy `document.execCommand("copy")` path so the
8+// copy buttons work everywhere.
9+//
10+// Returns whether the copy succeeded, so callers only show their "copied"
11+// confirmation when the value actually reached the clipboard.
12+export async function copyToClipboard(text: string): Promise<boolean> {
13+ // Preferred path: the async Clipboard API (secure contexts).
14+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
15+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the Clipboard API rejects (permission denied, blur) and we recover via the legacy path
16+ try {
17+ await navigator.clipboard.writeText(text);
18+ return true;
19+ } catch {
20+ // Permission denied / document not focused — fall through to the legacy
21+ // path rather than failing the copy.
22+ }
23+ }
24+
25+ return legacyCopy(text);
26+}
27+
28+// Legacy fallback for non-secure origins (plain-HTTP self-host). Select the text
29+// via a document Range on a hidden node, then `execCommand("copy")`.
30+//
31+// Crucially this selects a Range WITHOUT moving focus. A throwaway <textarea>
32+// that we `.focus()` would be yanked straight back by a focus trap (e.g. the
33+// Radix dialog that shows a freshly created API key), leaving nothing selected
34+// when the copy fires. A document selection survives the focus trap, so the
35+// copy works wherever the button lives.
36+function legacyCopy(text: string): boolean {
37+ if (typeof document === "undefined" || !document.body) return false;
38+ const selection = document.getSelection();
39+ if (!selection) return false;
40+
41+ const node = document.createElement("span");
42+ node.textContent = text;
43+ node.style.whiteSpace = "pre"; // preserve spaces/newlines exactly
44+ node.style.userSelect = "text";
45+ node.style.position = "fixed";
46+ node.style.top = "0";
47+ node.style.left = "0";
48+ node.style.opacity = "0";
49+ node.style.pointerEvents = "none";
50+ document.body.appendChild(node);
51+
52+ const previousRange = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
53+ const range = document.createRange();
54+ range.selectNodeContents(node);
55+ selection.removeAllRanges();
56+ selection.addRange(range);
57+
58+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: execCommand can throw in restricted DOM contexts; selection + node must be restored regardless
59+ try {
60+ return document.execCommand("copy");
61+ } catch {
62+ return false;
63+ } finally {
64+ selection.removeAllRanges();
65+ if (previousRange) selection.addRange(previousRange);
66+ document.body.removeChild(node);
67+ }
68+}
packages/react/src/multiplayer/shell.tsx+5−20
@@ -11,7 +11,6 @@ import { · 10 unmodified lines
1111 DropdownMenuContent,
1212 DropdownMenuItem,
1313 DropdownMenuLabel,
14− DropdownMenuSeparator,
1514 DropdownMenuTrigger,
1615 } from "../components/dropdown-menu";
1716 import {
@@ -41,7 +40,9 @@ import { useAuth } from "./auth-context"; · 23 unmodified lines
4140 export type ShellNavItem = { readonly to: string; readonly label: string };
4241
4342 /** Integrations lives at "/", plus the standard tool-management sections. Hosts
44− * spread this and append their own (e.g. Organization, Billing). */
43+ * spread this and append their own (e.g. API keys, Organization, Billing). A
44+ * host only adds API keys if it actually serves them in-app (Cloudflare manages
45+ * them in Access, so it omits the link). */
4546 export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [
4647 { to: "/", label: "Integrations" },
4748 { to: "/secrets", label: "Providers" },
@@ -71,8 +72,6 @@ export interface ShellProps { · 23 unmodified lines
7172 readonly onSignOut: () => void | Promise<void>;
7273 /** Nav sections; defaults to {@link defaultShellNavItems}. */
7374 readonly navItems?: ReadonlyArray<ShellNavItem>;
74− /** Where the "API keys" footer link goes; null hides it. Default "/api-keys". */
75− readonly apiKeysTo?: string | null;
7675 /** Injected into the account dropdown — cloud's org switcher / create-org. */
7776 readonly orgMenuSlot?: ReactNode;
7877 /** Injected support button above the account footer (cloud). */
@@ -207,11 +206,9 @@ function Avatar(props: { url: string | null; name: string | null; email: string · 128 unmodified lines
207206
208207 // ── UserFooter ──────────────────────────────────────────────────────────
209208
210−function UserFooter(props: Pick<ShellProps, "onSignOut" | "apiKeysTo" | "orgMenuSlot">) {
209+function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) {
211210 const auth = useAuth();
212211 if (auth.status !== "authenticated") return null;
213− const apiKeysTo = props.apiKeysTo === undefined ? "/api-keys" : props.apiKeysTo;
214− const apiKeysScopedTo = apiKeysTo == null ? null : orgScopedTo(apiKeysTo);
215212
216213 return (
217214 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
@@ -248,14 +245,6 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "apiKeysTo" | "orgMenu · 30 unmodified lines
248245 </DropdownMenuTrigger>
249246 <DropdownMenuContent align="start" side="top" className="w-64">
250247 {props.orgMenuSlot}
251− {apiKeysScopedTo && (
252− <>
253− <DropdownMenuItem asChild className="text-xs">
254− <Link to={apiKeysScopedTo}>API keys</Link>
255− </DropdownMenuItem>
256− <DropdownMenuSeparator />
257− </>
258− )}
259248 <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
260249 Signed in as
261250 </DropdownMenuLabel>
@@ -316,11 +305,7 @@ function SidebarContent( · 54 unmodified lines
316305
317306 {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>}
318307
319− <UserFooter
320− onSignOut={props.onSignOut}
321− apiKeysTo={props.apiKeysTo}
322− orgMenuSlot={props.orgMenuSlot}
323− />
308+ <UserFooter onSignOut={props.onSignOut} orgMenuSlot={props.orgMenuSlot} />
324309 </>
325310 );
326311 }