From 197713b7dffc30d530fabbbba8a177799b2c7678 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Thu, 10 Sep 2026 07:39:15 -0700 Subject: [PATCH 01/17] =?UTF-8?q?chore:=20publish=20branch=20work=20in=20.?= =?UTF-8?q?agents/skills,=20.changeset/core-reusable-sidebar-and-alpha-bad?= =?UTF-8?q?ge.md,=20.claude/launch.json,=20=E2=80=A6=20(78=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/secrets/SKILL.md | 7 + .../core-reusable-sidebar-and-alpha-badge.md | 6 + .claude/launch.json | 52 + .../template-dispatch-vault-integrations.mdx | 10 + .../client/EnvironmentBadge.render.spec.tsx | 79 +- packages/core/src/client/EnvironmentBadge.tsx | 113 ++- packages/core/src/client/app-providers.tsx | 2 +- packages/core/src/client/auth/AuthPage.tsx | 86 -- .../components/icons/AgentNativeIcon.tsx | 1 + packages/core/src/client/index.ts | 19 + .../src/client/org/workspace-app-links.ts | 27 +- .../client/settings/SecretsSection.spec.tsx | 162 +++- .../src/client/settings/SecretsSection.tsx | 271 +++++- packages/core/src/client/ui/AppSidebar.tsx | 65 ++ packages/core/src/client/ui/index.ts | 19 + packages/core/src/config.ts | 26 +- .../src/localization/core-messages/ar-SA.ts | 17 + .../src/localization/core-messages/de-DE.ts | 20 + .../src/localization/core-messages/en-US.ts | 17 + .../src/localization/core-messages/es-ES.ts | 18 + .../src/localization/core-messages/fr-FR.ts | 18 + .../src/localization/core-messages/hi-IN.ts | 17 + .../src/localization/core-messages/ja-JP.ts | 17 + .../src/localization/core-messages/ko-KR.ts | 18 + .../src/localization/core-messages/pt-BR.ts | 17 + .../src/localization/core-messages/zh-CN.ts | 17 + .../src/localization/core-messages/zh-TW.ts | 17 + .../core/src/localization/default-messages.ts | 17 + packages/core/src/secrets/index.ts | 1 + packages/core/src/secrets/routes.spec.ts | 313 +++++- packages/core/src/secrets/routes.ts | 142 ++- packages/core/src/secrets/storage.ts | 6 + packages/core/src/server/beta-opt-out-html.ts | 4 +- .../src/server/credential-provider.spec.ts | 127 +++ .../core/src/server/credential-provider.ts | 78 +- .../default/.agents/skills/secrets/SKILL.md | 7 + .../headless/.agents/skills/secrets/SKILL.md | 7 + .../.agents/skills/secrets/SKILL.md | 7 + .../dispatch/src/components/layout/Layout.tsx | 64 +- packages/dispatch/src/routes/pages/vault.tsx | 277 +++--- .../dispatch/src/server/lib/vault-store.ts | 2 +- packages/toolkit/agent-native.eject.json | 9 +- packages/toolkit/src/app-shell/index.ts | 1 + .../toolkit/src/app-shell/sidebar.spec.tsx | 144 +++ packages/toolkit/src/app-shell/sidebar.tsx | 909 ++++++++++++++++++ .../analytics/.agents/skills/secrets/SKILL.md | 7 + .../app/components/layout/Sidebar.tsx | 87 +- .../assets/.agents/skills/secrets/SKILL.md | 7 + .../assets/app/components/layout/Sidebar.tsx | 303 ++---- .../brain/.agents/skills/secrets/SKILL.md | 7 + .../brain/app/components/layout/Sidebar.tsx | 305 ++---- .../calendar/.agents/skills/secrets/SKILL.md | 7 + .../app/components/layout/Sidebar.tsx | 249 ++--- .../chat/.agents/skills/secrets/SKILL.md | 7 + .../chat/app/components/layout/Sidebar.tsx | 299 ++---- .../clips/.agents/skills/secrets/SKILL.md | 7 + .../content/.agents/skills/secrets/SKILL.md | 7 + .../components/sidebar/DocumentSidebar.tsx | 155 +-- templates/crm/.agents/skills/secrets/SKILL.md | 7 + .../crm/app/components/layout/CrmSidebar.tsx | 416 +++----- .../design/.agents/skills/secrets/SKILL.md | 7 + .../design/app/components/layout/Sidebar.tsx | 269 ++---- .../dispatch/.agents/skills/secrets/SKILL.md | 7 + .../factory/.agents/skills/secrets/SKILL.md | 7 + .../factory/app/components/layout/Sidebar.tsx | 315 ++---- .../forms/.agents/skills/secrets/SKILL.md | 7 + .../forms/app/components/layout/Sidebar.tsx | 499 +++------- .../macros/.agents/skills/secrets/SKILL.md | 7 + .../app/components/layout/AppLayout.tsx | 218 ++--- .../mail/.agents/skills/secrets/SKILL.md | 7 + .../mail/app/components/layout/AppLayout.tsx | 247 +++-- .../plan/.agents/skills/secrets/SKILL.md | 7 + .../plan/app/components/layout/Sidebar.tsx | 276 ++---- .../slides/.agents/skills/secrets/SKILL.md | 7 + .../app/components/layout/Sidebar.test.tsx | 95 +- .../slides/app/components/layout/Sidebar.tsx | 296 +----- .../tasks/.agents/skills/secrets/SKILL.md | 7 + .../tasks/app/components/layout/Sidebar.tsx | 301 ++---- 78 files changed, 4250 insertions(+), 3452 deletions(-) create mode 100644 .changeset/core-reusable-sidebar-and-alpha-badge.md create mode 100644 packages/core/src/client/ui/AppSidebar.tsx create mode 100644 packages/toolkit/src/app-shell/sidebar.spec.tsx create mode 100644 packages/toolkit/src/app-shell/sidebar.tsx diff --git a/.agents/skills/secrets/SKILL.md b/.agents/skills/secrets/SKILL.md index 5b760243a2c..5a8a70a8a94 100644 --- a/.agents/skills/secrets/SKILL.md +++ b/.agents/skills/secrets/SKILL.md @@ -411,6 +411,13 @@ Dispatch workspaces have a vault access policy for workspace app credentials: Use `get-vault-access-settings` before deciding whether to create grants, and use `set-vault-access-settings` only when the user asks to change the policy. +Vault keys land in the shared `app_secrets` store at `org` scope, so an app's +Settings → Integrations → Keys section reports them as `Set · Vault` through +`resolveSecretDetailed` (`source`/`scopeId`) instead of the registered-scope +row alone. Runtime precedence is personal (`user`) row → shared `org` row → +legacy `workspace` row → designated vault org → deploy env. Never add a second +place to enter a key that the Vault already provides; label the source instead. + ### Key Files (ad-hoc) | File | Purpose | diff --git a/.changeset/core-reusable-sidebar-and-alpha-badge.md b/.changeset/core-reusable-sidebar-and-alpha-badge.md new file mode 100644 index 00000000000..da84d1191ff --- /dev/null +++ b/.changeset/core-reusable-sidebar-and-alpha-badge.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +--- + +Add reusable AppSidebar in toolkit and core, support top-left configurable alpha badges, and update app layouts to match the new sidebar design. diff --git a/.claude/launch.json b/.claude/launch.json index b7d8ff467bd..f150d373c74 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -684,6 +684,58 @@ "3197" ], "port": 3197 + }, + { + "name": "dispatch-vault-verify", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "exec", + "tsx", + "scripts/claude-launch.ts", + "--name", + "dispatch-vault-verify", + "--dir", + "templates/dispatch", + "--env", + "DATABASE_URL=", + "--env", + "SLACK_BOT_TOKEN=", + "--env", + "SLACK_SIGNING_SECRET=", + "--env", + "TELEGRAM_BOT_TOKEN=", + "--env", + "SENDGRID_API_KEY=", + "--env", + "WORKSPACE_GATEWAY_URL=", + "--env", + "AGENT_NATIVE_ENABLE_RECURRING_JOBS=0", + "--env", + "SENTRY_DSN=", + "--env", + "SENTRY_SERVER_DSN=", + "--env", + "SENTRY_CLIENT_DSN=", + "--env", + "VITE_SENTRY_CLIENT_DSN=", + "--env", + "AGENT_NATIVE_ANALYTICS_ENDPOINT=", + "--env", + "VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT=", + "--env", + "AMPLITUDE_API_KEY=", + "--env", + "VITE_AMPLITUDE_API_KEY=", + "--env", + "VITE_AGENT_NATIVE_SESSION_REPLAY_ENABLED=0", + "--env", + "AGENT_NATIVE_BUILDER_SSO_SECRET=", + "--", + "dev", + "--port", + "3190" + ], + "port": 3190 } ] } diff --git a/packages/core/docs/content/template-dispatch-vault-integrations.mdx b/packages/core/docs/content/template-dispatch-vault-integrations.mdx index 229068f7993..fe8a7f287e7 100644 --- a/packages/core/docs/content/template-dispatch-vault-integrations.mdx +++ b/packages/core/docs/content/template-dispatch-vault-integrations.mdx @@ -15,6 +15,16 @@ A central store for API keys, OAuth tokens, and shared credentials. Apps in the - A **grant** gives one named app access to a secret. Requesting access when you don't have a grant creates a **request** that an admin reviews and approves or denies. - Every use is written to an **audit log**: who used which secret, from which app, and when. +### How apps see Vault keys {#vault-precedence} + +Saving a key in the Vault writes it to the workspace's shared credential store under the key's `credential_key`, so you never re-enter it per app. Each app's **Settings → Integrations → Keys** section shows the value the app actually uses and where it comes from, in this order: + +1. **Personal** — a key you saved for yourself in an app's Keys section. Only your own sessions use it, and it overrides the shared value; the Keys section says so and offers to remove it. +2. **Vault / Workspace** — the shared value every app and every member uses. Vault-managed keys show as `Set · Vault` and link back here; edit them in the Vault, not in the app. +3. **Environment** — a deploy-level `.env` value, used only when nothing is saved above it. + +The **+ New** menu in an app's Keys section lists the keys that app declares; type any other name to add it as a custom key. Custom keys and Vault keys are both available to automations and the `web-request` tool as `${keys.NAME}`. + { vi.clearAllMocks(); }); - it("renders a non-navigating dev pill for configured local development", () => { + it("renders a non-navigating alpha pill for configured local development", () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -77,7 +77,7 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); const badge = container.querySelector('[role="status"]'); - expect(badge?.textContent).toBe("dev"); + expect(badge?.textContent).toBe("alpha"); expect(badge?.getAttribute("aria-label")).toBe( "Local development environment", ); @@ -87,7 +87,7 @@ describe("EnvironmentBadge render", () => { expect(container.querySelector("a")).toBeNull(); }); - it("supports an inline dev pill for app-owned brand slots", () => { + it("supports an inline alpha pill for app-owned brand slots", () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -107,14 +107,14 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); const badge = container.querySelector('[role="status"]'); - expect(badge?.textContent).toBe("dev"); + expect(badge?.textContent).toBe("alpha"); expect(badge?.className).toContain("inline-flex"); expect(badge?.className).toContain("h-5"); expect(badge?.className).not.toContain("fixed"); expect(badge?.className).not.toContain("bottom-3"); }); - it("defers the dev pill to a post-mount effect so the first client commit matches SSR's null output", async () => { + it("defers the alpha pill to a post-mount effect so the first client commit matches SSR's null output", async () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -141,7 +141,7 @@ describe("EnvironmentBadge render", () => { await act(async () => {}); - expect(container.querySelector('[role="status"]')?.textContent).toBe("dev"); + expect(container.querySelector('[role="status"]')?.textContent).toBe("alpha"); }); it.each([ @@ -162,7 +162,7 @@ describe("EnvironmentBadge render", () => { expect(container.innerHTML).toBe(""); }); - it("renders the beta chip for signed-out visitors", () => { + it("renders the badge for signed-out visitors without interaction", () => { useSessionMock.mockReturnValue({ session: null, status: "unauthenticated", @@ -170,18 +170,16 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); - const trigger = container.querySelector("button"); - expect(trigger?.textContent).toContain("beta"); - expect(trigger?.className).toContain("border-primary/80"); - expect(trigger?.className).toContain("bottom-3"); - expect(trigger?.className).toContain("left-3"); - expect(trigger?.className).not.toContain("top-3"); - expect(trigger?.className).not.toContain("right-3"); - expect(trigger?.className).not.toContain("bg-background/95"); - expect(container.textContent).toContain("beta"); + const badge = container.querySelector('[role="status"]'); + expect(badge?.textContent).toBe("alpha"); + expect(badge?.className).toContain("border-primary/80"); + expect(badge?.className).toContain("bottom-3"); + expect(badge?.className).toContain("left-3"); + expect(container.querySelector("button")).toBeNull(); + expect(container.textContent).toContain("alpha"); }); - it("renders the beta chip for non-builder users", () => { + it("renders the badge for non-builder users without interaction", () => { useSessionMock.mockReturnValue({ session: { email: "person@example.com" }, status: "authenticated", @@ -189,20 +187,22 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); - expect(container.querySelector("button")?.textContent).toContain("beta"); - expect(container.textContent).toContain("beta"); + expect(container.querySelector("button")).toBeNull(); + const badge = container.querySelector('[role="status"]'); + expect(badge?.textContent).toBe("alpha"); + expect(container.textContent).toContain("alpha"); }); - it("opens an inline beta chip below an app-owned brand slot", () => { + it("opens an inline chip popover for @builder.io employees", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "shawn@builder.io" }, + status: "authenticated", }); act(() => root.render()); const trigger = container.querySelector("button"); - expect(trigger?.textContent).toContain("beta"); + expect(trigger?.textContent).toBe("alpha"); expect(trigger?.className).toContain("inline-flex"); expect(trigger?.className).not.toContain("fixed"); @@ -216,10 +216,10 @@ describe("EnvironmentBadge render", () => { expect(document.body.querySelector('[data-side="bottom"]')).not.toBeNull(); }); - it("keeps the beta chip linked to production for every visitor", () => { + it("keeps the chip linked to production for builder visitors", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "steve@builder.io" }, + status: "authenticated", }); act(() => root.render()); @@ -251,8 +251,8 @@ describe("EnvironmentBadge render", () => { it("hides the badge for the current page without persisting the choice", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "steve@builder.io" }, + status: "authenticated", }); act(() => root.render()); @@ -278,13 +278,26 @@ describe("EnvironmentBadge render", () => { act(() => hideButton?.click()); expect(container.innerHTML).toBe(""); - expect(document.body.querySelector('[data-side="top"]')).toBeNull(); - expect(window.sessionStorage.getItem("agent-native:force-production")).toBe( - null, - ); + expect(window.localStorage?.getItem("agent-native:beta-opt-out-until")).toBeNull(); + }); + + it("supports configurable badgeText prop and config", () => { + useSessionMock.mockReturnValue({ + session: { email: "steve@builder.io" }, + status: "authenticated", + }); + + act(() => root.render()); + expect(container.querySelector("button")?.textContent).toBe("beta"); + + injectedAgentNativeConfigMock.mockReturnValue({ + deployment: { badgeText: "custom" }, + }); + act(() => root.render()); + expect(container.querySelector("button")?.textContent).toBe("custom"); }); - it("hides the production chip for non-employee sessions", () => { + it("renders non-interactive chip on production for non-employee sessions", () => { Object.defineProperty(window, "location", { configurable: true, value: { diff --git a/packages/core/src/client/EnvironmentBadge.tsx b/packages/core/src/client/EnvironmentBadge.tsx index 5e01be68666..dbd2724f764 100644 --- a/packages/core/src/client/EnvironmentBadge.tsx +++ b/packages/core/src/client/EnvironmentBadge.tsx @@ -196,12 +196,18 @@ function EnvironmentBadgeContent({ environment, placement, targets, + badgeText, + className, }: { environment: "beta" | "production"; placement: EnvironmentBadgePlacement; targets: EnvironmentBadgeTargets; + badgeText?: string; + className?: string; }) { const [isHidden, setIsHidden] = useState(false); + const { session } = useSession(); + const isBuilder = isBuilderIoEmployee(session?.email); if (typeof window === "undefined") return null; if (isHidden) return null; @@ -214,23 +220,44 @@ function EnvironmentBadgeContent({ ); if (environment === "beta" ? !productionHref : !betaHref) return null; - const label = environment === "beta" ? "beta" : "prod"; + const label = badgeText ?? "alpha"; const title = environment === "beta" - ? "You're on Agent-Native Beta" + ? `You're on Agent-Native ${label.charAt(0).toUpperCase() + label.slice(1)}` : "You're on Agent-Native Production"; + const badgeClasses = cn( + environmentBadgePlacementClasses[placement], + environment === "beta" + ? "border-primary/80" + : "border-border/80 bg-background/95 text-foreground", + className, + ); + + if (!isBuilder) { + return ( +
+ {label} +
+ ); + } + return ( - - - ); const marketingSurface = marketingCopy ? ( {localePicker} - {environmentBadge} {initialPrompt ? (
{authCard}
) : ( diff --git a/packages/core/src/client/components/icons/AgentNativeIcon.tsx b/packages/core/src/client/components/icons/AgentNativeIcon.tsx index ceb6307f833..37301c1492d 100644 --- a/packages/core/src/client/components/icons/AgentNativeIcon.tsx +++ b/packages/core/src/client/components/icons/AgentNativeIcon.tsx @@ -23,6 +23,7 @@ export function AgentNativeIcon({ }: AgentNativeIconProps) { return ( app.isDispatch); + if (dispatch) return appendPath(dispatchBaseHref(dispatch.href), page); + return appendPath(workspaceHref("/dispatch", null, env), page); +} + export function dispatchOverviewHref( apps: OrgSwitcherAppLink[], env: RuntimeEnv = runtimeEnv(), ): string { - const dispatch = apps.find((app) => app.isDispatch); - if (dispatch) return appendPath(dispatchBaseHref(dispatch.href), "overview"); - return appendPath(workspaceHref("/dispatch", null, env), "overview"); + return dispatchPageHref(apps, "overview", env); } export function dispatchAppsHref( apps: OrgSwitcherAppLink[], env: RuntimeEnv = runtimeEnv(), ): string { - const dispatch = apps.find((app) => app.isDispatch); - if (dispatch) return appendPath(dispatchBaseHref(dispatch.href), "apps"); - return appendPath(workspaceHref("/dispatch", null, env), "apps"); + return dispatchPageHref(apps, "apps", env); +} + +export function dispatchVaultHref( + apps: OrgSwitcherAppLink[], + env: RuntimeEnv = runtimeEnv(), +): string { + return dispatchPageHref(apps, "vault", env); } export function visibleOrgAppLinks( @@ -331,6 +344,7 @@ export interface UseOrgSwitcherAppLinksResult { isLoading: boolean; dispatchHref: string; dispatchAllAppsHref: string; + dispatchVaultHref: string; } export function useOrgSwitcherAppLinks( @@ -384,5 +398,6 @@ export function useOrgSwitcherAppLinks( isLoading, dispatchHref: dispatchOverviewHref(apps, env), dispatchAllAppsHref: dispatchAppsHref(apps, env), + dispatchVaultHref: dispatchVaultHref(apps, env), }; } diff --git a/packages/core/src/client/settings/SecretsSection.spec.tsx b/packages/core/src/client/settings/SecretsSection.spec.tsx index a80536c586b..5593b2524fd 100644 --- a/packages/core/src/client/settings/SecretsSection.spec.tsx +++ b/packages/core/src/client/settings/SecretsSection.spec.tsx @@ -11,6 +11,17 @@ vi.mock("../api-path.js", () => ({ agentNativePath: (path: string) => path, })); +vi.mock("../org/workspace-app-links.js", () => ({ + useOrgSwitcherAppLinks: () => ({ + isWorkspace: true, + dispatchVaultHref: "/dispatch/vault", + apps: [], + isLoading: false, + dispatchHref: "", + dispatchAllAppsHref: "", + }), +})); + const registeredSecrets = [ { key: "OPENAI_API_KEY", @@ -20,6 +31,8 @@ const registeredSecrets = [ kind: "api-key", required: false, status: "set", + source: "personal", + managedHere: true, last4: "1234", }, { @@ -56,7 +69,7 @@ function renderSecretsSection(root: Root, focusKey?: string) { ); } -async function click(element: Element | undefined) { +async function click(element: Element | undefined | null) { expect(element).toBeTruthy(); await act(async () => { element!.dispatchEvent( @@ -69,6 +82,39 @@ async function openNewMenu() { await click(findButton("New")); } +async function openRow(label: string) { + const toggle = Array.from(document.querySelectorAll("button")).find( + (button) => + button.hasAttribute("aria-expanded") && + button.textContent?.includes(label), + ); + await click(toggle); +} + +function mockFetchWithSecrets(secrets: unknown[]) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/secrets/adhoc")) { + return Response.json([ + { + name: "CUSTOM_TOKEN", + scope: "user", + scopeId: "user-1", + source: "personal", + description: "Custom service", + last4: "5678", + createdAt: 1, + updatedAt: 1, + }, + ]); + } + return Response.json(secrets); + }), + ); +} + describe("SecretsSection", () => { let container: HTMLDivElement; let root: Root; @@ -83,26 +129,7 @@ describe("SecretsSection", () => { disconnect() {} }, ); - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request) => { - const url = String(input); - if (url.endsWith("/secrets/adhoc")) { - return Response.json([ - { - name: "CUSTOM_TOKEN", - scope: "user", - scopeId: "user-1", - description: "Custom service", - last4: "5678", - createdAt: 1, - updatedAt: 1, - }, - ]); - } - return Response.json(registeredSecrets); - }), - ); + mockFetchWithSecrets(registeredSecrets); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -132,7 +159,7 @@ describe("SecretsSection", () => { expect(document.body.textContent).toContain("Brave Search API Key"); expect(document.body.textContent).toContain("Tavily API Key"); - expect(document.body.textContent).toContain("Custom"); + expect(document.body.textContent).toContain("Custom key"); expect(document.body.textContent).not.toContain( "Choose a keyOpenAI API key", ); @@ -158,7 +185,7 @@ describe("SecretsSection", () => { await openNewMenu(); const customItem = Array.from( document.querySelectorAll('[role="option"]'), - ).find((item) => item.textContent?.trim() === "Custom"); + ).find((item) => item.textContent?.includes("Custom key")); await click(customItem); expect( @@ -212,4 +239,93 @@ describe("SecretsSection", () => { document.activeElement, ); }); + + it("shows a Vault-provided key as shadowed with no Rotate/Remove", async () => { + mockFetchWithSecrets([ + { + key: "OPENAI_API_KEY", + label: "OpenAI API key", + description: "OpenAI services", + scope: "user", + kind: "api-key", + required: false, + status: "set", + source: "vault", + managedHere: false, + last4: "1234", + }, + ]); + + await act(async () => { + renderSecretsSection(root); + }); + + expect(container.textContent).toContain("Set · Vault"); + + await openRow("OpenAI API key"); + + expect(container.textContent).toContain( + "Managed in the workspace Vault. Every app in this workspace uses this value.", + ); + expect(findButton("Rotate")).toBeUndefined(); + expect(findButton("Delete")).toBeUndefined(); + }); + + it("adds a custom key by typed name from the New search", async () => { + await act(async () => { + renderSecretsSection(root); + }); + + await openNewMenu(); + const search = document.querySelector( + 'input[placeholder="Search keys..."]', + ); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(search, "hubspot"); + search!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const customItem = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.includes("HUBSPOT")); + expect(customItem?.textContent).toContain("Add “HUBSPOT” as a custom key"); + + await click(customItem); + + const nameInput = container.querySelector( + '[aria-label="Key name"]', + ); + expect(nameInput?.value).toBe("HUBSPOT"); + }); + + it("shows the overrides note for a personal key shadowing the Vault", async () => { + mockFetchWithSecrets([ + { + key: "OPENAI_API_KEY", + label: "OpenAI API key", + description: "OpenAI services", + scope: "user", + kind: "api-key", + required: false, + status: "set", + source: "personal", + managedHere: true, + overrides: "vault", + last4: "1234", + }, + ]); + + await act(async () => { + renderSecretsSection(root); + }); + + await openRow("OpenAI API key"); + + expect(container.textContent).toContain( + "This personal key overrides the workspace Vault value. Remove it to use the Vault key.", + ); + }); }); diff --git a/packages/core/src/client/settings/SecretsSection.tsx b/packages/core/src/client/settings/SecretsSection.tsx index 422b0d9cb09..778470c26b6 100644 --- a/packages/core/src/client/settings/SecretsSection.tsx +++ b/packages/core/src/client/settings/SecretsSection.tsx @@ -11,7 +11,6 @@ import { } from "@agent-native/toolkit/ui/button"; import { Command, - CommandEmpty, CommandGroup, CommandInput, CommandItem, @@ -42,6 +41,7 @@ import { TooltipTrigger, } from "../components/ui/tooltip.js"; import { useT } from "../i18n.js"; +import { useOrgSwitcherAppLinks } from "../org/workspace-app-links.js"; import { cn } from "../utils.js"; import { SettingsSkeleton } from "./SettingsSkeleton.js"; @@ -61,15 +61,42 @@ const Button = React.forwardRef< )); Button.displayName = "SecretsPrimitiveButton"; +/** Where a stored value's effective source is, as reported by the server. */ +type SecretSource = "personal" | "workspace" | "vault" | "env"; + +const SOURCE_LABEL_KEY: Record, string> = { + vault: "secrets.sourceVault", + workspace: "secrets.sourceWorkspace", + env: "secrets.sourceEnvironment", +}; + +const OUTLINE_LINK_CLASSNAME = + "inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground"; + interface SecretStatus { key: string; label: string; description?: string; docsUrl?: string; - scope: "user" | "workspace"; + scope: "user" | "workspace" | "org"; kind: "api-key" | "oauth"; required: boolean; - status: "set" | "unset" | "invalid"; + /** + * "set" = a value is in effect; "unset" = not configured; "invalid" = the + * validator rejected the stored value; "unknown" = the credential store + * could not be read. + */ + status: "set" | "unset" | "invalid" | "unknown"; + /** Where the effective value comes from — only present when status === "set". */ + source?: SecretSource; + /** + * True when the effective value is the row this UI writes for the + * registered scope, so Rotate/Remove apply. False when a Vault, + * workspace, or env value is in use instead. + */ + managedHere?: boolean; + /** A shared value this row overrides; removing the row falls back to it. */ + overrides?: "vault" | "workspace"; last4?: string; updatedAt?: number; oauthProvider?: string; @@ -100,7 +127,12 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { const [openSecretKey, setOpenSecretKey] = useState( focusKey ?? null, ); - const [customKeyOpen, setCustomKeyOpen] = useState(false); + const [customKeyOpen, setCustomKeyOpen] = useState<{ + open: boolean; + initialName?: string; + }>({ open: false }); + const { isWorkspace, dispatchVaultHref } = useOrgSwitcherAppLinks(true); + const vaultHref = isWorkspace ? dispatchVaultHref : null; useEffect(() => { let cancelled = false; @@ -126,7 +158,7 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { useEffect(() => { if (focusKey) { - setCustomKeyOpen(false); + setCustomKeyOpen({ open: false }); setOpenSecretKey(focusKey); } }, [focusKey]); @@ -144,11 +176,17 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { if (secrets.length === 0) { return (
- setCustomKeyOpen(true)} /> + + setCustomKeyOpen({ open: true, initialName }) + } + /> setCustomKeyOpen({ open })} showEmptyState + vaultHref={vaultHref} />
); @@ -166,12 +204,12 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { { - setCustomKeyOpen(false); + setCustomKeyOpen({ open: false }); setOpenSecretKey(key); }} - onCustomKey={() => { + onCustomKey={(initialName) => { setOpenSecretKey(null); - setCustomKeyOpen(true); + setCustomKeyOpen({ open: true, initialName }); }} /> {visibleSecrets.length > 0 && ( @@ -181,9 +219,10 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { key={secret.key} secret={secret} onChanged={reload} + vaultHref={vaultHref} open={openSecretKey === secret.key} onOpenChange={(open) => { - if (open) setCustomKeyOpen(false); + if (open) setCustomKeyOpen({ open: false }); setOpenSecretKey(open ? secret.key : null); }} focusInput={openSecretKey === secret.key} @@ -192,9 +231,11 @@ export function SecretsSection({ focusKey }: SecretsSectionProps) { )} setCustomKeyOpen({ open })} showEmptyState={visibleSecrets.length === 0} + vaultHref={vaultHref} /> ); @@ -207,14 +248,23 @@ function KeysHeader({ }: { availableSecrets?: SecretStatus[]; onSecret?: (key: string) => void; - onCustomKey: () => void; + onCustomKey: (initialName?: string) => void; }) { + const t = useT(); const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const normalized = query.toUpperCase().replace(/[^A-Z0-9_-]/g, ""); return (

Keys

- + { + setOpen(next); + if (!next) setQuery(""); + }} + > - {availableSecrets.length > 0 && ( - - )} + - No keys found. {availableSecrets.length > 0 && ( <> @@ -264,15 +314,31 @@ function KeysHeader({ )} + {/* `value` always contains the current search text, so this + item stays visible (and selectable) no matter what's typed. */} { setOpen(false); - onCustomKey(); + onCustomKey(normalized || undefined); }} + className="flex items-center justify-between gap-3" > - - Custom + {normalized ? ( + + {t("secrets.addCustomKeyNamed", { name: normalized })} + + ) : ( + <> + + + {t("secrets.customKey")} + + + {t("secrets.customKeyHint")} + + + )} @@ -286,6 +352,8 @@ function KeysHeader({ interface SecretCardProps { secret: SecretStatus; onChanged: () => void; + /** Dispatch Vault page, when running inside a workspace. */ + vaultHref: string | null; open: boolean; onOpenChange: (open: boolean) => void; focusInput?: boolean; @@ -294,6 +362,7 @@ interface SecretCardProps { function SecretCard({ secret, onChanged, + vaultHref, open, onOpenChange, focusInput, @@ -422,12 +491,29 @@ function SecretCard({ } }; + const isManagedSet = secret.status === "set" && secret.managedHere !== false; + const isShadowedSet = secret.status === "set" && secret.managedHere === false; + const pill = useMemo(() => { if (secret.status === "set") { + const sourceLabel = + !isManagedSet && secret.source && secret.source !== "personal" + ? t(SOURCE_LABEL_KEY[secret.source]) + : null; return ( - Set + {sourceLabel ? `Set · ${sourceLabel}` : "Set"} + + ); + } + if (secret.status === "unknown") { + return ( + + {t("secrets.statusUnavailable")} ); } @@ -443,10 +529,23 @@ function SecretCard({ Optional ); - }, [secret.status, secret.required]); + }, [ + isManagedSet, + secret.status, + secret.required, + secret.source, + secret.error, + t, + ]); const isOAuth = secret.kind === "oauth"; - const showRotationForm = secret.status !== "set" || isRotating; + // Vault/workspace-shadowed rows only show the value form once the user + // opts into a personal override; an env-shadowed row shows it directly + // since a saved row always wins over env. + const showRotationForm = + (secret.status !== "set" && secret.status !== "unknown") || + (isShadowedSet && secret.source === "env") || + isRotating; return (
@@ -497,16 +596,18 @@ function SecretCard({ href={secret.docsUrl} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] no-underline text-muted-foreground hover:text-foreground" + className={OUTLINE_LINK_CLASSNAME} > Docs )}
+ ) : secret.status === "unknown" ? ( +

{secret.error}

) : (
- {secret.status === "set" && ( + {isManagedSet && ( <>
Stored value ending in @@ -514,6 +615,15 @@ function SecretCard({ {secret.last4}
+ {secret.overrides && ( +

+ {t( + secret.overrides === "vault" + ? "secrets.overridesVault" + : "secrets.overridesWorkspace", + )} +

+ )}
+ {secret.source === "vault" && vaultHref && ( + + {t("secrets.openVault")} + + + )} + {secret.source !== "env" && secret.scope === "user" && ( + + )} +
+ + )} {showRotationForm && (

@@ -575,6 +735,11 @@ function SecretCard({ } className="w-full text-[11px]" /> + {isShadowedSet && secret.source === "vault" && ( +

+ {t("secrets.scopePersonalDescription")} +

+ )}
{key.description && ( @@ -960,7 +1141,19 @@ function AdHocKeysSection({
- {confirmDeleteName === key.name ? ( + {key.source === "vault" ? ( + vaultHref && ( + + {t("secrets.openVault")} + + + ) + ) : confirmDeleteName === key.name ? (
+ @@ -2518,7 +2534,7 @@ export function Layout({ data-collapsed={sidebarCollapsed ? "true" : "false"} className={cn( "agent-layout-left-drawer hidden shrink-0 flex-col border-e !border-e-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out lg:flex", - sidebarCollapsed ? "w-14" : "w-56", + sidebarCollapsed ? "w-14" : "w-[260px]", )} > { - toast.success("Secret created"); + toast.success("Key added"); setOpen(false); setCredentialKey(""); setName(""); setValue(""); setProvider(""); setDescription(""); + setMoreOpen(false); }, onError: (err) => toast.error(String(err)), }); @@ -105,27 +107,19 @@ function AddSecretDialog() { - Add vault secret + Add key - Store a credential that can be granted to workspace apps. + Saved once here, then available to every app in this workspace.
- - setName(e.target.value)} - /> -
-
- + Value setValue(e.target.value)} />
-
- - -
-
- -