Files
paperclip/ui/src/pages/Secrets.tsx
T
Dotta 778e775c35 Add secrets provider vaults and remote import (#5429)
## Thinking Path

> - Paperclip orchestrates AI-agent companies and needs secrets handling
to work across local development, hosted operators, and governed agent
execution.
> - The affected subsystem is the company-scoped secrets control plane:
database schema, server services/routes, CLI workflows, and the Secrets
settings UI.
> - The gap was that secrets were local-only and operators could not
manage provider vaults or import existing remote references without
exposing plaintext.
> - This branch adds provider vault configuration plus an AWS Secrets
Manager remote-import path while preserving company boundaries, binding
context, and audit trails.
> - I kept the PR to a single branch PR, removed unrelated
lockfile/package drift, rebased the full branch onto the current
`public-gh/master`, and addressed fresh Greptile findings.
> - The benefit is a reviewable implementation of provider-backed
secrets with focused tests covering provider selection, import
conflicts, deleted secret reuse, rotation guards, and AWS signing
behavior.

## What Changed

- Added provider vault support for company secrets, including provider
config storage, default vault handling, health checks, binding usage,
access events, and remote import preview/commit.
- Added an AWS Secrets Manager provider using SigV4 request signing,
bounded request timeouts, namespace guardrails, cached runtime
credential resolution, and external-reference linking without plaintext
reads.
- Added Secrets UI surfaces for vault management and remote import, plus
CLI/API documentation for setup and operations.
- Stabilized routine webhook secret binding paths and SSH
environment-driver fixture bindings discovered during verification.
- Addressed Greptile and CI findings: no lockfile/package drift,
monotonic migration metadata, disabled-vault default races, soft-deleted
secret hiding/recreate behavior, remove behavior with disabled vaults,
soft-deleted external-reference re-import, non-active rotation guards,
managed-secret soft deletion through PATCH, and per-call AWS SDK
credential client churn.
- Rebased this branch onto `public-gh/master` at `0e1a5828` and
force-pushed with lease to keep this as the single PR for the branch.

## Verification

- `git fetch public-gh master`
- `git rebase public-gh/master`
- `git diff --name-only public-gh/master...HEAD | grep
'^pnpm-lock\.yaml$' || true` confirmed `pnpm-lock.yaml` is not in the PR
diff.
- Confirmed migration ordering: master ends at `0081_optimal_dormammu`;
this PR adds `0082_dry_vision` and
`0083_company_secret_provider_configs`.
- Inspected migrations for repeat safety: new tables/indexes use `IF NOT
EXISTS`; foreign keys are guarded by `DO $$ ... IF NOT EXISTS`; column
additions use `ADD COLUMN IF NOT EXISTS`.
- `pnpm -r typecheck` passed before the Greptile follow-up commits.
- `pnpm test:run` ran the full stable Vitest path before the Greptile
follow-up commits; it completed with 3 timing-related failures under
parallel load: `codex-local-execute.test.ts`,
`cursor-local-execute.test.ts`, and `environment-service.test.ts`.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/codex-local-execute.test.ts
src/__tests__/cursor-local-execute.test.ts
src/__tests__/environment-service.test.ts` passed on targeted rerun
(`24/24`).
- `pnpm build` passed before the Greptile follow-up commits. Vite
reported existing chunk-size/dynamic-import warnings.
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/secrets-service.test.ts` passed (`26/26`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/aws-secrets-manager-provider.test.ts
src/__tests__/secrets-service.test.ts` passed (`39/39`).
- After Greptile follow-up commits: `pnpm --filter @paperclipai/server
typecheck` passed.
- Captured Storybook screenshots from `ui/storybook-static` for visual
review.
- Latest PR checks on `5ca3a5cf`: `policy`, serialized server suites
1/4-4/4, `Canary Dry Run`, `e2e`, `security/snyk`, and `Greptile Review`
pass; aggregate `verify` is still registering the completed child
checks.
- Greptile review loop continued through the latest requested pass; all
Greptile review threads are resolved and the latest `Greptile Review`
check on `5ca3a5cf` passed with 0 comments added.

## Screenshots

Before: the provider-vault and remote-import surfaces did not exist on
`master`; these are after-state screenshots from the Storybook fixtures.

![Secrets
inventory](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secrets-inventory.png)

![Secret binding
picker](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/secret-binding-picker.png)

![Environment editor with
secrets](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2339-secrets-make-a-plan/doc/pr/5429/env-editor-with-secrets.png)

## Risks

- Migration risk: this adds new secret provider tables and extends
existing secret rows. The migrations were checked for monotonic ordering
and idempotent guards, but reviewers should still inspect upgrade
behavior carefully.
- Provider risk: AWS support uses direct SigV4 requests. Automated tests
cover signing, request timeouts, vault-config selection, namespace
guardrails, pending-version archival, sanitized provider errors, and
service-level cleanup paths. A real-vault AWS smoke test remains
deployment validation for an operator with AWS credentials rather than
an unverified merge blocker in this local branch.
- UI risk: the Secrets page and import dialog are large new surfaces;
screenshots are included above for reviewer inspection.
- Verification risk: the full local stable test command hit
parallel-load timing failures, although the exact failed files passed
when rerun directly.
- Operational risk: remote import intentionally avoids plaintext reads;
operators must understand that imported external references resolve at
runtime and may fail if AWS permissions 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, GPT-5 coding agent with local shell/tool use in the
Paperclip worktree. Exact context-window size was not exposed by the
runtime.

## 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 18:22:17 -05:00

2156 lines
84 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
ArchiveRestore,
Archive,
Ban,
CheckCircle2,
Cloud,
Database,
Edit3,
ExternalLink,
KeyRound,
Link2,
Loader2,
Plus,
RefreshCw,
Search,
ShieldCheck,
Star,
Trash2,
X,
Filter,
Info,
} from "lucide-react";
import { Link } from "react-router-dom";
import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
SecretProviderConfigStatus,
SecretProviderDescriptor,
SecretStatus,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import {
secretsApi,
type CreateSecretInput,
type CreateSecretProviderConfigInput,
type SecretProviderHealthResponse,
type UpdateSecretProviderConfigInput,
} from "../api/secrets";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { cn } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
type CreateMode = "managed" | "external";
type SecretsTab = "secrets" | "vaults";
type ProviderVaultForm = {
provider: SecretProvider;
displayName: string;
status: SecretProviderConfigStatus;
isDefault: boolean;
backupReminderAcknowledged: boolean;
region: string;
namespace: string;
secretNamePrefix: string;
kmsKeyId: string;
ownerTag: string;
environmentTag: string;
projectId: string;
location: string;
address: string;
mountPath: string;
secretPathPrefix: string;
};
const PROVIDER_ORDER: SecretProvider[] = [
"local_encrypted",
"aws_secrets_manager",
"gcp_secret_manager",
"vault",
];
function defaultProviderVaultStatus(provider: SecretProvider): SecretProviderConfigStatus {
return provider === "gcp_secret_manager" || provider === "vault" ? "coming_soon" : "ready";
}
function emptyProviderVaultForm(provider: SecretProvider = "local_encrypted"): ProviderVaultForm {
return {
provider,
displayName: "",
status: defaultProviderVaultStatus(provider),
isDefault: false,
backupReminderAcknowledged: false,
region: "",
namespace: "",
secretNamePrefix: "",
kmsKeyId: "",
ownerTag: "",
environmentTag: "",
projectId: "",
location: "",
address: "",
mountPath: "",
secretPathPrefix: "",
};
}
function providerConfigValue(config: CompanySecretProviderConfig["config"], key: string) {
if (!config || typeof config !== "object" || Array.isArray(config)) return "";
const value = (config as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
function providerVaultFormFromConfig(config: CompanySecretProviderConfig): ProviderVaultForm {
return {
...emptyProviderVaultForm(config.provider),
displayName: config.displayName,
status: config.status,
isDefault: config.isDefault,
backupReminderAcknowledged:
Boolean((config.config as Record<string, unknown> | undefined)?.backupReminderAcknowledged),
region: providerConfigValue(config.config, "region"),
namespace: providerConfigValue(config.config, "namespace"),
secretNamePrefix: providerConfigValue(config.config, "secretNamePrefix"),
kmsKeyId: providerConfigValue(config.config, "kmsKeyId"),
ownerTag: providerConfigValue(config.config, "ownerTag"),
environmentTag: providerConfigValue(config.config, "environmentTag"),
projectId: providerConfigValue(config.config, "projectId"),
location: providerConfigValue(config.config, "location"),
address: providerConfigValue(config.config, "address"),
mountPath: providerConfigValue(config.config, "mountPath"),
secretPathPrefix: providerConfigValue(config.config, "secretPathPrefix"),
};
}
function formatRelative(value: Date | string | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
const diff = Date.now() - date.getTime();
if (diff < 0) return date.toLocaleString();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
}
function statusTextTone(status: SecretStatus) {
switch (status) {
case "active":
return "text-emerald-700 dark:text-emerald-300";
case "disabled":
return "text-amber-700 dark:text-amber-300";
case "archived":
return "text-muted-foreground";
case "deleted":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
function providerLabel(providers: SecretProviderDescriptor[] | undefined, id: SecretProvider) {
return providers?.find((p) => p.id === id)?.label ?? id.replaceAll("_", " ");
}
function normalizeSecretKeyForPreview(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120);
}
function modeLabel(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external";
}
function modeDescription(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed"
? "Paperclip owns create and rotation writes for this provider secret."
: "Paperclip resolves this provider reference but does not rotate the provider value.";
}
function healthEntryForProvider(
health: SecretProviderHealthResponse | null,
providerId: SecretProvider,
) {
return health?.providers.find((entry) => entry.provider === providerId) ?? null;
}
export function getCreateProviderBlockReason(
provider: SecretProviderDescriptor | null | undefined,
mode: CreateMode,
health: SecretProviderHealthResponse | null,
) {
if (!provider) return "Select a provider.";
if (mode === "managed" && provider.supportsManagedValues === false) {
return `${provider.label} does not support Paperclip-managed secret values.`;
}
if (mode === "external" && provider.supportsExternalReferences === false) {
return `${provider.label} does not support linked external references.`;
}
if (provider.configured === false) {
const healthEntry = healthEntryForProvider(health, provider.id);
return healthEntry?.message
? `${provider.label} is not configured in this deployment. ${healthEntry.message}`
: `${provider.label} is not configured in this deployment.`;
}
const healthEntry = healthEntryForProvider(health, provider.id);
if (healthEntry?.status === "error") {
return `${provider.label} health check failed: ${healthEntry.message}`;
}
return null;
}
function providerHealthText(
provider: SecretProviderDescriptor | null | undefined,
health: SecretProviderHealthResponse | null,
) {
if (!provider) return null;
const entry = healthEntryForProvider(health, provider.id);
if (!entry) return null;
const warnings = entry.warnings?.join(" ");
return [entry.message, warnings].filter(Boolean).join(" ");
}
function detailString(details: Record<string, unknown> | undefined, key: string) {
const value = details?.[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function getProviderConfigBlockReason(
config: CompanySecretProviderConfig | null | undefined,
) {
if (!config) return null;
if (config.status === "disabled") return "This provider vault is disabled.";
if (config.status === "coming_soon") return "This provider vault is saved as draft metadata only.";
if (config.healthStatus === "error") {
return config.healthMessage ?? "This provider vault health check failed.";
}
return null;
}
export function getDefaultProviderConfigId(
configs: CompanySecretProviderConfig[],
provider: SecretProvider,
) {
const providerConfigs = configs.filter((config) => config.provider === provider);
const selectable = providerConfigs.filter((config) => !getProviderConfigBlockReason(config));
return (
selectable.find((config) => config.isDefault)?.id ??
selectable[0]?.id ??
providerConfigs.find((config) => config.isDefault)?.id ??
""
);
}
function providerVaultLabel(configs: CompanySecretProviderConfig[], id: string | null | undefined) {
if (!id) return "Deployment default";
return configs.find((config) => config.id === id)?.displayName ?? "Unknown vault";
}
function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unknown> {
const compact = (value: string) => value.trim() || null;
switch (form.provider) {
case "local_encrypted":
return { backupReminderAcknowledged: form.backupReminderAcknowledged };
case "aws_secrets_manager":
return {
region: form.region.trim(),
namespace: compact(form.namespace),
secretNamePrefix: compact(form.secretNamePrefix),
kmsKeyId: compact(form.kmsKeyId),
ownerTag: compact(form.ownerTag),
environmentTag: compact(form.environmentTag),
};
case "gcp_secret_manager":
return {
projectId: compact(form.projectId),
location: compact(form.location),
namespace: compact(form.namespace),
secretNamePrefix: compact(form.secretNamePrefix),
};
case "vault":
return {
address: compact(form.address),
namespace: compact(form.namespace),
mountPath: compact(form.mountPath),
secretPathPrefix: compact(form.secretPathPrefix),
};
default:
return {};
}
}
export function getAwsManagedPathPreview(input: {
provider: SecretProviderDescriptor | null | undefined;
health: SecretProviderHealthResponse | null;
companyId: string;
secretKeySource: string;
}) {
if (input.provider?.id !== "aws_secrets_manager") return null;
const healthEntry = healthEntryForProvider(input.health, "aws_secrets_manager");
const prefix = detailString(healthEntry?.details, "prefix") ?? "paperclip";
const deploymentId = detailString(healthEntry?.details, "deploymentId") ?? "{deploymentId}";
const secretKey = normalizeSecretKeyForPreview(input.secretKeySource) || "{secretKey}";
return `${prefix}/${deploymentId}/${input.companyId}/${secretKey}`;
}
export function Secrets() {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const [activeTab, setActiveTab] = useState<SecretsTab>("secrets");
const [secretDetailTab, setSecretDetailTab] = useState("details");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<SecretStatus | "all">("active");
const [providerFilter, setProviderFilter] = useState<SecretProvider | "all">("all");
const [selectedSecretId, setSelectedSecretId] = useState<string | null>(null);
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [createMode, setCreateMode] = useState<CreateMode>("managed");
const [createForm, setCreateForm] = useState({
name: "",
key: "",
value: "",
description: "",
externalRef: "",
provider: "local_encrypted" as SecretProvider,
providerConfigId: "",
});
const [createError, setCreateError] = useState<string | null>(null);
const [rotateOpen, setRotateOpen] = useState(false);
const [rotateValue, setRotateValue] = useState("");
const [rotateExternalRef, setRotateExternalRef] = useState("");
const [rotateProviderConfigId, setRotateProviderConfigId] = useState("");
const [rotateError, setRotateError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
const [vaultError, setVaultError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Secrets" }]);
}, [setBreadcrumbs]);
const secretsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.list(selectedCompanyId)
: ["secrets", "__disabled__"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const providersQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.providers(selectedCompanyId)
: ["secret-providers", "__disabled__"],
queryFn: () => secretsApi.providers(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
staleTime: 5 * 60_000,
});
const providerHealthQuery = useQuery({
queryKey: selectedCompanyId
? ["secret-provider-health", selectedCompanyId]
: ["secret-provider-health", "__disabled__"],
queryFn: () => secretsApi.providerHealth(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
refetchInterval: 60_000,
retry: false,
});
const providerConfigsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.providerConfigs(selectedCompanyId)
: ["secret-provider-configs", "__disabled__"],
queryFn: () => secretsApi.providerConfigs(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
retry: false,
});
const secrets = secretsQuery.data ?? [];
const providers = providersQuery.data ?? [];
const providerConfigs = providerConfigsQuery.data ?? [];
const selectedSecret = useMemo(
() => secrets.find((secret) => secret.id === selectedSecretId) ?? null,
[secrets, selectedSecretId],
);
const usageDialogSecret = useMemo(
() => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null,
[secrets, usageDialogSecretId],
);
const selectedCreateProvider = useMemo(
() => providers.find((provider) => provider.id === createForm.provider) ?? null,
[providers, createForm.provider],
);
const createProviderConfigs = useMemo(
() => providerConfigs.filter((config) => config.provider === createForm.provider),
[createForm.provider, providerConfigs],
);
const selectedCreateProviderConfig = useMemo(
() => providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null,
[createForm.providerConfigId, providerConfigs],
);
const selectedRotateProviderConfigs = useMemo(
() => providerConfigs.filter((config) => config.provider === selectedSecret?.provider),
[providerConfigs, selectedSecret?.provider],
);
const selectedRotateProviderConfig = useMemo(
() => providerConfigs.find((config) => config.id === rotateProviderConfigId) ?? null,
[providerConfigs, rotateProviderConfigId],
);
const createProviderBlockReason = getCreateProviderBlockReason(
selectedCreateProvider,
createMode,
providerHealthQuery.data ?? null,
) ?? getProviderConfigBlockReason(selectedCreateProviderConfig);
const rotateProviderBlockReason = getProviderConfigBlockReason(selectedRotateProviderConfig);
const createProviderHealthText = providerHealthText(
selectedCreateProvider,
providerHealthQuery.data ?? null,
);
const awsManagedPathPreview = getAwsManagedPathPreview({
provider: selectedCreateProvider,
health: providerHealthQuery.data ?? null,
companyId: selectedCompanyId ?? "{companyId}",
secretKeySource: createForm.key.trim() || createForm.name,
});
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
return secrets.filter((secret) => {
if (statusFilter !== "all" && secret.status !== statusFilter) return false;
if (providerFilter !== "all" && secret.provider !== providerFilter) return false;
if (!needle) return true;
return (
secret.name.toLowerCase().includes(needle) ||
secret.key.toLowerCase().includes(needle) ||
(secret.description?.toLowerCase().includes(needle) ?? false) ||
(secret.externalRef?.toLowerCase().includes(needle) ?? false)
);
});
}, [secrets, search, statusFilter, providerFilter]);
const activeSecretFilterCount = (statusFilter === "active" ? 0 : 1) + (providerFilter === "all" ? 0 : 1);
const usageQuery = useQuery({
queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"],
queryFn: () => secretsApi.usage(selectedSecret!.id),
enabled: Boolean(selectedSecret),
});
const eventsQuery = useQuery({
queryKey: selectedSecret
? queryKeys.secrets.accessEvents(selectedSecret.id)
: ["secrets", "access-events", "__disabled__"],
queryFn: () => secretsApi.accessEvents(selectedSecret!.id),
enabled: Boolean(selectedSecret),
});
const usageDialogQuery = useQuery({
queryKey: usageDialogSecret
? queryKeys.secrets.usage(usageDialogSecret.id)
: ["secrets", "usage-dialog", "__disabled__"],
queryFn: () => secretsApi.usage(usageDialogSecret!.id),
enabled: Boolean(usageDialogSecret),
});
function invalidateAll(extraIds: string[] = []) {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.providerConfigs(selectedCompanyId) });
for (const id of extraIds) {
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.accessEvents(id) });
}
}
const createMutation = useMutation({
mutationFn: () => {
const input: CreateSecretInput = {
name: createForm.name.trim(),
provider: createForm.provider,
providerConfigId: createForm.providerConfigId || null,
managedMode: createMode === "external" ? "external_reference" : "paperclip_managed",
description: createForm.description.trim() || null,
};
if (createForm.key.trim()) input.key = createForm.key.trim();
if (createMode === "managed") {
input.value = createForm.value;
} else {
input.externalRef = createForm.externalRef.trim();
}
return secretsApi.create(selectedCompanyId!, input);
},
onSuccess: (created) => {
pushToast({ title: "Secret created", body: created.name, tone: "success" });
setCreateOpen(false);
setCreateForm({
name: "",
key: "",
value: "",
description: "",
externalRef: "",
provider: createForm.provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider),
});
setCreateError(null);
setSelectedSecretId(created.id);
invalidateAll([created.id]);
},
onError: (error) => {
setCreateError(error instanceof ApiError ? error.message : (error as Error).message);
},
});
const rotateMutation = useMutation({
mutationFn: () => {
if (!selectedSecret) throw new Error("Select a secret first");
if (selectedSecret.managedMode === "external_reference") {
return secretsApi.rotate(selectedSecret.id, {
externalRef: rotateExternalRef.trim() || selectedSecret.externalRef || undefined,
providerConfigId: rotateProviderConfigId || null,
});
}
return secretsApi.rotate(selectedSecret.id, {
value: rotateValue,
providerConfigId: rotateProviderConfigId || null,
});
},
onSuccess: (updated) => {
pushToast({ title: "Rotated", body: `${updated.name} → v${updated.latestVersion}`, tone: "success" });
setRotateOpen(false);
setRotateValue("");
setRotateExternalRef("");
setRotateProviderConfigId("");
setRotateError(null);
invalidateAll([updated.id]);
},
onError: (error) => {
setRotateError(error instanceof Error ? error.message : "Rotate failed");
},
});
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: SecretStatus }) => {
switch (status) {
case "active":
return secretsApi.enable(id);
case "disabled":
return secretsApi.disable(id);
case "archived":
return secretsApi.archive(id);
default:
return secretsApi.update(id, { status });
}
},
onSuccess: (updated) => {
pushToast({ title: `Secret ${updated.status}`, body: updated.name, tone: "info" });
invalidateAll([updated.id]);
},
onError: (error) => {
pushToast({
title: "Status update failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => secretsApi.remove(id),
onSuccess: (_response, id) => {
pushToast({ title: "Secret deleted", tone: "info" });
setDeleteConfirm(null);
if (selectedSecretId === id) setSelectedSecretId(null);
invalidateAll([id]);
},
onError: (error) => {
pushToast({
title: "Delete failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const saveVaultMutation = useMutation({
mutationFn: () => {
const data: CreateSecretProviderConfigInput | UpdateSecretProviderConfigInput = {
displayName: vaultForm.displayName.trim(),
status: vaultForm.status,
isDefault: vaultForm.isDefault,
config: buildProviderVaultConfig(vaultForm),
};
if (editingVault) {
return secretsApi.updateProviderConfig(editingVault.id, data);
}
return secretsApi.createProviderConfig(selectedCompanyId!, {
...(data as UpdateSecretProviderConfigInput),
provider: vaultForm.provider,
} as CreateSecretProviderConfigInput);
},
onSuccess: (saved) => {
pushToast({ title: editingVault ? "Provider vault updated" : "Provider vault created", body: saved.displayName, tone: "success" });
setVaultDialogOpen(false);
setEditingVault(null);
setVaultForm(emptyProviderVaultForm());
setVaultError(null);
invalidateAll();
},
onError: (error) => {
setVaultError(error instanceof ApiError ? error.message : (error as Error).message);
},
});
const disableVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
onSuccess: (updated) => {
pushToast({ title: "Provider vault disabled", body: updated.displayName, tone: "info" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Disable failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const defaultVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
onSuccess: (updated) => {
pushToast({ title: "Default vault set", body: updated.displayName, tone: "success" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Default update failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const healthVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.checkProviderConfigHealth(id),
onSuccess: (health) => {
pushToast({ title: "Health checked", body: health.message, tone: health.status === "error" ? "error" : "info" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Health check failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
useEffect(() => {
if (!createOpen || providers.length === 0) return;
const currentBlockReason = getCreateProviderBlockReason(
providers.find((provider) => provider.id === createForm.provider) ?? null,
createMode,
providerHealthQuery.data ?? null,
);
if (!currentBlockReason) return;
const replacement = providers.find(
(provider) =>
!getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null),
);
if (replacement && replacement.id !== createForm.provider) {
setCreateForm((current) => ({
...current,
provider: replacement.id,
providerConfigId: getDefaultProviderConfigId(providerConfigs, replacement.id),
}));
}
}, [createForm.provider, createMode, createOpen, providerConfigs, providerHealthQuery.data, providers]);
useEffect(() => {
if (!createOpen) return;
const current = providerConfigs.find((config) => config.id === createForm.providerConfigId);
if (current?.provider === createForm.provider) return;
setCreateForm((form) => ({
...form,
providerConfigId: getDefaultProviderConfigId(providerConfigs, form.provider),
}));
}, [createForm.provider, createForm.providerConfigId, createOpen, providerConfigs]);
useEffect(() => {
if (!rotateOpen || !selectedSecret) return;
setRotateProviderConfigId(
selectedSecret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, selectedSecret.provider),
);
}, [providerConfigs, rotateOpen, selectedSecret]);
function openCreateVault(provider: SecretProvider = "local_encrypted") {
setEditingVault(null);
setVaultForm(emptyProviderVaultForm(provider));
setVaultError(null);
setVaultDialogOpen(true);
}
function openEditVault(config: CompanySecretProviderConfig) {
setEditingVault(config);
setVaultForm(providerVaultFormFromConfig(config));
setVaultError(null);
setVaultDialogOpen(true);
}
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<div className="flex items-center gap-2">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Secrets</h1>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as SecretsTab)}
className="flex min-h-0 flex-1 flex-col gap-4"
>
<PageTabBar
items={[
{ value: "secrets", label: "Secrets" },
{ value: "vaults", label: "Provider vaults" },
]}
align="start"
value={activeTab}
onValueChange={(value) => setActiveTab(value as SecretsTab)}
/>
<TabsContent value="secrets" className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<SecretsHowToUse />
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, key, ref"
className="pl-7 text-xs sm:text-sm"
aria-label="Search secrets"
data-page-search-target="true"
/>
</div>
<SecretsFiltersPopover
statusFilter={statusFilter}
providerFilter={providerFilter}
providers={providers}
activeFilterCount={activeSecretFilterCount}
onStatusChange={setStatusFilter}
onProviderChange={setProviderFilter}
/>
<ImportFromVaultButton
providerConfigs={providerConfigs}
onClick={() => setImportOpen(true)}
onManageVaults={() => setActiveTab("vaults")}
className="ml-auto"
/>
<Button onClick={() => setCreateOpen(true)} size="sm">
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{secretsQuery.isError ? (
<div className="text-sm text-destructive flex items-center gap-2 py-4">
<AlertCircle className="h-4 w-4" /> Failed to load secrets:{" "}
{(secretsQuery.error as Error).message}
<Button variant="ghost" size="sm" onClick={() => secretsQuery.refetch()}>
Retry
</Button>
</div>
) : secrets.length === 0 && !secretsQuery.isPending ? (
<EmptyState
icon={KeyRound}
message="No secrets yet. Create your first managed secret or link an external reference."
action="New secret"
onAction={() => setCreateOpen(true)}
/>
) : filtered.length === 0 ? (
<EmptyState icon={Search} message="No secrets match your filters." />
) : (
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-2 py-2 text-left font-medium">Mode</th>
<th className="px-2 py-2 text-left font-medium">Provider</th>
<th className="px-2 py-2 text-left font-medium">Status</th>
<th className="px-2 py-2 text-left font-medium">Version</th>
<th className="px-2 py-2 text-left font-medium">Last rotated</th>
<th className="px-2 py-2 text-left font-medium">Last resolved</th>
<th className="px-2 py-2 text-left font-medium">References</th>
<th className="px-2 py-2 text-left font-medium">Reference</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filtered.map((secret) => (
<tr
key={secret.id}
className={cn(
"border-b border-border/60 hover:bg-accent/40 cursor-pointer",
selectedSecretId === secret.id && "bg-accent/60",
)}
onClick={() => setSelectedSecretId(secret.id)}
>
<td className="px-3 py-2.5">
<div className="font-medium text-foreground">{secret.name}</div>
</td>
<td className="px-2 py-2.5 text-xs text-muted-foreground">
{modeLabel(secret.managedMode)}
</td>
<td className="px-2 py-2.5 text-xs">
<div>{providerLabel(providers, secret.provider)}</div>
</td>
<td className="px-2 py-2.5">
<span className={cn("text-xs font-medium", statusTextTone(secret.status))}>
{secret.status}
</span>
</td>
<td className="px-2 py-2.5 text-xs font-mono">v{secret.latestVersion}</td>
<td className="px-2 py-2.5 text-xs text-muted-foreground">
{formatRelative(secret.lastRotatedAt)}
</td>
<td className="px-2 py-2.5 text-xs text-muted-foreground">
{formatRelative(secret.lastResolvedAt)}
</td>
<td className="px-2 py-2.5 text-xs">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
aria-label={`View references for ${secret.name}`}
onClick={(event) => {
event.stopPropagation();
setUsageDialogSecretId(secret.id);
}}
>
{secret.referenceCount ?? 0}
</Button>
</td>
<td className="px-2 py-2.5 text-xs">
{secret.managedMode === "external_reference" ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground">
<Link2 className="h-3 w-3" />
{secret.externalRef ?? "—"}
</span>
) : (
<span className="text-muted-foreground">Owned</span>
)}
</td>
<td className="px-3 py-2.5 text-right">
<Button
variant="ghost"
size="sm"
onClick={(event) => {
event.stopPropagation();
setSelectedSecretId(secret.id);
}}
>
Open
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</TabsContent>
<TabsContent value="vaults" className="min-h-0 flex-1 overflow-y-auto">
<ProviderVaultsTab
providers={providers}
providerConfigs={providerConfigs}
loading={providerConfigsQuery.isPending}
error={providerConfigsQuery.error}
onRetry={() => providerConfigsQuery.refetch()}
onCreate={openCreateVault}
onEdit={openEditVault}
onDisable={(config) => disableVaultMutation.mutate(config.id)}
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
pendingActionId={
disableVaultMutation.variables ??
defaultVaultMutation.variables ??
healthVaultMutation.variables ??
null
}
/>
</TabsContent>
</Tabs>
<Sheet open={Boolean(selectedSecret)} onOpenChange={(open) => !open && setSelectedSecretId(null)}>
<SheetContent className="w-full sm:max-w-xl flex flex-col gap-0">
{selectedSecret ? (
<>
<SheetHeader>
<SheetTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
{selectedSecret.name}
<span className={cn("ml-2 text-sm font-normal", statusTextTone(selectedSecret.status))}>
{selectedSecret.status}
</span>
</SheetTitle>
<SheetDescription>
{providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)}
</SheetDescription>
</SheetHeader>
<div className="flex flex-wrap gap-2 px-4 pb-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setRotateOpen(true);
setRotateValue("");
setRotateExternalRef("");
setRotateProviderConfigId(
selectedSecret.providerConfigId ??
getDefaultProviderConfigId(providerConfigs, selectedSecret.provider),
);
setRotateError(null);
}}
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{selectedSecret.managedMode === "external_reference" ? "Update reference" : "Update value"}
</Button>
{selectedSecret.status === "active" ? (
<Button
size="sm"
variant="outline"
onClick={() => statusMutation.mutate({ id: selectedSecret.id, status: "disabled" })}
disabled={statusMutation.isPending}
>
<Ban className="h-3.5 w-3.5 mr-1" /> Disable
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => statusMutation.mutate({ id: selectedSecret.id, status: "active" })}
disabled={statusMutation.isPending}
>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" /> Activate
</Button>
)}
{selectedSecret.status === "archived" ? (
<Button
size="sm"
variant="outline"
onClick={() => statusMutation.mutate({ id: selectedSecret.id, status: "active" })}
disabled={statusMutation.isPending}
>
<ArchiveRestore className="h-3.5 w-3.5 mr-1" /> Unarchive
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => statusMutation.mutate({ id: selectedSecret.id, status: "archived" })}
disabled={statusMutation.isPending}
>
<Archive className="h-3.5 w-3.5 mr-1" /> Archive
</Button>
)}
<Button
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteConfirm(selectedSecret)}
>
<Trash2 className="h-3.5 w-3.5 mr-1" /> Delete
</Button>
</div>
<Tabs value={secretDetailTab} onValueChange={setSecretDetailTab} className="flex-1 min-h-0 flex flex-col">
<div className="border-b border-border px-4">
<PageTabBar
items={[
{ value: "details", label: "Details" },
{ value: "usage", label: usageQuery.data ? `Usage (${usageQuery.data.bindings.length})` : "Usage" },
{ value: "events", label: "Access events" },
]}
align="start"
value={secretDetailTab}
onValueChange={setSecretDetailTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<SecretDetailsTab secret={selectedSecret} providerConfigs={providerConfigs} />
</TabsContent>
<TabsContent value="usage">
<SecretUsageTab loading={usageQuery.isPending} bindings={usageQuery.data?.bindings ?? []} />
</TabsContent>
<TabsContent value="events">
<SecretEventsTab loading={eventsQuery.isPending} events={eventsQuery.data ?? []} />
</TabsContent>
</div>
</Tabs>
</>
) : null}
</SheetContent>
</Sheet>
<Dialog
open={Boolean(usageDialogSecret)}
onOpenChange={(open) => !open && setUsageDialogSecretId(null)}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Secret references</DialogTitle>
<DialogDescription>
{usageDialogSecret
? `${usageDialogSecret.name} is referenced by ${usageDialogSecret.referenceCount ?? 0} ${
(usageDialogSecret.referenceCount ?? 0) === 1 ? "place" : "places"
}.`
: null}
</DialogDescription>
</DialogHeader>
<SecretUsageTab
loading={usageDialogQuery.isPending}
bindings={usageDialogQuery.data?.bindings ?? []}
/>
</DialogContent>
</Dialog>
{selectedCompanyId && (
<ImportFromVaultDialog
open={importOpen}
onOpenChange={setImportOpen}
companyId={selectedCompanyId}
providerConfigs={providerConfigs}
existingSecrets={secrets}
onManageVaults={() => {
setImportOpen(false);
setActiveTab("vaults");
}}
onImportComplete={() => {
void secretsQuery.refetch();
}}
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create secret</DialogTitle>
<DialogDescription>
Choose whether Paperclip should own future provider writes, or only resolve an existing
provider reference at runtime.
</DialogDescription>
</DialogHeader>
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="managed">Managed value</TabsTrigger>
<TabsTrigger value="external">External reference</TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, name: event.target.value }))
}
placeholder="OPENAI_API_KEY"
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-key">
Key <span className="text-muted-foreground/70">(optional)</span>
</label>
<Input
id="new-secret-key"
value={createForm.key}
onChange={(event) =>
setCreateForm((current) => ({ ...current, key: event.target.value }))
}
placeholder="auto from name"
/>
</div>
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-provider">Provider</label>
<select
id="new-secret-provider"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={createForm.provider}
onChange={(event) =>
setCreateForm((current) => {
const provider = event.target.value as SecretProvider;
return {
...current,
provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, provider),
};
})
}
>
{providers.map((provider) => (
<option
key={provider.id}
value={provider.id}
disabled={Boolean(
getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null),
)}
>
{provider.label}
{provider.configured === false
? " (not configured)"
: provider.requiresExternalRef
? " (external only)"
: ""}
</option>
))}
</select>
{createProviderBlockReason ? (
<p className="mt-1 flex items-center gap-1 text-[11px] text-destructive">
<AlertCircle className="h-3 w-3" />
{createProviderBlockReason}
</p>
) : createProviderHealthText ? (
<p className="mt-1 text-[11px] text-muted-foreground">{createProviderHealthText}</p>
) : null}
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-vault">Provider vault</label>
<select
id="new-secret-vault"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={createForm.providerConfigId}
onChange={(event) =>
setCreateForm((current) => ({ ...current, providerConfigId: event.target.value }))
}
>
<option value="">Deployment default</option>
{createProviderConfigs.map((config) => {
const blockReason = getProviderConfigBlockReason(config);
return (
<option key={config.id} value={config.id} disabled={Boolean(blockReason)}>
{config.displayName}
{config.isDefault ? " (default)" : ""}
{blockReason ? ` (${blockReason})` : ""}
</option>
);
})}
</select>
{selectedCreateProviderConfig ? (
<ProviderVaultInlineWarning config={selectedCreateProviderConfig} />
) : (
<p className="mt-1 text-[11px] 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-[11px] 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="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-[11px] 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>
)}
<div>
<label className="text-xs font-medium" htmlFor="new-secret-description">
Description <span className="text-muted-foreground/70">(optional)</span>
</label>
<Input
id="new-secret-description"
value={createForm.description}
onChange={(event) =>
setCreateForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="What is this secret used for? (no values)"
/>
</div>
{createError ? <p className="text-xs text-destructive">{createError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setCreateError(null);
createMutation.mutate();
}}
disabled={
createMutation.isPending ||
Boolean(createProviderBlockReason) ||
!createForm.name.trim() ||
(createMode === "managed" ? !createForm.value : !createForm.externalRef.trim())
}
>
{createMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{createMode === "managed" ? "Create secret" : "Link reference"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={vaultDialogOpen} onOpenChange={setVaultDialogOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingVault ? "Edit provider vault" : "Create provider vault"}</DialogTitle>
<DialogDescription>
Save only non-sensitive routing metadata. Credentials stay in the runtime environment or provider identity.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium" htmlFor="vault-provider">Provider</label>
<select
id="vault-provider"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none disabled:opacity-60"
value={vaultForm.provider}
disabled={Boolean(editingVault)}
onChange={(event) => {
const provider = event.target.value as SecretProvider;
setVaultForm(emptyProviderVaultForm(provider));
}}
>
{PROVIDER_ORDER.map((provider) => (
<option key={provider} value={provider}>
{providerLabel(providers, provider)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium" htmlFor="vault-name">Display name</label>
<Input
id="vault-name"
value={vaultForm.displayName}
onChange={(event) =>
setVaultForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="Production local vault"
/>
</div>
<div>
<label className="text-xs font-medium" htmlFor="vault-status">Status</label>
<select
id="vault-status"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={vaultForm.status}
onChange={(event) => {
const status = event.target.value as SecretProviderConfigStatus;
setVaultForm((current) => ({
...current,
status,
isDefault:
status === "coming_soon" || status === "disabled" ? false : current.isDefault,
}));
}}
>
<option value="ready" disabled={vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault"}>
Ready
</option>
<option value="warning" disabled={vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault"}>
Warning
</option>
<option value="coming_soon">Coming soon</option>
<option value="disabled">Disabled</option>
</select>
</div>
<label className="flex items-center gap-2 pt-6 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-border"
checked={vaultForm.isDefault}
disabled={vaultForm.status === "coming_soon" || vaultForm.status === "disabled"}
onChange={(event) =>
setVaultForm((current) => ({ ...current, isDefault: event.target.checked }))
}
/>
Default for {providerLabel(providers, vaultForm.provider)}
</label>
</div>
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
the provider module is implemented and reviewed.
</div>
) : null}
{vaultError ? <p className="text-xs text-destructive">{vaultError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVaultDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setVaultError(null);
saveVaultMutation.mutate();
}}
disabled={
saveVaultMutation.isPending ||
!vaultForm.displayName.trim() ||
(vaultForm.provider === "aws_secrets_manager" && !vaultForm.region.trim())
}
>
{saveVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{editingVault ? "Save vault" : "Create vault"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rotateOpen} onOpenChange={setRotateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{selectedSecret?.managedMode === "external_reference" ? "Update external reference" : "Update secret value"}
</DialogTitle>
<DialogDescription>
{selectedSecret?.managedMode === "external_reference"
? "Creates a new Paperclip metadata version that points at an existing provider secret. Paperclip does not write a new provider value."
: "Creates a new provider-backed version. Consumers pinned to latest pick up the new value on the next run."}
</DialogDescription>
</DialogHeader>
<div>
<label className="text-xs font-medium" htmlFor="rotate-secret-vault">Provider vault</label>
<select
id="rotate-secret-vault"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={rotateProviderConfigId}
onChange={(event) => setRotateProviderConfigId(event.target.value)}
>
<option value="">Deployment default</option>
{selectedRotateProviderConfigs.map((config) => {
const blockReason = getProviderConfigBlockReason(config);
return (
<option key={config.id} value={config.id} disabled={Boolean(blockReason)}>
{config.displayName}
{config.isDefault ? " (default)" : ""}
{blockReason ? ` (${blockReason})` : ""}
</option>
);
})}
</select>
{selectedRotateProviderConfig ? (
<ProviderVaultInlineWarning config={selectedRotateProviderConfig} />
) : (
<p className="mt-1 text-[11px] text-muted-foreground">
Rotating with the deployment default preserves current fallback behavior.
</p>
)}
</div>
{selectedSecret?.managedMode === "external_reference" ? (
<div>
<label className="text-xs font-medium" htmlFor="rotate-ref">External reference</label>
<Input
id="rotate-ref"
value={rotateExternalRef}
onChange={(event) => setRotateExternalRef(event.target.value)}
placeholder={selectedSecret.externalRef ?? "Updated reference"}
className="font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Rotate the actual value in the provider before changing this Paperclip reference.
</p>
</div>
) : (
<div>
<label className="text-xs font-medium" htmlFor="rotate-value">New value</label>
<Textarea
id="rotate-value"
value={rotateValue}
onChange={(event) => setRotateValue(event.target.value)}
rows={3}
className="font-mono text-xs"
placeholder="Paste the new value"
/>
</div>
)}
{rotateError ? <p className="text-xs text-destructive">{rotateError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRotateOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setRotateError(null);
rotateMutation.mutate();
}}
disabled={
rotateMutation.isPending ||
Boolean(rotateProviderBlockReason) ||
(selectedSecret?.managedMode === "external_reference"
? !rotateExternalRef.trim() && !selectedSecret?.externalRef
: !rotateValue)
}
>
{rotateMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{selectedSecret?.managedMode === "external_reference" ? "Update reference" : "Update value"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deleteConfirm)} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete secret</DialogTitle>
<DialogDescription>
Permanently removes <strong>{deleteConfirm?.name}</strong>. Active bindings will fail until you remap them.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SecretsHowToUse() {
return (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="space-y-1">
<p className="font-medium text-foreground">Use secrets by binding them to runtime environment variables.</p>
<p>
Create or link a secret here, then open an agent&apos;s Environment variables or a project&apos;s Env field.
Add the env key the process expects, for example <code className="font-mono">GH_TOKEN</code>, choose{" "}
<span className="font-medium text-foreground">Secret</span>, and select the stored secret version.
</p>
<p>
Paperclip resolves the value server-side when the run starts and injects it as that env var. Project env
applies to every issue in the project and overrides agent env on matching keys.
</p>
</div>
</div>
);
}
function SecretsFiltersPopover({
statusFilter,
providerFilter,
providers,
activeFilterCount,
onStatusChange,
onProviderChange,
}: {
statusFilter: SecretStatus | "all";
providerFilter: SecretProvider | "all";
providers: SecretProviderDescriptor[];
activeFilterCount: number;
onStatusChange: (value: SecretStatus | "all") => void;
onProviderChange: (value: SecretProvider | "all") => void;
}) {
const resetFilters = () => {
onStatusChange("active");
onProviderChange("all");
};
const statusOptions: Array<{ value: SecretStatus | "all"; label: string }> = [
{ value: "active", label: "Active" },
{ value: "all", label: "All statuses" },
{ value: "disabled", label: "Disabled" },
{ value: "archived", label: "Archived" },
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn("relative h-8 w-8 shrink-0", activeFilterCount > 0 && "text-blue-600 dark:text-blue-400")}
title={activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}
>
<Filter className="h-3.5 w-3.5" />
{activeFilterCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">
{activeFilterCount}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[min(520px,calc(100vw-2rem))] max-h-[min(80vh,34rem)] overflow-y-auto overscroll-contain p-0"
>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={resetFilters}
>
<X className="h-3 w-3" />
Clear
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Status</span>
<div className="space-y-0.5">
{statusOptions.map((option) => (
<label key={option.value} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={statusFilter === option.value}
onCheckedChange={() => onStatusChange(option.value)}
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Provider</span>
<div className="max-h-48 space-y-0.5 overflow-y-auto pr-1">
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={providerFilter === "all"}
onCheckedChange={() => onProviderChange("all")}
/>
<span className="text-sm">All providers</span>
</label>
{providers.map((provider) => (
<label key={provider.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={providerFilter === provider.id}
onCheckedChange={() => onProviderChange(provider.id)}
/>
<span className="text-sm">{provider.label}</span>
</label>
))}
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
function providerConfigStatusTone(status: SecretProviderConfigStatus) {
switch (status) {
case "ready":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
case "warning":
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
case "coming_soon":
return "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300";
case "disabled":
return "border-muted bg-muted text-muted-foreground";
default:
return "border-border bg-muted text-muted-foreground";
}
}
function providerFamilyIcon(provider: SecretProvider) {
switch (provider) {
case "local_encrypted":
return Database;
case "aws_secrets_manager":
return Cloud;
case "gcp_secret_manager":
return ShieldCheck;
case "vault":
return KeyRound;
default:
return KeyRound;
}
}
function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderConfig }) {
const blockReason = getProviderConfigBlockReason(config);
const message = blockReason ?? config.healthMessage;
if (!message) {
return (
<p className="mt-1 text-[11px] text-muted-foreground">
{config.isDefault ? "Default vault" : "Vault"} · {config.status.replace("_", " ")}
</p>
);
}
const warning = config.status === "warning" || config.healthStatus === "warning";
return (
<p className={cn("mt-1 flex items-center gap-1 text-[11px]", warning ? "text-amber-600 dark:text-amber-400" : "text-destructive")}>
{warning ? <AlertTriangle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{message}
</p>
);
}
interface ImportFromVaultButtonProps {
providerConfigs: CompanySecretProviderConfig[];
onClick: () => void;
onManageVaults: () => void;
className?: string;
}
function ImportFromVaultButton({
providerConfigs,
onClick,
onManageVaults,
className,
}: ImportFromVaultButtonProps) {
const awsConfigs = providerConfigs.filter(
(config) => config.provider === "aws_secrets_manager",
);
const eligible = awsConfigs.filter(
(config) => config.status === "ready" || config.status === "warning",
);
if (awsConfigs.length === 0) return null;
if (eligible.length === 0) {
return (
<Button
variant="ghost"
size="sm"
onClick={onManageVaults}
className={cn("text-xs text-muted-foreground", className)}
title="Configure an AWS provider vault to enable remote import"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> AWS vault disabled manage
</Button>
);
}
return (
<Button
variant="outline"
size="sm"
onClick={onClick}
className={className}
data-testid="import-from-vault-button"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> Import from vault
</Button>
);
}
export function ProviderVaultsTab({
providers,
providerConfigs,
loading,
error,
onRetry,
onCreate,
onEdit,
onDisable,
onSetDefault,
onHealthCheck,
pendingActionId,
}: {
providers: SecretProviderDescriptor[];
providerConfigs: CompanySecretProviderConfig[];
loading: boolean;
error: unknown;
onRetry: () => void;
onCreate: (provider: SecretProvider) => void;
onEdit: (config: CompanySecretProviderConfig) => void;
onDisable: (config: CompanySecretProviderConfig) => void;
onSetDefault: (config: CompanySecretProviderConfig) => void;
onHealthCheck: (config: CompanySecretProviderConfig) => void;
pendingActionId: string | null;
}) {
if (loading) {
return (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading provider vaults
</div>
);
}
if (error) {
return (
<div className="py-4 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> Failed to load provider vaults: {(error as Error).message}
<Button variant="ghost" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
const providerRows = PROVIDER_ORDER.map((providerId) => ({
id: providerId,
provider: providerMap.get(providerId),
Icon: providerFamilyIcon(providerId),
isComingSoonFamily: providerId === "gcp_secret_manager" || providerId === "vault",
configs: providerConfigs.filter((config) => config.provider === providerId),
}));
return (
<div className="flex min-h-full gap-6">
<aside className="hidden w-56 shrink-0 md:block">
<nav className="sticky top-0 space-y-1">
{providerRows.map(({ id, provider, Icon }) => (
<a
key={id}
href={`#provider-vaults-${id}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
>
<Icon className="h-4 w-4" />
<span className="truncate">{provider?.label ?? id.replaceAll("_", " ")}</span>
</a>
))}
</nav>
</aside>
<div className="min-w-0 flex-1 space-y-6">
{providerRows.map(({ id, provider, Icon, isComingSoonFamily, configs }) => (
<section key={id} id={`provider-vaults-${id}`} className={cn("scroll-mt-6 space-y-2", isComingSoonFamily && "opacity-50")}>
<div className="flex flex-wrap items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{provider?.label ?? id.replaceAll("_", " ")}</h2>
{isComingSoonFamily ? (
<span className="ml-auto text-xs text-muted-foreground">Coming soon</span>
) : (
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(id)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add vault
</Button>
)}
</div>
{configs.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-4 text-sm text-muted-foreground">
{isComingSoonFamily
? "Not yet supported."
: "No company-specific vaults yet. Secrets can still use the deployment default provider settings."}
</div>
) : (
<div className="space-y-3">
{configs.map((config) => (
<ProviderVaultCard
key={config.id}
config={config}
pending={pendingActionId === config.id}
onEdit={() => onEdit(config)}
onDisable={() => onDisable(config)}
onSetDefault={() => onSetDefault(config)}
onHealthCheck={() => onHealthCheck(config)}
/>
))}
</div>
)}
</section>
))}
</div>
</div>
);
}
function ProviderVaultCard({
config,
pending,
onEdit,
onDisable,
onSetDefault,
onHealthCheck,
}: {
config: CompanySecretProviderConfig;
pending: boolean;
onEdit: () => void;
onDisable: () => void;
onSetDefault: () => void;
onHealthCheck: () => void;
}) {
const blockReason = getProviderConfigBlockReason(config);
const details = config.healthDetails;
return (
<div className="rounded-md border border-border bg-background p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium leading-snug">{config.displayName}</h3>
{config.isDefault ? (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<Star className="h-3 w-3 fill-current" />
Default
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn("font-medium", providerConfigStatusTone(config.status))}>
{config.status.replace("_", " ")}
</Badge>
{config.healthStatus ? (
<span className="text-xs text-muted-foreground">
Health {config.healthStatus.replace("_", " ")} · {formatRelative(config.healthCheckedAt)}
</span>
) : (
<span className="text-xs text-muted-foreground">Health not checked</span>
)}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
</div>
{config.healthMessage || blockReason ? (
<div className={cn("mt-3 rounded-md p-2 text-xs", blockReason ? "bg-destructive/5 text-destructive" : "bg-muted/40 text-muted-foreground")}>
{blockReason ?? config.healthMessage}
{details?.guidance?.length ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{details.guidance.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onHealthCheck} disabled={pending}>
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
Check health
</Button>
<Button
variant="outline"
size="sm"
onClick={onSetDefault}
disabled={pending || Boolean(blockReason) || config.isDefault}
>
<Star className="h-3.5 w-3.5 mr-1" />
Make default
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDisable}
disabled={pending || config.status === "disabled"}
>
<Ban className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
</div>
</div>
);
}
function ProviderVaultFields({
form,
onChange,
}: {
form: ProviderVaultForm;
onChange: React.Dispatch<React.SetStateAction<ProviderVaultForm>>;
}) {
const setField = (key: keyof ProviderVaultForm, value: string | boolean) => {
onChange((current) => ({ ...current, [key]: value }));
};
if (form.provider === "local_encrypted") {
return (
<label className="flex items-start gap-2 rounded-md border border-border bg-muted/20 p-3 text-sm">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
checked={form.backupReminderAcknowledged}
onChange={(event) => setField("backupReminderAcknowledged", event.target.checked)}
/>
<span>
I understand backup and restore require both the database metadata and the local encrypted master key file.
</span>
</label>
);
}
if (form.provider === "aws_secrets_manager") {
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="AWS region" value={form.region} onChange={(value) => setField("region", value)} placeholder="us-east-1" required />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="production" />
<TextField label="Secret name prefix" value={form.secretNamePrefix} onChange={(value) => setField("secretNamePrefix", value)} placeholder="paperclip" />
<TextField label="KMS key id" value={form.kmsKeyId} onChange={(value) => setField("kmsKeyId", value)} placeholder="alias/paperclip-secrets" />
<TextField label="Owner tag" value={form.ownerTag} onChange={(value) => setField("ownerTag", value)} placeholder="platform" />
<TextField label="Environment tag" value={form.environmentTag} onChange={(value) => setField("environmentTag", value)} placeholder="prod" />
</div>
);
}
if (form.provider === "gcp_secret_manager") {
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="Project id" value={form.projectId} onChange={(value) => setField("projectId", value)} placeholder="paperclip-prod" />
<TextField label="Location" value={form.location} onChange={(value) => setField("location", value)} placeholder="global" />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="production" />
<TextField label="Secret name prefix" value={form.secretNamePrefix} onChange={(value) => setField("secretNamePrefix", value)} placeholder="paperclip" />
</div>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="Address" value={form.address} onChange={(value) => setField("address", value)} placeholder="https://vault.example.com" />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="admin" />
<TextField label="Mount path" value={form.mountPath} onChange={(value) => setField("mountPath", value)} placeholder="secret" />
<TextField label="Secret path prefix" value={form.secretPathPrefix} onChange={(value) => setField("secretPathPrefix", value)} placeholder="paperclip/prod" />
</div>
);
}
function TextField({
label,
value,
onChange,
placeholder,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
}) {
const id = `provider-vault-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<div>
<label className="text-xs font-medium" htmlFor={id}>
{label}
{required ? null : <span className="text-muted-foreground/70"> (optional)</span>}
</label>
<Input id={id} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
</div>
);
}
function SecretDetailsTab({
secret,
providerConfigs,
}: {
secret: CompanySecret;
providerConfigs: CompanySecretProviderConfig[];
}) {
return (
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<DetailRow label="Description">
<span>{secret.description ?? <span className="text-muted-foreground"></span>}</span>
</DetailRow>
<DetailRow label="Custody">{modeLabel(secret.managedMode)}</DetailRow>
<DetailRow label="Provider">{secret.provider.replaceAll("_", " ")}</DetailRow>
<DetailRow label="Provider vault">{providerVaultLabel(providerConfigs, secret.providerConfigId)}</DetailRow>
<DetailRow label="Latest version">v{secret.latestVersion}</DetailRow>
<DetailRow label="Created">{formatRelative(secret.createdAt)}</DetailRow>
<DetailRow label="Updated">{formatRelative(secret.updatedAt)}</DetailRow>
<DetailRow label="Last rotated">{formatRelative(secret.lastRotatedAt)}</DetailRow>
<DetailRow label="Last resolved">{formatRelative(secret.lastResolvedAt)}</DetailRow>
{secret.externalRef ? (
<div className="col-span-2">
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
{secret.managedMode === "external_reference" ? "Linked provider reference" : "Provider-managed path"}
</dt>
<dd className="font-mono text-xs break-all flex items-center gap-1">
<ExternalLink className="h-3 w-3" /> {secret.externalRef}
</dd>
</div>
) : null}
<div className="col-span-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-300">
{modeDescription(secret.managedMode)} Paperclip never re-displays stored values.
</div>
</dl>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="text-foreground">{children}</dd>
</div>
);
}
function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
if (loading) {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading</div>;
}
if (bindings.length === 0) {
return (
<div className="py-6 text-center text-xs text-muted-foreground">
No active bindings. Add this secret in agent, project, environment, or plugin config to start using it.
</div>
);
}
return (
<div className="space-y-2">
{bindings.map((binding) => (
<div
key={binding.id}
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium capitalize">{binding.target.type}</span>
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
{binding.target.href ? (
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
{binding.target.label}
</Link>
) : (
<span className="truncate font-medium">{binding.target.label}</span>
)}
{binding.target.status ? (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-normal">
{binding.target.status.replaceAll("_", " ")}
</Badge>
) : null}
</div>
<div className="font-mono text-[11px] text-muted-foreground break-all">
{binding.targetId}
</div>
<div className="text-[11px] text-muted-foreground">
{binding.configPath} {binding.required ? "· required" : "· optional"}
</div>
</div>
))}
</div>
);
}
function SecretEventsTab({ loading, events }: { loading: boolean; events: SecretAccessEvent[] }) {
if (loading) {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading</div>;
}
if (events.length === 0) {
return (
<div className="py-6 text-center text-xs text-muted-foreground">
No access events recorded yet. Each runtime resolution writes a redacted entry here.
</div>
);
}
return (
<div className="space-y-1.5">
{events.map((event) => (
<div key={event.id} className="rounded border border-border px-2 py-1.5 text-xs">
<div className="flex items-center justify-between">
<span className="capitalize">
{event.consumerType} · {event.outcome}
</span>
<span className="text-[11px] text-muted-foreground">{formatRelative(event.createdAt)}</span>
</div>
<div className="font-mono text-[11px] text-muted-foreground break-all">
{event.consumerId}
</div>
{event.errorCode ? (
<div className="text-[11px] text-destructive">{event.errorCode}</div>
) : null}
</div>
))}
</div>
);
}