Watch
1
0
Fork
You've already forked RedFlag
0

web: ConfirmDialog primitive — window.confirm is gone

ConfirmProvider + useConfirm, 11 call sites converted (4 more than expected). Danger variant: red button, Enter inert, cancel takes focus — a stray Enter can never destroy. Modal now yields focus to autoFocus children.
This commit is contained in:
Fimeg 2026-06-12 14:11:16 -04:00
commit 31c4ae74e5
12 changed files with 416 additions and 44 deletions

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
GitBranch,
Plus,
@ -74,6 +75,7 @@ const AgentSoftwareBindings: React.FC<AgentSoftwareBindingsProps> = ({ agentId }
const { data: trackedData } = useTrackedSoftware();
const upsert = useUpsertAgentBinding(agentId);
const remove = useDeleteAgentBinding(agentId);
const confirm = useConfirm();
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState<CreateAgentBindingRequest>({
@ -289,10 +291,14 @@ const AgentSoftwareBindings: React.FC<AgentSoftwareBindingsProps> = ({ agentId }
</a>
)}
<button
onClick={() => {
if (confirm(`Remove binding for ${b.name}? The tracked entry itself stays.`)) {
remove.mutate(b.binding_id);
}
onClick={async () => {
if (!(await confirm({
title: 'Remove Binding',
body: `Remove binding for ${b.name}? The tracked entry itself stays.`,
confirmLabel: 'Remove',
danger: true,
}))) return;
remove.mutate(b.binding_id);
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
title="Remove binding"

View file

@ -0,0 +1,169 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { ConfirmProvider, useConfirm } from './ConfirmDialog';
// Helper: a button that fires confirm() with the given options and records the result.
interface TriggerProps {
options: Parameters<ReturnType<typeof useConfirm>>[0];
onResult: (v: boolean) => void;
}
const Trigger: React.FC<TriggerProps> = ({ options, onResult }) => {
const confirm = useConfirm();
return (
<button
onClick={async () => {
const result = await confirm(options);
onResult(result);
}}
>
Open
</button>
);
};
const wrap = (props: TriggerProps) =>
render(
<ConfirmProvider>
<Trigger {...props} />
</ConfirmProvider>,
);
describe('ConfirmDialog', () => {
it('renders title and body when opened', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Are you sure?', body: 'This cannot be undone.' }, onResult });
fireEvent.click(screen.getByText('Open'));
expect(await screen.findByText('Are you sure?')).toBeInTheDocument();
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument();
});
it('resolves true when the confirm button is clicked', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Confirm', body: 'Do it?' }, onResult });
fireEvent.click(screen.getByText('Open'));
await screen.findByText('Confirm');
fireEvent.click(screen.getByRole('button', { name: 'OK' }));
await waitFor(() => expect(onResult).toHaveBeenCalledWith(true));
expect(screen.queryByText('Do it?')).not.toBeInTheDocument();
});
it('resolves false when the cancel button is clicked', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Confirm', body: 'Really?' }, onResult });
fireEvent.click(screen.getByText('Open'));
await screen.findByText('Confirm');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(onResult).toHaveBeenCalledWith(false));
});
it('uses custom confirmLabel and cancelLabel', async () => {
const onResult = vi.fn();
wrap({
options: { title: 'Delete Item', body: 'Permanent.', confirmLabel: 'Delete', cancelLabel: 'Keep', danger: true },
onResult,
});
fireEvent.click(screen.getByText('Open'));
await screen.findByText('Delete Item');
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
});
it('resolves true on Enter key for non-danger dialog', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Proceed?', body: 'Go ahead.' }, onResult });
fireEvent.click(screen.getByText('Open'));
const body = await screen.findByText('Go ahead.');
fireEvent.keyDown(body.parentElement!, { key: 'Enter' });
await waitFor(() => expect(onResult).toHaveBeenCalledWith(true));
});
it('does not resolve on Enter key for danger dialog', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Danger', body: 'Careful.', danger: true }, onResult });
fireEvent.click(screen.getByText('Open'));
const body = await screen.findByText('Careful.');
fireEvent.keyDown(body.parentElement!, { key: 'Enter' });
// Dialog should still be open
expect(screen.getByText('Careful.')).toBeInTheDocument();
expect(onResult).not.toHaveBeenCalled();
});
it('resolves false on Escape key', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Escape test', body: 'Press Escape.' }, onResult });
fireEvent.click(screen.getByText('Open'));
await screen.findByText('Escape test');
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(onResult).toHaveBeenCalledWith(false));
});
it('danger variant confirm button has red styling', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Danger', body: 'Destructive.', danger: true, confirmLabel: 'Destroy' }, onResult });
fireEvent.click(screen.getByText('Open'));
const btn = await screen.findByRole('button', { name: 'Destroy' });
expect(btn.className).toMatch(/red/);
});
it('non-danger confirm button has blue styling', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Normal', body: 'Safe.', confirmLabel: 'Proceed' }, onResult });
fireEvent.click(screen.getByText('Open'));
const btn = await screen.findByRole('button', { name: 'Proceed' });
expect(btn.className).toMatch(/blue/);
});
it('focuses cancel on danger dialogs so Enter cannot destroy', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Danger', body: 'Careful.', danger: true, confirmLabel: 'Destroy' }, onResult });
fireEvent.click(screen.getByText('Open'));
await screen.findByRole('button', { name: 'Destroy' });
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
it('focuses confirm on non-danger dialogs', async () => {
const onResult = vi.fn();
wrap({ options: { title: 'Normal', body: 'Safe.', confirmLabel: 'Proceed' }, onResult });
fireEvent.click(screen.getByText('Open'));
const btn = await screen.findByRole('button', { name: 'Proceed' });
expect(btn).toHaveFocus();
});
it('throws when useConfirm is used outside ConfirmProvider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const BadComponent: React.FC = () => {
useConfirm();
return null;
};
expect(() => render(<BadComponent />)).toThrow('useConfirm must be used within a ConfirmProvider');
spy.mockRestore();
});
});

