778e775c35
## 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.    ## 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>
2156 lines
84 KiB
TypeScript
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's Environment variables or a project'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>
|
|
);
|
|
}
|