mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Preselect vault when importing secrets from a specific vault
Adds an Import secrets action to each provider vault row that opens the ImportFromVaultDialog pre-scoped to that vault via initialProviderConfigId, and resets the preselection when the dialog closes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
bac15ebd09
commit
df532d85f0
3 files changed
+117
-4
No files matched your search
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanySecretProviderConfig,
|
||||
RemoteSecretImportPreviewResult,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
SecretProviderDescriptor,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -25,6 +26,8 @@ const mockSecretsApi = vi.hoisted(() => ({
|
||||
removeProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
remoteImportPreview: vi.fn(),
|
||||
remoteImport: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
@@ -195,6 +198,18 @@ function makeDiscoveryPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function makeRemoteImportPreview(
|
||||
overrides: Partial<RemoteSecretImportPreviewResult> = {},
|
||||
): RemoteSecretImportPreviewResult {
|
||||
return {
|
||||
providerConfigId: "vault-aws",
|
||||
provider: "aws_secrets_manager",
|
||||
nextToken: null,
|
||||
candidates: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
@@ -242,6 +257,7 @@ describe("Secrets page layout", () => {
|
||||
});
|
||||
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
|
||||
mockSecretsApi.remoteImportPreview.mockResolvedValue(makeRemoteImportPreview());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -292,6 +308,7 @@ describe("Secrets page layout", () => {
|
||||
onRemove={vi.fn()}
|
||||
onSetDefault={vi.fn()}
|
||||
onHealthCheck={vi.fn()}
|
||||
onImportSecrets={vi.fn()}
|
||||
pendingActionId={null}
|
||||
/>,
|
||||
);
|
||||
@@ -308,6 +325,58 @@ describe("Secrets page layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes existing AWS secrets from a provider vault card", async () => {
|
||||
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();
|
||||
|
||||
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 refreshButton = document.querySelector(
|
||||
'[data-testid="provider-vault-refresh-secrets-vault-aws"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(refreshButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
refreshButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Import from AWS Secrets Manager");
|
||||
expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledWith("company-1", {
|
||||
providerConfigId: "vault-aws",
|
||||
query: null,
|
||||
nextToken: null,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("warns that removing a provider vault only removes Paperclip config", async () => {
|
||||
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -406,6 +406,7 @@ export function Secrets() {
|
||||
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importInitialVaultId, setImportInitialVaultId] = useState<string | null>(null);
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("managed");
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
@@ -844,6 +845,11 @@ export function Secrets() {
|
||||
setVaultDialogOpen(true);
|
||||
}
|
||||
|
||||
function openImportFromVault(config?: CompanySecretProviderConfig | null) {
|
||||
setImportInitialVaultId(config?.id ?? null);
|
||||
setImportOpen(true);
|
||||
}
|
||||
|
||||
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
|
||||
if (candidate.provider !== "aws_secrets_manager") return;
|
||||
const config = candidate.config as Record<string, unknown>;
|
||||
@@ -911,7 +917,7 @@ export function Secrets() {
|
||||
/>
|
||||
<ImportFromVaultButton
|
||||
providerConfigs={providerConfigs}
|
||||
onClick={() => setImportOpen(true)}
|
||||
onClick={() => openImportFromVault()}
|
||||
onManageVaults={() => setActiveTab("vaults")}
|
||||
className="ml-auto"
|
||||
/>
|
||||
@@ -1040,6 +1046,7 @@ export function Secrets() {
|
||||
onRemove={(config) => setRemoveVaultConfirm(config)}
|
||||
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
|
||||
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
|
||||
onImportSecrets={openImportFromVault}
|
||||
pendingActionId={
|
||||
disableVaultMutation.variables ??
|
||||
removeVaultMutation.variables ??
|
||||
@@ -1187,12 +1194,17 @@ export function Secrets() {
|
||||
{selectedCompanyId && (
|
||||
<ImportFromVaultDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onOpenChange={(open) => {
|
||||
setImportOpen(open);
|
||||
if (!open) setImportInitialVaultId(null);
|
||||
}}
|
||||
companyId={selectedCompanyId}
|
||||
providerConfigs={providerConfigs}
|
||||
existingSecrets={secrets}
|
||||
initialProviderConfigId={importInitialVaultId}
|
||||
onManageVaults={() => {
|
||||
setImportOpen(false);
|
||||
setImportInitialVaultId(null);
|
||||
setActiveTab("vaults");
|
||||
}}
|
||||
onImportComplete={() => {
|
||||
@@ -1910,6 +1922,7 @@ export function ProviderVaultsTab({
|
||||
onRemove,
|
||||
onSetDefault,
|
||||
onHealthCheck,
|
||||
onImportSecrets,
|
||||
pendingActionId,
|
||||
}: {
|
||||
providers: SecretProviderDescriptor[];
|
||||
@@ -1923,6 +1936,7 @@ export function ProviderVaultsTab({
|
||||
onRemove: (config: CompanySecretProviderConfig) => void;
|
||||
onSetDefault: (config: CompanySecretProviderConfig) => void;
|
||||
onHealthCheck: (config: CompanySecretProviderConfig) => void;
|
||||
onImportSecrets: (config: CompanySecretProviderConfig) => void;
|
||||
pendingActionId: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
@@ -2004,6 +2018,7 @@ export function ProviderVaultsTab({
|
||||
onRemove={() => onRemove(config)}
|
||||
onSetDefault={() => onSetDefault(config)}
|
||||
onHealthCheck={() => onHealthCheck(config)}
|
||||
onImportSecrets={() => onImportSecrets(config)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2023,6 +2038,7 @@ function ProviderVaultCard({
|
||||
onRemove,
|
||||
onSetDefault,
|
||||
onHealthCheck,
|
||||
onImportSecrets,
|
||||
}: {
|
||||
config: CompanySecretProviderConfig;
|
||||
pending: boolean;
|
||||
@@ -2031,9 +2047,12 @@ function ProviderVaultCard({
|
||||
onRemove: () => void;
|
||||
onSetDefault: () => void;
|
||||
onHealthCheck: () => void;
|
||||
onImportSecrets: () => void;
|
||||
}) {
|
||||
const blockReason = getProviderConfigBlockReason(config);
|
||||
const details = config.healthDetails;
|
||||
const canRefreshRemoteSecrets =
|
||||
config.provider === "aws_secrets_manager" && !blockReason;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -2081,6 +2100,23 @@ function ProviderVaultCard({
|
||||
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
|
||||
Check health
|
||||
</Button>
|
||||
{config.provider === "aws_secrets_manager" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onImportSecrets}
|
||||
disabled={pending || !canRefreshRemoteSecrets}
|
||||
title={
|
||||
canRefreshRemoteSecrets
|
||||
? "Refresh AWS metadata and import existing secrets"
|
||||
: blockReason ?? "This vault cannot refresh secrets right now."
|
||||
}
|
||||
data-testid={`provider-vault-refresh-secrets-${config.id}`}
|
||||
>
|
||||
<Cloud className="h-3.5 w-3.5 mr-1" />
|
||||
Refresh secrets
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -60,6 +60,7 @@ interface ImportFromVaultDialogProps {
|
||||
companyId: string;
|
||||
providerConfigs: CompanySecretProviderConfig[];
|
||||
existingSecrets: CompanySecret[];
|
||||
initialProviderConfigId?: string | null;
|
||||
onImportComplete?: (result: RemoteSecretImportResult) => void;
|
||||
onManageVaults?: () => void;
|
||||
}
|
||||
@@ -83,9 +84,15 @@ function eligibleVaults(configs: CompanySecretProviderConfig[]): CompanySecretPr
|
||||
return configs.filter(isAwsSelectable);
|
||||
}
|
||||
|
||||
function pickDefaultVault(configs: CompanySecretProviderConfig[]): string | null {
|
||||
function pickDefaultVault(
|
||||
configs: CompanySecretProviderConfig[],
|
||||
preferredId?: string | null,
|
||||
): string | null {
|
||||
const eligible = eligibleVaults(configs);
|
||||
if (eligible.length === 0) return null;
|
||||
if (preferredId && eligible.some((vault) => vault.id === preferredId)) {
|
||||
return preferredId;
|
||||
}
|
||||
return (eligible.find((vault) => vault.isDefault) ?? eligible[0]).id;
|
||||
}
|
||||
|
||||
@@ -327,6 +334,7 @@ export function ImportFromVaultDialog({
|
||||
companyId,
|
||||
providerConfigs,
|
||||
existingSecrets,
|
||||
initialProviderConfigId,
|
||||
onImportComplete,
|
||||
onManageVaults,
|
||||
}: ImportFromVaultDialogProps) {
|
||||
@@ -360,7 +368,7 @@ export function ImportFromVaultDialog({
|
||||
setSelection(new Map());
|
||||
setImportResult(null);
|
||||
setShowOnlySelected(false);
|
||||
const next = pickDefaultVault(providerConfigs);
|
||||
const next = pickDefaultVault(providerConfigs, initialProviderConfigId);
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user