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
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
20const navItems = [21const 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 <SharedShell36 <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}
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
20// resolves to authenticated; the unauthenticated branch can only happen when20// 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 the23// 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
27export const Route = createRootRoute({27export 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 );
apps/host-selfhost/web/routes/__root.tsx+8−4
@@ -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 shared30// 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 it31// 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.
33const selfHostNavItems = [...defaultShellNavItems, { to: "/admin", label: "Admin" }];33const selfHostNavItems = [
34 ...defaultShellNavItems,
35 { to: "/api-keys", label: "API keys" },
36 { to: "/admin", label: "Admin" },
37];
34 38
35const signOut = async () => {39const signOut = async () => {
36 await authClient.signOut();40 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.
15import { randomBytes } from "node:crypto";
16
17import { expect } from "@effect/vitest";
18import { Effect } from "effect";
19import { AccountHttpApi } from "@executor-js/api";
20
21import { scenario } from "../src/scenario";
22import { Api, Browser, Target } from "../src/services";
23
24declare 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
32scenario(
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
63scenario(
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
11 toolsAllAtom,11 toolsAllAtom,
12} from "@executor-js/react/api/atoms";12} from "@executor-js/react/api/atoms";
13import { Button } from "@executor-js/react/components/button";13import { Button } from "@executor-js/react/components/button";
14import { copyToClipboard } from "@executor-js/react/lib/clipboard";
14import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon";15import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon";
15import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account";16import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account";
16import { CommandPalette } from "@executor-js/react/components/command-palette";17import { 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 });
packages/react/src/components/code-block.tsx+3−1
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 type ShikiThemeProp,9 type ShikiThemeProp,
10} from "../lib/shiki";10} from "../lib/shiki";
11import { cn } from "../lib/utils";11import { cn } from "../lib/utils";
12import { copyToClipboard } from "../lib/clipboard";
12import { Button } from "./button";13import { 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);
packages/react/src/components/copy-button.tsx+3−1
@@ -2,6 +2,7 @@ import { useState } from "react"; · 1 unmodified line
2import { Copy, Check } from "lucide-react";2import { Copy, Check } from "lucide-react";
3import { Button } from "./button";3import { Button } from "./button";
4import { cn } from "../lib/utils";4import { cn } from "../lib/utils";
5import { copyToClipboard } from "../lib/clipboard";
5 6
6function CopyButton({7function 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);
packages/react/src/components/expandable-code-block.tsx+3−1
@@ -1,6 +1,7 @@
1import { useCallback, useMemo, useState, type CSSProperties } from "react";1import { useCallback, useMemo, useState, type CSSProperties } from "react";
2import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki";2import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki";
3import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
4import { copyToClipboard } from "../lib/clipboard";
4import { Button } from "./button";5import { Button } from "./button";
5import type { ThemedToken } from "shiki/core";6import 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 });
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.
12export 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.
36function 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
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";
17import {16import {
@@ -41,7 +40,9 @@ import { useAuth } from "./auth-context"; · 23 unmodified lines
41export type ShellNavItem = { readonly to: string; readonly label: string };40export type ShellNavItem = { readonly to: string; readonly label: string };
42 41
43/** Integrations lives at "/", plus the standard tool-management sections. Hosts42/** 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). */
45export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [46export 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
210function UserFooter(props: Pick<ShellProps, "onSignOut" | "apiKeysTo" | "orgMenuSlot">) {209function 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 as249 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 <UserFooter308 <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}