import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dialogs, Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; import { useProfile } from "@/modules/profile/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; import { formatErrorMessage } from "@/lib/errors.ts"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; enum ConnectionState { Disconnected = "disconnected", Connecting = "connecting", Connected = "connected", Disconnecting = "disconnecting", } // Only three user-visible labels: Disconnected, Connecting, Connected. // Disconnecting maps to "Disconnected" so the optimistic flip on click // reads as the user's intent (off) rather than naming an intermediate // state. NeedsLogin / SessionExpired / DaemonUnavailable never reach // this map — connState collapses them into Connecting or Disconnected // upstream. const STATUS_KEY: Record = { [ConnectionState.Disconnected]: "connect.status.disconnected", [ConnectionState.Connecting]: "connect.status.connecting", [ConnectionState.Connected]: "connect.status.connected", [ConnectionState.Disconnecting]: "connect.status.disconnected", }; const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; const EVENT_TRIGGER_LOGIN = "trigger-login"; const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); const errorMessage = formatErrorMessage; // startLogin drives the daemon's SSO login end-to-end. The BrowserLogin // popup window is the only login UI; errors surface as a native // Dialogs.Error. Concurrent calls are dropped via the inFlight guard. let loginInFlight = false; async function startLogin(): Promise { if (loginInFlight) return; loginInFlight = true; let cancelled = false; let offCancel: (() => void) | undefined; try { const result = await Connection.Login({ profileName: "", username: "", managementUrl: "", setupKey: "", preSharedKey: "", hostname: "", hint: "", }); if (result.needsSsoLogin) { const uri = result.verificationUriComplete || result.verificationUri; if (uri) { // Open the in-app sign-in popup first; the dialog itself // fires Connection.OpenURL after it's actually on screen // (see WaitingForBrowserDialog) so the system browser // doesn't land on top of a still-hidden NetBird window. try { await WindowManager.OpenBrowserLogin(uri); } catch (e) { console.error(e); } } const cancelPromise = new Promise((resolve) => { offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { cancelled = true; resolve(); }); }); const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "", }); try { await Promise.race([waitPromise, cancelPromise]); } finally { WindowManager.CloseBrowserLogin().catch(console.error); } if (cancelled) { // Tell the daemon to drop the in-flight WaitSSOLogin so a // future Login starts fresh; see services/connection.go:74. try { await Connection.Down(); } catch (e) { console.error(e); } return; } } await Connection.Up({ profileName: "", username: "" }); } catch (e) { WindowManager.CloseBrowserLogin().catch(console.error); if (cancelled) return; await Dialogs.Error({ Title: i18next.t("connect.error.loginTitle"), Message: errorMessage(e), }); } finally { offCancel?.(); loginInFlight = false; } } export const ConnectionStatusSwitch = () => { const { t } = useTranslation(); const { status, refresh } = useStatus(); const { activeProfile, username } = useProfile(); const daemonState = status?.status ?? "Idle"; const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); const unreachable = daemonState === "DaemonUnavailable"; // Tracks an in-flight user action so we can show a transitional label // and disable the switch without lying about the daemon's actual state. // // "connect" — user clicked Up; waiting for daemon to settle // "logging-in" — SSO flow is driving the daemon (Login → browser → // Up). Keeps the switch in "Connecting" while the // daemon flaps NeedsLogin → Idle → NeedsLogin → // Connecting that Login's internal Down causes. // "disconnect" — user clicked Down; waiting for daemon to settle type Action = "connect" | "logging-in" | "disconnect" | null; const [action, setAction] = useState(null); // Guards startLogin from being fired twice in parallel (effect path + // tray trigger-login + handleSwitch). startLogin's module-level // loginInFlight already drops the second daemon call, but its // Promise would resolve immediately and the .finally clear our // "logging-in" latch while the first flow is still running. const loginGuard = useRef(false); const driveLogin = useCallback(() => { if (loginGuard.current) return; loginGuard.current = true; setAction("logging-in"); void startLogin().finally(() => { loginGuard.current = false; setAction(null); void refresh(); }); }, [refresh]); const connState: ConnectionState = useMemo(() => { if (action === "disconnect" && daemonState === "Connected") { return ConnectionState.Disconnecting; } if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") { return ConnectionState.Connecting; } switch (daemonState) { case "Connected": return ConnectionState.Connected; case "Connecting": return ConnectionState.Connecting; case "Idle": case "NeedsLogin": case "LoginFailed": case "SessionExpired": case "DaemonUnavailable": // NeedsLogin / SessionExpired without an in-flight user // action read as Disconnected — the switch only flips to // Connecting once the user (or the tray's trigger-login) // kicks off the SSO flow, which sets action = "logging-in" // and is handled by the guard above. return ConnectionState.Disconnected; default: return ConnectionState.Disconnected; } }, [daemonState, action]); const connect = async () => { setAction("connect"); try { await Connection.Up({ profileName: activeProfile, username, }); await refresh(); } catch (e) { setAction(null); await refresh(); await Dialogs.Error({ Title: t("connect.error.connectTitle"), Message: errorMessage(e), }); } // Don't clear action here on success — the daemon's first status // push (Connecting / NeedsLogin / ...) may land after Up returns, // and clearing eagerly would let connState fall back to // Disconnected for one render. The effect below clears the latch // once daemonState catches up. }; const disconnect = async () => { setAction("disconnect"); try { await Connection.Down(); await refresh(); } catch (e) { setAction(null); await refresh(); await Dialogs.Error({ Title: t("connect.error.disconnectTitle"), Message: errorMessage(e), }); } // See connect() above — clear via the effect, not eagerly. }; // Tracks whether the daemon has entered Connecting during the // current "connect" action. Lets us distinguish "still waiting for // the daemon to start" (Idle → Idle) from "the connect flow was // cancelled externally" (Connecting → Idle, e.g. tray Disconnect // while the UI was Connecting). Reset whenever action returns to // null. const sawConnectingRef = useRef(false); // Release the action latch when the daemon settles on a terminal // state for the user's intent — and, in the connect → NeedsLogin // case, hand off to driveLogin so the user doesn't have to click // the switch a second time. "logging-in" is cleared by driveLogin's // .finally, not here: Login's internal Down makes the daemon flap // through Idle, which would otherwise look like a terminal state. useEffect(() => { if (action === null) { sawConnectingRef.current = false; return; } if (daemonState === "Connecting") { sawConnectingRef.current = true; } if (action === "connect") { if (needsLogin) { driveLogin(); return; } if (daemonState === "Connected" || unreachable) { setAction(null); return; } // Cancelled externally (e.g. tray Disconnect during our // Connecting): the daemon went back to Idle after we'd // observed Connecting. Clear the latch so the UI stops // showing Connecting forever. if (sawConnectingRef.current && daemonState === "Idle") { setAction(null); } return; } if (action === "disconnect") { if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) { setAction(null); } } }, [action, daemonState, needsLogin, unreachable, driveLogin]); // The tray clicks Connect via its own gRPC call. When the daemon flips // to NeedsLogin afterwards, the tray emits trigger-login so the React // UI (which owns the SSO orchestration and the browser-login window) // takes over. driveLogin's loginGuard handles concurrent tray + // switch clicks. useEffect(() => { const off = Events.On(EVENT_TRIGGER_LOGIN, () => { driveLogin(); }); return () => off(); }, [driveLogin]); const handleSwitch = (next: boolean) => { if (unreachable || action !== null) return; if (needsLogin) { driveLogin(); return; } if (next && connState === ConnectionState.Disconnected) { void connect(); } else if (!next && connState === ConnectionState.Connected) { void disconnect(); } }; const isTransitioning = connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; const isOn = connState === ConnectionState.Connected || connState === ConnectionState.Connecting; const showLocal = connState === ConnectionState.Connected; const fqdn = status?.local.fqdn || ""; const ip = status?.local.ip || ""; return (
{"NetBird"}

{t(STATUS_KEY[connState])}

{fqdn || " "}

{ip || " "}

); };