From 2a24a505ea82b3d3157c0cfcd6da25c809211bdd Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 16:33:48 +0100 Subject: [PATCH] Confirm before revoking API tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Settings/APITokenManager.tsx | 47 ++++++++++++++++++- .../__tests__/APITokenManager.test.tsx | 22 +++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 347095b6b..12f6446d8 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -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 = (props) => { + const [tokenToRevoke, setTokenToRevoke] = createSignal(null); const { API_SCOPE_LABELS, API_TOKEN_SCOPES_DOC_URL, @@ -399,7 +402,7 @@ export const APITokenManager: Component = (props) => { align: 'right', render: (token) => ( + + + + + ); }; diff --git a/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx index 7021fccd6..ef461f04c 100644 --- a/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx @@ -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 => ({ ...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(''); + expect(apiAccessPanelSource).toContain(' { 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'); });