View file

@ -0,0 +1,139 @@
import React, {
createContext,
useCallback,
useContext,
useRef,
useState,
} from 'react';
import Modal from './Modal';
/**
* ConfirmDialog imperative async confirm() primitive.
*
* Usage (wrap the app once with ConfirmProvider, then call useConfirm in any component):
* ```tsx
* // main.tsx
* <ConfirmProvider>
* <App />
* </ConfirmProvider>
*
* // any component
* const confirm = useConfirm();
* if (!(await confirm({ title: 'Delete?', body: 'Cannot be undone.', danger: true }))) return;
* doTheDestructiveThing();
* ```
*
* Keyboard behavior:
* Escape always cancels (handled by Modal).
* Enter confirms on non-danger dialogs only.
* Danger dialogs require an explicit click on the confirm button; Enter does nothing.
* Rationale: an accidental Enter on a destructive action (delete, revoke, reset) is a
* worse outcome than the minor friction of a deliberate click.
*/
export interface ConfirmOptions {
title: string;
body: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Render the confirm button in red and block Enter-to-confirm. */
danger?: boolean;
}
type Resolver = (value: boolean) => void;
interface ConfirmContextValue {
confirm: (opts: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
interface DialogState {
opts: ConfirmOptions;
resolve: Resolver;
}
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [dialog, setDialog] = useState<DialogState | null>(null);
const resolverRef = useRef<Resolver | null>(null);
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
setDialog({ opts, resolve });
});
}, []);
const settle = useCallback((value: boolean) => {
resolverRef.current?.(value);
resolverRef.current = null;
setDialog(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && dialog && !dialog.opts.danger) {
e.preventDefault();
settle(true);
}
},
[dialog, settle],
);
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{dialog && (
<Modal
open
onClose={() => settle(false)}
title={dialog.opts.title}
maxWidth="sm"
>
<div onKeyDown={handleKeyDown}>
<Modal.Body>
<div className="text-sm text-gray-700">{dialog.opts.body}</div>
</Modal.Body>
<Modal.Footer>
{/* Danger dialogs focus Cancel so a stray Enter lands on the safe
action; the wrapper's Enter handler is also gated on !danger.
Non-danger dialogs focus the confirm button. */}
<button
type="button"
autoFocus={!dialog.opts.danger}
onClick={() => settle(true)}
className={
dialog.opts.danger
? 'inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-red-700 rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500'
: 'inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-blue-700 rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500'
}
>
{dialog.opts.confirmLabel ?? (dialog.opts.danger ? 'Confirm' : 'OK')}
</button>
<button
type="button"
autoFocus={dialog.opts.danger}
onClick={() => settle(false)}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{dialog.opts.cancelLabel ?? 'Cancel'}
</button>
</Modal.Footer>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
};
/**
* Returns the async confirm() function from the nearest ConfirmProvider.
* Throws if called outside a ConfirmProvider.
*/
export function useConfirm(): (opts: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmContext);
if (!ctx) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return ctx.confirm;
}

