diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index 56a35c826..74094deba 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -186,6 +186,10 @@ if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError(); } + function credentialLifecyclePending(credentialId: string): boolean { + return isPending(`copy-${credentialId}`) || isPending(`rotate-${credentialId}`) || isPending(`delete-${credentialId}`); + } + function credentialEditorPending(): boolean { const credentialRotationPrefix = `${pageEpoch}:rotate-`; const hostRotationPrefix = `${pageEpoch}:rotate-host-`; @@ -354,6 +358,7 @@ } async function copyPublicKey(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; const operation = startOperation(`copy-${credential.credential_id}`); if (!operation) return; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; @@ -414,6 +419,7 @@ } async function openCredentialRotation(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; credentialReturnFocus = currentFocus(); credentialForm = null; credentialFormNotice = null; @@ -439,6 +445,7 @@ } async function rotateCredential(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; rotateErrors = validateCredentialRotation(rotatePrivateKey); if (Object.keys(rotateErrors).length > 0) { await focusFirstInvalid(rotationFormElement); @@ -485,9 +492,8 @@ } async function deleteCredential(credential: RepositorySshCredential) { - if (credential.referenced_repositories.length > 0) return; + if (credential.referenced_repositories.length > 0 || credentialLifecyclePending(credential.credential_id)) return; const operationName = `delete-${credential.credential_id}`; - if (isPending(operationName)) return; if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; const operation = startOperation(operationName); if (!operation) return; @@ -711,7 +717,7 @@ CredentialRoleFingerprintRepository applicationActions {#each credentials as credential (credential.credential_id)} - + {credential.name}{credential.credential_id} {credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'} {credential.public_key_algorithm}{credential.public_key_fingerprint} @@ -726,13 +732,13 @@
- {#if credential.credential_id !== workspaceDefaultCredentialId} - + {#if credential.referenced_repositories.length === 0} - + {/if} {/if}
@@ -789,7 +795,7 @@ -
+
{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts index 68b204f30..f73e78a9e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -212,7 +212,7 @@ test("fences a deferred editor while an unrelated host draft remains active afte expect(fetchMock).toHaveBeenCalledTimes(2); }); -test("keeps a deferred row failure on that row while unrelated actions remain enabled", async () => { +test("fences same-credential actions during a deferred row failure while unrelated controls remain enabled", async () => { const publicKeyResponse = deferred(); const fetchMock = vi.fn(() => publicKeyResponse.promise); vi.stubGlobal("fetch", fetchMock); @@ -220,6 +220,13 @@ test("keeps a deferred row failure on that row while unrelated actions remain en const row = screen.getByText("Deploy key").closest("tr"); if (!row) throw new Error("missing credential row"); + const rotate = within(row).getByRole("button", { name: "Rotate" }); + await fireEvent.click(rotate); + const rotateSubmit = screen.getByRole("button", { + name: "Rotate credential", + }); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false); + const copy = within(row).getByRole("button", { name: "Copy public key" }); await fireEvent.click(copy); @@ -240,13 +247,14 @@ test("keeps a deferred row failure on that row while unrelated actions remain en name: "Add pinned key", }) as HTMLButtonElement).disabled, ).toBe(false); - const rotate = within(row).getByRole("button", { name: "Rotate" }); const remove = within(row).getByRole("button", { name: "Delete" }); expect((rotate as HTMLButtonElement).disabled).toBe(true); expect((remove as HTMLButtonElement).disabled).toBe(true); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(rotateSubmit); await fireEvent.click(rotate); await fireEvent.click(remove); - expect(screen.queryByRole("heading", { name: "Rotate Deploy key" })) + expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not .toBeNull(); await fireEvent.click(within(row).getByRole("button", { name: "Loading…" })); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -255,6 +263,9 @@ test("keeps a deferred row failure on that row while unrelated actions remain en const alert = await within(row).findByRole("alert"); expect(alert.textContent).toContain("Unable to load this public key"); expect(screen.getAllByRole("alert")).toHaveLength(1); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not + .toBeNull(); }); test("rerendering for another Workspace resets local state and fences stale responses", async () => {