Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/cloud/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand All @@ -34,7 +36,6 @@ export function Shell(props: { readonly content?: React.ReactNode }) {
<SharedShell
onSignOut={signOut}
navItems={navItems}
apiKeysTo="/api-keys"
orgMenuSlot={<OrgMenuSlot />}
supportSlot={<SupportSlot />}
content={props.content}
Expand Down
6 changes: 3 additions & 3 deletions apps/host-cloudflare/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -66,7 +66,7 @@ function AuthenticatedApp() {
// slug. There's only ever one org, so no other slug is reachable.
const gated = (
<>
<Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />
<Shell onSignOut={signOut} navItems={defaultShellNavItems} />
<Toaster />
</>
);
Expand Down
12 changes: 8 additions & 4 deletions apps/host-selfhost/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
200 changes: 200 additions & 0 deletions e2e/selfhost/api-keys-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}
}

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 <nav>. Scoping to <nav> is what makes this the repro:
// it ignores the dropdown and asserts a first-class sidebar item. The
// failure message lists the items the sidebar actually has today.
const navLinks = await page.locator("nav").getByRole("link").allInnerTexts();
expect(navLinks, "API keys should be a first-class item in the main sidebar nav").toContain(
"API keys",
);
});
});
}),
);

scenario(
"API keys · the copy button copies a new key on a plain-HTTP self-host",
{},
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: apiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* apiClient(AccountHttpApi, identity);

// Selfhost is single-tenant, so name the key uniquely and revoke it after.
const keyName = `copy-repro-${randomBytes(3).toString("hex")}`;

yield* browser
.session(identity, async ({ page, step }) => {
// Recreate the real self-host deployment: a plain-HTTP, non-secure
// origin. There the browser does not expose `navigator.clipboard`, so
// the page must fall back to `document.execCommand("copy")` (the
// universal insecure-context copy path). We drop the Clipboard API
// exactly as a non-secure origin does, and record what the page copies
// through the fallback so we can assert the key actually reached the
// clipboard. (The harness itself runs on http://localhost, which IS a
// secure context — without this it would mask the bug entirely.)
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => undefined,
});
const copied: Array<string> = [];
window.__e2eCopied = copied;
const exec = document.execCommand?.bind(document);
document.execCommand = (command, ...rest) => {
if (String(command).toLowerCase() === "copy") {
// execCommand("copy") copies the current selection — capture it.
const selected = window.getSelection ? String(window.getSelection()) : "";
const active = document.activeElement;
const fromField =
active && "value" in active && typeof active.value === "string" ? active.value : "";
copied.push(selected || fromField);
}
return exec ? exec(command, ...rest) : false;
};
});

await step("Open the API keys page", async () => {
await page.goto("/api-keys", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "API keys", exact: true }).waitFor();
});

await step("Create a new key", async () => {
await page.getByRole("button", { name: "New key" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Name").fill(keyName);
await dialog.getByRole("button", { name: "Create key" }).click();
// The one-time secret panel renders once the key exists.
await dialog.getByText("It is only shown once").waitFor();
});

await step("Copy the key with the copy button", async () => {
const dialog = page.getByRole("dialog");
// The exact secret the copy button should place on the clipboard.
const keyValue = await dialog.locator("input[readonly]").first().inputValue();
expect(keyValue, "the one-time secret is shown to copy").not.toBe("");

// The copy button must place the key on the clipboard even here, where
// navigator.clipboard is unavailable — it falls back to
// execCommand("copy"), which has to survive the dialog's focus trap.
await dialog.getByRole("button", { name: "Copy" }).first().click();

const copied = await page.evaluate(() => window.__e2eCopied ?? []);
expect(copied, "clicking copy should put the key on the clipboard").toContain(keyValue);
});
})
.pipe(
// Revoke the key whether the copy assertion passed or failed, so the
// shared single-tenant instance is left clean.
Effect.ensuring(
Effect.gen(function* () {
const list = yield* client.account.listApiKeys();
const mine = list.apiKeys.find((key) => key.name === keyName);
if (mine) yield* client.account.revokeApiKey({ params: { apiKeyId: mine.id } });
}).pipe(Effect.ignore),
),
);
}),
);

