diff --git a/ui/src/pages/Secrets.test.ts b/ui/src/pages/Secrets.test.ts index e650ab7a590..771babbca51 100644 --- a/ui/src/pages/Secrets.test.ts +++ b/ui/src/pages/Secrets.test.ts @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; -import type { SecretProviderDescriptor } from "@paperclipai/shared"; +import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared"; import { + findCreateProviderReplacement, getAwsManagedPathPreview, getCreateProviderBlockReason, getDefaultProviderConfigId, @@ -19,6 +20,37 @@ const awsProvider: SecretProviderDescriptor = { configured: true, }; +const localProvider: SecretProviderDescriptor = { + id: "local_encrypted", + label: "Local encrypted (default)", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: false, + configured: true, +}; + +function providerConfig( + overrides: Partial & Pick, +): CompanySecretProviderConfig { + return { + companyId: "company-1", + displayName: overrides.id, + status: "ready", + isDefault: false, + config: {}, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + describe("Secrets page provider helpers", () => { it("previews the derived AWS managed path from provider health details", () => { const health: SecretProviderHealthResponse = { @@ -52,7 +84,9 @@ describe("Secrets page provider helpers", () => { "managed", null, ), - ).toBe("AWS Secrets Manager is not configured in this deployment."); + ).toBe( + "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default.", + ); }); it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => { @@ -74,27 +108,72 @@ describe("Secrets page provider helpers", () => { health, ), ).toBe( - "AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", + "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.", ); }); + it("allows an unconfigured AWS deployment default when a ready AWS provider vault is selected", () => { + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "external", + null, + providerConfig({ + id: "aws-prod", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + }), + ), + ).toBeNull(); + }); + + it("names the selected provider vault block before deployment-default AWS config", () => { + expect( + getCreateProviderBlockReason( + { ...awsProvider, configured: false }, + "external", + null, + providerConfig({ + id: "aws-disabled", + provider: "aws_secrets_manager", + displayName: "AWS disabled", + status: "disabled", + }), + ), + ).toBe("This provider vault is disabled."); + }); + it("blocks provider modes the backend does not support", () => { expect( getCreateProviderBlockReason( - { - id: "local_encrypted", - label: "Local encrypted (default)", - requiresExternalRef: false, - supportsManagedValues: true, - supportsExternalReferences: false, - configured: true, - }, + localProvider, "external", null, ), ).toBe("Local encrypted (default) does not support linked external references."); }); + it("switching to external mode prefers AWS instead of staying on local encrypted when a ready AWS vault exists", () => { + expect( + findCreateProviderReplacement({ + providers: [localProvider, { ...awsProvider, configured: false }], + providerConfigs: [ + providerConfig({ + id: "aws-prod", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: true, + }), + ], + currentProvider: "local_encrypted", + mode: "external", + health: null, + })?.id, + ).toBe("aws_secrets_manager"); + }); + it("chooses the ready default provider vault for a provider", () => { expect( getDefaultProviderConfigId( diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 78fe12f29ea..b8da13bd529 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -295,6 +295,7 @@ export function getCreateProviderBlockReason( provider: SecretProviderDescriptor | null | undefined, mode: CreateMode, health: SecretProviderHealthResponse | null, + providerConfig?: CompanySecretProviderConfig | null, ) { if (!provider) return "Select a provider."; if (mode === "managed" && provider.supportsManagedValues === false) { @@ -303,11 +304,20 @@ export function getCreateProviderBlockReason( if (mode === "external" && provider.supportsExternalReferences === false) { return `${provider.label} does not support linked external references.`; } + const selectedProviderConfigBlockReason = providerConfig?.provider === provider.id + ? getProviderConfigBlockReason(providerConfig) + : null; + const selectedProviderConfigReady = + providerConfig?.provider === provider.id && !selectedProviderConfigBlockReason; if (provider.configured === false) { + if (selectedProviderConfigReady) return null; + if (selectedProviderConfigBlockReason) return selectedProviderConfigBlockReason; const healthEntry = healthEntryForProvider(health, provider.id); + const deploymentMessage = `Deployment default ${provider.label} is not configured.`; + const nextStep = " Select a ready provider vault or configure the deployment default."; return healthEntry?.message - ? `${provider.label} is not configured in this deployment. ${healthEntry.message}` - : `${provider.label} is not configured in this deployment.`; + ? `${deploymentMessage}${nextStep} ${healthEntry.message}` + : `${deploymentMessage}${nextStep}`; } const healthEntry = healthEntryForProvider(health, provider.id); if (healthEntry?.status === "error") { @@ -319,8 +329,16 @@ export function getCreateProviderBlockReason( function providerHealthText( provider: SecretProviderDescriptor | null | undefined, health: SecretProviderHealthResponse | null, + providerConfig?: CompanySecretProviderConfig | null, ) { if (!provider) return null; + if ( + provider.configured === false && + providerConfig?.provider === provider.id && + !getProviderConfigBlockReason(providerConfig) + ) { + return `Using selected provider vault. Deployment default ${provider.label} is not configured.`; + } const entry = healthEntryForProvider(health, provider.id); if (!entry) return null; const warnings = entry.warnings?.join(" "); @@ -344,20 +362,57 @@ export function getProviderConfigBlockReason( return null; } -export function getDefaultProviderConfigId( +export function getSelectableProviderConfig( 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 && !getProviderConfigBlockReason(config)) ?? + providerConfigs.find((config) => !getProviderConfigBlockReason(config)) ?? + null + ); +} + +export function getDefaultProviderConfigId( + configs: CompanySecretProviderConfig[], + provider: SecretProvider, +) { + const selected = getSelectableProviderConfig(configs, provider); + const providerConfigs = configs.filter((config) => config.provider === provider); + return ( + selected?.id ?? providerConfigs.find((config) => config.isDefault)?.id ?? "" ); } +export function findCreateProviderReplacement({ + providers, + providerConfigs, + currentProvider, + mode, + health, +}: { + providers: SecretProviderDescriptor[]; + providerConfigs: CompanySecretProviderConfig[]; + currentProvider: SecretProvider; + mode: CreateMode; + health: SecretProviderHealthResponse | null; +}) { + return ( + providers.find((provider) => { + const selectedConfig = + provider.id === currentProvider + ? providerConfigs.find( + (config) => config.provider === provider.id && !getProviderConfigBlockReason(config), + ) ?? null + : getSelectableProviderConfig(providerConfigs, provider.id); + return !getCreateProviderBlockReason(provider, mode, health, selectedConfig); + }) ?? null + ); +} + function providerVaultLabel(configs: CompanySecretProviderConfig[], id: string | null | undefined) { if (!id) return "Deployment default"; return configs.find((config) => config.id === id)?.displayName ?? "Unknown vault"; @@ -573,11 +628,13 @@ export function Secrets() { selectedCreateProvider, createMode, providerHealthQuery.data ?? null, + selectedCreateProviderConfig, ) ?? getProviderConfigBlockReason(selectedCreateProviderConfig); const rotateProviderBlockReason = getProviderConfigBlockReason(selectedRotateProviderConfig); const createProviderHealthText = providerHealthText( selectedCreateProvider, providerHealthQuery.data ?? null, + selectedCreateProviderConfig, ); const awsManagedPathPreview = getAwsManagedPathPreview({ provider: selectedCreateProvider, @@ -1006,12 +1063,16 @@ export function Secrets() { providers.find((provider) => provider.id === createForm.provider) ?? null, createMode, providerHealthQuery.data ?? null, + providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null, ); if (!currentBlockReason) return; - const replacement = providers.find( - (provider) => - !getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null), - ); + const replacement = findCreateProviderReplacement({ + providers, + providerConfigs, + currentProvider: createForm.provider, + mode: createMode, + health: providerHealthQuery.data ?? null, + }); if (replacement && replacement.id !== createForm.provider) { setCreateForm((current) => ({ ...current, @@ -1635,7 +1696,7 @@ export function Secrets() { )} - + {editingDefinition ? "Edit user-provided secret" : "Create secret"} @@ -1643,7 +1704,7 @@ export function Secrets() {
-
+
- + Company Each user @@ -1735,9 +1796,19 @@ export function Secrets() { {secretValueProvider === "company" ? ( <> setCreateMode(value as CreateMode)}> - - Managed value - External reference + + + Managed value + + + External reference +
@@ -1762,12 +1833,18 @@ export function Secrets() { key={provider.id} value={provider.id} disabled={Boolean( - getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null), + getCreateProviderBlockReason( + provider, + createMode, + providerHealthQuery.data ?? null, + getSelectableProviderConfig(providerConfigs, provider.id), + ), )} > {provider.label} - {provider.configured === false - ? " (not configured)" + {provider.configured === false && + !getSelectableProviderConfig(providerConfigs, provider.id) + ? " (deployment default missing)" : provider.requiresExternalRef ? " (external only)" : ""}