feat(safety): wire the lockout verdict into every guarded UI surface (v0.19.2-beta)

The verdict dialog only reached the VLANs tab, so a predicted lockout on the other
guarded paths still surfaced as a generic "failed" message with the reasoning lost.
Every surface that can push a guarded change now explains the refusal:

- SwitchPortDiagram — port VLAN (PVID / tagged / untagged, the shape of the real
  incident), bond create and destroy, and the bridge vlan-filtering toggle.
- RoutingTab — static route add and delete.
- SystemConfigTab — IP address add and remove.
- SecurityTab — /ip/service enable/disable. This route also has an older hard
  refusal that returns 409 with a reason but no verdict; that text is now shown
  instead of a generic error, where previously it was swallowed entirely.

Supporting changes: confirm_lockout threaded through the affected API methods
(DELETE routes send it as a request body), and deviceName threaded down to
SwitchPortDiagram, RoutingTab and SecurityTab so the typed confirmation can ask
for the actual device name.

Verified the shipped bundle contains the dialog and the typed-confirm prompt.
Full CI preflight green; 152 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-16 21:30:17 -07:00
co-authored by Claude Opus 4.8
parent 118dd9ee8f
commit d67698bac9
10 changed files with 198 additions and 49 deletions
@@ -4,6 +4,7 @@ import {
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText,
} from 'lucide-react';
import { devicesApi } from '../../services/api';
import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog';
import type { Device, IpAddress, Interface } from '../../types';
import clsx from 'clsx';
import { useCanWrite } from '../../hooks/useCanWrite';
@@ -98,23 +99,42 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
const [newIp, setNewIp] = useState({ address: '', interface: '' });
const [ipError, setIpError] = useState('');
// Predicted lockout (409 + verdict) plus the retry that confirms it.
const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null);
const addIpMutation = useMutation({
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
mutationFn: (confirm: boolean) =>
devicesApi.addIpAddress(deviceId, confirm ? { ...newIp, confirm_lockout: true } : newIp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
setNewIp({ address: '', interface: '' });
setIpError('');
setLockout(null);
},
onError: (err: unknown) => {
const verdict = lockoutVerdictOf(err);
if (verdict) { setLockout({ verdict, retry: () => addIpMutation.mutate(true) }); return; }
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setIpError(msg || 'Failed to add IP address');
},
});
const removeIpMutation = useMutation({
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
mutationFn: ({ id, confirm = false }: { id: string; confirm?: boolean }) =>
devicesApi.removeIpAddress(deviceId, id, confirm),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
setLockout(null);
},
onError: (err: unknown, vars) => {
const verdict = lockoutVerdictOf(err);
if (verdict) {
setLockout({ verdict, retry: () => removeIpMutation.mutate({ id: vars.id, confirm: true }) });
return;
}
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setIpError(msg || 'Failed to remove IP address');
},
});
// ─── RouterOS update check / install ─────────────────────────────────────
@@ -471,7 +491,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
<button
onClick={() => {
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
removeIpMutation.mutate(ip['.id']);
removeIpMutation.mutate({ id: ip['.id'] });
}
}}
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
@@ -511,7 +531,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
</select>
</div>
<button
onClick={() => addIpMutation.mutate()}
onClick={() => addIpMutation.mutate(false)}
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
className="btn-primary flex items-center gap-2"
>
@@ -722,6 +742,16 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
)}
</div>
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={device.name}
pending={addIpMutation.isPending || removeIpMutation.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}