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:
parent
d75939cb6a
commit
31c4ae74e5
12 changed files with 416 additions and 44 deletions
|
|
@ -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"
|
||||
|
|
|
|||
169
web/src/components/primitives/ConfirmDialog.test.tsx
Normal file
169
web/src/components/primitives/ConfirmDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
139
web/src/components/primitives/ConfirmDialog.tsx
Normal file
139
web/src/components/primitives/ConfirmDialog.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue