[codex] Preselect vault when importing secrets (#8614)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operator secrets and provider vaults let a company connect external
secret stores without copying every value manually.
> - The existing AWS Secrets Manager import dialog already supports
importing remote secrets from any eligible vault.
> - From the Provider vaults tab, operators can see a specific vault
row, but there was no row-level import action that preserved that vault
context.
> - That meant importing from a specific vault required opening the
generic import dialog and reselecting the intended provider config.
> - This pull request adds a row-level refresh/import action that opens
the import dialog with the selected vault preselected.
> - The benefit is a tighter, less error-prone flow when a company has
multiple AWS provider vaults configured.

## Linked Issues or Issue Description

No public issue exists for this small UI follow-up.

Problem description:
- Users with multiple AWS Secrets Manager provider vaults need to
refresh/import secrets from the vault they are currently looking at.
- The existing import dialog defaulted to the configured default vault
or first eligible vault, even when the operator initiated the flow from
a specific provider-vault row.
- This creates extra selection work and makes it easier to import from
the wrong vault.

Related public PRs: Refs #5429, #8586.

## What Changed

- Added a Provider vaults row action for AWS Secrets Manager configs to
refresh/import existing remote secrets.
- Threaded an optional initial provider config id into
`ImportFromVaultDialog` so the selected vault is preselected when
eligible.
- Reset the initial vault selection when the dialog closes or routes to
vault management.
- Added render coverage proving the row action opens the import dialog
and previews against the selected vault id.

## Verification

- `pnpm exec vitest run ui/src/pages/Secrets.render.test.tsx` passed: 1
file, 9 tests.
- `pnpm --filter @paperclipai/ui typecheck` passed.

## Risks

Low risk. This is UI-only behavior scoped to AWS Secrets Manager
provider-vault rows and the existing import dialog. The main risk is
that future provider types may need their own row-level import labels or
eligibility rules instead of sharing this AWS-specific action.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via Paperclip/Codex local agent, with repository file
access, shell command execution, GitHub CLI, and TypeScript/Vitest
verification. Exact context-window metadata was not surfaced in this
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-06-26 17:44:52 -05:00 committed by GitHub
parent 500a75f7ce
commit f3f50e2ecd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 4 deletions

View File

@ -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);

View File

@ -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,6 +2047,7 @@ function ProviderVaultCard({
onRemove: () => void;
onSetDefault: () => void;
onHealthCheck: () => void;
onImportSecrets: () => void;
}) {
const blockReason = getProviderConfigBlockReason(config);
const details = config.healthDetails;
@ -2081,6 +2098,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 || Boolean(blockReason)}
title={
blockReason
? blockReason
: "Refresh AWS metadata and import existing secrets"
}
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"

View File

@ -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.

View File

@ -1375,6 +1375,35 @@ export const storybookSecretProviderConfigs: CompanySecretProviderConfig[] = [
createdAt: recent(1_800),
updatedAt: recent(18),
},
{
id: "provider-config-aws-blocked",
companyId: "company-storybook",
provider: "aws_secrets_manager",
displayName: "AWS staging blocked",
status: "ready",
isDefault: false,
config: {
region: "us-west-2",
namespace: "staging",
secretNamePrefix: "paperclip",
kmsKeyId: "",
ownerTag: "platform",
environmentTag: "staging",
},
healthStatus: "error",
healthCheckedAt: recent(22),
healthMessage: "AWS Secrets Manager denied ListSecrets for this vault.",
healthDetails: {
code: "access_denied",
message: "AWS Secrets Manager denied ListSecrets for this vault.",
guidance: ["Grant secretsmanager:ListSecrets before importing from this vault."],
},
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-board",
createdAt: recent(1_200),
updatedAt: recent(22),
},
];
export const storybookSecretProviderDiscoveryPreview: SecretProviderConfigDiscoveryPreviewResult = {