mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(ui): move agent secret access to searchable secrets tab (#11283)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The agent configuration UI controls each agent and its allowed secrets. > - The environment variable editor already has a secret selector with search and folder navigation. > - The secret access editor used a basic list and made large secret stores hard to use. > - The secret access controls also occupied the main Configuration tab. > - This pull request reuses the rich selector and moves secret access to a dedicated Secrets tab. > - The benefit is one consistent secret selection workflow with clearer agent configuration navigation. ## Linked Issues or Issue Description **What existing behavior does this improve?** The agent detail configuration view and its secret access editor. **Subsystem affected** `ui/` — React and Vite board UI. **Current behavior** The secret access editor uses a basic select control. It does not provide the search and folder navigation available in the environment variable editor. The editor also appears inside the Configuration tab. **Proposed behavior** The secret access editor uses the shared secret picker. Users can search secrets and browse slash-delimited folders. Agent details provide a dedicated Secrets tab for this editor. **Reason and benefit** Large secret stores are slow to scan in a flat list. Reusing one selector reduces UI differences and makes scoped secret access easier to manage. **Breaking changes** None. The API and saved secret access data do not change. ## What Changed - Reused the environment variable secret picker in the agent secret access editor. - Preserved secret version selection and the create-secret action, including nested-popover focus handling. - Added a route-backed Secrets tab to agent details and removed secret access controls from Configuration. - Guarded unsaved configuration across tab, link, browser-history, and action-triggered navigation. - Rechecked dirty state when navigation-producing agent actions finish, covering edits made while a request is pending. - Added component, page, and Storybook coverage for the workflow. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/AgentActionButtons.test.tsx src/components/AgentConfigForm.render.test.tsx src/components/AgentSecretAccessEditor.test.tsx src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx src/pages/AgentDetail.progress.test.ts` — 82 tests passed. - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - All GitHub PR checks passed on `5209c5b787`, including build, canary, general and serialized tests, and all three e2e shards. - Greptile completed at 5/5 with zero unresolved review threads. ## Risks - Low risk. The API and persisted binding format are unchanged; this changes agent configuration navigation and secret selection UI. - Dirty-state guards now cover direct navigation, Back/Forward history, and navigation-producing agent actions, including pending-request races. - Tests cover tab separation, secret access updates, search, folder navigation, focus restoration, and navigation rejection. - No documentation change is required because commands, contracts, and setup steps do not change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-5. This runtime did not expose a more specific model ID or context window. The model used agentic reasoning, repository tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aac6ce82e1
commit
dc6fcd1ff1
11 files changed
+629
-108
No files matched your search
@@ -126,7 +126,7 @@ describe("AgentActionButtons", () => {
|
||||
});
|
||||
|
||||
function render(agent: Agent, props: Partial<ComponentProps<typeof AgentActionButtons>> = {}) {
|
||||
root = createRoot(container);
|
||||
root ??= createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentActionButtons agent={agent} companyId="company-1" runLabel="Run Heartbeat" {...props} />
|
||||
@@ -205,4 +205,64 @@ describe("AgentActionButtons", () => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
|
||||
});
|
||||
|
||||
it("does not terminate when navigation away from a dirty detail page is rejected", async () => {
|
||||
const onBeforeNavigate = vi.fn().mockReturnValue(false);
|
||||
render(makeAgent(), { onBeforeNavigate, onTerminateSuccess: vi.fn() });
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const terminateButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Terminate"));
|
||||
await act(async () => {
|
||||
terminateButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(onBeforeNavigate).toHaveBeenCalledOnce();
|
||||
expect(mockAgentsApi.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks navigation when the form becomes dirty while termination is pending", async () => {
|
||||
let resolveTermination!: (agent: Agent) => void;
|
||||
mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => {
|
||||
resolveTermination = resolve;
|
||||
}));
|
||||
const onBeforeNavigate = vi.fn().mockReturnValueOnce(true).mockReturnValue(false);
|
||||
const onTerminateSuccess = vi.fn();
|
||||
const agent = makeAgent();
|
||||
render(agent, {
|
||||
hasPendingNavigationChanges: false,
|
||||
onBeforeNavigate,
|
||||
onTerminateSuccess,
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
});
|
||||
await flushReact();
|
||||
const terminateButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Terminate"));
|
||||
await act(async () => {
|
||||
terminateButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
render(agent, {
|
||||
hasPendingNavigationChanges: true,
|
||||
onBeforeNavigate,
|
||||
onTerminateSuccess,
|
||||
});
|
||||
await flushReact();
|
||||
resolveTermination(makeAgent({ status: "terminated" }));
|
||||
await flushReact();
|
||||
|
||||
expect(onBeforeNavigate).toHaveBeenCalledTimes(2);
|
||||
expect(onTerminateSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -165,6 +165,8 @@ export function AgentActionButtons({
|
||||
workActionsDisabled = false,
|
||||
workActionsDisabledReason,
|
||||
navigateToRunOnInvoke = true,
|
||||
hasPendingNavigationChanges = false,
|
||||
onBeforeNavigate,
|
||||
onActionError,
|
||||
onTerminateSuccess,
|
||||
pauseConfirm,
|
||||
@@ -182,6 +184,10 @@ export function AgentActionButtons({
|
||||
workActionsDisabled?: boolean;
|
||||
workActionsDisabledReason?: string;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/** Whether the caller currently has an unsaved draft that navigation would discard. */
|
||||
hasPendingNavigationChanges?: boolean;
|
||||
/** Return false to stop an action whose success would navigate away. */
|
||||
onBeforeNavigate?: () => boolean;
|
||||
/**
|
||||
* When set, pausing prompts a confirmation dialog first (e.g. for built-in
|
||||
* agents that power a feature). Omit for the immediate-pause default.
|
||||
@@ -207,6 +213,25 @@ export function AgentActionButtons({
|
||||
const { pushToast } = useToastActions();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false);
|
||||
const pendingNavigationChangesRef = useRef(hasPendingNavigationChanges);
|
||||
const beforeNavigateRef = useRef(onBeforeNavigate);
|
||||
const agentActionStartedDirtyRef = useRef(false);
|
||||
const duplicateStartedDirtyRef = useRef(false);
|
||||
pendingNavigationChangesRef.current = hasPendingNavigationChanges;
|
||||
beforeNavigateRef.current = onBeforeNavigate;
|
||||
|
||||
function confirmNavigationStart(startedDirtyRef: React.MutableRefObject<boolean>) {
|
||||
startedDirtyRef.current = pendingNavigationChangesRef.current;
|
||||
return beforeNavigateRef.current?.() !== false;
|
||||
}
|
||||
|
||||
function confirmLateNavigationChanges(startedDirtyRef: React.MutableRefObject<boolean>) {
|
||||
return (
|
||||
!pendingNavigationChangesRef.current ||
|
||||
startedDirtyRef.current ||
|
||||
beforeNavigateRef.current?.() !== false
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedCompanyId = companyId ?? agent.companyId;
|
||||
const canonicalAgentRef = agentRouteRef(agent);
|
||||
@@ -251,9 +276,11 @@ export function AgentActionButtons({
|
||||
onActionError?.(null);
|
||||
invalidateAgent();
|
||||
if (action === "terminate") {
|
||||
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
||||
onTerminateSuccess?.(data as Agent);
|
||||
}
|
||||
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
||||
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
@@ -285,6 +312,7 @@ export function AgentActionButtons({
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
|
||||
if (!confirmLateNavigationChanges(duplicateStartedDirtyRef)) return;
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -299,7 +327,7 @@ export function AgentActionButtons({
|
||||
const nextName = duplicateAgentName(agent.name);
|
||||
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
|
||||
setMoreOpen(false);
|
||||
if (!confirmed) return;
|
||||
if (!confirmed || !confirmNavigationStart(duplicateStartedDirtyRef)) return;
|
||||
duplicateAgent.mutate();
|
||||
}, [agent.name, duplicateAgent]);
|
||||
|
||||
@@ -334,7 +362,10 @@ export function AgentActionButtons({
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
onClick={() => {
|
||||
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
||||
agentAction.mutate("invoke");
|
||||
}}
|
||||
disabled={assignAndRunDisabled}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
@@ -423,8 +454,9 @@ export function AgentActionButtons({
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
||||
agentAction.mutate("terminate");
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
||||
@@ -205,7 +205,10 @@ function setInputValue(input: HTMLInputElement, value: string) {
|
||||
async function renderForm(
|
||||
environments: Environment[],
|
||||
agentOverrides: Partial<Agent> = {},
|
||||
options: { showAdapterTestEnvironmentButton?: boolean } = {},
|
||||
options: {
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
content?: "configuration" | "secrets";
|
||||
} = {},
|
||||
) {
|
||||
mockEnvironmentsApi.list.mockResolvedValue(environments);
|
||||
|
||||
@@ -229,6 +232,7 @@ async function renderForm(
|
||||
agent={makeAgent(agentOverrides)}
|
||||
onSave={vi.fn()}
|
||||
hidePromptTemplate
|
||||
content={options.content}
|
||||
showAdapterTypeField={false}
|
||||
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
|
||||
/>
|
||||
@@ -418,6 +422,28 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(result.container.querySelector("select")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps secret access out of the main Configuration content", async () => {
|
||||
const result = await renderForm([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
]);
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).not.toContain("Secret access");
|
||||
});
|
||||
|
||||
it("renders secret access as dedicated form content", async () => {
|
||||
const result = await renderForm(
|
||||
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
|
||||
{},
|
||||
{ content: "secrets" },
|
||||
);
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).toContain("Secret access");
|
||||
expect(result.container.textContent).toContain("No secrets are bound to this agent yet.");
|
||||
expect(result.container.textContent).not.toContain("Environment variables");
|
||||
});
|
||||
|
||||
it("shows concise Environment copy when one runnable non-local environment exists", async () => {
|
||||
const result = await renderForm([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
|
||||
@@ -102,6 +102,8 @@ type AgentConfigFormProps = {
|
||||
hideInstructionsFile?: boolean;
|
||||
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
|
||||
hidePromptTemplate?: boolean;
|
||||
/** Render the main configuration sections or the dedicated edit-only Secrets surface. */
|
||||
content?: "configuration" | "secrets";
|
||||
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||
sectionLayout?: "inline" | "cards";
|
||||
} & (
|
||||
@@ -1051,6 +1053,43 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!isCreate && props.content === "secrets") {
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{isDirty && !props.hideInlineSave && (
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
<Button size="sm" onClick={handleSave} disabled={props.isSaving}>
|
||||
{props.isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
|
||||
}
|
||||
<div className={cn(cards ? "space-y-3 rounded-lg border border-border p-4" : "space-y-3 px-4 pb-3")}>
|
||||
<p className="text-xs text-muted-foreground">{help.secretAccess}</p>
|
||||
<AgentSecretAccessEditor
|
||||
config={{ ...config, ...overlay.adapterConfig }}
|
||||
secrets={availableSecrets}
|
||||
onChange={applyAccessGrants}
|
||||
onCreateSecret={(name, value) => createSecret.mutateAsync({ name, value })}
|
||||
proposals={agentBindingProposals}
|
||||
onApproveProposal={proposalReview.requestApprove}
|
||||
onRejectProposal={proposalReview.requestReject}
|
||||
/>
|
||||
{proposalReview.dialogs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
@@ -1538,20 +1577,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isCreate && (
|
||||
<Field label="Secret access" hint={help.secretAccess}>
|
||||
<AgentSecretAccessEditor
|
||||
config={{ ...config, ...overlay.adapterConfig }}
|
||||
secrets={availableSecrets}
|
||||
onChange={applyAccessGrants}
|
||||
proposals={agentBindingProposals}
|
||||
onApproveProposal={proposalReview.requestApprove}
|
||||
onRejectProposal={proposalReview.requestReject}
|
||||
/>
|
||||
{proposalReview.dialogs}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Edit-only: timeout + grace period */}
|
||||
{!isCreate && (
|
||||
<>
|
||||
|
||||
@@ -5,18 +5,29 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared";
|
||||
|
||||
// Stub SecretBindingPicker so the editor renders without CompanyContext /
|
||||
// react-query. The stub exposes a button that binds a fixed secret.
|
||||
vi.mock("./SecretBindingPicker", () => ({
|
||||
SecretBindingPicker: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (next: { secretId: string; version?: number | "latest" } | null) => void;
|
||||
}) => (
|
||||
<button type="button" data-testid="pick-secret" onClick={() => onChange({ secretId: "s1", version: "latest" })}>
|
||||
pick
|
||||
</button>
|
||||
),
|
||||
const mockSecretPickerRender = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Keep this component test focused on access-row behavior while asserting that
|
||||
// the editor routes selection through the shared, folder-aware env picker.
|
||||
vi.mock("./environment-variables-editor/SecretPicker", () => ({
|
||||
SecretPicker: (props: {
|
||||
secretId: string;
|
||||
secrets: readonly CompanySecret[];
|
||||
onSelect: (secretId: string) => void;
|
||||
onCreateNew?: (query: string) => void;
|
||||
}) => {
|
||||
mockSecretPickerRender(props);
|
||||
return <>
|
||||
<button type="button" data-testid="pick-secret" onClick={() => props.onSelect("s1")}>
|
||||
pick
|
||||
</button>
|
||||
{props.onCreateNew ? (
|
||||
<button type="button" data-testid="create-secret" onClick={() => props.onCreateNew?.("new_secret")}>
|
||||
create
|
||||
</button>
|
||||
) : null}
|
||||
</>;
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -155,12 +166,43 @@ describe("AgentSecretAccessEditor component", () => {
|
||||
|
||||
// Bind a secret via the stubbed picker.
|
||||
const pick = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
|
||||
expect(mockSecretPickerRender).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ secretId: "", secrets }),
|
||||
);
|
||||
flushSync(() => pick.click());
|
||||
|
||||
const last = emitted.at(-1)!;
|
||||
expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } });
|
||||
});
|
||||
|
||||
it("keeps the create form open when focus returns to the shared picker anchor", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(
|
||||
<AgentSecretAccessEditor
|
||||
config={{ "access.STRIPE": { type: "secret_ref", secretId: "s1" } }}
|
||||
secrets={secrets}
|
||||
onChange={() => {}}
|
||||
onCreateSecret={async () => secrets[0]!}
|
||||
/>,
|
||||
);
|
||||
|
||||
const createButton = container.querySelector<HTMLButtonElement>('[data-testid="create-secret"]')!;
|
||||
flushSync(() => createButton.click());
|
||||
flushSync(() => vi.runAllTimers());
|
||||
expect(document.body.textContent).toContain("Create secret");
|
||||
|
||||
const pickerButton = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
|
||||
pickerButton.focus();
|
||||
flushSync(() => {});
|
||||
|
||||
expect(document.body.textContent).toContain("Create secret");
|
||||
expect(document.querySelector('input[aria-label="Secret name"]')).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders pending binding proposals as Proposed rows with approve/reject", () => {
|
||||
const approved: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
@@ -9,14 +9,16 @@ import type {
|
||||
import { cn } from "../lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
|
||||
import { SecretPicker } from "./environment-variables-editor/SecretPicker";
|
||||
import { CreateSecretPopover } from "./environment-variables-editor/CreateSecretPopover";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import {
|
||||
AGENT_ACCESS_CONFIG_PATH_PREFIX,
|
||||
ENV_CONFIG_PATH_PREFIX,
|
||||
SECRET_ALIAS_RE,
|
||||
deliveryModeDescription,
|
||||
} from "../lib/secret-delivery";
|
||||
import { envKeyFromSecretName } from "./environment-variables-editor/model";
|
||||
import { envKeyFromSecretName, secretNameFromKey } from "./environment-variables-editor/model";
|
||||
import {
|
||||
DeliveryBadge as ProposalDeliveryBadge,
|
||||
ProposalActions,
|
||||
@@ -157,6 +159,8 @@ export interface AgentSecretAccessEditorProps {
|
||||
* parent diffs this against the current `access.*` keys to add/remove them.
|
||||
*/
|
||||
onChange: (next: Record<string, EnvSecretRefBinding>) => void;
|
||||
/** Create a company secret from the shared picker's pinned create action. */
|
||||
onCreateSecret?: (name: string, value: string) => Promise<CompanySecret>;
|
||||
disabled?: boolean;
|
||||
/** Pending binding proposals targeting this agent (PAP-14731). */
|
||||
proposals?: readonly SecretProposalView[];
|
||||
@@ -191,6 +195,7 @@ export function AgentSecretAccessEditor({
|
||||
config,
|
||||
secrets,
|
||||
onChange,
|
||||
onCreateSecret,
|
||||
disabled,
|
||||
proposals,
|
||||
onApproveProposal,
|
||||
@@ -208,6 +213,8 @@ export function AgentSecretAccessEditor({
|
||||
const incomingKey = useMemo(() => normalizeAccessMapKey(incomingMap), [incomingMap]);
|
||||
|
||||
const [rows, setRows] = useState<AccessRow[]>(() => entriesToRows(apiBindings));
|
||||
const [createRequest, setCreateRequest] = useState<{ rowId: string; name: string } | null>(null);
|
||||
const pickerAnchorRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
const lastEmittedKeyRef = useRef(incomingKey);
|
||||
const lastIncomingKeyRef = useRef(incomingKey);
|
||||
|
||||
@@ -334,9 +341,7 @@ export function AgentSecretAccessEditor({
|
||||
const trimmedAlias = row.alias.trim();
|
||||
const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias);
|
||||
const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1;
|
||||
const bindingValue: SecretBindingValue | null = row.secretId
|
||||
? { secretId: row.secretId, version: row.version }
|
||||
: null;
|
||||
const selectedSecret = secrets.find((secret) => secret.id === row.secretId) ?? null;
|
||||
return (
|
||||
<div key={row.id} className="space-y-1">
|
||||
<div className="grid grid-cols-(--gtc-65) items-start gap-1.5">
|
||||
@@ -360,23 +365,106 @@ export function AgentSecretAccessEditor({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SecretBindingPicker
|
||||
value={bindingValue}
|
||||
onChange={(next) =>
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<Popover
|
||||
open={createRequest?.rowId === row.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && createRequest?.rowId === row.id) setCreateRequest(null);
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (node) pickerAnchorRefs.current.set(row.id, node);
|
||||
else pickerAnchorRefs.current.delete(row.id);
|
||||
}}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<SecretPicker
|
||||
secretId={row.secretId}
|
||||
secrets={secrets}
|
||||
onSelect={(secretId) =>
|
||||
patchRow(row.id, {
|
||||
secretId,
|
||||
version: "latest",
|
||||
alias:
|
||||
!row.alias.trim() && secretId
|
||||
? envKeyFromSecretName(secretName(secretId))
|
||||
: row.alias,
|
||||
})
|
||||
}
|
||||
onCreateNew={onCreateSecret
|
||||
? (query) => {
|
||||
window.setTimeout(() => {
|
||||
setCreateRequest({
|
||||
rowId: row.id,
|
||||
name: secretNameFromKey(query) || query.trim(),
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
: undefined}
|
||||
disabled={disabled}
|
||||
triggerClassName="h-9 min-h-9"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto p-3"
|
||||
onInteractOutside={(event) => {
|
||||
// Closing the picker returns focus to its trigger inside
|
||||
// this popover's anchor. Keep that focus restoration from
|
||||
// dismissing the create form that just opened.
|
||||
const target = event.detail.originalEvent.target as Node | null;
|
||||
if (target && pickerAnchorRefs.current.get(row.id)?.contains(target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createRequest?.rowId === row.id && onCreateSecret ? (
|
||||
<CreateSecretPopover
|
||||
initialName={createRequest.name}
|
||||
initialValue=""
|
||||
existingSecretNames={secrets.map((secret) => secret.name)}
|
||||
onCancel={() => setCreateRequest(null)}
|
||||
onSubmit={async (name, value) => {
|
||||
const created = await onCreateSecret(name, value);
|
||||
patchRow(row.id, {
|
||||
secretId: created.id,
|
||||
version: "latest",
|
||||
alias: row.alias.trim() || envKeyFromSecretName(created.name),
|
||||
});
|
||||
setCreateRequest(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<select
|
||||
className="h-9 shrink-0 rounded-md border border-border bg-background px-2 text-xs outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={row.version === undefined ? "latest" : String(row.version)}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
patchRow(row.id, {
|
||||
secretId: next?.secretId ?? "",
|
||||
version: next?.version ?? "latest",
|
||||
alias:
|
||||
!row.alias.trim() && next?.secretId
|
||||
? envKeyFromSecretName(secretName(next.secretId))
|
||||
: row.alias,
|
||||
})
|
||||
}
|
||||
label=""
|
||||
placeholder="Select secret"
|
||||
disabled={disabled}
|
||||
/>
|
||||
version: raw === "latest" ? "latest" : Number.parseInt(raw, 10),
|
||||
});
|
||||
}}
|
||||
disabled={disabled || !selectedSecret}
|
||||
aria-label="Version"
|
||||
>
|
||||
<option value="latest">latest</option>
|
||||
{selectedSecret
|
||||
? Array.from({ length: Math.max(0, selectedSecret.latestVersion) }, (_, index) => {
|
||||
const version = selectedSecret.latestVersion - index;
|
||||
if (version <= 0) return null;
|
||||
return (
|
||||
<option key={version} value={version}>
|
||||
v{version}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PageTabItem {
|
||||
}
|
||||
|
||||
interface PageTabBarProps {
|
||||
items: PageTabItem[];
|
||||
items: readonly PageTabItem[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
align?: "center" | "start";
|
||||
|
||||
@@ -132,7 +132,7 @@ export interface SecretPickerProps {
|
||||
disabled?: boolean;
|
||||
onSelect: (secretId: string) => void;
|
||||
/** Open the create-secret popover, seeded with the current query. */
|
||||
onCreateNew: (query: string) => void;
|
||||
onCreateNew?: (query: string) => void;
|
||||
triggerClassName?: string;
|
||||
/** SearchableSelect auto-opens on focus; suppress for programmatic control. */
|
||||
disablePortal?: boolean;
|
||||
@@ -317,21 +317,23 @@ export function SecretPicker({
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
createItem={{
|
||||
render: (query) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Plus className="size-3.5 shrink-0" />
|
||||
{query.trim() ? (
|
||||
<span>
|
||||
Create secret <span className="font-mono">“{query.trim()}”</span>…
|
||||
createItem={onCreateNew
|
||||
? {
|
||||
render: (query) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Plus className="size-3.5 shrink-0" />
|
||||
{query.trim() ? (
|
||||
<span>
|
||||
Create secret <span className="font-mono">“{query.trim()}”</span>…
|
||||
</span>
|
||||
) : (
|
||||
<span>Create new secret…</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>Create new secret…</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onSelect: (query) => onCreateNew(query),
|
||||
}}
|
||||
),
|
||||
onSelect: (query) => onCreateNew(query),
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user