Rework secrets dialog and add in-sheet agent access (#9797)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents and the credentials those agents need for work. > - The secrets UI is responsible for making credential creation understandable and for showing where credentials are used. > - The create flow previously exposed an editable generated key too early, used an unnatural field order, and rendered uneven provider tab rows. > - The detail sheet also lacked an in-context way to see and manage which agents reference a selected secret. > - This pull request makes the create dialog follow a predictable name-to-value flow and adds agent access management directly to the secret details sheet. > - The benefit is a clearer secrets workflow with fewer accidental key edits and less navigation when granting or revoking agent access. ## Linked Issues or Issue Description **Subsystem affected:** `ui/` — React + Vite board UI **Problem or motivation:** Creating a secret currently makes its generated key look immediately editable, places fields in an awkward keyboard order, and applies provider-tab overrides that produce uneven rows. After creation, operators cannot inspect or change agent access from the selected secret's detail sheet. **Proposed solution:** Generate the key from the path-style name and keep it read-only until an explicit Edit action; place Value directly after Name; use the standard tab sizing; and add an Agent access section that reads and updates `secret_ref` / `user_secret_ref` environment bindings through agent adapter configuration. **Alternatives considered:** Keeping access management only on agent configuration screens was rejected because it hides a secret-centric question—“which agents can use this?”—and requires repetitive navigation. Keeping the key always editable was rejected because the generated value should be the safe default. **Roadmap alignment:** No matching item was found in `ROADMAP.md`; this is a focused usability and access-management improvement to the existing secrets surface. **Additional context:** GitHub search found no duplicate PR for this dialog and in-sheet access change. PR #9321 also mentions user-secret resolution but addresses unrelated skills-route behavior. ## What Changed - Auto-generate the create-secret key from Name, keep it read-only by default, and expose an explicit Edit action. - Use a path-style Name placeholder (`/dev/foo/bar`) and place Value immediately after Name for natural keyboard navigation. - Remove tab sizing/whitespace overrides that caused uneven provider-tab row heights. - Add an Agent access section to the Details tab that lists referencing agents and grants or revokes `secret_ref` / `user_secret_ref` environment bindings in place. - Cover company secrets and each-user definitions with focused render tests. ## Verification - `pnpm exec vitest run ui/src/pages/Secrets.render.test.tsx` — 17/17 passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — changed files are clean; the repository-wide command currently reports five pre-existing `#9627` violations in unrelated files (`Sidebar.tsx`, `Inbox.tsx`, `IssueDetail.tsx`, `Issues.tsx`, and `Routines.tsx`). - Additional secrets verification completed during implementation: 35/35 adjacent secrets tests passed, and the flow was checked in a real browser in light and dark themes. ## Risks - Agent access mutations update adapter environment configuration, so malformed legacy env entries could affect how a binding is displayed or revoked. - Each-user definitions use `user_secret_ref` rather than `secret_ref`; focused tests cover selecting the correct binding type. - No database or API contract changes are included. > 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 - Anthropic Claude Fable 5 assisted with the implementation using repository-aware code editing and test execution. - OpenAI GPT-5.5 via Codex CLI assisted with PR preparation, branch hygiene, focused verification, GitHub operations, and review/check loops. ## 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: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
83765f08d1
commit
67616dd7ea
|
|
@ -52,6 +52,12 @@ const mockSecretsApi = vi.hoisted(() => ({
|
|||
removeMyUserSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
@ -59,6 +65,10 @@ vi.mock("../api/secrets", () => ({
|
|||
secretsApi: mockSecretsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -346,6 +356,7 @@ describe("Secrets page layout", () => {
|
|||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
||||
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -673,6 +684,18 @@ describe("Secrets page layout", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
const companyKeyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
||||
expect(companyKeyInput.readOnly).toBe(true);
|
||||
|
||||
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Edit",
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
editKeyButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
expect(companyKeyInput.readOnly).toBe(false);
|
||||
|
||||
const eachUserButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Each user",
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
|
@ -686,6 +709,12 @@ describe("Secrets page layout", () => {
|
|||
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
||||
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
||||
const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement;
|
||||
expect(keyInput.readOnly).toBe(true);
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Edit",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(document.getElementById("new-secret-provider")).toBeNull();
|
||||
expect(document.getElementById("new-secret-vault")).toBeNull();
|
||||
expect(document.getElementById("new-secret-value")).toBeNull();
|
||||
|
|
@ -1191,6 +1220,185 @@ describe("Secrets page layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("auto-generates the key from the name and keeps it read-only until Edit", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("New secret"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
newSecretButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
||||
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
||||
const valueTextarea = document.getElementById("new-secret-value") as HTMLTextAreaElement;
|
||||
|
||||
// Path-style placeholder and value directly after name for natural tab order.
|
||||
expect(nameInput.placeholder).toBe("/dev/foo/bar");
|
||||
expect(
|
||||
nameInput.compareDocumentPosition(valueTextarea) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
valueTextarea.compareDocumentPosition(keyInput) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
expect(keyInput.readOnly).toBe(true);
|
||||
await act(async () => {
|
||||
setInputValue(nameInput, "OpenAI API Key");
|
||||
});
|
||||
await flushReact();
|
||||
expect(keyInput.value).toBe("openai-api-key");
|
||||
|
||||
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Edit",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editKeyButton).toBeDefined();
|
||||
await act(async () => {
|
||||
editKeyButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect((document.getElementById("new-secret-key") as HTMLInputElement).readOnly).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(document.getElementById("new-secret-key") as HTMLInputElement, "custom-key");
|
||||
setInputValue(nameInput, "OpenAI API Key v2");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Once edited, the key stops following the name.
|
||||
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe("custom-key");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("grants and revokes agent access from the secret detail sheet", async () => {
|
||||
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
||||
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] });
|
||||
mockSecretsApi.accessEvents.mockResolvedValue([]);
|
||||
const coder = {
|
||||
id: "agent-coder",
|
||||
name: "CodexCoder",
|
||||
status: "active",
|
||||
adapterConfig: {},
|
||||
};
|
||||
const reviewer = {
|
||||
id: "agent-reviewer",
|
||||
name: "Reviewer",
|
||||
status: "active",
|
||||
adapterConfig: {
|
||||
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
|
||||
},
|
||||
};
|
||||
mockAgentsApi.list.mockResolvedValue([coder, reviewer]);
|
||||
mockAgentsApi.get.mockImplementation(async (id: string) =>
|
||||
id === "agent-coder" ? coder : reviewer,
|
||||
);
|
||||
mockAgentsApi.update.mockImplementation(async (id: string) =>
|
||||
id === "agent-coder" ? coder : reviewer,
|
||||
);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
||||
(row) => row.textContent?.includes("OPENAI_API_KEY"),
|
||||
) as HTMLElement | undefined;
|
||||
await act(async () => {
|
||||
companyRow?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// Existing access is listed right in the Details tab.
|
||||
expect(document.body.textContent).toContain("Agent access");
|
||||
expect(document.body.textContent).toContain("Reviewer");
|
||||
|
||||
const agentSelect = document.getElementById("agent-access-agent") as HTMLSelectElement;
|
||||
const envKeyInput = document.getElementById("agent-access-env-key") as HTMLInputElement;
|
||||
expect(envKeyInput.value).toBe("OPENAI_API_KEY");
|
||||
// Agents that already have access are not offered again.
|
||||
expect(Array.from(agentSelect.options).map((option) => option.textContent)).not.toContain("Reviewer");
|
||||
|
||||
await act(async () => {
|
||||
setSelectValue(agentSelect, "agent-coder");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const addButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Add",
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
addButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.update).toHaveBeenCalledWith(
|
||||
"agent-coder",
|
||||
{
|
||||
adapterConfig: {
|
||||
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
|
||||
},
|
||||
replaceAdapterConfig: true,
|
||||
},
|
||||
"company-1",
|
||||
);
|
||||
|
||||
const revokeButton = document.body.querySelector(
|
||||
'button[aria-label="Remove access for Reviewer"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(revokeButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
revokeButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.update).toHaveBeenCalledWith(
|
||||
"agent-reviewer",
|
||||
{ adapterConfig: { env: {} }, replaceAdapterConfig: true },
|
||||
"company-1",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an empty AWS discovery result without blocking manual entry", async () => {
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
|
||||
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ import {
|
|||
} from "../api/secrets";
|
||||
import { ApiError } from "../api/client";
|
||||
import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { envKeyFromSecretName } from "../components/environment-variables-editor/model";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -615,6 +617,7 @@ export function Secrets() {
|
|||
const [createMode, setCreateMode] = useState<CreateMode>("managed");
|
||||
const [editingDefinition, setEditingDefinition] = useState<UserSecretDefinition | null>(null);
|
||||
const [createKeyDirty, setCreateKeyDirty] = useState(false);
|
||||
const [createKeyEditable, setCreateKeyEditable] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
key: "",
|
||||
|
|
@ -712,6 +715,14 @@ export function Secrets() {
|
|||
() => userDefinitions.find((definition) => definition.id === selectedDefinitionId) ?? null,
|
||||
[selectedDefinitionId, userDefinitions],
|
||||
);
|
||||
const selectedSecretAccessReference = useMemo<AgentAccessReference | null>(
|
||||
() => selectedSecret ? { kind: "company", secret: selectedSecret } : null,
|
||||
[selectedSecret],
|
||||
);
|
||||
const selectedDefinitionAccessReference = useMemo<AgentAccessReference | null>(
|
||||
() => selectedDefinition ? { kind: "user", definition: selectedDefinition } : null,
|
||||
[selectedDefinition],
|
||||
);
|
||||
const selectedDefinitionMyEntry = useMemo(() => {
|
||||
if (!selectedDefinition) return null;
|
||||
return myUserSecrets.find((entry) => entry.definition.id === selectedDefinition.id) ?? {
|
||||
|
|
@ -845,6 +856,7 @@ export function Secrets() {
|
|||
setSecretValueProvider("company");
|
||||
setCreateMode("managed");
|
||||
setCreateKeyDirty(false);
|
||||
setCreateKeyEditable(false);
|
||||
setCreateError(null);
|
||||
setCreateForm({
|
||||
name: "",
|
||||
|
|
@ -864,6 +876,7 @@ export function Secrets() {
|
|||
setSecretValueProvider("user");
|
||||
setCreateMode("managed");
|
||||
setCreateKeyDirty(true);
|
||||
setCreateKeyEditable(false);
|
||||
setCreateError(null);
|
||||
setCreateForm({
|
||||
name: definition.name,
|
||||
|
|
@ -933,6 +946,7 @@ export function Secrets() {
|
|||
setEditingDefinition(null);
|
||||
setSecretValueProvider("company");
|
||||
setCreateKeyDirty(false);
|
||||
setCreateKeyEditable(false);
|
||||
setCreateForm({
|
||||
name: "",
|
||||
key: "",
|
||||
|
|
@ -1835,12 +1849,18 @@ export function Secrets() {
|
|||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||
<TabsContent value="details">
|
||||
<SecretDetailsTab
|
||||
secret={selectedSecret}
|
||||
providers={providers}
|
||||
providerConfigs={providerConfigs}
|
||||
onViewUsage={() => setSecretDetailTab("usage")}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<AgentAccessSection
|
||||
companyId={selectedCompanyId}
|
||||
reference={selectedSecretAccessReference!}
|
||||
/>
|
||||
<SecretDetailsTab
|
||||
secret={selectedSecret}
|
||||
providers={providers}
|
||||
providerConfigs={providerConfigs}
|
||||
onViewUsage={() => setSecretDetailTab("usage")}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="usage">
|
||||
<SecretUsageTab loading={usageQuery.isPending} bindings={usageQuery.data?.bindings ?? []} />
|
||||
|
|
@ -1968,11 +1988,17 @@ export function Secrets() {
|
|||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||
<TabsContent value="details">
|
||||
<UserSecretDetailsTab
|
||||
companyId={selectedCompanyId}
|
||||
definition={selectedDefinition}
|
||||
onViewCoverage={() => setSecretDetailTab("coverage")}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<AgentAccessSection
|
||||
companyId={selectedCompanyId}
|
||||
reference={selectedDefinitionAccessReference!}
|
||||
/>
|
||||
<UserSecretDetailsTab
|
||||
companyId={selectedCompanyId}
|
||||
definition={selectedDefinition}
|
||||
onViewCoverage={() => setSecretDetailTab("coverage")}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="coverage">
|
||||
<UserSecretCoverageTab
|
||||
|
|
@ -2045,53 +2071,167 @@ export function Secrets() {
|
|||
Choose who provides the value. Shared fields keep their values when you switch modes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
|
||||
<Input
|
||||
id="new-secret-name"
|
||||
value={createForm.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
<div className="space-y-4">
|
||||
{!editingDefinition ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-foreground">Who provides the value?</p>
|
||||
<Tabs
|
||||
value={secretValueProvider}
|
||||
onValueChange={(value) => {
|
||||
const next = value as SecretValueProvider;
|
||||
setSecretValueProvider(next);
|
||||
setCreateKeyEditable(false);
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: secretValueProvider === "user"
|
||||
? normalizeUserSecretKeyForPreview(name)
|
||||
: normalizeSecretKeyForPreview(name),
|
||||
: next === "user"
|
||||
? normalizeUserSecretKeyForPreview(current.name)
|
||||
: normalizeSecretKeyForPreview(current.name),
|
||||
}));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "OPENAI_API_KEY"}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-key">
|
||||
Key {secretValueProvider === "company" ? <span className="text-muted-foreground/70">(optional)</span> : null}
|
||||
</label>
|
||||
<Input
|
||||
id="new-secret-key"
|
||||
value={createForm.key}
|
||||
onChange={(event) => {
|
||||
setCreateKeyDirty(true);
|
||||
setCreateForm((current) => ({ ...current, key: event.target.value }));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"}
|
||||
disabled={Boolean(editingDefinition)}
|
||||
className={secretValueProvider === "user" ? "font-mono text-sm" : undefined}
|
||||
/>
|
||||
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{secretValueProvider === "user"
|
||||
? editingDefinition
|
||||
? "Stable env binding key. Cannot be changed."
|
||||
: "Env-style key used by user-secret bindings."
|
||||
: "Shared secret keys keep lowercase dash normalization."}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="company">Company</TabsTrigger>
|
||||
<TabsTrigger value="user">Each user</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
Company stores one shared value. Each user lets every member supply their own value under My secrets.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{secretValueProvider === "company" && !editingDefinition ? (
|
||||
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="managed">Managed value</TabsTrigger>
|
||||
<TabsTrigger value="external">External reference</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
|
||||
<Input
|
||||
id="new-secret-name"
|
||||
value={createForm.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: secretValueProvider === "user"
|
||||
? normalizeUserSecretKeyForPreview(name)
|
||||
: normalizeSecretKeyForPreview(name),
|
||||
}));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secretValueProvider === "company" && createMode === "managed" ? (
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
|
||||
<Textarea
|
||||
id="new-secret-value"
|
||||
value={createForm.value}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, value: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
|
||||
placeholder="Stored once, never re-displayed"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{secretValueProvider === "company" && createMode === "external" ? (
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
|
||||
<Input
|
||||
id="new-secret-ref"
|
||||
value={createForm.externalRef}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
|
||||
}
|
||||
placeholder="arn:aws:secretsmanager:..."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground mt-1">
|
||||
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
|
||||
then update this reference only if the path, ARN, or version changes.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{secretValueProvider === "user" ? (
|
||||
<>
|
||||
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
|
||||
Every member supplies their own value under My secrets. Agents resolve the responsible
|
||||
user's value at runtime.
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="new-secret-usage-guidance">
|
||||
Usage guidance <span className="text-muted-foreground/70">(optional)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="new-secret-usage-guidance"
|
||||
value={createForm.usageGuidance}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, usageGuidance: event.target.value }))
|
||||
}
|
||||
placeholder="Tell members how to create their token, required scopes, etc."
|
||||
className="min-h-(--sz-70px) text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-key">Key</label>
|
||||
{!createKeyEditable && !editingDefinition ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-(length:--text-micro) text-muted-foreground"
|
||||
onClick={() => setCreateKeyEditable(true)}
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" /> Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
id="new-secret-key"
|
||||
value={createForm.key}
|
||||
readOnly={!createKeyEditable}
|
||||
tabIndex={createKeyEditable && !editingDefinition ? undefined : -1}
|
||||
onChange={(event) => {
|
||||
if (!createKeyEditable || editingDefinition) return;
|
||||
setCreateKeyDirty(true);
|
||||
setCreateForm((current) => ({ ...current, key: event.target.value }));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"}
|
||||
disabled={Boolean(editingDefinition)}
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
!createKeyEditable && !editingDefinition && "border-dashed bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{editingDefinition
|
||||
? "Stable env binding key. Cannot be changed."
|
||||
: !createKeyEditable
|
||||
? "Generated from the name."
|
||||
: secretValueProvider === "user"
|
||||
? "Env-style key used by user-secret bindings."
|
||||
: "Shared secret keys keep lowercase dash normalization."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-description">
|
||||
Description <span className="text-muted-foreground/70">(optional)</span>
|
||||
|
|
@ -2106,53 +2246,9 @@ export function Secrets() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{!editingDefinition ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-foreground">Who provides the value?</p>
|
||||
<Tabs
|
||||
value={secretValueProvider}
|
||||
onValueChange={(value) => {
|
||||
const next = value as SecretValueProvider;
|
||||
setSecretValueProvider(next);
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: next === "user"
|
||||
? normalizeUserSecretKeyForPreview(current.name)
|
||||
: normalizeSecretKeyForPreview(current.name),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger value="company">Company</TabsTrigger>
|
||||
<TabsTrigger value="user">Each user</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
Company stores one shared value. Each user lets every member supply their own value under My secrets.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{secretValueProvider === "company" ? (
|
||||
<>
|
||||
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="managed"
|
||||
className="min-h-9 whitespace-normal px-1.5 text-center text-xs leading-tight sm:text-sm"
|
||||
>
|
||||
Managed value
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="external"
|
||||
className="min-h-9 whitespace-normal px-1.5 text-center text-xs leading-tight sm:text-sm"
|
||||
>
|
||||
External reference
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-provider">Provider</label>
|
||||
<select
|
||||
|
|
@ -2226,81 +2322,25 @@ export function Secrets() {
|
|||
</select>
|
||||
{selectedCreateProviderConfig ? (
|
||||
<ProviderVaultInlineWarning config={selectedCreateProviderConfig} />
|
||||
) : (
|
||||
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
|
||||
Existing deployment-level provider settings stay available for backwards compatibility.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{createMode === "managed" ? (
|
||||
<>
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-(length:--text-micro) text-emerald-700 dark:text-emerald-300">
|
||||
Paperclip-managed secrets are created in the selected provider and future rotations
|
||||
write a new provider version through Paperclip.
|
||||
{awsManagedPathPreview ? (
|
||||
<div className="mt-1">
|
||||
AWS managed path:{" "}
|
||||
<code className="break-all rounded bg-background/70 px-1 py-0.5">
|
||||
{awsManagedPathPreview}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
|
||||
<Textarea
|
||||
id="new-secret-value"
|
||||
value={createForm.value}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, value: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
|
||||
placeholder="Stored once, never re-displayed"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
|
||||
<Input
|
||||
id="new-secret-ref"
|
||||
value={createForm.externalRef}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
|
||||
}
|
||||
placeholder="arn:aws:secretsmanager:..."
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground mt-1">
|
||||
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
|
||||
then update this reference only if the path, ARN, or version changes.
|
||||
</p>
|
||||
{createMode === "managed" ? (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-(length:--text-micro) text-emerald-700 dark:text-emerald-300">
|
||||
Paperclip-managed secrets are created in the selected provider and future rotations
|
||||
write a new provider version through Paperclip.
|
||||
{awsManagedPathPreview ? (
|
||||
<div className="mt-1">
|
||||
AWS managed path:{" "}
|
||||
<code className="break-all rounded bg-background/70 px-1 py-0.5">
|
||||
{awsManagedPathPreview}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
|
||||
Every member supplies their own value under My secrets. Agents resolve the responsible
|
||||
user's value at runtime.
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="new-secret-usage-guidance">
|
||||
Usage guidance <span className="text-muted-foreground/70">(optional)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="new-secret-usage-guidance"
|
||||
value={createForm.usageGuidance}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, usageGuidance: event.target.value }))
|
||||
}
|
||||
placeholder="Tell members how to create their token, required scopes, etc."
|
||||
className="min-h-(--sz-70px) text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
{createError ? (
|
||||
<SecretCreateError
|
||||
error={createError}
|
||||
|
|
@ -3719,6 +3759,263 @@ function UserSecretAccessEventsTab() {
|
|||
);
|
||||
}
|
||||
|
||||
type AgentAccessReference =
|
||||
| { kind: "company"; secret: CompanySecret }
|
||||
| { kind: "user"; definition: UserSecretDefinition };
|
||||
|
||||
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/** Env keys in an agent's env config that resolve to this secret/definition. */
|
||||
function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference): string[] {
|
||||
if (typeof env !== "object" || env === null || Array.isArray(env)) return [];
|
||||
return Object.entries(env as Record<string, unknown>)
|
||||
.filter(([, binding]) => {
|
||||
if (typeof binding !== "object" || binding === null) return false;
|
||||
const record = binding as Record<string, unknown>;
|
||||
return reference.kind === "company"
|
||||
? record.type === "secret_ref" && record.secretId === reference.secret.id
|
||||
: record.type === "user_secret_ref" && record.key === reference.definition.key;
|
||||
})
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function AgentAccessSection({
|
||||
companyId,
|
||||
reference,
|
||||
}: {
|
||||
companyId: string;
|
||||
reference: AgentAccessReference;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [envKey, setEnvKey] = useState("");
|
||||
const [envKeyDirty, setEnvKeyDirty] = useState(false);
|
||||
const [accessError, setAccessError] = useState<string | null>(null);
|
||||
|
||||
const referenceId = reference.kind === "company" ? reference.secret.id : reference.definition.id;
|
||||
const referenceName = reference.kind === "company" ? reference.secret.name : reference.definition.name;
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const agents = useMemo(
|
||||
() => (agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
[agentsQuery.data],
|
||||
);
|
||||
const agentAccess = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.map((agent) => ({
|
||||
agent,
|
||||
envKeys: envKeysReferencingSecret(
|
||||
(agent.adapterConfig as Record<string, unknown> | null)?.env,
|
||||
reference,
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.envKeys.length > 0),
|
||||
[agents, reference],
|
||||
);
|
||||
const grantableAgents = useMemo(
|
||||
() => agents.filter((agent) => !agentAccess.some((entry) => entry.agent.id === agent.id)),
|
||||
[agents, agentAccess],
|
||||
);
|
||||
|
||||
const effectiveEnvKey = envKeyDirty
|
||||
? envKey
|
||||
: reference.kind === "user"
|
||||
? reference.definition.key
|
||||
: envKeyFromSecretName(referenceName);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAgentId("");
|
||||
setEnvKey("");
|
||||
setEnvKeyDirty(false);
|
||||
setAccessError(null);
|
||||
}, [referenceId]);
|
||||
|
||||
function invalidateAfterChange(agentId: string) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
if (reference.kind === "company") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(reference.secret.id) });
|
||||
}
|
||||
}
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: async ({ agentId, key }: { agentId: string; key: string }) => {
|
||||
// Re-fetch right before patching so we merge into the freshest env config.
|
||||
const detail = await agentsApi.get(agentId, companyId);
|
||||
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
|
||||
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
|
||||
if (env[key] !== undefined) {
|
||||
throw new Error(`${detail.name} already has an env var named ${key}.`);
|
||||
}
|
||||
env[key] =
|
||||
reference.kind === "company"
|
||||
? { type: "secret_ref", secretId: reference.secret.id }
|
||||
: { type: "user_secret_ref", key: reference.definition.key };
|
||||
return agentsApi.update(
|
||||
agentId,
|
||||
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
onSuccess: (agent, variables) => {
|
||||
setSelectedAgentId("");
|
||||
setEnvKey("");
|
||||
setEnvKeyDirty(false);
|
||||
setAccessError(null);
|
||||
invalidateAfterChange(variables.agentId);
|
||||
pushToast({ title: "Access granted", body: `${agent.name} now receives ${variables.key}`, tone: "success" });
|
||||
},
|
||||
onError: (error) => setAccessError(readableErrorMessage(error)),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
const detail = await agentsApi.get(agentId, companyId);
|
||||
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
|
||||
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
|
||||
const keys = envKeysReferencingSecret(env, reference);
|
||||
if (keys.length === 0) return detail;
|
||||
for (const key of keys) delete env[key];
|
||||
return agentsApi.update(
|
||||
agentId,
|
||||
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
onSuccess: (agent, variables) => {
|
||||
setAccessError(null);
|
||||
invalidateAfterChange(variables.agentId);
|
||||
pushToast({ title: "Access removed", body: agent.name, tone: "info" });
|
||||
},
|
||||
onError: (error) => setAccessError(readableErrorMessage(error)),
|
||||
});
|
||||
|
||||
const envKeyValid = ENV_KEY_PATTERN.test(effectiveEnvKey);
|
||||
const canGrant = Boolean(selectedAgentId) && envKeyValid && !grantMutation.isPending;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-medium text-foreground">Agent access</h3>
|
||||
</div>
|
||||
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
|
||||
{reference.kind === "company"
|
||||
? "These agents receive this secret as an environment variable at run start."
|
||||
: "These agents resolve the responsible user's value as an environment variable at run start."}
|
||||
</p>
|
||||
{agentsQuery.isPending ? (
|
||||
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">Loading agents…</p>
|
||||
) : agentsQuery.isError ? (
|
||||
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">
|
||||
Agent list unavailable. Manage access from each agent's configuration instead.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{agentAccess.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{agentAccess.map(({ agent, envKeys }) => (
|
||||
<li
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 rounded border border-border/60 bg-background px-2 py-1"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{agent.name}</span>
|
||||
<code className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{envKeys.join(", ")}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 shrink-0 p-0 text-muted-foreground"
|
||||
aria-label={`Remove access for ${agent.name}`}
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate({ agentId: agent.id })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">No agents have access yet.</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="text-(length:--text-micro) font-medium text-muted-foreground"
|
||||
htmlFor="agent-access-agent"
|
||||
>
|
||||
Agent
|
||||
</label>
|
||||
<select
|
||||
id="agent-access-agent"
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs outline-none"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => setSelectedAgentId(event.target.value)}
|
||||
>
|
||||
<option value="">Select agent…</option>
|
||||
{grantableAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="text-(length:--text-micro) font-medium text-muted-foreground"
|
||||
htmlFor="agent-access-env-key"
|
||||
>
|
||||
Env var
|
||||
</label>
|
||||
<Input
|
||||
id="agent-access-env-key"
|
||||
value={effectiveEnvKey}
|
||||
onChange={(event) => {
|
||||
setEnvKeyDirty(true);
|
||||
setEnvKey(event.target.value.toUpperCase());
|
||||
}}
|
||||
className="h-8 font-mono text-xs"
|
||||
placeholder="MY_SECRET"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
disabled={!canGrant}
|
||||
onClick={() => grantMutation.mutate({ agentId: selectedAgentId, key: effectiveEnvKey })}
|
||||
>
|
||||
{grantMutation.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{effectiveEnvKey && !envKeyValid ? (
|
||||
<p className="mt-1 text-(length:--text-micro) text-destructive">
|
||||
Env keys use letters, digits, and underscores, and cannot start with a digit.
|
||||
</p>
|
||||
) : null}
|
||||
{accessError ? (
|
||||
<p className="mt-1 text-(length:--text-micro) text-destructive">{accessError}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretDetailsTab({
|
||||
secret,
|
||||
providers,
|
||||
|
|
|
|||
Loading…
Reference in New Issue