mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +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 { Card } from '@/components/shared/Card';
|
||||||
|
import { Dialog } from '@/components/shared/Dialog';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
|
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
|
||||||
import BadgeCheck from 'lucide-solid/icons/badge-check';
|
import BadgeCheck from 'lucide-solid/icons/badge-check';
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
getAPITokenDockerPodmanUsageTitle,
|
getAPITokenDockerPodmanUsageTitle,
|
||||||
} from '@/utils/apiTokenPresentation';
|
} from '@/utils/apiTokenPresentation';
|
||||||
import { useAPITokenManagerState } from './useAPITokenManagerState';
|
import { useAPITokenManagerState } from './useAPITokenManagerState';
|
||||||
|
import type { APITokenRecord } from '@/types/api';
|
||||||
|
|
||||||
interface APITokenManagerProps {
|
interface APITokenManagerProps {
|
||||||
currentTokenHint?: string;
|
currentTokenHint?: string;
|
||||||
@@ -19,6 +21,7 @@ interface APITokenManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
|
const [tokenToRevoke, setTokenToRevoke] = createSignal<APITokenRecord | null>(null);
|
||||||
const {
|
const {
|
||||||
API_SCOPE_LABELS,
|
API_SCOPE_LABELS,
|
||||||
API_TOKEN_SCOPES_DOC_URL,
|
API_TOKEN_SCOPES_DOC_URL,
|
||||||
@@ -399,7 +402,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (token) => (
|
render: (token) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(token)}
|
onClick={() => setTokenToRevoke(token)}
|
||||||
disabled={!canManage()}
|
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"
|
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
|
Scope reference
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DOCKER_MANAGE_SCOPE,
|
DOCKER_MANAGE_SCOPE,
|
||||||
DOCKER_REPORT_SCOPE,
|
DOCKER_REPORT_SCOPE,
|
||||||
} from '@/constants/apiScopes';
|
} from '@/constants/apiScopes';
|
||||||
|
import apiAccessPanelSource from '../APIAccessPanel.tsx?raw';
|
||||||
import { APITokenManager } from '../APITokenManager';
|
import { APITokenManager } from '../APITokenManager';
|
||||||
|
|
||||||
const listTokensMock = vi.fn();
|
const listTokensMock = vi.fn();
|
||||||
@@ -99,6 +100,23 @@ const makeResource = (overrides: Partial<Resource> = {}): Resource => ({
|
|||||||
...overrides,
|
...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', () => {
|
describe('APITokenManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listTokensMock.mockReset();
|
listTokensMock.mockReset();
|
||||||
@@ -266,6 +284,10 @@ describe('APITokenManager', () => {
|
|||||||
|
|
||||||
fireEvent.click(within(row as HTMLTableRowElement).getByRole('button', { name: 'Revoke' }));
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(deleteTokenMock).toHaveBeenCalledWith('token-runtime');
|
expect(deleteTokenMock).toHaveBeenCalledWith('token-runtime');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user