From 6000f0cb6a12e99fd57dc966f6d94e9f4ff6c9f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 08:52:27 -0500 Subject: [PATCH] Add provider vault settings UI Co-Authored-By: Paperclip --- ui/src/api/secrets.ts | 32 ++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/Secrets.test.ts | 57 ++ ui/src/pages/Secrets.tsx | 1017 +++++++++++++++++++++++++++++++--- 4 files changed, 1037 insertions(+), 70 deletions(-) diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index 7864a74ddff..0c198a2c6fb 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -1,9 +1,12 @@ import type { CompanySecret, CompanySecretBinding, + CompanySecretProviderConfig, SecretAccessEvent, SecretManagedMode, SecretProvider, + SecretProviderConfigStatus, + SecretProviderConfigHealthResponse, SecretProviderDescriptor, SecretStatus, } from "@paperclipai/shared"; @@ -23,6 +26,7 @@ export interface CreateSecretInput { description?: string | null; externalRef?: string | null; providerVersionRef?: string | null; + providerConfigId?: string | null; providerMetadata?: Record | null; } @@ -50,6 +54,22 @@ export interface RotateSecretInput { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; + providerConfigId?: string | null; +} + +export interface CreateSecretProviderConfigInput { + provider: SecretProvider; + displayName: string; + status?: SecretProviderConfigStatus; + isDefault?: boolean; + config?: Record; +} + +export interface UpdateSecretProviderConfigInput { + displayName?: string; + status?: SecretProviderConfigStatus; + isDefault?: boolean; + config?: Record; } export const secretsApi = { @@ -58,6 +78,18 @@ export const secretsApi = { api.get(`/companies/${companyId}/secret-providers`), providerHealth: (companyId: string) => api.get(`/companies/${companyId}/secret-providers/health`), + providerConfigs: (companyId: string) => + api.get(`/companies/${companyId}/secret-provider-configs`), + createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) => + api.post(`/companies/${companyId}/secret-provider-configs`, data), + updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) => + api.patch(`/secret-provider-configs/${id}`, data), + disableProviderConfig: (id: string) => + api.delete(`/secret-provider-configs/${id}`), + setDefaultProviderConfig: (id: string) => + api.post(`/secret-provider-configs/${id}/default`, {}), + checkProviderConfigHealth: (id: string) => + api.post(`/secret-provider-configs/${id}/health`, {}), create: (companyId: string, data: CreateSecretInput) => api.post(`/companies/${companyId}/secrets`, data), update: (id: string, data: UpdateSecretInput) => diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index dbc4f673470..24aa097f5fe 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -135,6 +135,7 @@ export const queryKeys = { secrets: { list: (companyId: string) => ["secrets", companyId] as const, providers: (companyId: string) => ["secret-providers", companyId] as const, + providerConfigs: (companyId: string) => ["secret-provider-configs", companyId] as const, usage: (secretId: string) => ["secrets", "usage", secretId] as const, accessEvents: (secretId: string) => ["secrets", "access-events", secretId] as const, }, diff --git a/ui/src/pages/Secrets.test.ts b/ui/src/pages/Secrets.test.ts index da2b94d6b98..e650ab7a590 100644 --- a/ui/src/pages/Secrets.test.ts +++ b/ui/src/pages/Secrets.test.ts @@ -5,6 +5,8 @@ import type { SecretProviderDescriptor } from "@paperclipai/shared"; import { getAwsManagedPathPreview, getCreateProviderBlockReason, + getDefaultProviderConfigId, + getProviderConfigBlockReason, } from "./Secrets"; import type { SecretProviderHealthResponse } from "../api/secrets"; @@ -53,6 +55,29 @@ describe("Secrets page provider helpers", () => { ).toBe("AWS Secrets Manager is not configured in this deployment."); }); + it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => { + const health: SecretProviderHealthResponse = { + providers: [ + { + provider: "aws_secrets_manager", + status: "warn", + message: + "AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", + }, + ], + }; + + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "managed", + health, + ), + ).toBe( + "AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", + ); + }); + it("blocks provider modes the backend does not support", () => { expect( getCreateProviderBlockReason( @@ -69,4 +94,36 @@ describe("Secrets page provider helpers", () => { ), ).toBe("Local encrypted (default) does not support linked external references."); }); + + it("chooses the ready default provider vault for a provider", () => { + expect( + getDefaultProviderConfigId( + [ + { + id: "draft", + provider: "aws_secrets_manager", + status: "disabled", + isDefault: true, + }, + { + id: "prod", + provider: "aws_secrets_manager", + status: "ready", + isDefault: true, + }, + ] as never, + "aws_secrets_manager", + ), + ).toBe("prod"); + }); + + it("explains why coming-soon provider vaults cannot be selected", () => { + expect( + getProviderConfigBlockReason({ + id: "vault-draft", + provider: "vault", + status: "coming_soon", + } as never), + ).toBe("This provider vault is saved as draft metadata only."); + }); }); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index e6f9c12612a..196219b05aa 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -7,6 +7,9 @@ import { Archive, Ban, CheckCircle2, + Cloud, + Database, + Edit3, ExternalLink, KeyRound, Link2, @@ -14,22 +17,32 @@ import { Plus, RefreshCw, Search, + ShieldCheck, + Star, Trash2, Wifi, } from "lucide-react"; import type { CompanySecret, CompanySecretBinding, + 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 SecretProviderHealthResponse } from "../api/secrets"; +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"; @@ -56,6 +69,86 @@ import { Badge } from "@/components/ui/badge"; import { cn } from "../lib/utils"; 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)[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 | 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 "—"; @@ -138,7 +231,10 @@ export function getCreateProviderBlockReason( return `${provider.label} does not support linked external references.`; } if (provider.configured === false) { - return `${provider.label} is not configured in this deployment.`; + 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") { @@ -163,6 +259,70 @@ function detailString(details: Record | undefined, key: string) 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 { + 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; @@ -182,6 +342,7 @@ export function Secrets() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToastActions(); + const [activeTab, setActiveTab] = useState("secrets"); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); const [providerFilter, setProviderFilter] = useState("all"); @@ -195,13 +356,19 @@ export function Secrets() { description: "", externalRef: "", provider: "local_encrypted" as SecretProvider, + providerConfigId: "", }); const [createError, setCreateError] = useState(null); const [rotateOpen, setRotateOpen] = useState(false); const [rotateValue, setRotateValue] = useState(""); const [rotateExternalRef, setRotateExternalRef] = useState(""); + const [rotateProviderConfigId, setRotateProviderConfigId] = useState(""); const [rotateError, setRotateError] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [vaultDialogOpen, setVaultDialogOpen] = useState(false); + const [editingVault, setEditingVault] = useState(null); + const [vaultForm, setVaultForm] = useState(() => emptyProviderVaultForm()); + const [vaultError, setVaultError] = useState(null); useEffect(() => { setBreadcrumbs([{ label: "Secrets" }]); @@ -234,8 +401,18 @@ export function Secrets() { 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], @@ -244,11 +421,28 @@ export function Secrets() { () => 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, @@ -291,6 +485,7 @@ export function Secrets() { 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) }); @@ -302,6 +497,7 @@ export function Secrets() { 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, }; @@ -316,7 +512,15 @@ export function Secrets() { onSuccess: (created) => { pushToast({ title: "Secret created", body: created.name, tone: "success" }); setCreateOpen(false); - setCreateForm({ name: "", key: "", value: "", description: "", externalRef: "", provider: createForm.provider }); + setCreateForm({ + name: "", + key: "", + value: "", + description: "", + externalRef: "", + provider: createForm.provider, + providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider), + }); setCreateError(null); setSelectedSecretId(created.id); invalidateAll([created.id]); @@ -332,15 +536,20 @@ export function Secrets() { 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 }); + 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]); }, @@ -392,6 +601,80 @@ export function Secrets() { }, }); + 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( @@ -405,9 +688,44 @@ export function Secrets() { !getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null), ); if (replacement && replacement.id !== createForm.provider) { - setCreateForm((current) => ({ ...current, provider: replacement.id })); + setCreateForm((current) => ({ + ...current, + provider: replacement.id, + providerConfigId: getDefaultProviderConfigId(providerConfigs, replacement.id), + })); } - }, [createForm.provider, createMode, createOpen, providerHealthQuery.data, providers]); + }, [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 ( @@ -415,7 +733,6 @@ export function Secrets() { ); } - const activeProviders = providers.length; const activeSecrets = secrets.filter((s) => s.status === "active").length; return ( @@ -425,44 +742,52 @@ export function Secrets() {

Secrets

- {activeSecrets}/{secrets.length} active · {activeProviders} provider{activeProviders === 1 ? "" : "s"} + {activeSecrets}/{secrets.length} active · {providerConfigs.length} vault{providerConfigs.length === 1 ? "" : "s"}
-
- - setSearch(event.target.value)} - placeholder="Search by name, key, ref" - className="pl-7 h-8 w-64" - /> -
- - - + {activeTab === "secrets" ? ( + <> +
+ + setSearch(event.target.value)} + placeholder="Search by name, key, ref" + className="pl-7 h-8 w-64" + /> +
+ + + + + ) : ( + + )}
@@ -473,26 +798,33 @@ export function Secrets() { error={providersQuery.error ?? providerHealthQuery.error} /> -
- {secretsQuery.isError ? ( -
- Failed to load secrets:{" "} - {(secretsQuery.error as Error).message} - -
- ) : secrets.length === 0 && !secretsQuery.isPending ? ( - setCreateOpen(true)} - /> - ) : filtered.length === 0 ? ( - - ) : ( - + setActiveTab(value as SecretsTab)} className="flex min-h-0 flex-1 flex-col"> +
+ + Secrets + Provider vaults + +
+ + {secretsQuery.isError ? ( +
+ Failed to load secrets:{" "} + {(secretsQuery.error as Error).message} + +
+ ) : secrets.length === 0 && !secretsQuery.isPending ? ( + setCreateOpen(true)} + /> + ) : filtered.length === 0 ? ( + + ) : ( +
@@ -525,7 +857,12 @@ export function Secrets() { {modeLabel(secret.managedMode)} - + ))} -
Name {providerLabel(providers, secret.provider)} +
{providerLabel(providers, secret.provider)}
+
+ {providerVaultLabel(providerConfigs, secret.providerConfigId)} +
+
{secret.status} @@ -563,9 +900,30 @@ export function Secrets() {
- )} -
+ + )} + + + 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 + } + /> + + !open && setSelectedSecretId(null)}> @@ -595,6 +953,10 @@ export function Secrets() { setRotateOpen(true); setRotateValue(""); setRotateExternalRef(""); + setRotateProviderConfigId( + selectedSecret.providerConfigId ?? + getDefaultProviderConfigId(providerConfigs, selectedSecret.provider), + ); setRotateError(null); }} > @@ -658,7 +1020,7 @@ export function Secrets() {
- + @@ -723,7 +1085,14 @@ export function Secrets() { 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) => ({ ...current, provider: event.target.value as SecretProvider })) + setCreateForm((current) => { + const provider = event.target.value as SecretProvider; + return { + ...current, + provider, + providerConfigId: getDefaultProviderConfigId(providerConfigs, provider), + }; + }) } > {providers.map((provider) => ( @@ -752,6 +1121,36 @@ export function Secrets() {

{createProviderHealthText}

) : null}
+
+ + + {selectedCreateProviderConfig ? ( + + ) : ( +

+ Existing deployment-level provider settings stay available for backwards compatibility. +

+ )} +
{createMode === "managed" ? ( <>
@@ -836,6 +1235,118 @@ export function Secrets() { + + + + {editingVault ? "Edit provider vault" : "Create provider vault"} + + Save only non-sensitive routing metadata. Credentials stay in the runtime environment or provider identity. + + +
+
+
+ + +
+
+ + + setVaultForm((current) => ({ ...current, displayName: event.target.value })) + } + placeholder="Production local vault" + /> +
+
+ + +
+ +
+ + + + {vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? ( +
+ This provider can save draft routing metadata, but runtime writes and resolution stay disabled until + the provider module is implemented and reviewed. +
+ ) : null} + {vaultError ?

{vaultError}

: null} +
+ + + + +
+
+ @@ -848,6 +1359,34 @@ export function Secrets() { : "Creates a new provider-backed version. Consumers pinned to latest pick up the new value on the next run."} +
+ + + {selectedRotateProviderConfig ? ( + + ) : ( +

+ Rotating with the deployment default preserves current fallback behavior. +

+ )} +
{selectedSecret?.managedMode === "external_reference" ? (
@@ -887,6 +1426,7 @@ export function Secrets() { }} disabled={ rotateMutation.isPending || + Boolean(rotateProviderBlockReason) || (selectedSecret?.managedMode === "external_reference" ? !rotateExternalRef.trim() && !selectedSecret?.externalRef : !rotateValue) @@ -996,7 +1536,343 @@ function ProviderHealthBar({ ); } -function SecretDetailsTab({ secret }: { secret: CompanySecret }) { +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 ( +

+ {config.isDefault ? "Default vault" : "Vault"} · {config.status.replace("_", " ")} +

+ ); + } + const warning = config.status === "warning" || config.healthStatus === "warning"; + return ( +

+ {warning ? : } + {message} +

+ ); +} + +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 ( +
+ + Loading provider vaults +
+ ); + } + + if (error) { + return ( +
+ Failed to load provider vaults: {(error as Error).message} + +
+ ); + } + + const providerMap = new Map(providers.map((provider) => [provider.id, provider])); + + return ( +
+ {PROVIDER_ORDER.map((providerId) => { + const provider = providerMap.get(providerId); + const configs = providerConfigs.filter((config) => config.provider === providerId); + const Icon = providerFamilyIcon(providerId); + const isComingSoonFamily = providerId === "gcp_secret_manager" || providerId === "vault"; + return ( +
+
+ +

{provider?.label ?? providerId.replaceAll("_", " ")}

+ {provider?.supportsManagedValues ? : null} + {provider?.supportsExternalReferences ? : null} + {isComingSoonFamily ? ( + + coming soon + + ) : null} + +
+ {configs.length === 0 ? ( +
+ {isComingSoonFamily + ? "No draft vaults saved. You can capture non-sensitive routing metadata before runtime support lands." + : "No company-specific vaults yet. Secrets can still use the deployment default provider settings."} +
+ ) : ( +
+ {configs.map((config) => ( + onEdit(config)} + onDisable={() => onDisable(config)} + onSetDefault={() => onSetDefault(config)} + onHealthCheck={() => onHealthCheck(config)} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} + +function CapabilityBadge({ label }: { label: string }) { + return ( + + {label} + + ); +} + +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 ( +
+
+
+
+

{config.displayName}

+ {config.isDefault ? ( + + + Default + + ) : null} +
+
+ + {config.status.replace("_", " ")} + + {config.healthStatus ? ( + + Health {config.healthStatus.replace("_", " ")} · {formatRelative(config.healthCheckedAt)} + + ) : ( + Health not checked + )} +
+
+ +
+ {config.healthMessage || blockReason ? ( +
+ {blockReason ?? config.healthMessage} + {details?.guidance?.length ? ( +
    + {details.guidance.map((item) => ( +
  • {item}
  • + ))} +
+ ) : null} +
+ ) : null} +
+ + + +
+
+ ); +} + +function ProviderVaultFields({ + form, + onChange, +}: { + form: ProviderVaultForm; + onChange: React.Dispatch>; +}) { + const setField = (key: keyof ProviderVaultForm, value: string | boolean) => { + onChange((current) => ({ ...current, [key]: value })); + }; + + if (form.provider === "local_encrypted") { + return ( + + ); + } + + if (form.provider === "aws_secrets_manager") { + return ( +
+ setField("region", value)} placeholder="us-east-1" required /> + setField("namespace", value)} placeholder="production" /> + setField("secretNamePrefix", value)} placeholder="paperclip" /> + setField("kmsKeyId", value)} placeholder="alias/paperclip-secrets" /> + setField("ownerTag", value)} placeholder="platform" /> + setField("environmentTag", value)} placeholder="prod" /> +
+ ); + } + + if (form.provider === "gcp_secret_manager") { + return ( +
+ setField("projectId", value)} placeholder="paperclip-prod" /> + setField("location", value)} placeholder="global" /> + setField("namespace", value)} placeholder="production" /> + setField("secretNamePrefix", value)} placeholder="paperclip" /> +
+ ); + } + + return ( +
+ setField("address", value)} placeholder="https://vault.example.com" /> + setField("namespace", value)} placeholder="admin" /> + setField("mountPath", value)} placeholder="secret" /> + setField("secretPathPrefix", value)} placeholder="paperclip/prod" /> +
+ ); +} + +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 ( +
+ + onChange(event.target.value)} placeholder={placeholder} /> +
+ ); +} + +function SecretDetailsTab({ + secret, + providerConfigs, +}: { + secret: CompanySecret; + providerConfigs: CompanySecretProviderConfig[]; +}) { return (
@@ -1004,6 +1880,7 @@ function SecretDetailsTab({ secret }: { secret: CompanySecret }) { {modeLabel(secret.managedMode)} {secret.provider.replaceAll("_", " ")} + {providerVaultLabel(providerConfigs, secret.providerConfigId)} v{secret.latestVersion} {formatRelative(secret.createdAt)} {formatRelative(secret.updatedAt)}