refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
@@ -13,9 +13,7 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
// Sustained gRPC failure during install is taken as success — the daemon
// gets restarted by the installer mid-flight, mirroring the legacy Fyne
// UI's branch in client/ui/update.go.
// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight).
const DAEMON_DOWN_GRACE_MS = 5000;
const WINDOW_WIDTH = 360;
@@ -36,79 +34,87 @@ export default function UpdateInProgressDialog() {
useEffect(() => {
let cancelled = false;
let done = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const start = Date.now();
let firstUnreachableAt: number | null = null;
const timer = setInterval(async () => {
if (cancelled) return;
const poll = async () => {
if (cancelled || done) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
clearInterval(timer);
done = true;
setPhase({ kind: "timeout" });
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
if (cancelled || done || phaseRef.current.kind !== "running") return;
firstUnreachableAt = null;
if (r.success) {
clearInterval(timer);
UpdateSvc.Quit();
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
if (r.errorMsg) {
clearInterval(timer);
done = true;
setPhase(mapInstallError(r.errorMsg));
return;
}
} catch {
if (cancelled || done || phaseRef.current.kind !== "running") return;
const now = Date.now();
if (firstUnreachableAt === null) {
firstUnreachableAt = now;
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
clearInterval(timer);
UpdateSvc.Quit();
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
}
}, POLL_INTERVAL_MS);
if (!cancelled && !done) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
if (timer) clearTimeout(timer);
};
}, []);
const isError = phase.kind !== "running";
const errorInfo = isError ? classifyPhase(phase, version, t) : null;
const updatingHeading = version
? t("update.overlay.updatingVersion", { version })
: t("update.overlay.updating");
return (
<ConfirmDialog ref={contentRef}>
{isError ? (
<SquareIcon
icon={XCircle}
className={"bg-red-500 [&_svg]:text-white"}
/>
<SquareIcon icon={XCircle} className={"bg-red-500 [&_svg]:text-white"} />
) : (
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
)}
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{isError
? errorInfo!.title
: version
? t("update.overlay.updatingVersion", { version })
: t("update.overlay.updating")}
{errorInfo ? errorInfo.title : updatingHeading}
</DialogHeading>
<DialogDescription>
{isError ? (
{errorInfo ? (
<>
{errorInfo!.description}
{errorInfo!.message && (
{errorInfo.description}
{errorInfo.message && (
<>
<br />
<span className={"first-letter:uppercase"}>
{errorInfo!.message}
{errorInfo.message}
</span>
</>
)}
@@ -126,9 +132,7 @@ export default function UpdateInProgressDialog() {
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={() =>
WindowManager.CloseInstallProgress().catch(console.error)
}
onClick={() => WindowManager.CloseInstallProgress().catch(console.error)}
>
{t("common.close")}
</Button>
@@ -9,7 +9,9 @@ import { cn } from "@/lib/cn";
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function UpdateVersionCard() {
@@ -62,7 +64,7 @@ export function UpdateVersionCard() {
);
}
function Card({ children, className }: { children: ReactNode; className?: string }) {
function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
return (
<div
className={cn(
@@ -75,11 +77,11 @@ function Card({ children, className }: { children: ReactNode; className?: string
);
}
function Title({ children }: { children: ReactNode }) {
function Title({ children }: Readonly<{ children: ReactNode }>) {
return <p className={"text-sm font-semibold"}>{children}</p>;
}
function Link({ url, children }: { url: string; children: ReactNode }) {
function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
return (
<button
type={"button"}
@@ -13,16 +13,6 @@ import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 380;
// ErrorDialog is the app's error surface — a frameless, always-on-top
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
// native OS MessageBox. Title and message arrive as query params (see
// services/windowmanager.go errorDialogURL); both are caller-localised. The
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
// it's repeated as the heading here so it stays visible on macOS, where the
// hidden-inset title bar doesn't render the chrome title. The single Close
// button (and the Escape key) dismisses the window via WindowManager.CloseError
// — the Go side destroys it on close.
export default function ErrorDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
@@ -35,15 +25,12 @@ export default function ErrorDialog() {
WindowManager.CloseError().catch(console.error);
}, []);
// Escape closes — keyboard-accessible cancellation, matching the native
// dialog's behaviour. The primary button is autoFocused below so Enter
// also dismisses.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [close]);
return (
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
@@ -12,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
@@ -34,12 +33,7 @@ export default function LoginWaitingForBrowserDialog() {
[t],
);
// Open the system browser only after the dialog has mounted (which
// means useAutoSizeWindow has called Window.Show). startLogin used to
// fire OpenURL itself but the browser typically beat React's mount
// and landed on top of the still-hidden NetBird popup. The ref guard
// keeps StrictMode's intentional double-invoke in dev (and any future
// remount) from launching two browser tabs.
// Open the browser only after mount, or it lands on top of the still-hidden popup.
useEffect(() => {
if (!uri || openedRef.current) return;
openedRef.current = true;
@@ -52,20 +46,17 @@ export default function LoginWaitingForBrowserDialog() {
}, [uri, reportOpenFailure]);
const cancel = useCallback(() => {
void Events.Emit(EVENT_CANCEL);
Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel", err),
);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon
icon={Loader2}
className={"[&_svg]:animate-spin"}
/>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{t("browserLogin.title")}
</DialogHeading>
<DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading>
<DialogDescription>
{t("browserLogin.notSeeing")}{" "}
<button
@@ -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
@@ -8,15 +8,13 @@ import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const { exitNodes, toggleExitNode } = useNetworks();
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
@@ -27,19 +25,23 @@ export const MainExitNodeSwitcher = () => {
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active) void toggleExitNode(active.id, true);
if (active)
toggleExitNode(active.id, true).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
return;
}
if (active && active.id === next) return;
void toggleExitNode(next, false);
if (active?.id === next) return;
toggleExitNode(next, false).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
};
const title = active ? active.id : t("exitNodes.card.title");
const description = !hasAny
? t("exitNodes.empty.title")
: active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
const activeDescription = active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
const description = hasAny ? activeDescription : t("exitNodes.empty.title");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
@@ -36,22 +36,18 @@ export const MainHeader = () => {
const openSettings = useCallback(() => {
setMenuOpen(false);
void WindowManager.OpenSettings("").catch(() => {});
WindowManager.OpenSettings("").catch(() => {});
}, []);
// Mirror the tray's Settings accelerator so the keystroke works while
// the main window has focus too. The tray's SetAccelerator paints the
// glyph on macOS/Linux but only fires the menu item — it can't reach the
// webview's input loop, hence the parallel React-side listener.
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
const openAbout = () => {
setMenuOpen(false);
void WindowManager.OpenSettings("about").catch(() => {});
WindowManager.OpenSettings("about").catch(() => {});
};
const openManageProfiles = () => {
void WindowManager.OpenSettings("profiles").catch(() => {});
WindowManager.OpenSettings("profiles").catch(() => {});
};
const selectMode = (mode: ViewMode) => {
@@ -130,16 +126,6 @@ export const MainHeader = () => {
</div>
);
// The inner grid is locked to 356px (the default-mode content width:
// 380px window 12px px-3 each side). It stays left-anchored regardless
// of window size, so the profile keeps the exact same absolute X
// position when the user flips to advanced view. The settings button is
// pulled out as an absolute, right-anchored element so it tracks the
// window's right edge in both modes.
// Header height matches the Settings window's top traffic-light strip
// so the right panel ends up the same height in both windows. The h-10
// of the inner buttons (profile trigger, more-vertical) defines the
// natural height; the strip in SettingsLayout is sized to mirror it.
return (
<div
className={cn(
@@ -147,8 +133,7 @@ export const MainHeader = () => {
"flex items-center h-12 top-3",
)}
>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
className={cn(
@@ -13,7 +13,7 @@ import { Networks } from "@/modules/main/advanced/networks/Networks";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
import {isWindows} from "@/lib/platform.ts";
import { isWindows } from "@/lib/platform.ts";
export const MainPage = () => {
return (
@@ -34,10 +34,14 @@ const MainBody = () => {
return (
<div className={"wails-draggable flex flex-1 min-h-0"}>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div
className={cn(
"relative flex flex-col items-center shrink-0 ",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<MainConnectionStatusSwitch />
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
@@ -19,7 +19,7 @@ type Props = {
};
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
@@ -34,7 +34,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
};
return (
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
@@ -62,21 +62,10 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
<span className={"w-4 shrink-0 flex items-center justify-center"}>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
@@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
@@ -12,37 +13,26 @@ import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { mockNetworkRoutes, mockOr } from "@/lib/mock";
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
// The daemon stringifies route.Network via netip.Prefix.String(). For
// DNS-based routes the prefix is the zero value, which Go renders as
// "invalid Prefix". Those rows render their domain + resolved IPs instead.
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
const INVALID_PREFIX = "invalid Prefix";
const isDnsRoute = (n: Network): boolean =>
n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
// Mirror management's NetworkResourceType (resource.go GetResourceType):
// a CIDR is a host when its prefix length equals the address width
// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with
// domains attached are domain resources.
type ResourceType = "host" | "subnet" | "domain";
const isHostCidr = (cidr: string): boolean => {
const [addr, bitsStr] = cidr.split("/");
if (!addr || !bitsStr) return false;
const bits = Number(bitsStr);
// IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'.
const isV6 = addr.includes(":");
return isV6 ? bits === 128 : bits === 32;
};
const resourceTypeOf = (n: Network): ResourceType => {
if (isDnsRoute(n)) return "domain";
// n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs
// come comma-joined, but those are filtered out upstream — guard
// defensively by inspecting only the first segment.
const primary = n.range.split(",")[0].trim();
return isHostCidr(primary) ? "host" : "subnet";
};
@@ -53,9 +43,6 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
return NetworkIcon;
};
// Map every range string -> ids of CIDR routes that share it. Domain routes
// are skipped (they overlap on domain, not prefix). Single-entry buckets
// aren't overlaps.
const buildOverlapMap = (
routes: { id: string; range: string; domains: string[] }[],
): Map<string, string[]> => {
@@ -77,8 +64,7 @@ export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
@@ -106,26 +92,19 @@ export const Networks = () => {
[networkRoutes, overlapById],
);
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it, and newly discovered routes append at
// the end (sorted active-first / by-id among themselves). The ref carries
// the previous order across renders so the reconciliation is synchronous
// with networkRoutes updates (no useEffect lag → no visual hop).
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = networkRoutes
.filter((r) => !known.has(r.id))
.sort((a, b) => {
const { order, items } = reconcileOrder(
orderRef.current,
networkRoutes,
(r) => r.id,
(a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((r) => r.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
},
);
orderRef.current = order;
return items;
}, [networkRoutes]);
const filtered = useMemo(() => {
@@ -158,13 +137,15 @@ export const Networks = () => {
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
void setNetworksSelected(
setNetworksSelected(
filtered.map((r) => r.id),
false,
);
).catch((err: unknown) => console.error("disable all networks failed", err));
} else {
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
void setNetworksSelected(ids, true);
setNetworksSelected(ids, true).catch((err: unknown) =>
console.error("enable all networks failed", err),
);
}
};
@@ -247,17 +228,26 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
{data.map((n) => (
<li
key={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
"wails-no-draggable",
)}
>
<button
type={"button"}
aria-label={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={"absolute inset-0 cursor-pointer"}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={n.id}>
<CopyToClipboard message={n.id} className={"pointer-events-auto"}>
<TruncatedText
text={n.id}
className={
@@ -268,7 +258,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
</div>
<Subtitle network={n} />
</div>
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
<div className={"shrink-0 self-center relative"}>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
@@ -388,25 +378,28 @@ type ToggleProps = {
mixed?: boolean;
};
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<button
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
className={cn(
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
>
<span
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return (
<button
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
/>
</button>
);
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
)}
/>
</button>
);
};
@@ -30,7 +30,6 @@ import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { mockOr, mockPeers } from "@/lib/mock";
import { peerStatusLabelKey } from "./Peers";
const DEFAULT_TRANSITION: Transition = {
@@ -60,12 +59,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
// Keep `selected` in sync with the live peer list so the panel reflects
// status / latency / byte updates without re-opening. If the peer
// disappears, close the panel.
useEffect(() => {
if (!selected) return;
const peers = mockOr(status?.peers ?? [], mockPeers);
const peers = status?.peers ?? [];
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
@@ -74,11 +70,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// Re-render every second so the relative timestamps in PeerDetails
// ("Xs ago", "Xm ago") tick. The daemon updates latency/bytes/handshake
// silently without pushing a fresh status snapshot — see
// status.go UpdateLatency / UpdateWireGuardPeerState — so without this
// the displayed age would freeze for a stably-Connected peer.
// Daemon updates latency/bytes/handshake without pushing a fresh status
// snapshot, so tick locally to keep relative timestamps live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!selected) return;
@@ -90,9 +83,6 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
// Refresh over the unix socket usually completes in <50ms, faster
// than the spin animation can show. Hold the spinning state for at
// least one full rotation so the click feels responsive.
const MIN_SPIN_MS = 600;
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
try {
@@ -102,14 +92,13 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
}
}, [refresh, refreshing]);
// Esc closes the panel.
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
return (
@@ -371,10 +360,6 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
);
};
// Single "View {n}" badge with a chevron that opens a click popover listing
// each routed resource on its own line with a click-to-copy entry. Avoids
// the repetitive "first item + N more" pattern given the row already has a
// "Resources" label and Layers icon.
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
@@ -19,7 +19,7 @@ type Props = {
};
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
@@ -34,7 +34,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
};
return (
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
@@ -4,6 +4,7 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
@@ -13,7 +14,6 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { mockOr, mockPeers } from "@/lib/mock";
import { PeerFilters, StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
@@ -29,8 +29,6 @@ const dotClass = (connStatus: string): string => {
}
};
// The daemon reports "Idle" for not-connected peers; surface it as
// "Disconnected" in the UI. Connected / Connecting pass through.
export const peerStatusLabelKey = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
@@ -49,15 +47,12 @@ export const Peers = () => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);
// Peers is only mounted in advanced view (see pages/Main.tsx), so a
// mount-time focus is equivalent to "focus when the user toggles into
// advanced view".
useEffect(() => {
searchRef.current?.focus();
}, []);
const isConnected = status?.status === "Connected";
const peers = mockOr(status?.peers ?? [], mockPeers);
const peers = status?.peers ?? [];
const counts = useMemo<Record<StatusFilter, number>>(() => {
const online = peers.filter((p) => isOnline(p.connStatus)).length;
@@ -68,34 +63,21 @@ export const Peers = () => {
};
}, [peers]);
// Initial order: online-first, then alphabetically by fqdn / ip. Once
// peers have settled, positions become sticky — a peer flipping
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
// peers append at the end (sorted online-first / by-name among
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
// pattern.
//
// Stay in live-sort mode until every peer has reached a stable state
// (Connected or Idle). The daemon emits all peers as "Connecting" right
// after Up, which collapses the online-first sort into pure
// alphabetical — committing then would lock that incorrect order and
// the list would stay alphabetical even after every peer becomes
// Connected. Once nothing is Connecting we commit and go sticky.
// Stay in live-sort until every peer is stable. Right after Up the daemon
// emits all peers as "Connecting"; committing then would lock that
// alphabetical-only order forever.
const orderRef = useRef<string[]>([]);
const stickyRef = useRef(false);
const ordered = useMemo(() => {
const sortOnlineFirst = (list: PeerStatus[]) =>
[...list].sort((a, b) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
});
const compare = (a: PeerStatus, b: PeerStatus) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
};
// Reset on empty (Disconnect → reconnect) so the next session
// re-sorts from scratch instead of replaying the stale orderRef.
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
@@ -103,7 +85,7 @@ export const Peers = () => {
}
if (!stickyRef.current) {
const sorted = sortOnlineFirst(peers);
const sorted = [...peers].sort(compare);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
@@ -111,15 +93,9 @@ export const Peers = () => {
return sorted;
}
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
(p) => p.pubKey,
);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((k) => byKey.get(k)!);
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
orderRef.current = order;
return items;
}, [peers]);
const filtered = useMemo(() => {
@@ -190,24 +166,36 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
return (
<li
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-default",
"wails-no-draggable",
)}
>
<button
type={"button"}
aria-label={shortenDns(peer.fqdn)}
onClick={() => setSelected(peer)}
className={"absolute inset-0 cursor-default"}
/>
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={peer.fqdn}>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
@@ -217,7 +205,10 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</CopyToClipboard>
</div>
<div>
<CopyToClipboard message={peer.ip}>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
@@ -227,7 +218,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums",
"shrink-0 self-center text-xs tabular-nums relative pointer-events-none",
latencyColor(peer.latencyMs),
)}
>
@@ -237,7 +228,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
<ChevronRightIcon
size={16}
className={cn(
"shrink-0 self-center text-nb-gray-300",
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
@@ -19,10 +19,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/cn";
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
// just like "test" does. The list is scanned in order, so more-specific
// tokens (e.g. "staging" before "stage") should come first when they share
// roots.
// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage").
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
[/(default|personal)/i, UserCircle],
[/(work|business|office|company|corp|corporate)/i, Briefcase],
@@ -18,19 +18,11 @@ import {
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
// onCreate receives the sanitized profile name and the management URL the
// user picked (the cloud default for Cloud mode, the normalized self-
// hosted URL otherwise).
onCreate: (name: string, managementUrl: string) => void;
};
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
// `_` and `-` survive on the Go side. We additionally lowercase and convert
// spaces to `-` so what the user sees in the input is exactly what the
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
// ghost row and breaks subsequent delete.
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
const sanitizeProfileInput = (value: string): string =>
value
.toLowerCase()
@@ -46,9 +38,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
const [url, setUrl] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
// unreachable: soft warning. A second submit with the same URL proceeds
// anyway (matches the onboarding management step's behaviour for self-
// hosted servers behind internal DNS / VPN).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
@@ -65,8 +54,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
}
}, [open]);
// Reset the URL warnings whenever the user edits the URL or flips mode —
// otherwise a stale warning lingers next to a just-corrected value.
useEffect(() => {
setUrlError(null);
setUnreachable(false);
@@ -100,9 +87,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: soft warning + bail. A second submit with the
// same URL skips re-checking (unreachable still true) so the user can
// proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
@@ -117,16 +101,14 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
if (nameError) setNameError(null);
};
// Live syntactic feedback: flag a non-empty, malformed URL as the user
// types instead of waiting for submit. Empty is not an error yet (handled
// on submit); the unreachable soft-warning only applies once syntax is OK.
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
mode === ManagementMode.SelfHosted &&
trimmedUrl !== "" &&
!isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
@@ -178,7 +160,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
<Input
ref={urlRef}
autoFocus
placeholder={t("settings.general.management.urlPlaceholder")}
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
@@ -1,6 +1,5 @@
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog } from "@/lib/dialogs.ts";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
@@ -10,7 +9,7 @@ import type { Profile } from "@bindings/services/models.js";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -59,79 +58,77 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const displayName = activeProfile || t("profile.selector.loading");
return (
<>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ProfileTriggerButton name={displayName} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="center"
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ProfileTriggerButton name={displayName} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="center"
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-60 px-1">
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSelect={handleSelect}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="-mx-1 h-px bg-nb-gray-910" />
</>
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-60 px-1">
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSelect={handleSelect}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="-mx-1 h-px bg-nb-gray-910" />
</>
)}
<div className={"pt-1"}>
<Command.Item
value={MANAGE_VALUE}
onSelect={handleManage}
disabled={!onManageProfiles}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</>
<div className={"pt-1"}>
<Command.Item
value={MANAGE_VALUE}
onSelect={handleManage}
disabled={!onManageProfiles}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -186,7 +183,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
>
<div className="flex flex-col min-w-0 flex-1 leading-tight">
<span className="truncate">{profile.name}</span>
{showEmail && <TruncatedEmail email={profile.email!} />}
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
{isActive && (
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
@@ -1,6 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog } from "@/lib/dialogs.ts";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
@@ -17,7 +16,8 @@ import { SetConfigParams } from "@bindings/services/models.js";
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { reconcileOrder } from "@/lib/sorting";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE = "default";
@@ -38,38 +38,22 @@ export function ProfilesTab() {
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
// The display order is established once — the active profile first, then
// the rest alphabetically — and then held stable for the lifetime of the
// window. Switching profiles must only flip the "active" badge, never
// reorder the rows (otherwise the row the user just clicked jumps to the
// top under their cursor). New profiles append at the end; removed ones
// drop out. `orderRef` is the source of truth for row order; the active
// badge is derived live from `activeProfile`.
// Order is held stable so switching only flips the badge, never reorders rows
// (else the clicked row jumps to the top under the cursor).
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const present = new Set(profiles.map((p) => p.name));
if (orderRef.current.length === 0) {
// First population: active-first, then alphabetical.
orderRef.current = [...profiles]
.sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
})
.map((p) => p.name);
} else {
// Preserve the established order; drop removed, append added.
const kept = orderRef.current.filter((n) => present.has(n));
const added = profiles
.map((p) => p.name)
.filter((n) => !orderRef.current.includes(n))
.sort((a, b) => a.localeCompare(b));
orderRef.current = [...kept, ...added];
}
const byName = new Map(profiles.map((p) => [p.name, p]));
return orderRef.current
.map((n) => byName.get(n))
.filter((p): p is Profile => p !== undefined);
const { order, items } = reconcileOrder(
orderRef.current,
profiles,
(p) => p.name,
(a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
},
);
orderRef.current = order;
return items;
}, [profiles, activeProfile]);
const guarded = async (title: string, fn: () => Promise<void>) => {
@@ -120,27 +104,17 @@ export function ProfilesTab() {
};
const handleCreate = async (name: string, managementUrl: string) => {
try {
await guarded(i18next.t("profile.error.createTitle"), async () => {
await addProfile(name);
// Only persist a management URL for self-hosted; a fresh profile
// already defaults to NetBird Cloud, so writing the cloud URL
// would be a no-op. Do it before switching so any reconnect the
// switch triggers already targets the right deployment. SetConfig
// is keyed by profile name, so it writes the new profile even
// though it isn't active yet (adminUrl left empty — the daemon
// keeps its loaded value).
// SetConfig is keyed by profile name, so it writes the not-yet-active
// profile. Write before switching so any reconnect targets the right deployment.
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: name, username, managementUrl }),
);
}
await switchProfile(name);
} catch (e) {
await errorDialog({
Title: i18next.t("profile.error.createTitle"),
Message: formatErrorMessage(e),
});
}
});
};
return (
@@ -193,7 +167,11 @@ export function ProfilesTab() {
</SettingsBottomBar>
</SectionGroup>
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
<ProfileCreationModal
open={newOpen}
onOpenChange={setNewOpen}
onCreate={handleCreate}
/>
</div>
);
}
@@ -230,12 +208,16 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
/>
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
<div className={"flex items-center gap-2 min-w-0"}>
<span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}>
<span
className={
"truncate font-medium text-nb-gray-100 select-text cursor-text"
}
>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div>
{showEmail && <TruncatedEmail email={profile.email!} />}
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</div>
</td>
@@ -265,7 +247,10 @@ const TruncatedEmail = ({ email }: { email: string }) => {
}, [email]);
const span = (
<span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}>
<span
ref={ref}
className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}
>
{email}
</span>
);
@@ -294,11 +279,10 @@ const RowActions = ({
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
const deleteLabel = isDefault
? t("profile.delete.disabledDefault")
: isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
const nonDefaultDeleteLabel = isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
@@ -329,10 +313,8 @@ type ActionIconButtonProps = {
icon: typeof CircleMinus;
onClick: () => void;
variant?: "default" | "danger";
/** When true the button still occupies space (preserves row layout)
* but is invisible and non-interactive. */
/** Occupies space but invisible and non-interactive (preserves row layout). */
hidden?: boolean;
/** When true the button is visible but non-interactive (greyed out). */
disabled?: boolean;
};
@@ -359,7 +341,8 @@ const ActionIconButton = ({
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
hidden && "opacity-0 pointer-events-none",
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
disabled &&
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)}
>
<Icon size={16} />
@@ -368,9 +351,7 @@ const ActionIconButton = ({
if (hidden) return button;
return (
<Tooltip
content={
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
}
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
side={"top"}
>
{button}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { AlertCircleIcon, ClockIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
@@ -12,30 +11,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors.ts";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
// Below this, the situation is genuinely "soon" and the title/description
// uses the urgent wording. Above it (e.g. opened with hours remaining), the
// "later" variant drops the urgency cue so it doesn't read absurdly.
const SOON_THRESHOLD_SECONDS = 60 * 60;
// Renders the countdown with only the units that matter: mm:ss under an
// hour, hh:mm:ss under a day, dd:hh:mm:ss otherwise. Two-digit zero pad
// throughout so columns don't jump as digits roll over.
function formatRemaining(seconds: number): string {
const s = Math.max(0, seconds | 0);
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const secs = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
return `${pad(minutes)}:${pad(secs)}`;
}
export default function SessionExpirationDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
@@ -49,27 +31,31 @@ export default function SessionExpirationDialog() {
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const busyRef = useRef(busy);
busyRef.current = busy;
const expired = remaining <= 0;
const soon = remaining <= SOON_THRESHOLD_SECONDS;
const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater");
const activeDescription = soon
? t("sessionExpiration.description")
: t("sessionExpiration.descriptionLater");
useEffect(() => {
setRemaining(initialSeconds);
}, [initialSeconds]);
useEffect(() => {
if (remaining <= 0) return;
const id = window.setInterval(() => {
const id = globalThis.setInterval(() => {
setRemaining((s) => (s <= 1 ? 0 : s - 1));
}, 1000);
return () => window.clearInterval(id);
}, [remaining]);
return () => globalThis.clearInterval(id);
}, [initialSeconds]);
// Auto-close when the daemon flips back to Connected — covers extend
// flows started from outside this window (tray notification action,
// another UI surface) so the user isn't left staring at a stale dialog.
// Suppressed while `busy`: the tunnel stays up so Connected re-fires for
// unrelated reasons (peer/route changes), and closing would abort our own WaitExtend.
useEffect(() => {
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
if (ev?.data?.status === "Connected") {
if (!busyRef.current && ev?.data?.status === "Connected") {
WindowManager.CloseSessionExpiration().catch(console.error);
}
});
@@ -78,11 +64,6 @@ export default function SessionExpirationDialog() {
};
}, []);
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
// opens the browser for the user to sign in, blocks on the daemon until
// the new deadline arrives. Tunnel stays up; success simply closes the
// dialog, failure surfaces a native error dialog and leaves this one
// open so the user can retry or logout.
const stay = useCallback(async () => {
if (busy) return;
setBusy(true);
@@ -101,13 +82,8 @@ export default function SessionExpirationDialog() {
userCode: start.userCode,
});
if (result.preempted) {
// Another UI surface (e.g. the tray "Extend now"
// notification action) started a flow for the same
// deadline and took over. Keep the dialog open so the
// user can re-trigger if the other flow also fails;
// a successful extend elsewhere refreshes the deadline
// and this window auto-closes when it's no longer
// relevant.
// Another surface took over this deadline's flow; keep the dialog
// open to retry. A successful extend elsewhere auto-closes this window.
return;
}
WindowManager.CloseSessionExpiration().catch(console.error);
@@ -152,18 +128,10 @@ export default function SessionExpirationDialog() {
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>
{expired
? t("sessionExpiration.expired")
: soon
? t("sessionExpiration.title")
: t("sessionExpiration.titleLater")}
{expired ? t("sessionExpiration.expired") : activeTitle}
</DialogHeading>
<DialogDescription>
{expired
? t("sessionExpiration.expiredDescription")
: soon
? t("sessionExpiration.description")
: t("sessionExpiration.descriptionLater")}
{expired ? t("sessionExpiration.expiredDescription") : activeDescription}
</DialogDescription>
</div>
@@ -187,9 +155,7 @@ export default function SessionExpirationDialog() {
onClick={stay}
disabled={busy}
>
{expired
? t("sessionExpiration.authenticate")
: t("sessionExpiration.stay")}
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
</Button>
<Button
variant={"secondary"}
@@ -1,15 +1,29 @@
import type { ComponentType, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
import netbirdFull from "@/assets/logos/netbird-full.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/>
</svg>
);
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/>
</svg>
);
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useStatus } from "@/contexts/StatusContext.tsx";
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function SettingsAbout() {
@@ -20,16 +34,23 @@ export function SettingsAbout() {
const handleVersionClick = useAccentTrigger();
const COMMUNITY_LINKS: { label: string; url: string; Icon: LucideIcon }[] = [
const COMMUNITY_LINKS: {
label: string;
url: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
iconClassName?: string;
}[] = [
{
label: t("settings.about.community.github"),
url: "https://github.com/netbirdio/netbird",
Icon: Github,
Icon: GithubIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.slack"),
url: "https://docs.netbird.io/slack-url",
Icon: Slack,
Icon: SlackIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.forum"),
@@ -63,7 +84,8 @@ export function SettingsAbout() {
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p
<button
type={"button"}
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
onClick={handleVersionClick}
>
@@ -77,7 +99,7 @@ export function SettingsAbout() {
) : (
t("settings.about.client", { version: daemonVersion })
)}
</p>
</button>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{guiVersion === "development" ? (
<span>
@@ -100,7 +122,7 @@ export function SettingsAbout() {
<div
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
>
{COMMUNITY_LINKS.map(({ label, url, Icon }) => (
{COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => (
<button
key={url}
type={"button"}
@@ -109,7 +131,7 @@ export function SettingsAbout() {
"inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
}
>
<Icon className={"h-3.5 w-3.5"} />
<Icon className={iconClassName ?? "h-3.5 w-3.5"} />
<span>{label}</span>
</button>
))}
@@ -35,7 +35,7 @@ function triggerAccent() {
root.render(<Accent onDone={cleanup} />);
}
function Accent({ onDone }: { onDone: () => void }) {
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(false);
@@ -94,14 +94,14 @@ function Accent({ onDone }: { onDone: () => void }) {
};
raf = requestAnimationFrame(draw);
const timeout = window.setTimeout(() => {
const timeout = globalThis.setTimeout(() => {
setVisible(false);
window.setTimeout(onDone, 500);
globalThis.setTimeout(onDone, 500);
}, 9000);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timeout);
globalThis.clearTimeout(timeout);
window.removeEventListener("resize", resize);
};
}, [onDone]);
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { System } from "@wailsio/runtime";
import Button from "@/components/buttons/Button";
@@ -8,23 +8,16 @@ import { Label } from "@/components/typography/Label";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
// macOS: the Darwin utun control socket parses the digits after "utun" as the
// unit number, so the daemon (and the CLI's parseInterfaceName in
// client/cmd/up.go) only accepts utun<N>.
// Linux/Windows: no daemon-side validation; the Linux kernel caps names at
// IFNAMSIZ-1 = 15 chars and the safe charset across both is [A-Za-z0-9._-].
// macOS daemon/CLI only accept utun<N> (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars.
const IS_MAC = System.IsMac();
const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
const INTERFACE_NAME_ERROR_KEY = IS_MAC
? "settings.advanced.interfaceName.errorMac"
: "settings.advanced.interfaceName.error";
// Port 0 means "let the daemon pick a random free port" (see the hint text).
// Port 0 lets the daemon pick a random free port.
const PORT_MIN = 0;
const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host
// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for
// IPv6 the daemon still needs 1280 on the path (RFC 8200), but that is not
// the validator's job to enforce.
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
const MTU_MIN = 576;
const MTU_MAX = 8192;
@@ -40,6 +33,15 @@ export function SettingsAdvanced() {
});
const [saving, setSaving] = useState(false);
useEffect(() => {
setValues({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
const errors = useMemo(() => {
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
@@ -55,11 +57,7 @@ export function SettingsAdvanced() {
max: PORT_MAX,
});
}
if (
!Number.isInteger(values.mtu) ||
values.mtu < MTU_MIN ||
values.mtu > MTU_MAX
) {
if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) {
out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX });
}
return out;
@@ -89,9 +87,7 @@ export function SettingsAdvanced() {
label={t("settings.advanced.interfaceName.label")}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) =>
setValues((v) => ({ ...v, interfaceName: e.target.value }))
}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
/>
<div className={"grid grid-cols-2 gap-4"}>
<div>
@@ -118,9 +114,7 @@ export function SettingsAdvanced() {
max={MTU_MAX}
value={values.mtu}
error={errors.mtu}
onChange={(e) =>
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
}
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
/>
</div>
</SectionGroup>
@@ -128,17 +122,13 @@ export function SettingsAdvanced() {
<SectionGroup title={t("settings.advanced.section.security")}>
<div>
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
<HelpText>
{t("settings.advanced.psk.help")}
</HelpText>
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
showPasswordToggle
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={values.preSharedKey}
onChange={(e) =>
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
}
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
/>
</div>
</SectionGroup>
@@ -15,25 +15,13 @@ export function SettingsGeneral() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { autostart, setAutostartEnabled } = useAutostartSetting();
const {
mode,
setMode,
setUrl,
displayUrl,
showError,
canSave,
save,
checking,
unreachable,
} = useManagementUrl();
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl();
const inputRef = useRef<HTMLInputElement>(null);
const prevMode = useRef(mode);
useEffect(() => {
if (
prevMode.current === ManagementMode.Cloud &&
mode === ManagementMode.SelfHosted
) {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
inputRef.current?.focus();
}
prevMode.current = mode;
@@ -71,9 +59,7 @@ export function SettingsGeneral() {
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>{t("settings.general.management.label")}</Label>
<HelpText>
{t("settings.general.management.help")}
</HelpText>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
@@ -26,49 +26,49 @@ export const SettingsNavigation = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={t("settings.tabs.troubleshooting")}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={t("settings.tabs.about")}
adornment={aboutAdornment}
/>
</VerticalTabs.List>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={t("settings.tabs.troubleshooting")}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={t("settings.tabs.about")}
adornment={aboutAdornment}
/>
</VerticalTabs.List>
</div>
);
};
@@ -7,10 +7,7 @@ import { isMacOS } from "@/lib/platform";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx";
import {
AutostartSettingsProvider,
SettingsProvider,
} from "@/contexts/SettingsContext.tsx";
import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
@@ -22,21 +19,6 @@ import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
// The settings window mounts once at app startup (hidden) and stays at the
// single URL `/#/settings` forever — no SetURL between opens, so the
// `AppLayout` provider stack never re-mounts and we never see the
// `SettingsSkeleton` flash mid-reload. Tab is local state, driven by:
// - the `netbird:settings:open` Wails event from `WindowManager.OpenSettings`
// (sets the target tab, then Go calls `Show`/`Focus`); and
// - the same event with payload `"general"` from the close hook, so the
// window is already on General the next time Show fires (common case).
// In-window navigation state (e.g. the update-available header jump to About)
// still wins for that one render.
//
// The `h-12` draggable strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
// buttons float over invisible title bar) and mirrors the main window's
// Header height so AppRightPanel ends up the same height in both windows.
export const SettingsPage = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
@@ -55,72 +37,63 @@ export const SettingsPage = () => {
return (
<>
{isMacOS() ? (
<div
className={
"wails-draggable cursor-default select-none h-12 shrink-0"
}
/>
<div className={"wails-draggable cursor-default select-none h-12 shrink-0"} />
) : (
<div className={"h-px shrink-0 bg-nb-gray-920/0"} />
)}
<VerticalTabs
value={active}
onValueChange={setActive}
>
<VerticalTabs value={active} onValueChange={setActive}>
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<ScrollArea.Root
key={active}
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<ProfilesTab />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content
value={"troubleshooting"}
>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
<ScrollArea.Root
key={active}
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<ProfilesTab />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
@@ -50,7 +50,10 @@ export function SettingsSSH() {
/>
</SectionGroup>
<SectionGroup title={t("settings.ssh.section.capabilities")} disabled={!isSSHServerEnabled}>
<SectionGroup
title={t("settings.ssh.section.capabilities")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
@@ -77,7 +80,10 @@ export function SettingsSSH() {
/>
</SectionGroup>
<SectionGroup title={t("settings.ssh.section.authentication")} disabled={!isSSHServerEnabled}>
<SectionGroup
title={t("settings.ssh.section.authentication")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
@@ -92,9 +98,7 @@ export function SettingsSSH() {
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
<HelpText margin={false}>
{t("settings.ssh.jwtTtl.help")}
</HelpText>
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
@@ -18,10 +18,6 @@ export const SectionGroup = ({
</section>
);
// SettingsBottomBar renders the floating action bar at the bottom of a
// settings tab (Save Changes / Add Profile / Create Bundle). It pairs the
// absolutely positioned bar with an in-flow spacer of the same height so
// scrollable content above doesn't end up hidden behind the bar.
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[4rem] shrink-0"} aria-hidden />
@@ -38,11 +38,7 @@ export function SettingsTroubleshooting() {
if (stage.kind === "done") {
return (
<DoneResult
result={stage.result}
uploaded={stage.uploadAttempted}
onClose={reset}
/>
<DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} />
);
}
if (stage.kind !== "idle") {
@@ -115,7 +111,7 @@ export function SettingsTroubleshooting() {
);
}
function CenteredPanel({ children }: { children: ReactNode }) {
function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
return (
<div
className={
@@ -127,7 +123,10 @@ function CenteredPanel({ children }: { children: ReactNode }) {
);
}
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
function ProgressSection({
stage,
onCancel,
}: Readonly<{ stage: DebugStage; onCancel: () => void }>) {
const { t } = useTranslation();
const cancelling = stage.kind === "cancelling";
return (
@@ -135,9 +134,7 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: ()
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2 max-w-xs"}>
<DialogHeading className={"text-balance"}>
{stageLabel(stage, t)}
</DialogHeading>
<DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading>
<DialogDescription>
{t("settings.troubleshooting.progress.description")}
</DialogDescription>
@@ -163,17 +160,19 @@ function DoneResult({
result,
uploaded,
onClose,
}: {
}: Readonly<{
result: DebugBundleResult;
uploaded: boolean;
onClose: () => void;
}) {
}>) {
const { t } = useTranslation();
const showKey = uploaded && Boolean(result.uploadedKey);
const uploadFailed = uploaded && !result.uploadedKey;
const onRevealPath = () => {
if (!result.path) return;
void DebugSvc.RevealFile(result.path).catch(() => {});
DebugSvc.RevealFile(result.path).catch((err: unknown) =>
console.error("reveal debug bundle file", err),
);
};
return (
<CenteredPanel>
@@ -252,12 +251,7 @@ function DoneResult({
</Button>
)
)}
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={onClose}
>
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}>
{t("common.close")}
</Button>
</DialogActions>
@@ -265,7 +259,10 @@ function DoneResult({
);
}
const stageLabel = (stage: DebugStage, t: (key: string, options?: Record<string, unknown>) => string): string => {
const stageLabel = (
stage: DebugStage,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
switch (stage.kind) {
case "preparing-trace":
return t("settings.troubleshooting.stage.preparingTrace");
@@ -8,8 +8,7 @@ import {
import { SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog } from "@/lib/dialogs";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
@@ -17,18 +16,8 @@ import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
// WelcomeStep is the orchestrator's state machine. The transitions:
// tray → management (if eligible) → finish
// tray → finish (otherwise)
// Login itself is no longer part of onboarding — once the welcome window
// closes the user lands in the main window and clicks Connect there.
type WelcomeStep = "tray" | "management";
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
// on a pristine setup — default profile, no email recorded (no successful
// login yet), and the management URL is either unset or already the cloud
// default. Any other state means the user (or a previous run) already
// made a deliberate choice and we shouldn't second-guess it.
function shouldShowManagementStep(
activeProfile: string,
email: string,
@@ -39,10 +28,6 @@ function shouldShowManagementStep(
return isCloudManagementUrl(managementUrl);
}
// initial flow snapshot resolved at mount. Held in component state so the
// step-2 management input can hydrate from initialUrl, and so the
// "should we even show step 2" check is computed once (the user can't
// change profile / URL from inside the welcome window).
type InitialState = {
profileName: string;
username: string;
@@ -54,24 +39,12 @@ export default function WelcomeDialog() {
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
// ready=false until the daemon probe resolves — keeps the window
// Hidden so neither the empty padding-only frame (Linux/GNOME paints
// through) nor a placeholder div leaks onto screen.
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
// Probe daemon state on mount: who's the active profile, do they
// have an email recorded, and what management URL is configured?
// Errors fall through to "skip the management step" so a daemon
// hiccup never blocks onboarding entirely.
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Resolve username + active profile first so GetConfig + List
// can target the actual profile (passing empty strings would
// work today since the daemon falls back to the default
// profile, but being explicit shields us from future
// changes to that fallback).
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
@@ -97,8 +70,6 @@ export default function WelcomeDialog() {
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
// Conservative fallback: skip the management step rather
// than block onboarding behind a daemon hiccup.
setInitial({
profileName: "default",
username: "",
@@ -112,10 +83,6 @@ export default function WelcomeDialog() {
};
}, []);
// finish persists the onboarding flag, opens the main window so the
// user has somewhere to land, and closes the welcome window. Called
// at the end of every successful flow (tray-only and tray→management
// alike). The Connect button in the main window picks up from here.
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
@@ -148,10 +115,7 @@ export default function WelcomeDialog() {
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — pointer fields left
// undefined are preserved (services/settings.go). We only
// touch managementUrl; adminUrl stays empty here because
// the daemon already has its own value loaded.
// SetConfig is a partial update — undefined fields are preserved Go-side.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
@@ -18,16 +18,14 @@ import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
// initialUrl is the management URL the daemon is already configured
// with (empty / cloud-default both render as Cloud selected).
initialUrl: string;
// onContinue is invoked with the URL the user wants to persist. The
// parent owns the actual Settings.SetConfig call so the dialog stays
// free of context dependencies.
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
export function WelcomeStepManagement({
initialUrl,
onContinue,
}: Readonly<WelcomeStepManagementProps>) {
const { t } = useTranslation();
const startsCloud = isCloudManagementUrl(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
@@ -35,21 +33,13 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
// unreachable: soft warning. Continue stays enabled — user can confirm
// they typed it right and proceed (matches self-hosted-behind-internal-
// DNS / VPN scenarios where the in-app fetch would false-negative).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
// Continue is no longer disabled for an empty / invalid self-hosted
// URL; a Continue click in that state focuses the input and renders
// an inline error so the user actively notices what's missing.
const inputRef = useRef<HTMLInputElement | null>(null);
// Reset inline error/warning whenever the user edits the URL or flips
// mode — otherwise the warning lingers next to a just-corrected value.
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
@@ -58,9 +48,6 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
// Empty or syntactically invalid URL — Continue stays enabled
// so the click registers; surface the error inline and focus
// the input so the user has somewhere to fix it.
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
@@ -69,14 +56,11 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted) {
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: show soft warning + bail. A second click
// with the same URL skips the check (unreachable still true)
// so the user can proceed if they're sure.
if (!reachable && !unreachable) {
if (!reachable) {
setUnreachable(true);
return;
}
@@ -84,14 +68,10 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
try {
await onContinue(target);
} catch (e) {
// Parent surfaces save errors via errorDialog; keep a console
// breadcrumb but don't double-render.
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
// Syntax problems are hard errors (red); an unreachable-but-valid URL is
// a soft, non-blocking caveat (orange).
const inputError = syntaxError ?? undefined;
const inputWarning = useMemo(
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
@@ -8,12 +8,7 @@ import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// trayScreenshotForOS picks the marketing screenshot that shows the
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
// matches the chrome the user will actually be hunting for. Evaluated
// inside the component so initPlatform() has finished by the time
// isMacOS/isWindows run (the static imports above only load the bytes,
// no platform check).
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
@@ -24,7 +19,7 @@ type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();