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.
Unified · Split
| @@ -13,12 +13,14 @@ import { SupportSlot } from "./components/support-slot"; · 12 unmodified lines | |||
| 13 | // - nav items defaults + Organization + Billing (cloud-only sections) | 13 | // - nav items defaults + Organization + Billing (cloud-only sections) |
| 14 | // - org menu slot multi-org switcher + create-org dialog (cloud-only) | 14 | // - org menu slot multi-org switcher + create-org dialog (cloud-only) |
| 15 | // - support slot the "Get support" dialog button (cloud-only) | 15 | // - support slot the "Get support" dialog button (cloud-only) |
| 16 | // The shared shell already renders the account dropdown frame, API-keys link, | 16 | // API keys live in the main sidebar nav (via `defaultShellNavItems`); the |
| 17 | // and sign-out; `orgMenuSlot` is injected above the API-keys link. | 17 | // shared shell renders the account dropdown frame and sign-out, with |
| 18 | // `orgMenuSlot` injected at the top of the dropdown. | ||
| 18 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 19 | 20 | ||
| 20 | const navItems = [ | 21 | const navItems = [ |
| 21 | ...defaultShellNavItems.filter((item) => item.to !== "/secrets"), | 22 | ...defaultShellNavItems.filter((item) => item.to !== "/secrets"), |
| 23 | { to: "/api-keys", label: "API keys" }, | ||
| 22 | { to: "/org", label: "Organization" }, | 24 | { to: "/org", label: "Organization" }, |
| 23 | { to: "/billing", label: "Billing" }, | 25 | { to: "/billing", label: "Billing" }, |
| 24 | ]; | 26 | ]; |
| @@ -34,7 +36,6 @@ export function Shell(props: { readonly content?: React.ReactNode }) { · 9 unmodified lines | |||
| 34 | <SharedShell | 36 | <SharedShell |
| 35 | onSignOut={signOut} | 37 | onSignOut={signOut} |
| 36 | navItems={navItems} | 38 | navItems={navItems} |
| 37 | apiKeysTo="/api-keys" | ||
| 38 | orgMenuSlot={<OrgMenuSlot />} | 39 | orgMenuSlot={<OrgMenuSlot />} |
| 39 | supportSlot={<SupportSlot />} | 40 | supportSlot={<SupportSlot />} |
| 40 | content={props.content} | 41 | content={props.content} |
| @@ -20,8 +20,8 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; · 19 unmodified lines | |||
| 20 | // resolves to authenticated; the unauthenticated branch can only happen when | 20 | // resolves to authenticated; the unauthenticated branch can only happen when |
| 21 | // Access isn't in front yet (or a JWT expired) — we bounce to the Access login. | 21 | // Access isn't in front yet (or a JWT expired) — we bounce to the Access login. |
| 22 | // | 22 | // |
| 23 | // API keys + members are managed in Cloudflare Access, not in-app, so the | 23 | // API keys + members are managed in Cloudflare Access, not in-app, so this host |
| 24 | // API-keys footer is hidden (`apiKeysTo={null}`) and the nav is the default set. | 24 | // omits the API-keys nav item and just uses the default set. |
| 25 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 26 | 26 | ||
| 27 | export const Route = createRootRoute({ | 27 | export const Route = createRootRoute({ |
| @@ -66,7 +66,7 @@ function AuthenticatedApp() { · 38 unmodified lines | |||
| 66 | // slug. There's only ever one org, so no other slug is reachable. | 66 | // slug. There's only ever one org, so no other slug is reachable. |
| 67 | const gated = ( | 67 | const gated = ( |
| 68 | <> | 68 | <> |
| 69 | <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} /> | 69 | <Shell onSignOut={signOut} navItems={defaultShellNavItems} /> |
| 70 | <Toaster /> | 70 | <Toaster /> |
| 71 | </> | 71 | </> |
| 72 | ); | 72 | ); |
| @@ -27,10 +27,14 @@ export const Route = createRootRoute({ · 26 unmodified lines | |||
| 27 | component: RootComponent, | 27 | component: RootComponent, |
| 28 | }); | 28 | }); |
| 29 | 29 | ||
| 30 | // Self-host adds the instance Admin page (members + invite links) to the shared | 30 | // Self-host adds the account's API keys and the instance Admin page (members + |
| 31 | // nav. The page and its API gate to owner/admin, so a non-admin who opens it | 31 | // invite links) to the shared nav. The Admin page and its API gate to |
| 32 | // just sees the access notice. | 32 | // owner/admin, so a non-admin who opens it just sees the access notice. |
| 33 | const selfHostNavItems = [...defaultShellNavItems, { to: "/admin", label: "Admin" }]; | 33 | const selfHostNavItems = [ |
| 34 | ...defaultShellNavItems, | ||
| 35 | { to: "/api-keys", label: "API keys" }, | ||
| 36 | { to: "/admin", label: "Admin" }, | ||
| 37 | ]; | ||
| 34 | 38 | ||
| 35 | const signOut = async () => { | 39 | const signOut = async () => { |
| 36 | await authClient.signOut(); | 40 | await authClient.signOut(); |
| @@ -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 | ); | ||
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | toolsAllAtom, | 11 | toolsAllAtom, |
| 12 | } from "@executor-js/react/api/atoms"; | 12 | } from "@executor-js/react/api/atoms"; |
| 13 | import { Button } from "@executor-js/react/components/button"; | 13 | import { Button } from "@executor-js/react/components/button"; |
| 14 | import { copyToClipboard } from "@executor-js/react/lib/clipboard"; | ||
| 14 | import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon"; | 15 | import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon"; |
| 15 | import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account"; | 16 | import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account"; |
| 16 | import { CommandPalette } from "@executor-js/react/components/command-palette"; | 17 | import { CommandPalette } from "@executor-js/react/components/command-palette"; |
| @@ -134,7 +135,8 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { · 117 unmodified lines | |||
| 134 | const [copied, setCopied] = useState(false); | 135 | const [copied, setCopied] = useState(false); |
| 135 | 136 | ||
| 136 | const handleCopy = useCallback(() => { | 137 | const handleCopy = useCallback(() => { |
| 137 | void navigator.clipboard.writeText(command).then(() => { | 138 | void copyToClipboard(command).then((ok) => { |
| 139 | if (!ok) return; | ||
| 138 | setCopied(true); | 140 | setCopied(true); |
| 139 | setTimeout(() => setCopied(false), 1500); | 141 | setTimeout(() => setCopied(false), 1500); |
| 140 | }); | 142 | }); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type ShikiThemeProp, | 9 | type ShikiThemeProp, |
| 10 | } from "../lib/shiki"; | 10 | } from "../lib/shiki"; |
| 11 | import { cn } from "../lib/utils"; | 11 | import { cn } from "../lib/utils"; |
| 12 | import { copyToClipboard } from "../lib/clipboard"; | ||
| 12 | import { Button } from "./button"; | 13 | import { Button } from "./button"; |
| 13 | 14 | ||
| 14 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| @@ -102,7 +103,8 @@ export function CodeBlock(props: { · 87 unmodified lines | |||
| 102 | const maxH = !expanded && isLong ? (props.maxHeight ?? "24rem") : undefined; | 103 | const maxH = !expanded && isLong ? (props.maxHeight ?? "24rem") : undefined; |
| 103 | 104 | ||
| 104 | const handleCopy = useCallback(() => { | 105 | const handleCopy = useCallback(() => { |
| 105 | void navigator.clipboard.writeText(code).then(() => { | 106 | void copyToClipboard(code).then((ok) => { |
| 107 | if (!ok) return; | ||
| 106 | setCopied(true); | 108 | setCopied(true); |
| 107 | onCopy?.(); | 109 | onCopy?.(); |
| 108 | setTimeout(() => setCopied(false), 1500); | 110 | setTimeout(() => setCopied(false), 1500); |
| @@ -2,6 +2,7 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { Copy, Check } from "lucide-react"; | 2 | import { Copy, Check } from "lucide-react"; |
| 3 | import { Button } from "./button"; | 3 | import { Button } from "./button"; |
| 4 | import { cn } from "../lib/utils"; | 4 | import { cn } from "../lib/utils"; |
| 5 | import { copyToClipboard } from "../lib/clipboard"; | ||
| 5 | 6 | ||
| 6 | function CopyButton({ | 7 | function CopyButton({ |
| 7 | value, | 8 | value, |
| @@ -18,7 +19,8 @@ function CopyButton({ · 10 unmodified lines | |||
| 18 | const [copied, setCopied] = useState(false); | 19 | const [copied, setCopied] = useState(false); |
| 19 | 20 | ||
| 20 | const handleCopy = () => { | 21 | const handleCopy = () => { |
| 21 | void navigator.clipboard.writeText(value).then(() => { | 22 | void copyToClipboard(value).then((ok) => { |
| 23 | if (!ok) return; | ||
| 22 | setCopied(true); | 24 | setCopied(true); |
| 23 | onCopy?.(); | 25 | onCopy?.(); |
| 24 | setTimeout(() => setCopied(false), 1500); | 26 | setTimeout(() => setCopied(false), 1500); |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useCallback, useMemo, useState, type CSSProperties } from "react"; | 1 | import { useCallback, useMemo, useState, type CSSProperties } from "react"; |
| 2 | import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki"; | 2 | import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki"; |
| 3 | import { cn } from "../lib/utils"; | 3 | import { cn } from "../lib/utils"; |
| 4 | import { copyToClipboard } from "../lib/clipboard"; | ||
| 4 | import { Button } from "./button"; | 5 | import { Button } from "./button"; |
| 5 | import type { ThemedToken } from "shiki/core"; | 6 | import type { ThemedToken } from "shiki/core"; |
| 6 | 7 | ||
| @@ -358,7 +359,8 @@ export function ExpandableCodeBlock(props: { · 351 unmodified lines | |||
| 358 | }, []); | 359 | }, []); |
| 359 | 360 | ||
| 360 | const handleCopy = useCallback(() => { | 361 | const handleCopy = useCallback(() => { |
| 361 | void navigator.clipboard.writeText(displayCode).then(() => { | 362 | void copyToClipboard(displayCode).then((ok) => { |
| 363 | if (!ok) return; | ||
| 362 | setCopied(true); | 364 | setCopied(true); |
| 363 | setTimeout(() => setCopied(false), 1500); | 365 | setTimeout(() => setCopied(false), 1500); |
| 364 | }); | 366 | }); |
| @@ -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 | } | ||
| @@ -11,7 +11,6 @@ import { · 10 unmodified lines | |||
| 11 | DropdownMenuContent, | 11 | DropdownMenuContent, |
| 12 | DropdownMenuItem, | 12 | DropdownMenuItem, |
| 13 | DropdownMenuLabel, | 13 | DropdownMenuLabel, |
| 14 | DropdownMenuSeparator, | ||
| 15 | DropdownMenuTrigger, | 14 | DropdownMenuTrigger, |
| 16 | } from "../components/dropdown-menu"; | 15 | } from "../components/dropdown-menu"; |
| 17 | import { | 16 | import { |
| @@ -41,7 +40,9 @@ import { useAuth } from "./auth-context"; · 23 unmodified lines | |||
| 41 | export type ShellNavItem = { readonly to: string; readonly label: string }; | 40 | export type ShellNavItem = { readonly to: string; readonly label: string }; |
| 42 | 41 | ||
| 43 | /** Integrations lives at "/", plus the standard tool-management sections. Hosts | 42 | /** 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). */ | ||
| 45 | export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ | 46 | export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ |
| 46 | { to: "/", label: "Integrations" }, | 47 | { to: "/", label: "Integrations" }, |
| 47 | { to: "/secrets", label: "Providers" }, | 48 | { to: "/secrets", label: "Providers" }, |
| @@ -71,8 +72,6 @@ export interface ShellProps { · 23 unmodified lines | |||
| 71 | readonly onSignOut: () => void | Promise<void>; | 72 | readonly onSignOut: () => void | Promise<void>; |
| 72 | /** Nav sections; defaults to {@link defaultShellNavItems}. */ | 73 | /** Nav sections; defaults to {@link defaultShellNavItems}. */ |
| 73 | readonly navItems?: ReadonlyArray<ShellNavItem>; | 74 | readonly navItems?: ReadonlyArray<ShellNavItem>; |
| 74 | /** Where the "API keys" footer link goes; null hides it. Default "/api-keys". */ | ||
| 75 | readonly apiKeysTo?: string | null; | ||
| 76 | /** Injected into the account dropdown — cloud's org switcher / create-org. */ | 75 | /** Injected into the account dropdown — cloud's org switcher / create-org. */ |
| 77 | readonly orgMenuSlot?: ReactNode; | 76 | readonly orgMenuSlot?: ReactNode; |
| 78 | /** Injected support button above the account footer (cloud). */ | 77 | /** 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 | |||
| 207 | 206 | ||
| 208 | // ── UserFooter ────────────────────────────────────────────────────────── | 207 | // ── UserFooter ────────────────────────────────────────────────────────── |
| 209 | 208 | ||
| 210 | function UserFooter(props: Pick<ShellProps, "onSignOut" | "apiKeysTo" | "orgMenuSlot">) { | 209 | function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { |
| 211 | const auth = useAuth(); | 210 | const auth = useAuth(); |
| 212 | if (auth.status !== "authenticated") return null; | 211 | if (auth.status !== "authenticated") return null; |
| 213 | const apiKeysTo = props.apiKeysTo === undefined ? "/api-keys" : props.apiKeysTo; | ||
| 214 | const apiKeysScopedTo = apiKeysTo == null ? null : orgScopedTo(apiKeysTo); | ||
| 215 | 212 | ||
| 216 | return ( | 213 | return ( |
| 217 | <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5"> | 214 | <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 | |||
| 248 | </DropdownMenuTrigger> | 245 | </DropdownMenuTrigger> |
| 249 | <DropdownMenuContent align="start" side="top" className="w-64"> | 246 | <DropdownMenuContent align="start" side="top" className="w-64"> |
| 250 | {props.orgMenuSlot} | 247 | {props.orgMenuSlot} |
| 251 | {apiKeysScopedTo && ( | ||
| 252 | <> | ||
| 253 | <DropdownMenuItem asChild className="text-xs"> | ||
| 254 | <Link to={apiKeysScopedTo}>API keys</Link> | ||
| 255 | </DropdownMenuItem> | ||
| 256 | <DropdownMenuSeparator /> | ||
| 257 | </> | ||
| 258 | )} | ||
| 259 | <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> | 248 | <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> |
| 260 | Signed in as | 249 | Signed in as |
| 261 | </DropdownMenuLabel> | 250 | </DropdownMenuLabel> |
| @@ -316,11 +305,7 @@ function SidebarContent( · 54 unmodified lines | |||
| 316 | 305 | ||
| 317 | {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>} | 306 | {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>} |
| 318 | 307 | ||
| 319 | <UserFooter | 308 | <UserFooter onSignOut={props.onSignOut} orgMenuSlot={props.orgMenuSlot} /> |
| 320 | onSignOut={props.onSignOut} | ||
| 321 | apiKeysTo={props.apiKeysTo} | ||
| 322 | orgMenuSlot={props.orgMenuSlot} | ||
| 323 | /> | ||
| 324 | </> | 309 | </> |
| 325 | ); | 310 | ); |
| 326 | } | 311 | } |