mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add AWS provider vault discovery prefill UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9690a55dba
commit
9edac5ca20
3 files changed
+492
-1
No files matched your search
@@ -2,6 +2,7 @@ import type {
|
||||
CompanySecret,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
RemoteSecretImportPreviewResult,
|
||||
RemoteSecretImportResult,
|
||||
SecretAccessEvent,
|
||||
@@ -95,6 +96,14 @@ export interface RemoteImportInput {
|
||||
secrets: RemoteImportSelectionInput[];
|
||||
}
|
||||
|
||||
export interface SecretProviderConfigDiscoveryPreviewInput {
|
||||
provider: SecretProvider;
|
||||
config?: Record<string, unknown>;
|
||||
query?: string | null;
|
||||
nextToken?: string | null;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const secretsApi = {
|
||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||
providers: (companyId: string) =>
|
||||
@@ -103,6 +112,14 @@ export const secretsApi = {
|
||||
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
||||
providerConfigs: (companyId: string) =>
|
||||
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
||||
providerConfigDiscoveryPreview: (
|
||||
companyId: string,
|
||||
data: SecretProviderConfigDiscoveryPreviewInput,
|
||||
) =>
|
||||
api.post<SecretProviderConfigDiscoveryPreviewResult>(
|
||||
`/companies/${companyId}/secret-provider-configs/discovery/preview`,
|
||||
data,
|
||||
),
|
||||
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
||||
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
||||
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
||||
|
||||
@@ -4,15 +4,21 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import type {
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
SecretProviderDescriptor,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
providers: vi.fn(),
|
||||
providerHealth: vi.fn(),
|
||||
providerConfigs: vi.fn(),
|
||||
providerConfigDiscoveryPreview: vi.fn(),
|
||||
createProviderConfig: vi.fn(),
|
||||
updateProviderConfig: vi.fn(),
|
||||
disableProviderConfig: vi.fn(),
|
||||
@@ -133,6 +139,79 @@ async function flushReact() {
|
||||
});
|
||||
}
|
||||
|
||||
function makeDiscoveryPreview(
|
||||
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
|
||||
): SecretProviderConfigDiscoveryPreviewResult {
|
||||
return {
|
||||
provider: "aws_secrets_manager",
|
||||
nextToken: null,
|
||||
sampledSecretCount: 2,
|
||||
skippedForeignPaperclipSampleCount: 0,
|
||||
warnings: [],
|
||||
candidates: [
|
||||
{
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
namespace: "prod-use1",
|
||||
secretNamePrefix: "paperclip",
|
||||
kmsKeyId: "alias/paperclip-secrets",
|
||||
ownerTag: "platform",
|
||||
environmentTag: "production",
|
||||
},
|
||||
sampleCount: 2,
|
||||
samples: [
|
||||
{
|
||||
name: "paperclip/prod-use1/company-1/openai",
|
||||
hasKmsKey: true,
|
||||
tagKeys: ["owner", "environment"],
|
||||
},
|
||||
],
|
||||
signals: {
|
||||
namespace: "prod-use1",
|
||||
secretNamePrefix: "paperclip",
|
||||
environmentTag: "production",
|
||||
ownerTag: "platform",
|
||||
kmsKeyId: "alias/paperclip-secrets",
|
||||
hasKmsKey: true,
|
||||
sampleCount: 2,
|
||||
paperclipManagedSampleCount: 0,
|
||||
skippedForeignPaperclipSampleCount: 0,
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function openAwsVaultDialog() {
|
||||
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.includes("Provider vaults"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
vaultTabButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const addVaultButtons = [...document.querySelectorAll("button")].filter(
|
||||
(button) => button.textContent?.includes("Add vault"),
|
||||
) as HTMLButtonElement[];
|
||||
await act(async () => {
|
||||
addVaultButtons[1]?.click();
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
describe("Secrets page layout", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -153,6 +232,7 @@ describe("Secrets page layout", () => {
|
||||
],
|
||||
});
|
||||
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -344,4 +424,173 @@ describe("Secrets page layout", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openAwsVaultDialog();
|
||||
|
||||
const discoveryButton = document.querySelector(
|
||||
'[data-testid="aws-vault-discovery-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(discoveryButton).not.toBeNull();
|
||||
expect(discoveryButton?.disabled).toBe(true);
|
||||
|
||||
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null;
|
||||
const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null;
|
||||
expect(regionInput).not.toBeNull();
|
||||
await act(async () => {
|
||||
setInputValue(regionInput!, "us-east-1");
|
||||
setInputValue(prefixInput!, "paperclip");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(discoveryButton?.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
discoveryButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", {
|
||||
provider: "aws_secrets_manager",
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
namespace: null,
|
||||
secretNamePrefix: "paperclip",
|
||||
kmsKeyId: null,
|
||||
ownerTag: null,
|
||||
environmentTag: null,
|
||||
},
|
||||
query: "paperclip",
|
||||
pageSize: 25,
|
||||
});
|
||||
expect(document.body.textContent).toContain("AWS production");
|
||||
|
||||
const useValuesButton = [...document.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.includes("Use values"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
useValuesButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production");
|
||||
expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1");
|
||||
expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip");
|
||||
expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets");
|
||||
expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform");
|
||||
expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production");
|
||||
expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows AWS discovery errors without replacing manual vault form values", async () => {
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
|
||||
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
|
||||
details: { code: "access_denied" },
|
||||
}),
|
||||
);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openAwsVaultDialog();
|
||||
|
||||
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
||||
const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
setInputValue(regionInput, "us-west-2");
|
||||
setInputValue(namespaceInput, "manual-prod");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const discoveryButton = document.querySelector(
|
||||
'[data-testid="aws-vault-discovery-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
discoveryButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
|
||||
expect(regionInput.value).toBe("us-west-2");
|
||||
expect(namespaceInput.value).toBe("manual-prod");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an empty AWS discovery result without blocking manual entry", async () => {
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
|
||||
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
|
||||
);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openAwsVaultDialog();
|
||||
|
||||
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
setInputValue(regionInput, "us-east-2");
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("No AWS vault metadata candidates found");
|
||||
expect(regionInput.value).toBe("us-east-2");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
CompanySecret,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretProviderConfig,
|
||||
SecretProviderConfigDiscoveryCandidate,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
SecretAccessEvent,
|
||||
SecretManagedMode,
|
||||
SecretProvider,
|
||||
@@ -325,6 +327,16 @@ function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unkno
|
||||
}
|
||||
}
|
||||
|
||||
function getAwsProviderVaultDiscoveryQuery(form: ProviderVaultForm): string | null {
|
||||
return (
|
||||
form.secretNamePrefix.trim() ||
|
||||
form.namespace.trim() ||
|
||||
form.environmentTag.trim() ||
|
||||
form.ownerTag.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getAwsManagedPathPreview(input: {
|
||||
provider: SecretProviderDescriptor | null | undefined;
|
||||
health: SecretProviderHealthResponse | null;
|
||||
@@ -374,6 +386,9 @@ export function Secrets() {
|
||||
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
|
||||
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
|
||||
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||
const [vaultDiscovery, setVaultDiscovery] =
|
||||
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
|
||||
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Secrets" }]);
|
||||
@@ -648,6 +663,24 @@ export function Secrets() {
|
||||
},
|
||||
});
|
||||
|
||||
const discoverVaultMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, {
|
||||
provider: "aws_secrets_manager",
|
||||
config: buildProviderVaultConfig(vaultForm),
|
||||
query: getAwsProviderVaultDiscoveryQuery(vaultForm),
|
||||
pageSize: 25,
|
||||
}),
|
||||
onSuccess: (preview) => {
|
||||
setVaultDiscovery(preview);
|
||||
setVaultDiscoveryError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setVaultDiscovery(null);
|
||||
setVaultDiscoveryError(error instanceof ApiError ? error.message : (error as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
const disableVaultMutation = useMutation({
|
||||
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
|
||||
onSuccess: (updated) => {
|
||||
@@ -735,6 +768,8 @@ export function Secrets() {
|
||||
setEditingVault(null);
|
||||
setVaultForm(emptyProviderVaultForm(provider));
|
||||
setVaultError(null);
|
||||
setVaultDiscovery(null);
|
||||
setVaultDiscoveryError(null);
|
||||
setVaultDialogOpen(true);
|
||||
}
|
||||
|
||||
@@ -742,9 +777,26 @@ export function Secrets() {
|
||||
setEditingVault(config);
|
||||
setVaultForm(providerVaultFormFromConfig(config));
|
||||
setVaultError(null);
|
||||
setVaultDiscovery(null);
|
||||
setVaultDiscoveryError(null);
|
||||
setVaultDialogOpen(true);
|
||||
}
|
||||
|
||||
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
|
||||
if (candidate.provider !== "aws_secrets_manager") return;
|
||||
const config = candidate.config as Record<string, unknown>;
|
||||
setVaultForm((current) => ({
|
||||
...current,
|
||||
displayName: current.displayName.trim() ? current.displayName : candidate.displayName,
|
||||
region: providerConfigValue(config, "region"),
|
||||
namespace: providerConfigValue(config, "namespace"),
|
||||
secretNamePrefix: providerConfigValue(config, "secretNamePrefix"),
|
||||
kmsKeyId: providerConfigValue(config, "kmsKeyId"),
|
||||
ownerTag: providerConfigValue(config, "ownerTag"),
|
||||
environmentTag: providerConfigValue(config, "environmentTag"),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
|
||||
@@ -1305,6 +1357,8 @@ export function Secrets() {
|
||||
onChange={(event) => {
|
||||
const provider = event.target.value as SecretProvider;
|
||||
setVaultForm(emptyProviderVaultForm(provider));
|
||||
setVaultDiscovery(null);
|
||||
setVaultDiscoveryError(null);
|
||||
}}
|
||||
>
|
||||
{PROVIDER_ORDER.map((provider) => (
|
||||
@@ -1367,6 +1421,21 @@ export function Secrets() {
|
||||
|
||||
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
|
||||
|
||||
{!editingVault && vaultForm.provider === "aws_secrets_manager" ? (
|
||||
<AwsProviderVaultDiscoveryPanel
|
||||
form={vaultForm}
|
||||
preview={vaultDiscovery}
|
||||
error={vaultDiscoveryError}
|
||||
loading={discoverVaultMutation.isPending}
|
||||
onDiscover={() => {
|
||||
setVaultDiscovery(null);
|
||||
setVaultDiscoveryError(null);
|
||||
discoverVaultMutation.mutate();
|
||||
}}
|
||||
onApply={applyVaultDiscoveryCandidate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{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
|
||||
@@ -2002,6 +2071,162 @@ function ProviderVaultFields({
|
||||
);
|
||||
}
|
||||
|
||||
function AwsProviderVaultDiscoveryPanel({
|
||||
form,
|
||||
preview,
|
||||
error,
|
||||
loading,
|
||||
onDiscover,
|
||||
onApply,
|
||||
}: {
|
||||
form: ProviderVaultForm;
|
||||
preview: SecretProviderConfigDiscoveryPreviewResult | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
onDiscover: () => void;
|
||||
onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void;
|
||||
}) {
|
||||
const canDiscover = Boolean(form.region.trim());
|
||||
const warnings = preview?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t border-border pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">AWS discovery</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDiscover}
|
||||
disabled={!canDiscover || loading}
|
||||
data-testid="aws-vault-discovery-button"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
) : (
|
||||
<Search className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Find existing AWS values
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!canDiscover ? (
|
||||
<p className="text-xs text-muted-foreground">Enter an AWS region before discovery.</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Searching AWS Secrets Manager metadata
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-700 dark:text-amber-300">
|
||||
{warnings.map((warning) => (
|
||||
<div key={warning} className="flex gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preview && preview.candidates.length === 0 && !loading ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
No AWS vault metadata candidates found. Manual entry is still available.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preview && preview.candidates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "}
|
||||
{preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2" data-testid="aws-vault-discovery-candidates">
|
||||
{preview.candidates.map((candidate, index) => (
|
||||
<AwsProviderVaultDiscoveryCandidateRow
|
||||
key={`${candidate.displayName}-${index}`}
|
||||
candidate={candidate}
|
||||
onApply={() => onApply(candidate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AwsProviderVaultDiscoveryCandidateRow({
|
||||
candidate,
|
||||
onApply,
|
||||
}: {
|
||||
candidate: SecretProviderConfigDiscoveryCandidate;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const fieldSummary = [
|
||||
providerConfigValue(candidate.config, "region"),
|
||||
providerConfigValue(candidate.config, "namespace"),
|
||||
providerConfigValue(candidate.config, "secretNamePrefix"),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium leading-snug">{candidate.displayName}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"}
|
||||
</p>
|
||||
{candidate.samples[0] ? (
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{candidate.samples[0].name}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onApply}>
|
||||
Use values
|
||||
</Button>
|
||||
</div>
|
||||
{candidate.warnings.length > 0 ? (
|
||||
<div className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
{candidate.warnings.map((warning) => (
|
||||
<div key={warning} className="flex gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
|
||||
Reference in new issue
Block a user