Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/webapp/src/settings/OrgCredentialsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function OrgCredentialsPanel({
const [accessDraft, setAccessDraft] = useState<AccessDraft | null>(null);
const [savingAccess, setSavingAccess] = useState(false);
const [accessError, setAccessError] = useState<string | null>(null);
const [revokeError, setRevokeError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<OrgCredentialView | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);

Expand Down Expand Up @@ -255,16 +256,18 @@ export function OrgCredentialsPanel({

const revoke = async (credential: OrgCredentialView) => {
if (revoking !== null) return;
setRevokeTarget(null);
setRevoking(credential.name);
setError(null);
setRevokeError(null);
try {
await client.revokeOrgCredential(credential.name);
if (accessDraft?.name === credential.name) setAccessDraft(null);
if (rotating === credential.name) setRotating(null);
await reload();
// THE DIALOG CLOSES ONLY ON SUCCESS. A failure draws inside it, beside
// the button that caused it.
setRevokeTarget(null);
} catch (caught) {
setError(caughtErrorMessage(caught, 'Revoke failed.'));
setRevokeError(caughtErrorMessage(caught, 'Revoke failed.'));
} finally {
setRevoking(null);
}
Expand Down Expand Up @@ -308,7 +311,10 @@ export function OrgCredentialsPanel({
onDraftChange={(grants) => setAccessDraft({ name: credential.name, grants })}
onSaveAccess={() => { void saveAccess(); }}
onRotate={() => setRotating(credential.name)}
onRevoke={() => setRevokeTarget(credential)}
onRevoke={() => {
setRevokeError(null);
setRevokeTarget(credential);
}}
/>
))}
</div>
Expand Down Expand Up @@ -366,7 +372,12 @@ export function OrgCredentialsPanel({
title="Revoke this credential?"
description={`Revoke ${revokeTarget.name} for the whole organization? Every machine that pulls it is refused on the next ask.`}
confirmLabel="Revoke credential"
onCancel={() => setRevokeTarget(null)}
busy={revoking === revokeTarget.name}
error={revokeError}
onCancel={() => {
setRevokeError(null);
setRevokeTarget(null);
}}
onConfirm={() => { void revoke(revokeTarget); }}
/>
)}
Expand Down
47 changes: 47 additions & 0 deletions packages/webapp/test/org-credentials-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,53 @@ describe('OrgCredentialsPanel', () => {
await view.unmount();
});

it('keeps a refused revoke open, shows its error in the dialog, and keeps the credential listed', async () => {
const message = 'Credential revocation was refused.';
const revokeOrgCredential = vi.fn().mockRejectedValue(new Error(message));
const view = await render(<OrgCredentialsPanel client={client({ revokeOrgCredential })} viewer={viewer} />);
await settle();

await act(async () => field<HTMLButtonElement>(
view.container, 'button[aria-label="Revoke STRIPE_API_KEY"]').click());
await act(async () => buttonNamed(document.body, 'Revoke credential').click());
await settle();

const dialog = field<HTMLElement>(document.body, '[role="dialog"]');
expect(field<HTMLElement>(dialog, '.webapp-confirmation-error').textContent).toBe(message);
expect(view.container.textContent).toContain('STRIPE_API_KEY');
await view.unmount();
});

it('clears a refused revoke before another credential succeeds', async () => {
const message = 'Credential revocation was refused.';
const revokeOrgCredential = vi.fn()
.mockRejectedValueOnce(new Error(message))
.mockResolvedValueOnce(undefined);
const view = await render(<OrgCredentialsPanel client={client({ revokeOrgCredential })} viewer={adminViewer} />);
await settle();

await act(async () => field<HTMLButtonElement>(
view.container, 'button[aria-label="Revoke STRIPE_API_KEY"]').click());
await act(async () => buttonNamed(document.body, 'Revoke credential').click());
await settle();
const failedDialog = field<HTMLElement>(document.body, '[role="dialog"]');
expect(field<HTMLElement>(failedDialog, '.webapp-confirmation-error').textContent).toBe(message);

await act(async () => buttonNamed(failedDialog, 'No').click());
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
await act(async () => field<HTMLButtonElement>(view.container, 'button[aria-label="Revoke SENTRY_DSN"]').click());
const nextDialog = field<HTMLElement>(document.body, '[role="dialog"]');
expect(nextDialog.querySelector('.webapp-confirmation-error')).toBeNull();
await act(async () => buttonNamed(document.body, 'Revoke credential').click());
await settle();

expect(revokeOrgCredential).toHaveBeenCalledTimes(2);
expect(revokeOrgCredential).toHaveBeenNthCalledWith(1, 'STRIPE_API_KEY');
expect(revokeOrgCredential).toHaveBeenNthCalledWith(2, 'SENTRY_DSN');
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
await view.unmount();
});

it('expands a row in place from the chevron, and saves the whole audience', async () => {
const replaceOrgCredentialGrants = vi.fn().mockResolvedValue({ credential: stripe });
const view = await render(<OrgCredentialsPanel client={client({ replaceOrgCredentialGrants })} viewer={viewer} />);
Expand Down
Loading