From bdd65cdd5eb805adb74650b280a8bc7775faff9a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 11:21:09 -0300 Subject: [PATCH] feat(ui): system proxy exit guard via beforeunload + sendBeacon (fix2) --- .../TrafficInspectorPageClient.tsx | 23 +++++++- .../hooks/useSystemProxyExitGuard.ts | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx index 81c127b389..222014ab25 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { InterceptedRequest } from "@/mitm/inspector/types"; import { useTrafficStream } from "./hooks/useTrafficStream"; import { useTrafficFilters } from "./hooks/useTrafficFilters"; import { useResizablePanels } from "./hooks/useResizablePanels"; import { useSessionRecorder } from "./hooks/useSessionRecorder"; +import { useSystemProxyExitGuard } from "./hooks/useSystemProxyExitGuard"; import { CaptureModesToolbar } from "./components/CaptureModesToolbar"; import { TopBarControls } from "./components/TopBarControls"; import { RequestStreamingList } from "./components/RequestStreamingList"; @@ -22,6 +23,26 @@ export function TrafficInspectorPageClient() { const [{ listWidth, collapsed }, { startDrag, toggleCollapse }] = useResizablePanels(); const [streamState, streamActions] = useTrafficStream(filters); const recorder = useSessionRecorder(); + const [captureModes, setCaptureModes] = useState<{ systemProxy?: { applied: boolean } } | null>( + null + ); + + useEffect(() => { + let cancelled = false; + fetch("/api/tools/traffic-inspector/capture-modes") + .then((r) => (r.ok ? r.json() : null)) + .then((data: { systemProxy?: { applied: boolean } } | null) => { + if (!cancelled) setCaptureModes(data); + }) + .catch(() => { + /* best-effort */ + }); + return () => { + cancelled = true; + }; + }, []); + + useSystemProxyExitGuard({ applied: captureModes?.systemProxy?.applied ?? false }); const listContainerCallback = useCallback((el: HTMLDivElement | null) => { listContainerRef.current = el; diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts new file mode 100644 index 0000000000..c982cef570 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface UseSystemProxyExitGuardOpts { + applied: boolean; // current state (from GET capture-modes) + endpoint?: string; // POST /capture-modes/system-proxy +} + +/** + * On unmount / page hide / beforeunload, if system proxy is applied, + * silently fires a revert request via navigator.sendBeacon (best-effort, + * survives unload) AND attaches a beforeunload listener that prompts the + * user with a native confirm dialog (browser default — text is ignored + * by most browsers but the prompt itself appears). + */ +export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void { + // 1. Track latest 'applied' in a ref so the listener always sees fresh value + const appliedRef = useRef(opts.applied); + useEffect(() => { + appliedRef.current = opts.applied; + }, [opts.applied]); + + useEffect(() => { + const endpoint = + opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy"; + const body = JSON.stringify({ action: "revert" }); + const blob = new Blob([body], { type: "application/json" }); + + const beforeUnload = (e: BeforeUnloadEvent) => { + if (!appliedRef.current) return; + // Best-effort revert via sendBeacon (survives navigation) + try { + navigator.sendBeacon(endpoint, blob); + } catch { + /* ignore */ + } + // Show confirmation prompt + e.preventDefault(); + e.returnValue = "System-wide proxy still active — leave page anyway?"; + return e.returnValue; + }; + + window.addEventListener("beforeunload", beforeUnload); + return () => { + window.removeEventListener("beforeunload", beforeUnload); + // On component unmount (SPA navigation), fire revert too + if (appliedRef.current) { + try { + navigator.sendBeacon(endpoint, blob); + } catch { + /* ignore */ + } + } + }; + }, [opts.endpoint]); +}