Confirm before revoking API tokens

The Revoke button on /settings/api called handleDelete immediately —
a single accidental click permanently revoked the token, breaking any
agents, scripts, or integrations using it with no undo path.

Add a confirmation Dialog (matching the SSO Providers panel's pattern)
that opens when Revoke is clicked. The user has to explicitly press
"Revoke token" in the modal for the delete to fire; "Cancel" or
backdrop-click dismisses without action. Modal copy spells out that
revocation is permanent and lists the consequence so the user can
make an informed choice.

Updated the existing token-revocation test to click through the new
confirmation step.
This commit is contained in:
rcourtman
2026-05-10 16:33:48 +01:00
parent 642f01e5d1
commit 2a24a505ea
2 changed files with 67 additions and 2 deletions
@@ -1,5 +1,6 @@
import { Component, For, Show } from 'solid-js';
import { Component, For, Show, createSignal } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Dialog } from '@/components/shared/Dialog';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import BadgeCheck from 'lucide-solid/icons/badge-check';
@@ -10,6 +11,7 @@ import {
getAPITokenDockerPodmanUsageTitle,
} from '@/utils/apiTokenPresentation';
import { useAPITokenManagerState } from './useAPITokenManagerState';
import type { APITokenRecord } from '@/types/api';
interface APITokenManagerProps {
currentTokenHint?: string;
@@ -19,6 +21,7 @@ interface APITokenManagerProps {
}
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const [tokenToRevoke, setTokenToRevoke] = createSignal<APITokenRecord | null>(null);
const {
API_SCOPE_LABELS,
API_TOKEN_SCOPES_DOC_URL,
@@ -399,7 +402,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
align: 'right',
render: (token) => (
<button
onClick={() => handleDelete(token)}
onClick={() => setTokenToRevoke(token)}
disabled={!canManage()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
>
@@ -557,6 +560,46 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
Scope reference
</a>
</Card>
{/* Revoke confirmation modal — token deletion is irreversible
and breaks any agents/integrations relying on the token,
so guard the action behind an explicit confirm. */}
<Show when={tokenToRevoke()}>
<Dialog
isOpen={true}
onClose={() => setTokenToRevoke(null)}
panelClass="max-w-md"
ariaLabel="Revoke API token"
>
<div class="w-full p-6">
<h3 class="text-lg font-semibold text-base-content mb-2">Revoke API token?</h3>
<p class="text-sm text-muted mb-4">
This permanently revokes <span class="font-medium text-base-content">{tokenToRevoke()!.name || tokenToRevoke()!.id}</span>. Any
agents, scripts, or integrations using it will stop authenticating until you issue and configure a replacement token.
</p>
<div class="flex justify-end gap-3">
<button
type="button"
onClick={() => setTokenToRevoke(null)}
class="px-4 py-2 text-sm font-medium text-base-content border border-border rounded-md hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
onClick={() => {
const token = tokenToRevoke();
setTokenToRevoke(null);
if (token) void handleDelete(token);
}}
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-md hover:bg-red-700"
>
Revoke token
</button>
</div>
</div>
</Dialog>
</Show>
</div>
);
};
@@ -9,6 +9,7 @@ import {
DOCKER_MANAGE_SCOPE,
DOCKER_REPORT_SCOPE,
} from '@/constants/apiScopes';
import apiAccessPanelSource from '../APIAccessPanel.tsx?raw';
import { APITokenManager } from '../APITokenManager';
const listTokensMock = vi.fn();
@@ -99,6 +100,23 @@ const makeResource = (overrides: Partial<Resource> = {}): Resource => ({
...overrides,
});
describe('APITokenManager security surface', () => {
// The API Access tab is the canonical security surface for
// operator-controlled machine access. Tokens minted here are
// the credential the agent integrations panel directs operators
// to use for MCP / HTTP agent wiring. Pin that the agent
// integrations panel sits on this same tab so the security
// story stays coherent: minting a token and seeing what an
// agent does with it live side-by-side, not split across tabs.
it('hosts the agent integrations panel on the same security surface as token management', () => {
expect(apiAccessPanelSource).toContain(
"import AgentIntegrationsPanel from './AgentIntegrationsPanel';",
);
expect(apiAccessPanelSource).toContain('<AgentIntegrationsPanel />');
expect(apiAccessPanelSource).toContain('<APITokenManager');
});
});
describe('APITokenManager', () => {
beforeEach(() => {
listTokensMock.mockReset();
@@ -266,6 +284,10 @@ describe('APITokenManager', () => {
fireEvent.click(within(row as HTMLTableRowElement).getByRole('button', { name: 'Revoke' }));
// Confirm modal opens — click "Revoke token" to actually trigger the delete.
const confirmBtn = await screen.findByRole('button', { name: 'Revoke token' });
fireEvent.click(confirmBtn);
await waitFor(() => {
expect(deleteTokenMock).toHaveBeenCalledWith('token-runtime');
});