Add AWS remote secret import dialog

Wires the frontend half of [PAP-9023](/PAP/issues/PAP-9023). New
`ImportFromVaultDialog` component (Select → Review → Result) talks to the
preview/import routes shipped in PAP-9022, surfaces row-level state from the
backend (ready/duplicate/conflict), keeps selection persistent across
pagination and search, and reports per-row import outcomes. Entry point lives
beside `New secret` on the Secrets tab and only shows when an AWS vault is
configured.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent ae538db38f
commit 3caf4e604a
4 files changed
+2162 -1

No files matched your search

+29
View File
@@ -2,6 +2,8 @@ import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
@@ -72,6 +74,26 @@ export interface UpdateSecretProviderConfigInput {
config?: Record<string, unknown>;
}
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<string, unknown> | null;
}
export interface RemoteImportInput {
providerConfigId: string;
secrets: RemoteImportSelectionInput[];
}
export const secretsApi = {
list: (companyId: string) => api.get<CompanySecret[]>(`/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<SecretUsageResponse>(`/secrets/${id}/usage`),
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
api.post<RemoteSecretImportPreviewResult>(
`/companies/${companyId}/secrets/remote-import/preview`,
data,
),
remoteImport: (companyId: string, data: RemoteImportInput) =>
api.post<RemoteSecretImportResult>(`/companies/${companyId}/secrets/remote-import`, data),
};
+75 -1
View File
@@ -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<string | null>(null);
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [createMode, setCreateMode] = useState<CreateMode>("managed");
const [createForm, setCreateForm] = useState({
name: "",
@@ -793,7 +795,13 @@ export function Secrets() {
onStatusChange={setStatusFilter}
onProviderChange={setProviderFilter}
/>
<Button onClick={() => setCreateOpen(true)} size="sm" className="ml-auto">
<ImportFromVaultButton
providerConfigs={providerConfigs}
onClick={() => setImportOpen(true)}
onManageVaults={() => setActiveTab("vaults")}
className="ml-auto"
/>
<Button onClick={() => setCreateOpen(true)} size="sm">
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
</Button>
</div>
@@ -1060,6 +1068,23 @@ export function Secrets() {
</DialogContent>
</Dialog>
{selectedCompanyId && (
<ImportFromVaultDialog
open={importOpen}
onOpenChange={setImportOpen}
companyId={selectedCompanyId}
providerConfigs={providerConfigs}
existingSecrets={secrets}
onManageVaults={() => {
setImportOpen(false);
setActiveTab("vaults");
}}
onImportComplete={() => {
void secretsQuery.refetch();
}}
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -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 (
<Button
variant="ghost"
size="sm"
onClick={onManageVaults}
className={cn("text-xs text-muted-foreground", className)}
title="Configure an AWS provider vault to enable remote import"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> AWS vault disabled — manage
</Button>
);
}
return (
<Button
variant="outline"
size="sm"
onClick={onClick}
className={className}
data-testid="import-from-vault-button"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> Import from vault
</Button>
);
}
export function ProviderVaultsTab({
providers,
providerConfigs,
@@ -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> = {},
): 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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={existing}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[]}
existingSecrets={[]}
onManageVaults={vi.fn()}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
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();
});
});
});
File diff suppressed because it is too large. Load diff