View file

@ -62,8 +62,15 @@ const Modal: React.FC<ModalProps> & { Body: typeof ModalBody; Footer: typeof Mod
if (open) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
// Focus the content after a tick so the transition doesn't fight it
requestAnimationFrame(() => contentRef.current?.focus());
// Focus the content after a tick so the transition doesn't fight it.
// Yield if a child already took focus (e.g. an autoFocus button) —
// React commits autoFocus before this frame runs.
requestAnimationFrame(() => {
const el = contentRef.current;
if (!el) return;
if (document.activeElement && el.contains(document.activeElement) && document.activeElement !== el) return;
el.focus();
});
}
return () => {
document.removeEventListener('keydown', handleKeyDown);

View file

@ -6,6 +6,8 @@ export { default as FilterDropdown } from './FilterDropdown';
export { default as FilterCountButton } from './FilterCountButton';
export { default as PageState, PageSkeleton } from './PageState';
export { default as Modal } from './Modal';
export { ConfirmProvider, useConfirm } from './ConfirmDialog';
export type { ConfirmOptions } from './ConfirmDialog';
export { default as Pagination } from './Pagination';
export { default as StatCard, StatCardGroup } from './StatCard';
export { default as ScreenshotCard } from './ScreenshotCard';

View file

@ -5,6 +5,7 @@ import toast from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import type { SigningKey } from '@/types';
import { formatDateTime } from '@/lib/utils';
import { useConfirm } from '@/components/primitives';
// SigningKeyRoster renders every Ed25519 signing key the server has ever held.
// The primary key is highlighted; accepted-but-not-primary keys can be deprecated
@ -14,6 +15,7 @@ import { formatDateTime } from '@/lib/utils';
// roster + retirement half of rotation.
const SigningKeyRoster: React.FC = () => {
const queryClient = useQueryClient();
const confirm = useConfirm();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['signing-keys'],
@ -32,14 +34,17 @@ const SigningKeyRoster: React.FC = () => {
},
});
const handleDeprecate = (key: SigningKey) => {
const handleDeprecate = async (key: SigningKey) => {
if (key.is_primary) {
toast.error('The primary key cannot be deprecated. Promote a successor first.');
return;
}
if (!confirm(`Deprecate key ${key.key_id.slice(0, 16)}…?\n\nAgents that cached this key will need to refresh from /api/v1/public-keys before they can verify signed commands. This cannot be undone from the dashboard.`)) {
return;
}
if (!(await confirm({
title: 'Deprecate Signing Key',
body: `Deprecate key ${key.key_id.slice(0, 16)}? Agents that cached this key will need to refresh from /api/v1/public-keys before they can verify signed commands. This cannot be undone from the dashboard.`,
confirmLabel: 'Deprecate',
danger: true,
}))) return;
deprecate.mutate(key.key_id);
};

View file

@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App.tsx'
import { ConfirmProvider } from '@/components/primitives'
import './index.css'
const queryClient = new QueryClient({
@ -23,7 +24,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<ConfirmProvider>
<App />
</ConfirmProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

View file

@ -31,6 +31,7 @@ import {
CommandCard,
SortableTable,
StatusBadge,
useConfirm,
} from '@/components/primitives';
import type { Column } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
@ -64,6 +65,7 @@ const parseAgentDetailTab = (tab: string | null): AgentDetailTab => {
const Agents: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const confirm = useConfirm();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
@ -317,11 +319,12 @@ const Agents: React.FC = () => {
// Handle agent reboot
const handleRebootAgent = async (agentId: string, hostname: string) => {
if (!window.confirm(
`Schedule a system restart for agent "${hostname}"?\n\nThe system will restart in 1 minute. Any unsaved work may be lost.`
)) {
return;
}
if (!(await confirm({
title: 'Schedule System Restart',
body: `Schedule a system restart for agent "${hostname}"? The system will restart in 1 minute. Any unsaved work may be lost.`,
confirmLabel: 'Restart',
danger: true,
}))) return;
try {
await agentApi.rebootAgent(agentId);
@ -333,11 +336,12 @@ const Agents: React.FC = () => {
// Handle agent removal
const handleRemoveAgent = async (agentId: string, hostname: string) => {
if (!window.confirm(
`Are you sure you want to remove agent "${hostname}"? This action cannot be undone and will remove the agent from the system.`
)) {
return;
}
if (!(await confirm({
title: 'Remove Agent',
body: `Remove agent "${hostname}"? This action cannot be undone and will remove the agent from the system.`,
confirmLabel: 'Remove',
danger: true,
}))) return;
try {
await unregisterAgentMutation.mutateAsync(agentId);

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
Shield,
RefreshCw,
@ -66,6 +67,7 @@ const CATEGORIES: CategoryMeta[] = [
const RateLimiting: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
const { data: settings, refetch: refetchSettings, isLoading } = useRateLimitSettings();
const { data: stats } = useRateLimitStats();
@ -99,13 +101,22 @@ const RateLimiting: React.FC = () => {
updateSettings.mutate(editing, { onSuccess: () => setDirty(false) });
};
const handleReset = () => {
if (!confirm('Reset all rate limits to defaults? Any custom values will be lost.')) return;
const handleReset = async () => {
if (!(await confirm({
title: 'Reset to Defaults',
body: 'Reset all rate limits to defaults? Any custom values will be lost.',
confirmLabel: 'Reset',
danger: true,
}))) return;
resetSettings.mutate();
};
const handleCleanup = () => {
if (!confirm('Clean up expired rate-limit counters? Active limits are unaffected.')) return;
const handleCleanup = async () => {
if (!(await confirm({
title: 'Cleanup Counters',
body: 'Clean up expired rate-limit counters? Active limits are unaffected.',
confirmLabel: 'Clean Up',
}))) return;
cleanup.mutate();
};

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
Shield,
Plus,
@ -26,6 +27,7 @@ import { tokenStatusColor } from '@/components/primitives/statusColors';
const TokenManagement: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
@ -75,22 +77,34 @@ const TokenManagement: React.FC = () => {
});
};
const handleRevokeToken = (tokenId: string, tokenLabel: string) => {
if (confirm(`Revoke token "${tokenLabel}"? Agents using it will need to re-register.`)) {
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
}
const handleRevokeToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'Revoke Token',
body: `Revoke token "${tokenLabel}"? Agents using it will need to re-register.`,
confirmLabel: 'Revoke',
danger: true,
}))) return;
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleDeleteToken = (tokenId: string, tokenLabel: string) => {
if (confirm(`⚠️ PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`)) {
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
}
const handleDeleteToken = async (tokenId: string, tokenLabel: string) => {
if (!(await confirm({
title: 'PERMANENTLY DELETE Token',
body: `PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`,
confirmLabel: 'Delete',
danger: true,
}))) return;
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
};
const handleCleanup = () => {
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
}
const handleCleanup = async () => {
if (!(await confirm({
title: 'Cleanup Expired Tokens',
body: 'Clean up all expired tokens? This cannot be undone.',
confirmLabel: 'Clean Up',
danger: true,
}))) return;
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
};
const copyToClipboard = async (text: string) => {

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
ArrowLeft,
Plus,
@ -30,6 +31,7 @@ const emptyForm = (): CreateMaintenanceWindowRequest => ({
const MaintenanceWindowsPage: React.FC = () => {
const navigate = useNavigate();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateMaintenanceWindowRequest>(emptyForm());
@ -70,9 +72,13 @@ const MaintenanceWindowsPage: React.FC = () => {
};
const handleDelete = async (id: string) => {
if (window.confirm('Delete this maintenance window?')) {
await deleteMutation.mutateAsync(id);
}
if (!(await confirm({
title: 'Delete Maintenance Window',
body: 'Delete this maintenance window?',
confirmLabel: 'Delete',
danger: true,
}))) return;
await deleteMutation.mutateAsync(id);
};
return (

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useConfirm } from '@/components/primitives';
import {
GitBranch,
Plus,
@ -149,6 +150,7 @@ const UpstreamTracking: React.FC = () => {
const add = useAddTrackedSoftware();
const remove = useRemoveTrackedSoftware();
const sync = useSyncTrackedSoftware();
const confirm = useConfirm();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
@ -426,10 +428,14 @@ const UpstreamTracking: React.FC = () => {
</a>
)}
<button
onClick={() => {
if (confirm(`Stop tracking ${s.name}? Drift events are kept for audit.`)) {
remove.mutate(s.id);
}
onClick={async () => {
if (!(await confirm({
title: 'Stop Tracking',
body: `Stop tracking ${s.name}? Drift events are kept for audit.`,
confirmLabel: 'Stop Tracking',
danger: true,
}))) return;
remove.mutate(s.id);
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
>