scenario(
"API keys · a copy that can't reach the clipboard surfaces an error toast",
{},
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: apiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* apiClient(AccountHttpApi, identity);

const keyName = `copy-fail-${randomBytes(3).toString("hex")}`;

yield* browser
.session(identity, async ({ page, step }) => {
// Simulate a context where the copy genuinely can't happen: no
// navigator.clipboard (non-secure origin) AND execCommand("copy")
// refuses (some browsers/extensions block it). The copy then truly
// fails, and the button must say so rather than silently doing nothing.
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => undefined,
});
document.execCommand = (command) => String(command).toLowerCase() !== "copy";
});

await step("Create a new key", async () => {
await page.goto("/api-keys", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "New key" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Name").fill(keyName);
await dialog.getByRole("button", { name: "Create key" }).click();
await dialog.getByText("It is only shown once").waitFor();
});

await step("A failed copy tells the user instead of failing silently", async () => {
await page.getByRole("dialog").getByRole("button", { name: "Copy" }).first().click();
await page.getByText("Failed to copy to clipboard").waitFor();
});
})
.pipe(
Effect.ensuring(
Effect.gen(function* () {
const list = yield* client.account.listApiKeys();
const mine = list.apiKeys.find((key) => key.name === keyName);
if (mine) yield* client.account.revokeApiKey({ params: { apiKeyId: mine.id } });
}).pipe(Effect.ignore),
),
);
}),
);
8 changes: 7 additions & 1 deletion packages/app/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
toolsAllAtom,
} from "@executor-js/react/api/atoms";
import { Button } from "@executor-js/react/components/button";
import { toast } from "@executor-js/react/components/sonner";
import { copyToClipboard } from "@executor-js/react/lib/clipboard";
import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon";
import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account";
import { CommandPalette } from "@executor-js/react/components/command-palette";
Expand Down Expand Up @@ -134,7 +136,11 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(command).then(() => {
void copyToClipboard(command).then((ok) => {
if (!ok) {
toast.error("Failed to copy to clipboard");
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/components/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
resolveLang,
type ShikiThemeProp,
} from "../lib/shiki";
import { toast } from "sonner";
import { cn } from "../lib/utils";
import { copyToClipboard } from "../lib/clipboard";
import { Button } from "./button";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -102,7 +104,11 @@ export function CodeBlock(props: {
const maxH = !expanded && isLong ? (props.maxHeight ?? "24rem") : undefined;

const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(code).then(() => {
void copyToClipboard(code).then((ok) => {
if (!ok) {
toast.error("Failed to copy to clipboard");
return;
}
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 1500);
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "./button";
import { cn } from "../lib/utils";
import { copyToClipboard } from "../lib/clipboard";

function CopyButton({
value,
Expand All @@ -18,7 +20,11 @@ function CopyButton({
const [copied, setCopied] = useState(false);

const handleCopy = () => {
void navigator.clipboard.writeText(value).then(() => {
void copyToClipboard(value).then((ok) => {
if (!ok) {
toast.error("Failed to copy to clipboard");
return;
}
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 1500);
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/components/expandable-code-block.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback, useMemo, useState, type CSSProperties } from "react";
import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki";
import { toast } from "sonner";
import { cn } from "../lib/utils";
import { copyToClipboard } from "../lib/clipboard";
import { Button } from "./button";
import type { ThemedToken } from "shiki/core";

Expand Down Expand Up @@ -358,7 +360,11 @@ export function ExpandableCodeBlock(props: {
}, []);

const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(displayCode).then(() => {
void copyToClipboard(displayCode).then((ok) => {
if (!ok) {
toast.error("Failed to copy to clipboard");
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
Expand Down
Loading
Loading