diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index 3fc43b18738..fc46d4bc484 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -2,6 +2,8 @@ import type { CompanySecret, CompanySecretUsageBinding, CompanySecretProviderConfig, + RemoteSecretImportPreviewResult, + RemoteSecretImportResult, SecretAccessEvent, SecretManagedMode, SecretProvider, @@ -72,6 +74,26 @@ export interface UpdateSecretProviderConfigInput { config?: Record; } +export interface RemoteImportPreviewInput { + providerConfigId: string; + query?: string | null; + nextToken?: string | null; + pageSize?: number; +} + +export interface RemoteImportSelectionInput { + externalRef: string; + name?: string | null; + key?: string | null; + providerVersionRef?: string | null; + providerMetadata?: Record | null; +} + +export interface RemoteImportInput { + providerConfigId: string; + secrets: RemoteImportSelectionInput[]; +} + export const secretsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/secrets`), providers: (companyId: string) => @@ -105,4 +127,11 @@ export const secretsApi = { remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`), usage: (id: string) => api.get(`/secrets/${id}/usage`), accessEvents: (id: string) => api.get(`/secrets/${id}/access-events`), + remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) => + api.post( + `/companies/${companyId}/secrets/remote-import/preview`, + data, + ), + remoteImport: (companyId: string, data: RemoteImportInput) => + api.post(`/companies/${companyId}/secrets/remote-import`, data), }; diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 4a239967df2..ec290a45674 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -73,6 +73,7 @@ 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"; @@ -351,6 +352,7 @@ export function Secrets() { const [selectedSecretId, setSelectedSecretId] = useState(null); const [usageDialogSecretId, setUsageDialogSecretId] = useState(null); const [createOpen, setCreateOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [createMode, setCreateMode] = useState("managed"); const [createForm, setCreateForm] = useState({ name: "", @@ -793,7 +795,13 @@ export function Secrets() { onStatusChange={setStatusFilter} onProviderChange={setProviderFilter} /> - @@ -1060,6 +1068,23 @@ export function Secrets() { + {selectedCompanyId && ( + { + setImportOpen(false); + setActiveTab("vaults"); + }} + onImportComplete={() => { + void secretsQuery.refetch(); + }} + /> + )} + @@ -1665,6 +1690,55 @@ function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderC ); } +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 ( + + ); + } + + return ( + + ); +} + export function ProviderVaultsTab({ providers, providerConfigs, diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx new file mode 100644 index 00000000000..d5f54f72473 --- /dev/null +++ b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx @@ -0,0 +1,597 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { + CompanySecret, + CompanySecretProviderConfig, + RemoteSecretImportCandidate, + RemoteSecretImportPreviewResult, + RemoteSecretImportResult, +} from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSecretsApi = vi.hoisted(() => ({ + remoteImportPreview: vi.fn(), + remoteImport: vi.fn(), +})); + +const mockPushToast = vi.hoisted(() => vi.fn()); + +vi.mock("../../api/secrets", () => ({ + secretsApi: mockSecretsApi, +})); + +vi.mock("../../context/ToastContext", () => ({ + useToastActions: () => ({ + pushToast: mockPushToast, + dismissToast: vi.fn(), + clearToasts: vi.fn(), + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import { ImportFromVaultDialog } from "./ImportFromVaultDialog"; + +const awsVault: CompanySecretProviderConfig = { + id: "vault-aws", + companyId: "company-1", + provider: "aws_secrets_manager", + displayName: "AWS production", + status: "ready", + isDefault: true, + config: { region: "us-east-1" }, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function makeCandidate( + overrides: Partial = {}, +): RemoteSecretImportCandidate { + return { + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/foo-AbCdEf", + remoteName: "prod/foo", + name: "prod/foo", + key: "prod-foo", + providerVersionRef: null, + providerMetadata: { name: "prod/foo" }, + status: "ready", + importable: true, + conflicts: [], + ...overrides, + }; +} + +function makePreview( + candidates: RemoteSecretImportCandidate[], + nextToken: string | null = null, +): RemoteSecretImportPreviewResult { + return { + providerConfigId: awsVault.id, + provider: "aws_secrets_manager", + nextToken, + candidates, + }; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +async function flushDebounce() { + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 300)); + }); +} + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return { queryClient }; +} + +describe("ImportFromVaultDialog", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("loads candidates and selects rows, persisting through pagination", async () => { + mockSecretsApi.remoteImportPreview + .mockResolvedValueOnce( + makePreview( + [ + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC", + remoteName: "prod/stripe", + name: "prod/stripe", + key: "prod-stripe", + }), + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ", + remoteName: "prod/openai", + name: "prod/openai", + key: "prod-openai", + }), + ], + "page-2", + ), + ) + .mockResolvedValueOnce( + makePreview( + [ + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9", + remoteName: "prod/sendgrid", + name: "prod/sendgrid", + key: "prod-sendgrid", + }), + ], + null, + ), + ); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const tableBody = document.querySelector('[data-testid="vault-table-body"]'); + expect(tableBody).not.toBeNull(); + expect(document.body.textContent).toContain("prod/stripe"); + expect(document.body.textContent).toContain("prod/openai"); + + // Select stripe via row click + const stripeRow = document.querySelector( + '[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC"]', + ) as HTMLElement | null; + expect(stripeRow).not.toBeNull(); + await act(async () => { + stripeRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(document.body.textContent).toContain("1 selected"); + + // Load more page + const loadMore = document.querySelector('[data-testid="vault-load-more"]') as HTMLButtonElement | null; + expect(loadMore).not.toBeNull(); + await act(async () => { + loadMore!.click(); + }); + await flush(); + await flush(); + + expect(document.body.textContent).toContain("prod/sendgrid"); + // Selection persisted through pagination. + expect(document.body.textContent).toContain("1 selected"); + + await act(async () => { + root.unmount(); + }); + }); + + it("disables checkboxes for already-imported (duplicate) rows and shows a conflict badge for conflicts", async () => { + mockSecretsApi.remoteImportPreview.mockResolvedValueOnce( + makePreview([ + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9", + remoteName: "prod/sendgrid", + name: "prod/sendgrid", + key: "prod-sendgrid", + status: "duplicate", + importable: false, + conflicts: [ + { type: "exact_reference", message: "Already imported", existingSecretId: "secret-sg" }, + ], + }), + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ", + remoteName: "prod/openai", + name: "prod/openai", + key: "prod-openai", + status: "conflict", + importable: true, + conflicts: [ + { type: "name", message: "Name already in use" }, + ], + }), + ]), + ); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const duplicateRow = document.querySelector( + '[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9"]', + ); + expect(duplicateRow?.getAttribute("data-row-state")).toBe("duplicate"); + const duplicateCheckbox = duplicateRow?.querySelector( + 'button[role="checkbox"]', + ) as HTMLButtonElement | null; + expect(duplicateCheckbox?.getAttribute("data-disabled")).not.toBeNull(); + + expect(document.body.textContent).toContain("Conflict"); + expect(document.body.textContent).toContain("Name already in use"); + + await act(async () => { + root.unmount(); + }); + }); + + it("blocks import when a review row collides with an existing Paperclip secret", async () => { + const conflictCandidate = makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ", + remoteName: "prod/openai", + name: "OPENAI_API_KEY", + key: "openai_api_key", + status: "conflict", + conflicts: [{ type: "key", message: "Key already in use" }], + }); + mockSecretsApi.remoteImportPreview.mockResolvedValueOnce( + makePreview([conflictCandidate]), + ); + + const existing: CompanySecret[] = [ + { + id: "secret-existing", + companyId: "company-1", + key: "openai_api_key", + name: "OPENAI_API_KEY", + provider: "aws_secrets_manager", + status: "active", + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:other-XYZ", + providerConfigId: awsVault.id, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + // Select the conflict row + const row = document.querySelector( + '[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ"]', + ) as HTMLElement | null; + await act(async () => { + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + // Click "Continue → Review" button. + const continueBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.includes("Continue"), + ); + expect(continueBtn).toBeTruthy(); + await act(async () => { + continueBtn!.click(); + }); + await flush(); + + // Review step: error message visible, Import button disabled. + expect(document.body.textContent?.toLowerCase()).toContain("a paperclip secret already uses this"); + + const importBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.startsWith("Import "), + ) as HTMLButtonElement | undefined; + expect(importBtn).toBeTruthy(); + expect(importBtn?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + }); + }); + + it("renders mixed import results (created/skipped/failed) and shows error reason", async () => { + mockSecretsApi.remoteImportPreview.mockResolvedValueOnce( + makePreview([ + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA", + remoteName: "alpha", + name: "alpha", + key: "alpha", + }), + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB", + remoteName: "beta", + name: "beta", + key: "beta", + }), + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC", + remoteName: "gamma", + name: "gamma", + key: "gamma", + }), + ]), + ); + + const result: RemoteSecretImportResult = { + providerConfigId: awsVault.id, + provider: "aws_secrets_manager", + importedCount: 1, + skippedCount: 1, + errorCount: 1, + results: [ + { + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA", + name: "alpha", + key: "alpha", + status: "imported", + reason: null, + secretId: "secret-alpha", + conflicts: [], + }, + { + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB", + name: "beta", + key: "beta", + status: "skipped", + reason: "exact reference already imported", + secretId: null, + conflicts: [ + { type: "exact_reference", message: "exact reference already imported" }, + ], + }, + { + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC", + name: "gamma", + key: "gamma", + status: "error", + reason: "AccessDeniedException for arn:aws:…/c", + secretId: null, + conflicts: [], + }, + ], + }; + mockSecretsApi.remoteImport.mockResolvedValueOnce(result); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + // Select all loaded + const headerCheckbox = document.querySelector( + '[data-testid="vault-table-body"]', + )?.parentElement?.querySelector('thead button[role="checkbox"]') as HTMLButtonElement | null; + expect(headerCheckbox).toBeTruthy(); + await act(async () => { + headerCheckbox!.click(); + }); + await flush(); + + // Continue + const continueBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.includes("Continue"), + ); + await act(async () => { + continueBtn!.click(); + }); + await flush(); + + // Import + const importBtn = Array.from(document.querySelectorAll("button")).find( + (btn) => btn.textContent?.startsWith("Import "), + ) as HTMLButtonElement | undefined; + expect(importBtn).toBeTruthy(); + await act(async () => { + importBtn!.click(); + }); + await flush(); + await flush(); + + expect(mockSecretsApi.remoteImport).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Import complete"); + expect(document.body.textContent).toContain("1 created"); + expect(document.body.textContent).toContain("1 skipped"); + expect(document.body.textContent).toContain("1 failed"); + expect(document.body.textContent).toContain("AccessDeniedException"); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows an empty state when no AWS vault is configured", async () => { + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + + expect(document.querySelector('[data-testid="select-empty-vaults"]')).not.toBeNull(); + expect(mockSecretsApi.remoteImportPreview).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows a permission-error banner when AWS denies ListSecrets", async () => { + const error = Object.assign(new Error("AccessDeniedException"), { + name: "ApiError", + status: 403, + body: null, + }); + mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(error); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const banner = document.querySelector('[data-testid="preview-error-banner"]'); + expect(banner).not.toBeNull(); + expect(banner?.textContent).toContain("Could not load remote secrets"); + + await act(async () => { + root.unmount(); + }); + }); + + it("debounces search and uses the new query for the next preview", async () => { + mockSecretsApi.remoteImportPreview + .mockResolvedValueOnce(makePreview([makeCandidate()])) + .mockResolvedValueOnce(makePreview([ + makeCandidate({ + externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:stripe-XYZ", + remoteName: "stripe", + name: "stripe", + key: "stripe", + }), + ])); + + const { queryClient } = makeWrapper(); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const search = document.querySelector('[data-testid="vault-search"]') as HTMLInputElement; + expect(search).not.toBeNull(); + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + search.focus(); + valueSetter?.call(search, "stripe"); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushDebounce(); + await flush(); + + expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledTimes(2); + const lastCall = mockSecretsApi.remoteImportPreview.mock.calls.at(-1); + expect(lastCall?.[1]).toMatchObject({ query: "stripe" }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.tsx new file mode 100644 index 00000000000..e9fe67f3c13 --- /dev/null +++ b/ui/src/pages/secrets/ImportFromVaultDialog.tsx @@ -0,0 +1,1461 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + CheckCircle2, + Cloud, + Database, + ExternalLink, + Info, + Link2, + Loader2, + RefreshCw, + Search, + X, + XCircle, +} from "lucide-react"; +import type { + CompanySecret, + CompanySecretProviderConfig, + RemoteSecretImportCandidate, + RemoteSecretImportPreviewResult, + RemoteSecretImportResult, + RemoteSecretImportRowResult, +} from "@paperclipai/shared"; +import { ApiError } from "../../api/client"; +import { + secretsApi, + type RemoteImportInput, + type RemoteImportSelectionInput, +} from "../../api/secrets"; +import { useToastActions } from "../../context/ToastContext"; +import { queryKeys } from "../../lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { EmptyState } from "../../components/EmptyState"; +import { cn } from "../../lib/utils"; + +type Step = "select" | "review" | "result"; + +interface ImportFromVaultDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + companyId: string; + providerConfigs: CompanySecretProviderConfig[]; + existingSecrets: CompanySecret[]; + onImportComplete?: (result: RemoteSecretImportResult) => void; + onManageVaults?: () => void; +} + +interface DraftSelection { + candidate: RemoteSecretImportCandidate; + name: string; + key: string; + description: string; +} + +const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/; +const PAGE_SIZE = 50; + +function isAwsSelectable(config: CompanySecretProviderConfig) { + if (config.provider !== "aws_secrets_manager") return false; + return config.status === "ready" || config.status === "warning"; +} + +function eligibleVaults(configs: CompanySecretProviderConfig[]): CompanySecretProviderConfig[] { + return configs.filter(isAwsSelectable); +} + +function pickDefaultVault(configs: CompanySecretProviderConfig[]): string | null { + const eligible = eligibleVaults(configs); + if (eligible.length === 0) return null; + return (eligible.find((vault) => vault.isDefault) ?? eligible[0]).id; +} + +function awsVaultOptions(configs: CompanySecretProviderConfig[]): CompanySecretProviderConfig[] { + return configs.filter((vault) => vault.provider === "aws_secrets_manager"); +} + +function statusToneClasses(status: RemoteSecretImportCandidate["status"]) { + switch (status) { + case "duplicate": + return "text-muted-foreground border-border/60"; + case "conflict": + return "text-amber-600 border-amber-500/40 dark:text-amber-400"; + case "ready": + default: + return "text-emerald-600 border-emerald-500/40 dark:text-emerald-400"; + } +} + +function statusBadgeLabel(status: RemoteSecretImportCandidate["status"]) { + switch (status) { + case "duplicate": + return "Imported"; + case "conflict": + return "Conflict"; + case "ready": + default: + return "Ready"; + } +} + +function StatusBadge({ + status, +}: { + status: RemoteSecretImportCandidate["status"]; +}) { + const Icon = + status === "conflict" + ? AlertTriangle + : status === "duplicate" + ? Link2 + : CheckCircle2; + return ( + + + {statusBadgeLabel(status)} + + ); +} + +function RowResultBadge({ status }: { status: RemoteSecretImportRowResult["status"] }) { + switch (status) { + case "imported": + return ( + + Created + + ); + case "skipped": + return ( + + Skipped + + ); + case "error": + default: + return ( + + Failed + + ); + } +} + +function middleTruncate(value: string, max = 60) { + if (value.length <= max) return value; + const head = Math.floor((max - 1) / 2); + const tail = max - 1 - head; + return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; +} + +function formatRelativeShort(value: string | null | undefined): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + const diff = Date.now() - date.getTime(); + if (diff < 0) return date.toLocaleDateString(); + 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 readableErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.message || `Request failed: ${error.status}`; + } + if (error instanceof Error) return error.message; + return "Unexpected error"; +} + +function isPermissionError(error: unknown): boolean { + if (!(error instanceof ApiError)) return false; + if (error.status === 401 || error.status === 403) return true; + const message = error.message.toLowerCase(); + return ( + message.includes("accessdenied") || + message.includes("access denied") || + message.includes("not authorized") + ); +} + +function isThrottlingError(error: unknown): boolean { + if (!(error instanceof ApiError)) return false; + const message = error.message.toLowerCase(); + return message.includes("throttl") || message.includes("toomanyrequests"); +} + +function buildDraft(candidate: RemoteSecretImportCandidate): DraftSelection { + const meta = candidate.providerMetadata ?? {}; + const description = + typeof (meta as Record).description === "string" + ? ((meta as Record).description as string) + : ""; + return { + candidate, + name: candidate.name, + key: candidate.key, + description, + }; +} + +function validateDraftRow( + draft: DraftSelection, + existing: CompanySecret[], + otherDrafts: DraftSelection[], +): string | null { + if (!draft.name.trim()) return "Name is required."; + if (draft.name.length > 160) return "Name must be 160 characters or fewer."; + if (!draft.key.trim()) return "Key is required."; + if (!KEY_PATTERN.test(draft.key)) { + return "Key may only contain letters, numbers, dot, underscore, or hyphen."; + } + if (draft.key.length > 120) return "Key must be 120 characters or fewer."; + if (draft.description.length > 500) return "Description must be 500 characters or fewer."; + + const lowerName = draft.name.trim().toLowerCase(); + const lowerKey = draft.key.trim().toLowerCase(); + + for (const existingSecret of existing) { + if (existingSecret.name.trim().toLowerCase() === lowerName) { + return "A Paperclip secret already uses this name."; + } + if (existingSecret.key.trim().toLowerCase() === lowerKey) { + return "A Paperclip secret already uses this key."; + } + } + + for (const other of otherDrafts) { + if (other === draft) continue; + if (other.name.trim().toLowerCase() === lowerName) { + return "Another row in this batch already uses this name."; + } + if (other.key.trim().toLowerCase() === lowerKey) { + return "Another row in this batch already uses this key."; + } + } + + return null; +} + +function normalizeDraftKey(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function useDebounced(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(handle); + }, [value, delayMs]); + return debounced; +} + +interface PreviewState { + candidates: RemoteSecretImportCandidate[]; + nextToken: string | null; +} + +const EMPTY_PREVIEW: PreviewState = { candidates: [], nextToken: null }; + +export function ImportFromVaultDialog({ + open, + onOpenChange, + companyId, + providerConfigs, + existingSecrets, + onImportComplete, + onManageVaults, +}: ImportFromVaultDialogProps) { + const queryClient = useQueryClient(); + const toast = useToastActions(); + const awsVaults = useMemo(() => awsVaultOptions(providerConfigs), [providerConfigs]); + const eligible = useMemo(() => eligibleVaults(providerConfigs), [providerConfigs]); + const noEligibleVaults = eligible.length === 0; + + const [step, setStep] = useState("select"); + const [vaultId, setVaultId] = useState(null); + const [searchInput, setSearchInput] = useState(""); + const debouncedQuery = useDebounced(searchInput.trim(), 250); + + const [preview, setPreview] = useState(EMPTY_PREVIEW); + const [previewLoading, setPreviewLoading] = useState(false); + const [pageLoading, setPageLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [showOnlySelected, setShowOnlySelected] = useState(false); + + const [selection, setSelection] = useState>(new Map()); + const [importResult, setImportResult] = useState(null); + + // Reset state on open transition. + useEffect(() => { + if (!open) return; + setStep("select"); + setSearchInput(""); + setPreview(EMPTY_PREVIEW); + setPreviewError(null); + setSelection(new Map()); + setImportResult(null); + setShowOnlySelected(false); + const next = pickDefaultVault(providerConfigs); + setVaultId(next); + // We deliberately depend only on open so that re-opens reset the dialog; + // providerConfigs changes during a session are handled by next preview fetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const requestIdRef = useRef(0); + + // Run preview when vault or query changes (only on step "select"). + useEffect(() => { + if (!open || step !== "select" || !vaultId) return; + let cancelled = false; + const requestId = ++requestIdRef.current; + setPreviewLoading(true); + setPreviewError(null); + setPreview(EMPTY_PREVIEW); + secretsApi + .remoteImportPreview(companyId, { + providerConfigId: vaultId, + query: debouncedQuery || null, + nextToken: null, + pageSize: PAGE_SIZE, + }) + .then((result: RemoteSecretImportPreviewResult) => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreview({ + candidates: result.candidates, + nextToken: result.nextToken, + }); + }) + .catch((error) => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreviewError(error); + }) + .finally(() => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, step, vaultId, debouncedQuery, companyId]); + + // When the vault changes, drop any selection (they're scoped to a vault). + useEffect(() => { + setSelection(new Map()); + setShowOnlySelected(false); + }, [vaultId]); + + const visibleCandidates = useMemo(() => { + if (!showOnlySelected) return preview.candidates; + return preview.candidates.filter((candidate) => selection.has(candidate.externalRef)); + }, [preview.candidates, selection, showOnlySelected]); + + const selectableInLoaded = useMemo( + () => preview.candidates.filter((c) => c.importable), + [preview.candidates], + ); + + const selectableLoadedCount = selectableInLoaded.length; + const selectedLoadedCount = selectableInLoaded.filter((c) => + selection.has(c.externalRef), + ).length; + + const headerCheckboxState: boolean | "indeterminate" = + selectableLoadedCount === 0 + ? false + : selectedLoadedCount === 0 + ? false + : selectedLoadedCount === selectableLoadedCount + ? true + : "indeterminate"; + + const totalSelected = selection.size; + const selectedNotVisible = useMemo(() => { + if (!debouncedQuery) return 0; + let count = 0; + for (const ref of selection.keys()) { + if (!preview.candidates.some((c) => c.externalRef === ref)) count += 1; + } + return count; + }, [selection, preview.candidates, debouncedQuery]); + + const draftList = useMemo(() => Array.from(selection.values()), [selection]); + + const reviewErrors = useMemo>(() => { + const errors = new Map(); + for (const draft of draftList) { + const error = validateDraftRow(draft, existingSecrets, draftList); + if (error) errors.set(draft.candidate.externalRef, error); + } + return errors; + }, [draftList, existingSecrets]); + + const blockedReviewCount = reviewErrors.size; + const readyReviewCount = draftList.length - blockedReviewCount; + + const importMutation = useMutation({ + mutationFn: (input: RemoteImportInput) => secretsApi.remoteImport(companyId, input), + onSuccess: (result) => { + setImportResult(result); + setStep("result"); + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(companyId) }); + onImportComplete?.(result); + const vaultName = + awsVaults.find((vault) => vault.id === vaultId)?.displayName ?? "AWS"; + if (result.errorCount === draftList.length && result.errorCount > 0) { + toast.pushToast({ + title: "Import failed", + body: `No secrets were imported from ${vaultName}.`, + tone: "error", + }); + } else { + toast.pushToast({ + title: result.errorCount > 0 ? "Import completed with errors" : "Import complete", + body: `${result.importedCount} created · ${result.skippedCount} skipped · ${result.errorCount} failed`, + tone: result.errorCount > 0 ? "warn" : "success", + }); + } + }, + onError: (error) => { + toast.pushToast({ + title: "Import failed", + body: readableErrorMessage(error), + tone: "error", + }); + }, + }); + + function handleVaultChange(nextId: string) { + setVaultId(nextId); + setSearchInput(""); + } + + function handleRefresh() { + if (!vaultId || step !== "select") return; + let cancelled = false; + const requestId = ++requestIdRef.current; + setPreviewLoading(true); + setPreviewError(null); + secretsApi + .remoteImportPreview(companyId, { + providerConfigId: vaultId, + query: debouncedQuery || null, + nextToken: null, + pageSize: PAGE_SIZE, + }) + .then((result) => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreview({ candidates: result.candidates, nextToken: result.nextToken }); + }) + .catch((error) => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreviewError(error); + }) + .finally(() => { + if (cancelled || requestId !== requestIdRef.current) return; + setPreviewLoading(false); + }); + } + + function handleLoadMore() { + if (!vaultId || !preview.nextToken || pageLoading) return; + setPageLoading(true); + secretsApi + .remoteImportPreview(companyId, { + providerConfigId: vaultId, + query: debouncedQuery || null, + nextToken: preview.nextToken, + pageSize: PAGE_SIZE, + }) + .then((result) => { + setPreview((prev) => { + const seen = new Set(prev.candidates.map((c) => c.externalRef)); + const merged = [...prev.candidates]; + for (const candidate of result.candidates) { + if (!seen.has(candidate.externalRef)) merged.push(candidate); + } + return { candidates: merged, nextToken: result.nextToken }; + }); + }) + .catch((error) => { + toast.pushToast({ + title: "Could not load more results", + body: readableErrorMessage(error), + tone: "error", + }); + }) + .finally(() => setPageLoading(false)); + } + + function toggleRow(candidate: RemoteSecretImportCandidate) { + if (!candidate.importable) return; + setSelection((prev) => { + const next = new Map(prev); + if (next.has(candidate.externalRef)) { + next.delete(candidate.externalRef); + } else { + next.set(candidate.externalRef, buildDraft(candidate)); + } + return next; + }); + } + + function toggleAllLoaded() { + setSelection((prev) => { + const next = new Map(prev); + const allSelected = selectableInLoaded.every((c) => next.has(c.externalRef)); + if (allSelected) { + for (const candidate of selectableInLoaded) { + next.delete(candidate.externalRef); + } + } else { + for (const candidate of selectableInLoaded) { + if (!next.has(candidate.externalRef)) { + next.set(candidate.externalRef, buildDraft(candidate)); + } + } + } + return next; + }); + } + + function updateDraft(externalRef: string, patch: Partial) { + setSelection((prev) => { + const next = new Map(prev); + const existing = next.get(externalRef); + if (!existing) return prev; + next.set(externalRef, { ...existing, ...patch }); + return next; + }); + } + + function removeDraft(externalRef: string) { + setSelection((prev) => { + const next = new Map(prev); + next.delete(externalRef); + return next; + }); + } + + function handleClose(force = false) { + if (importMutation.isPending) return; + if (!force && step !== "result" && selection.size > 0 && !importResult) { + const ok = window.confirm( + `Discard ${selection.size} pending import${selection.size === 1 ? "" : "s"}?`, + ); + if (!ok) return; + } + onOpenChange(false); + } + + function handleSubmitImport() { + if (!vaultId || importMutation.isPending) return; + if (blockedReviewCount > 0) return; + if (draftList.length === 0) return; + const items: RemoteImportSelectionInput[] = draftList.map((draft) => ({ + externalRef: draft.candidate.externalRef, + name: draft.name.trim(), + key: draft.key.trim(), + providerVersionRef: draft.candidate.providerVersionRef, + providerMetadata: draft.candidate.providerMetadata, + })); + importMutation.mutate({ providerConfigId: vaultId, secrets: items }); + } + + return ( + { + if (next) { + onOpenChange(true); + } else { + handleClose(); + } + }} + > + +
+
+ + Import from AWS Secrets Manager + + + Bring AWS-managed secrets into Paperclip as external references. + + +
+ +
+ +
+ {step === "select" && ( + + )} + {step === "review" && ( + + )} + {step === "result" && importResult && ( + + )} +
+ +
+ +
+ {step !== "result" && ( + + )} + {step === "review" && ( + + )} + {step === "select" && ( + + )} + {step === "review" && ( + + )} + {step === "result" && ( + + )} +
+
+
+
+ ); +} + +function Stepper({ step }: { step: Step }) { + const steps: { id: Step; label: string }[] = [ + { id: "select", label: "Select" }, + { id: "review", label: "Review" }, + { id: "result", label: "Result" }, + ]; + const activeIndex = steps.findIndex((s) => s.id === step); + return ( +
+ {steps.map((s, index) => ( + + + {index + 1} + + + {s.label} + + {index < steps.length - 1 && ( + › + )} + + ))} +
+ ); +} + +interface SelectStepProps { + awsVaults: CompanySecretProviderConfig[]; + eligible: CompanySecretProviderConfig[]; + vaultId: string | null; + onVaultChange: (id: string) => void; + searchInput: string; + onSearchInput: (value: string) => void; + debouncedQuery: string; + onRefresh: () => void; + previewLoading: boolean; + pageLoading: boolean; + previewError: unknown; + candidates: RemoteSecretImportCandidate[]; + visibleCandidates: RemoteSecretImportCandidate[]; + selectableInLoaded: RemoteSecretImportCandidate[]; + selection: Map; + toggleRow: (candidate: RemoteSecretImportCandidate) => void; + toggleAllLoaded: () => void; + headerCheckboxState: boolean | "indeterminate"; + hasNextPage: boolean; + onLoadMore: () => void; + showOnlySelected: boolean; + onShowOnlySelectedChange: (value: boolean) => void; + selectedNotVisible: number; + noEligibleVaults: boolean; + onManageVaults?: () => void; +} + +function SelectStep(props: SelectStepProps) { + const { + awsVaults, + eligible, + vaultId, + onVaultChange, + searchInput, + onSearchInput, + debouncedQuery, + onRefresh, + previewLoading, + pageLoading, + previewError, + candidates, + visibleCandidates, + selectableInLoaded, + selection, + toggleRow, + toggleAllLoaded, + headerCheckboxState, + hasNextPage, + onLoadMore, + showOnlySelected, + onShowOnlySelectedChange, + selectedNotVisible, + noEligibleVaults, + onManageVaults, + } = props; + + if (noEligibleVaults) { + return ( +
+ +
+ ); + } + + const showSearchSpinner = previewLoading && Boolean(debouncedQuery); + + return ( +
+
+ + {awsVaults.length === 1 && eligible.length === 1 ? ( + + {eligible[0].displayName} + + ) : ( + + )} + +
+ + onSearchInput(event.target.value)} + placeholder="Search by name, ARN, tag" + className="pl-7 pr-7 text-xs" + aria-label="Search remote secrets" + data-testid="vault-search" + /> + {showSearchSpinner && ( + + )} +
+ + +
+ + {selectedNotVisible > 0 && ( +
+ + {selection.size} selected · {selectedNotVisible} not visible with current search + + +
+ )} + +
+ {previewError ? ( + + ) : previewLoading && candidates.length === 0 ? ( + + ) : candidates.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {visibleCandidates.map((candidate) => { + const isSelected = selection.has(candidate.externalRef); + const meta = (candidate.providerMetadata ?? {}) as Record; + const lastChanged = + typeof meta.lastChangedAt === "string" + ? meta.lastChangedAt + : typeof meta.lastChangedDate === "string" + ? meta.lastChangedDate + : null; + const description = + typeof meta.description === "string" ? meta.description : null; + return ( + toggleRow(candidate)} + data-testid={`vault-row-${candidate.externalRef}`} + data-row-state={candidate.status} + > + + + + + + + + ); + })} + {pageLoading && ( + + + + )} + +
+ toggleAllLoaded()} + aria-label={`Select all loaded (${selectableInLoaded.length})`} + disabled={selectableInLoaded.length === 0} + /> + Remote nameReferenceLast changedSuggested nameState
e.stopPropagation()}> + toggleRow(candidate)} + disabled={!candidate.importable} + aria-label={`Select ${candidate.remoteName}`} + /> + +
{candidate.remoteName}
+ {description && ( +
+ {description} +
+ )} +
+ + {middleTruncate(candidate.externalRef, 50)} + + + {formatRelativeShort(lastChanged)} + {candidate.key} +
+ + {candidate.status === "duplicate" && + candidate.conflicts.find((c) => c.type === "exact_reference")?.existingSecretId && ( + + Already imported + + )} +
+ {candidate.status === "conflict" && candidate.conflicts.length > 0 && ( +
+ {candidate.conflicts[0].message} +
+ )} +
+ +
+ )} + + {hasNextPage && !previewError && ( +
+ + {candidates.length} loaded + {selectableInLoaded.length > 0 && ( + · {selectableInLoaded.length} selectable + )} + + +
+ )} +
+
+ ); +} + +function PreviewErrorBanner({ error, onRetry }: { error: unknown; onRetry: () => void }) { + const isPermission = isPermissionError(error); + const isThrottling = isThrottlingError(error); + const message = readableErrorMessage(error); + return ( +
+ +
+
+ {isPermission + ? "AWS denied list access" + : isThrottling + ? "AWS throttled the listing request" + : "Could not load remote secrets"} +
+
+ {isPermission + ? "The AWS principal behind this vault is missing secretsmanager:ListSecrets. Update IAM and try again." + : message} +
+
+ + {isPermission && ( + + IAM reference + + )} +
+
+
+ ); +} + +function SkeletonRows({ rows }: { rows: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, idx) => ( + + ))} +
+ ); +} + +function EmptyCandidates({ query }: { query: string }) { + if (query) { + return ( + + ); + } + return ( + + ); +} + +interface ReviewStepProps { + drafts: DraftSelection[]; + reviewErrors: Map; + updateDraft: (externalRef: string, patch: Partial) => void; + removeDraft: (externalRef: string) => void; + importing: boolean; +} + +function ReviewStep({ drafts, reviewErrors, updateDraft, removeDraft, importing }: ReviewStepProps) { + if (drafts.length === 0) { + return ( +
+ +
+ ); + } + + const blocked = reviewErrors.size; + const ready = drafts.length - blocked; + + return ( +
+
+ {ready} secrets ready to import + {blocked > 0 && ( + + {blocked} need attention before import + + )} +
+
+ {drafts.map((draft) => { + const error = reviewErrors.get(draft.candidate.externalRef); + return ( +
+
+
+
+ {draft.candidate.remoteName} + + {middleTruncate(draft.candidate.externalRef, 60)} + +
+
+ + + +
+ {error && ( +
+ + {error} +
+ )} +
+ +
+
+ ); + })} +
+
+ ); +} + +interface ResultStepProps { + result: RemoteSecretImportResult; + draftList: DraftSelection[]; +} + +function ResultStep({ result, draftList }: ResultStepProps) { + const grouped = useMemo(() => { + const created: RemoteSecretImportRowResult[] = []; + const skipped: RemoteSecretImportRowResult[] = []; + const failed: RemoteSecretImportRowResult[] = []; + for (const row of result.results) { + if (row.status === "imported") created.push(row); + else if (row.status === "skipped") skipped.push(row); + else failed.push(row); + } + return { created, skipped, failed }; + }, [result]); + + const draftLookup = useMemo(() => { + const map = new Map(); + for (const draft of draftList) map.set(draft.candidate.externalRef, draft); + return map; + }, [draftList]); + + const heading = + result.errorCount === result.results.length && result.errorCount > 0 + ? "Import failed" + : result.errorCount === 0 && result.skippedCount === 0 + ? `All ${result.importedCount} secrets imported` + : "Import complete"; + + return ( +
+
+

{heading}

+
+ ✓ {result.importedCount} created + ⊘ {result.skippedCount} skipped + ⨯ {result.errorCount} failed +
+
+
+ {grouped.created.length > 0 && ( + + )} + {grouped.skipped.length > 0 && ( + + )} + {grouped.failed.length > 0 && ( + + )} +
+
+ ); +} + +function ResultGroup({ + label, + rows, + draftLookup, +}: { + label: string; + rows: RemoteSecretImportRowResult[]; + draftLookup: Map; +}) { + return ( +
+
+ {label} · {rows.length} +
+
    + {rows.map((row) => { + const draft = draftLookup.get(row.externalRef); + const remoteName = draft?.candidate.remoteName ?? row.name; + return ( +
  • + + {row.name} + {row.key} + + {middleTruncate(row.externalRef, 40)} + + + {row.status === "imported" && row.secretId && ( + {remoteName} + )} + {row.reason && ( + + {row.reason} + + )} + +
  • + ); + })} +
+
+ ); +} + +interface FooterStatusProps { + step: Step; + totalSelected: number; + readyReviewCount: number; + blockedReviewCount: number; + result: RemoteSecretImportResult | null; +} + +function FooterStatus({ + step, + totalSelected, + readyReviewCount, + blockedReviewCount, + result, +}: FooterStatusProps) { + if (step === "select") { + return ( +
+ {totalSelected === 0 + ? "Select remote secrets to import" + : `${totalSelected} selected`} +
+ ); + } + if (step === "review") { + return ( +
+ {readyReviewCount} ready + {blockedReviewCount > 0 && ( + + · {blockedReviewCount} blocked + + )} +
+ ); + } + if (result) { + return ( +
+ {result.importedCount} created + {result.skippedCount} skipped + {result.errorCount} failed +
+ ); + } + return null; +} +