mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators inspect agent configuration to understand which credentials an agent can use. > - Agent environment bindings can contain inline values, company secret references, and user secret references. > - The Secrets page did not mount the environment editor. Inline credentials were absent, and its empty message said no secrets were bound. > - The existing environment editor already handles these binding types and redacted values. > - This PR mounts that editor on the Secrets page in both UI layouts and clarifies the empty reference message. > - Operators can inspect and edit the configured bindings. Values remain masked, and the Runtime editor remains available. ## Linked Issues or Issue Description **What happened?** An agent can receive an inline credential through its environment while its Secrets page shows no matching entry. That page only rendered the secret-reference overview and API access controls. **Expected behavior** The agent Secrets page shows existing environment bindings as well as secret references. Saved inline values stay redacted. Saving another binding preserves existing credentials. **Steps to reproduce** 1. Save an agent with an inline environment credential. 2. Open the agent Secrets page. 3. The credential row is missing, although the runtime configuration contains it. **Paperclip version or commit** Reproduced on master `27270e9bf8b7a294cf03f8b84f4857f1b1ff05c7` before the fix. Related: [#12517](https://github.com/paperclipai/paperclip/pull/12517) proposes changes to secret assignment controls. This PR covers display of existing inline environment bindings through the current editor. ## What Changed - Show the environment editor on agent Secrets pages in both UI layouts. Keep the Runtime editor available. - Change the empty overview message to describe missing company secret references. Personal references remain visible in the environment editor. - Add native and legacy agent regressions through the actual page components. - Add a browser check for masked values, secret references, save/reload, and exact credential preservation through an opaque configuration revision. - Update the feature and agent access maps for the existing UI entry point. ## Verification - Red before the fix: the native and legacy page regressions could not find the saved environment credential row. - The focused UI suites pass: 207 tests, including all four layout/adapter page cases. - The server redaction and configuration revision suites pass: 42 tests. Native Runner environment assembly suites pass: 40 tests. - An isolated Chromium flow passes against the real local API and database. It checks a masked inline value, a company secret reference, and save/reload of an added variable. It then removes the new variable and checks that the opaque server revision matches its original value. This proves that the hidden credentials survive both saves without reading them. The initial browser attempt exposed a fixture ordering assumption; the test now compares variable-name sets because PostgreSQL JSONB does not retain insertion order. - `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, and `node scripts/check-maps.mjs` pass. - The full local `pnpm test:run` attempt found three failures in unchanged server fixtures. The chat-recovery case passes in isolation and in its 22-test group. The two workspace cases inherit `init.defaultBranch=main`; all 179 workspace tests pass when the child process uses `init.defaultBranch=master`, as the fixtures expect. The long local invocation was then stopped. Later local groups were not reached. All corresponding CI test shards pass. - [CI passes on the final commit](https://github.com/paperclipai/paperclip/actions/runs/40809064907). The first canary dry run found an npm version collision. Its single retry passed without a source change. - Apex passes at 5/5 on the current commit with no unresolved findings. ## Risks Low risk. The change reuses the existing editor and save path. No API, secret delivery, permission, or database contract changes. The tests cover masked values and existing references during save/reload. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The session does not expose a more specific model ID or context-window size. ## 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 — scoped checks pass; the full local command limits are recorded above - [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>
509 lines
22 KiB
TypeScript
509 lines
22 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { KeyRound, Plus, ServerCog, Trash2, Variable } from "lucide-react";
|
|
import type {
|
|
CompanySecret,
|
|
EnvSecretRefBinding,
|
|
SecretProposalView,
|
|
SecretVersionSelector,
|
|
} from "@paperclipai/shared";
|
|
import { cn } from "../lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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, secretNameFromKey } from "./environment-variables-editor/model";
|
|
import {
|
|
DeliveryBadge as ProposalDeliveryBadge,
|
|
ProposalActions,
|
|
ProposedBadge,
|
|
bindingEnvKey,
|
|
bindingSecretLabel,
|
|
} from "../pages/secrets/proposal-review";
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Pure model (exported for tests) */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface AgentSecretRefEntry {
|
|
/** env KEY (env delivery) or access ALIAS (API-access delivery). */
|
|
name: string;
|
|
secretId: string;
|
|
version: SecretVersionSelector;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readSecretRef(raw: unknown): { secretId: string; version: SecretVersionSelector } | null {
|
|
const binding = asRecord(raw);
|
|
if (!binding || binding.type !== "secret_ref") return null;
|
|
const secretId = typeof binding.secretId === "string" ? binding.secretId : "";
|
|
if (!secretId) return null;
|
|
const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest";
|
|
return { secretId, version };
|
|
}
|
|
|
|
/** Secret-ref bindings delivered as environment variables (`config.env.<KEY>`). */
|
|
export function parseEnvSecretRefs(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
|
|
const env = asRecord(config?.env);
|
|
if (!env) return [];
|
|
const entries: AgentSecretRefEntry[] = [];
|
|
for (const [key, raw] of Object.entries(env)) {
|
|
const ref = readSecretRef(raw);
|
|
if (ref) entries.push({ name: key, ...ref });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Secret-ref bindings delivered via the agent API (top-level `access.<ALIAS>`). */
|
|
export function parseAccessGrants(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
|
|
if (!config) return [];
|
|
const entries: AgentSecretRefEntry[] = [];
|
|
for (const [key, raw] of Object.entries(config)) {
|
|
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
|
|
const ref = readSecretRef(raw);
|
|
if (ref) entries.push({ name: key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length), ...ref });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export interface AgentSecretBindingSummary {
|
|
secretId: string;
|
|
envKeys: string[];
|
|
apiAliases: string[];
|
|
}
|
|
|
|
/** Group env + API bindings by secret so the overview can show delivery mode per secret. */
|
|
export function summarizeAgentBindings(
|
|
envBindings: readonly AgentSecretRefEntry[],
|
|
apiBindings: readonly AgentSecretRefEntry[],
|
|
): AgentSecretBindingSummary[] {
|
|
const bySecret = new Map<string, AgentSecretBindingSummary>();
|
|
const ensure = (secretId: string) => {
|
|
let summary = bySecret.get(secretId);
|
|
if (!summary) {
|
|
summary = { secretId, envKeys: [], apiAliases: [] };
|
|
bySecret.set(secretId, summary);
|
|
}
|
|
return summary;
|
|
};
|
|
for (const entry of envBindings) ensure(entry.secretId).envKeys.push(entry.name);
|
|
for (const entry of apiBindings) ensure(entry.secretId).apiAliases.push(entry.name);
|
|
return [...bySecret.values()];
|
|
}
|
|
|
|
let accessRowCounter = 0;
|
|
function nextAccessRowId(): string {
|
|
accessRowCounter += 1;
|
|
return `access-row-${accessRowCounter}`;
|
|
}
|
|
|
|
interface AccessRow {
|
|
id: string;
|
|
alias: string;
|
|
secretId: string;
|
|
version: SecretVersionSelector;
|
|
}
|
|
|
|
function entriesToRows(entries: readonly AgentSecretRefEntry[]): AccessRow[] {
|
|
return entries.map((entry) => ({
|
|
id: nextAccessRowId(),
|
|
alias: entry.name,
|
|
secretId: entry.secretId,
|
|
version: entry.version,
|
|
}));
|
|
}
|
|
|
|
/** Complete, valid API-access grants keyed by alias. Incomplete/invalid/duplicate rows are dropped. */
|
|
export function rowsToAccessMap(rows: readonly AccessRow[]): Record<string, EnvSecretRefBinding> {
|
|
const map: Record<string, EnvSecretRefBinding> = {};
|
|
for (const row of rows) {
|
|
const alias = row.alias.trim();
|
|
if (!alias || !SECRET_ALIAS_RE.test(alias) || !row.secretId) continue;
|
|
map[alias] = { type: "secret_ref", secretId: row.secretId, version: row.version };
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Stable key for change-detection between the controlled value and the local draft. */
|
|
export function normalizeAccessMapKey(map: Record<string, EnvSecretRefBinding>): string {
|
|
return JSON.stringify(
|
|
Object.keys(map)
|
|
.sort()
|
|
.map((alias) => {
|
|
const binding = map[alias]!;
|
|
return [alias, binding.secretId, binding.version ?? "latest"];
|
|
}),
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Component */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface AgentSecretAccessEditorProps {
|
|
/** Effective adapter config (env + top-level `access.*`), reflecting unsaved edits. */
|
|
config: Record<string, unknown>;
|
|
secrets: readonly CompanySecret[];
|
|
/**
|
|
* Emit the complete desired set of API-access grants (alias → secret_ref). The
|
|
* 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[];
|
|
/** Open the approve confirm dialog for a proposal (wired by the parent surface). */
|
|
onApproveProposal?: (proposal: SecretProposalView) => void;
|
|
/** Open the reject dialog for a proposal (wired by the parent surface). */
|
|
onRejectProposal?: (proposal: SecretProposalView) => void;
|
|
}
|
|
|
|
function DeliveryBadge({ mode }: { mode: "env" | "api" }) {
|
|
if (mode === "env") {
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
|
>
|
|
<Variable className="size-3" /> Env var
|
|
</Badge>
|
|
);
|
|
}
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
|
>
|
|
<ServerCog className="size-3" /> API access
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function AgentSecretAccessEditor({
|
|
config,
|
|
secrets,
|
|
onChange,
|
|
onCreateSecret,
|
|
disabled,
|
|
proposals,
|
|
onApproveProposal,
|
|
onRejectProposal,
|
|
}: AgentSecretAccessEditorProps) {
|
|
const bindingProposals = useMemo(
|
|
() => (proposals ?? []).filter((proposal) => proposal.kind === "binding"),
|
|
[proposals],
|
|
);
|
|
const envBindings = useMemo(() => parseEnvSecretRefs(config), [config]);
|
|
const apiBindings = useMemo(() => parseAccessGrants(config), [config]);
|
|
const summaries = useMemo(() => summarizeAgentBindings(envBindings, apiBindings), [envBindings, apiBindings]);
|
|
|
|
const incomingMap = useMemo(() => rowsToAccessMap(entriesToRows(apiBindings)), [apiBindings]);
|
|
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);
|
|
|
|
// Controlled sync (mirrors the env editor): adopt genuine external changes
|
|
// (Cancel / agent refetch) but never clobber a local draft that produced the
|
|
// incoming value (the echo of our own emit) or an in-progress incomplete row.
|
|
useEffect(() => {
|
|
if (incomingKey === lastIncomingKeyRef.current) return;
|
|
lastIncomingKeyRef.current = incomingKey;
|
|
if (incomingKey === lastEmittedKeyRef.current) return;
|
|
setRows(entriesToRows(apiBindings));
|
|
}, [incomingKey, apiBindings]);
|
|
|
|
const secretName = (secretId: string): string =>
|
|
secrets.find((secret) => secret.id === secretId)?.name ?? `${secretId.slice(0, 8)}…`;
|
|
|
|
function emit(nextRows: AccessRow[]) {
|
|
setRows(nextRows);
|
|
const map = rowsToAccessMap(nextRows);
|
|
lastEmittedKeyRef.current = normalizeAccessMapKey(map);
|
|
onChange(map);
|
|
}
|
|
|
|
function patchRow(id: string, patch: Partial<AccessRow>) {
|
|
emit(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
|
}
|
|
|
|
function removeRow(id: string) {
|
|
emit(rows.filter((row) => row.id !== id));
|
|
}
|
|
|
|
function addRow() {
|
|
setRows((prev) => [...prev, { id: nextAccessRowId(), alias: "", secretId: "", version: "latest" }]);
|
|
}
|
|
|
|
const aliasCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
for (const row of rows) {
|
|
const alias = row.alias.trim();
|
|
if (alias) counts.set(alias, (counts.get(alias) ?? 0) + 1);
|
|
}
|
|
return counts;
|
|
}, [rows]);
|
|
|
|
const hasBindings = summaries.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Overview: every secret bound to this agent + how it is delivered. */}
|
|
{hasBindings ? (
|
|
<div className="space-y-1.5">
|
|
{summaries.map((summary) => (
|
|
<div
|
|
key={summary.secretId}
|
|
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs"
|
|
>
|
|
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="font-medium">{secretName(summary.secretId)}</span>
|
|
{summary.envKeys.length > 0 ? <DeliveryBadge mode="env" /> : null}
|
|
{summary.apiAliases.length > 0 ? <DeliveryBadge mode="api" /> : null}
|
|
<span className="min-w-0 truncate font-mono text-(length:--text-micro) text-muted-foreground">
|
|
{[
|
|
...summary.envKeys.map((key) => `${ENV_CONFIG_PATH_PREFIX}${key}`),
|
|
...summary.apiAliases.map((alias) => `${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`),
|
|
].join(" · ")}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No company secret references are bound to this agent yet.</p>
|
|
)}
|
|
|
|
{/* Pending binding proposals targeting this agent (PAP-14731). */}
|
|
{bindingProposals.length > 0 && onApproveProposal && onRejectProposal ? (
|
|
<div className="space-y-2">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
|
Proposed access
|
|
</div>
|
|
{bindingProposals.map((proposal) => {
|
|
const secret = bindingSecretLabel(proposal);
|
|
const envKey = bindingEnvKey(proposal);
|
|
return (
|
|
<div
|
|
key={proposal.id}
|
|
className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs sm:flex-row sm:items-center"
|
|
>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
<ProposalDeliveryBadge configPath={proposal.configPath} />
|
|
<code className="font-mono">{envKey || proposal.configPath}</code>
|
|
<span className="text-muted-foreground">→</span>
|
|
<KeyRound className="size-3 text-muted-foreground" />
|
|
<span className="font-medium">{secret.name}</span>
|
|
{secret.pending ? <ProposedBadge /> : null}
|
|
</div>
|
|
<p className="flex flex-wrap items-center gap-1 text-muted-foreground">
|
|
<span>proposed by {proposal.proposedBy.name}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span className="truncate italic">“{proposal.justification}”</span>
|
|
</p>
|
|
</div>
|
|
<ProposalActions
|
|
proposal={proposal}
|
|
onApprove={onApproveProposal}
|
|
onReject={onRejectProposal}
|
|
disabled={disabled}
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Editable API-access grants (access.<ALIAS>). */}
|
|
<div className="space-y-2">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
|
API access (no env var)
|
|
</div>
|
|
{rows.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{rows.map((row) => {
|
|
const trimmedAlias = row.alias.trim();
|
|
const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias);
|
|
const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1;
|
|
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">
|
|
<div>
|
|
<Input
|
|
value={row.alias}
|
|
onChange={(event) => patchRow(row.id, { alias: event.target.value })}
|
|
onBlur={(event) => {
|
|
const next = event.target.value.trim();
|
|
if (next && !SECRET_ALIAS_RE.test(next)) {
|
|
const suggested = envKeyFromSecretName(next);
|
|
if (suggested && suggested !== next) patchRow(row.id, { alias: suggested });
|
|
}
|
|
}}
|
|
placeholder="ALIAS"
|
|
aria-label="Access alias"
|
|
disabled={disabled}
|
|
className={cn(
|
|
"h-9 font-mono text-sm",
|
|
(aliasInvalid || aliasDuplicate) && "border-destructive text-destructive",
|
|
)}
|
|
/>
|
|
</div>
|
|
<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, {
|
|
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"
|
|
onClick={() => removeRow(row.id)}
|
|
disabled={disabled}
|
|
aria-label="Remove API access"
|
|
className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
{aliasInvalid ? (
|
|
<p className="pl-0.5 text-(length:--text-micro) text-destructive">
|
|
Invalid alias — use letters, digits and _
|
|
</p>
|
|
) : aliasDuplicate ? (
|
|
<p className="pl-0.5 text-(length:--text-micro) text-destructive">Duplicate alias</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={addRow}
|
|
disabled={disabled}
|
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add API access
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-(length:--text-micro) text-muted-foreground/70">
|
|
{deliveryModeDescription("api")} The agent reads them by alias through <code>GET /agents/me/secrets</code>.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|