mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user