[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:
parent
500a75f7ce
commit
f3f50e2ecd
|
|
@ -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,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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue