mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 07:39:07 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -3,70 +3,29 @@ import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { contentTop } from "@/components/empty-state/EmptyState";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
|
||||
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
|
||||
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
|
||||
// device code.
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
|
||||
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
|
||||
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
|
||||
// The tray emits it from menu items so the React UI (which owns the SSO
|
||||
// orchestration and the browser-login window) takes over.
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
// loginInFlight is a module-level guard. SSO login involves multiple async
|
||||
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
|
||||
// call would race on the daemon's pending device code and on the popup
|
||||
// window's singleton, leading to confusing UX. Calls past the first are
|
||||
// dropped silently — the first invocation owns the flow until it settles.
|
||||
let loginInFlight = false;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end:
|
||||
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
|
||||
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
|
||||
// 3. Race WaitSSOLogin vs the user clicking Cancel.
|
||||
// 4. On success: Connection.Up.
|
||||
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
|
||||
// drops the abandoned device code (avoids an Idle blink on the tray).
|
||||
//
|
||||
// Errors that aren't user cancellations surface via errorDialog. Concurrent
|
||||
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
|
||||
// all exit paths so a stray popup doesn't outlive the flow.
|
||||
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
|
||||
// instant the flow itself is over (success, cancel, or error) — BEFORE the
|
||||
// error dialog is shown. Every guard that gates re-arming the login path
|
||||
// (the module-level loginInFlight here, and the caller's React-level
|
||||
// loginGuard via onSettled) must be released at that point, never gated on
|
||||
// the dialog.
|
||||
//
|
||||
// Why the dialog must be outside the guards: the native Windows MessageBox
|
||||
// disables its parent for its whole lifetime, and the main window's
|
||||
// WindowClosing hook hides instead of closing — the two race and the dialog
|
||||
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
|
||||
// release awaited the dialog, that guard would stay held for as long as the
|
||||
// box is open (or forever if it hangs), and every later Connect / tray
|
||||
// trigger-login would be silently dropped at the guard check until the
|
||||
// client is restarted. That was the original "can't log in again until
|
||||
// restart" bug.
|
||||
// onSettled (re-arm guards) must fire before the error dialog, never gated on it:
|
||||
// a hanging dialog would silently drop every later login until restart.
|
||||
async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
if (loginInFlight) {
|
||||
// The caller's guard must still be released — it was set before this
|
||||
// call. Without this the React-level loginGuard would wedge on a
|
||||
// dropped concurrent invocation.
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +76,7 @@ async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
|
||||
if (cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
void waitPromise.catch(() => {});
|
||||
waitPromise.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -128,9 +87,6 @@ async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
if (!cancelled) loginError = e;
|
||||
} finally {
|
||||
offCancel?.();
|
||||
// Release every guard before any UI work below — never gate re-arming
|
||||
// the login path on a dialog that can hang. loginInFlight is ours;
|
||||
// onSettled releases the caller's React-level loginGuard.
|
||||
loginInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
@@ -150,8 +106,6 @@ enum ConnectionState {
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
|
||||
// connState collapses them into Connecting or Disconnected upstream.
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
@@ -161,8 +115,6 @@ const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// Re-enable the switch after this long in a transitioning state so the user
|
||||
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
@@ -176,37 +128,18 @@ export const MainConnectionStatusSwitch = () => {
|
||||
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<Action>(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");
|
||||
// Release the React-level guard via onSettled — fired the instant the
|
||||
// flow ends, before startLogin's error dialog. Gating it on the full
|
||||
// startLogin() promise would keep loginGuard wedged for the whole
|
||||
// dialog lifetime, leaving the tray's trigger-login dropped at the
|
||||
// guard check until the client is restarted.
|
||||
void startLogin(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
void refresh();
|
||||
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
@@ -227,11 +160,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
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;
|
||||
@@ -254,11 +182,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
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 () => {
|
||||
@@ -274,23 +197,10 @@ export const MainConnectionStatusSwitch = () => {
|
||||
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;
|
||||
@@ -308,10 +218,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
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);
|
||||
}
|
||||
@@ -324,11 +230,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
}
|
||||
}, [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();
|
||||
@@ -359,9 +260,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const isOn =
|
||||
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
|
||||
|
||||
// When the daemon hangs in Connecting/Disconnecting, give the user an
|
||||
// escape hatch: after the delay, the switch becomes clickable again so a
|
||||
// tap fires Connection.Down (plus cancels any in-flight SSO flow).
|
||||
const [canForceCancel, setCanForceCancel] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTransitioning) {
|
||||
@@ -374,7 +272,9 @@ export const MainConnectionStatusSwitch = () => {
|
||||
|
||||
const forceCancel = async () => {
|
||||
if (action === "logging-in") {
|
||||
void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL);
|
||||
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
|
||||
console.error("emit browser-login cancel failed", err),
|
||||
);
|
||||
}
|
||||
WindowManager.CloseBrowserLogin().catch(() => {});
|
||||
setAction("disconnect");
|
||||
@@ -397,14 +297,8 @@ export const MainConnectionStatusSwitch = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Anchored from the top so the FQDN/IP lines below the toggle
|
||||
// can grow into a popover-aware layout without shifting the
|
||||
// toggle itself (justify-center would slide everything up
|
||||
// when the IP line is hidden during Disconnected).
|
||||
"flex flex-col h-full w-full items-center gap-4",
|
||||
"relative top-[11.7rem]",
|
||||
)}
|
||||
className={cn("flex flex-col h-full w-full items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
@@ -451,9 +345,6 @@ export const MainConnectionStatusSwitch = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
|
||||
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
|
||||
// a popover containing both v4 and v6, each independently click-to-copy.
|
||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasV6 = !!ipv6;
|
||||
@@ -489,10 +380,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
<button
|
||||
type={"button"}
|
||||
className={cn(
|
||||
// relative so the chevron can be absolutely
|
||||
// positioned alongside without widening the trigger
|
||||
// — keeps the IP text centred in its parent and
|
||||
// lets the popover centre cleanly on it.
|
||||
"group relative inline-flex items-center outline-none cursor-default",
|
||||
"transition-colors",
|
||||
)}
|
||||
@@ -540,9 +427,6 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
);
|
||||
};
|
||||
|
||||
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
|
||||
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
|
||||
// icon on the right that flips to a checkmark briefly after a successful copy.
|
||||
const IpRow = ({ value }: { value: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleClick = async () => {
|
||||
@@ -551,9 +435,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user