[management,client] 0.75.0 release with new desktop UI (#6473)

- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Maycon Santos
2026-07-06 13:47:16 +02:00
committed by GitHub
co-authored by Zoltan Papp Eduard Gert braginini Pascal Fischer riccardom
parent c9d387bd0d
commit 91acb8147c
389 changed files with 46269 additions and 6684 deletions
@@ -0,0 +1,27 @@
import { forwardRef, type HTMLAttributes } from "react";
import { ArrowUpCircleIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const UpdateBadge = forwardRef<HTMLDivElement, Props>(function UpdateBadge(
{ size = 15, className, ...rest },
ref,
) {
return (
<div
ref={ref}
className={cn("relative flex items-center justify-center", className)}
{...rest}
>
<span
className={
"pointer-events-none absolute inline-flex h-[15px] w-[15px] animate-ping rounded-full bg-netbird opacity-20"
}
/>
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
</div>
);
});
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Loader2, XCircle } from "lucide-react";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
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 (installer restarts the daemon mid-flight).
const DAEMON_DOWN_GRACE_MS = 5000;
const WINDOW_WIDTH = 360;
type Phase =
| { kind: "running" }
| { kind: "timeout" }
| { kind: "canceled" }
| { kind: "failed"; message: string };
export default function UpdateInProgressDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const version = params.get("version") ?? "";
const [phase, setPhase] = useState<Phase>({ kind: "running" });
const phaseRef = useRef(phase);
phaseRef.current = phase;
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
useEffect(() => {
let cancelled = false;
let done = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const start = Date.now();
let firstUnreachableAt: number | null = null;
const poll = async () => {
if (cancelled || done) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
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) {
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
if (r.errorMsg) {
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) {
done = true;
UpdateSvc.Quit().catch(console.error);
return;
}
}
if (!cancelled && !done) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
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={Loader2} className={"[&_svg]:animate-spin"} />
)}
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{errorInfo ? errorInfo.title : updatingHeading}
</DialogHeading>
<DialogDescription>
{errorInfo ? (
<>
{errorInfo.description}
{errorInfo.message && (
<>
<br />
<span className={"first-letter:uppercase"}>
{errorInfo.message}
</span>
</>
)}
</>
) : (
t("update.overlay.description")
)}
</DialogDescription>
</div>
{isError && (
<DialogActions>
<Button
autoFocus
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={() => WindowManager.CloseInstallProgress().catch(console.error)}
>
{t("common.close")}
</Button>
</DialogActions>
)}
</ConfirmDialog>
);
}
function mapInstallError(msg: string): Phase {
const m = msg.trim().toLowerCase();
if (m === "") return { kind: "failed", message: "" };
if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) {
return { kind: "timeout" };
}
if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) {
return { kind: "canceled" };
}
return { kind: "failed", message: msg };
}
type Variant = { title: string; description: string; message?: string };
function classifyPhase(
phase: Phase,
version: string,
t: (key: string, options?: Record<string, unknown>) => string,
): Variant {
const target = version
? t("update.overlay.error.targetVersion", { version })
: t("update.overlay.error.targetFallback");
switch (phase.kind) {
case "timeout":
return {
title: t("update.overlay.error.timeoutTitle"),
description: t("update.overlay.error.timeoutDescription", { target }),
};
case "canceled":
return {
title: t("update.overlay.error.canceledTitle"),
description: t("update.overlay.error.canceledDescription", { target }),
};
case "failed":
return {
title: t("update.overlay.error.failTitle"),
description: t("update.overlay.error.failDescription", { target }),
message: phase.message || t("update.overlay.error.unknownMessage"),
};
default:
return { title: "", description: "" };
}
}
@@ -0,0 +1,96 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { DownloadIcon, NotepadText } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function UpdateVersionCard() {
const { t } = useTranslation();
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
if (updateVersion) {
const titleKey = enforced
? "update.card.versionAvailableInstall"
: "update.card.versionAvailableDownload";
return (
<Card className={"max-w-lg"}>
<div>
<Title>{t(titleKey, { version: updateVersion })}</Title>
<Link
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
>
{t("update.card.whatsNew")}
</Link>
</div>
{enforced ? (
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
{t("update.card.installNow")}
</Button>
) : (
<Button
variant={"primary"}
size={"xs"}
onClick={() => openUrl(GITHUB_RELEASES)}
>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
</Button>
)}
</Card>
);
}
return (
<Card className={"max-w-lg"}>
<div>
<Title>{t("update.card.onLatestVersion")}</Title>
<p className={"text-sm text-nb-gray-300"}>{t("update.card.autoCheckInterval")}</p>
</div>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(GITHUB_RELEASES)}>
<NotepadText size={14} />
{t("update.card.changelog")}
</Button>
</Card>
);
}
function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
return (
<div
className={cn(
"flex w-full items-center justify-between gap-4 rounded-md border border-nb-gray-800 bg-nb-gray-910 px-4 py-3",
className,
)}
>
{children}
</div>
);
}
function Title({ children }: Readonly<{ children: ReactNode }>) {
return <p className={"text-sm font-semibold"}>{children}</p>;
}
function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
return (
<button
type={"button"}
onClick={() => openUrl(url)}
className={
"text-sm font-medium text-netbird hover:underline hover:decoration-[0.5px] hover:underline-offset-4"
}
>
{children}
</button>
);
}
@@ -0,0 +1,64 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 380;
export default function ErrorDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const title = params.get("title") || t("window.title.error");
const message = params.get("message") || "";
const close = useCallback(() => {
WindowManager.CloseError().catch(console.error);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [close]);
return (
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
{title}
</DialogHeading>
{message && (
<DialogDescription className={"text-balance"}>
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
</DialogDescription>
)}
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={close}
>
{t("common.close")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
export default function LoginWaitingForBrowserDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const openedRef = useRef(false);
const reportOpenFailure = useCallback(
(e: unknown) => {
void errorDialog({
Title: t("browserLogin.openFailedTitle"),
Message: formatErrorMessage(e),
});
},
[t],
);
// 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;
Connection.OpenURL(uri).catch(reportOpenFailure);
}, [uri, reportOpenFailure]);
const tryAgain = useCallback(() => {
if (!uri) return;
Connection.OpenURL(uri).catch(reportOpenFailure);
}, [uri, reportOpenFailure]);
const cancel = useCallback(() => {
Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel", err),
);
}, []);
return (
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading id={"nb-browser-login-title"} className={"text-balance"}>
{t("browserLogin.title")}
</DialogHeading>
<DialogDescription>
{t("browserLogin.notSeeing")}{" "}
<button
type={"button"}
onClick={tryAgain}
disabled={!uri}
className={
"wails-no-draggable text-netbird hover:underline disabled:cursor-not-allowed disabled:opacity-40"
}
>
{t("browserLogin.tryAgain")}
</button>
</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={cancel}
>
{t("common.cancel")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
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 { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import {
startConnection,
EVENT_BROWSER_LOGIN_CANCEL,
EVENT_TRIGGER_LOGIN,
} from "@/lib/connection.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { contentTop } from "@/components/empty-state/EmptyState";
import { useFocusVisible } from "@/hooks/useFocusVisible";
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";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
const { activeProfileId, username } = useProfile();
const daemonState = status?.status ?? "Idle";
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
const unreachable = daemonState === "DaemonUnavailable";
type Action = "connect" | "logging-in" | "disconnect" | null;
const [action, setAction] = useState<Action>(null);
const loginGuard = useRef(false);
const driveLogin = useCallback(() => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
void startConnection(() => {
loginGuard.current = false;
setAction(null);
refresh().catch((err: unknown) => console.error("refresh after login failed", err));
});
}, [refresh]);
const connState: ConnectionState = useMemo(() => {
if (action === "disconnect" && daemonState === "Connected") {
return ConnectionState.Disconnecting;
}
if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") {
return ConnectionState.Connecting;
}
switch (daemonState) {
case "Connected":
return ConnectionState.Connected;
case "Connecting":
return ConnectionState.Connecting;
case "Idle":
case "NeedsLogin":
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;
}
}, [daemonState, action]);
const connect = async () => {
setAction("connect");
try {
await Connection.Up({
profileName: activeProfileId,
username,
});
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.connectTitle"),
Message: errorMessage(e),
});
}
};
const disconnect = async () => {
setAction("disconnect");
try {
await Connection.Down();
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
}
};
const sawConnectingRef = useRef(false);
useEffect(() => {
if (action === null) {
sawConnectingRef.current = false;
return;
}
if (daemonState === "Connecting") {
sawConnectingRef.current = true;
}
if (action === "connect") {
if (needsLogin) {
driveLogin();
return;
}
if (daemonState === "Connected" || unreachable) {
setAction(null);
return;
}
if (sawConnectingRef.current && daemonState === "Idle") {
setAction(null);
}
return;
}
if (action === "disconnect") {
if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) {
setAction(null);
}
}
}, [action, daemonState, needsLogin, unreachable, driveLogin]);
useEffect(() => {
const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
driveLogin();
});
return () => off();
}, [driveLogin]);
const handleSwitch = (next: boolean) => {
if (unreachable) return;
if (isTransitioning) {
if (canForceCancel) void forceCancel();
return;
}
if (action !== null) return;
if (needsLogin) {
driveLogin();
return;
}
if (next && connState === ConnectionState.Disconnected) {
void connect();
} else if (!next && connState === ConnectionState.Connected) {
void disconnect();
}
};
const isTransitioning =
connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting;
const isOn =
connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
const [canForceCancel, setCanForceCancel] = useState(false);
useEffect(() => {
if (!isTransitioning) {
setCanForceCancel(false);
return;
}
const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS);
return () => clearTimeout(id);
}, [isTransitioning]);
const forceCancel = async () => {
if (action === "logging-in") {
Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
console.error("emit browser-login cancel failed", err),
);
}
WindowManager.CloseBrowserLogin().catch((err: unknown) =>
console.warn("close browser-login window failed", err),
);
setAction("disconnect");
try {
await Connection.Down();
await refresh();
} catch (e) {
setAction(null);
await refresh();
await errorDialog({
Title: t("connect.error.disconnectTitle"),
Message: errorMessage(e),
});
}
};
const show = connState === ConnectionState.Connected;
const fqdn = status?.local.fqdn || "";
const ip = status?.local.ip || "";
const ipv6 = status?.local.ipv6 || "";
return (
<div
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
style={{ top: contentTop("11.7rem") }}
>
<img
src={netbirdFullLogo}
alt={"NetBird"}
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
draggable={false}
/>
<ToggleSwitch
size={"large"}
checked={isOn}
onCheckedChange={handleSwitch}
disabled={(isTransitioning && !canForceCancel) || unreachable}
aria-label={t("connect.toggle.label")}
aria-describedby={"nb-connection-status"}
aria-busy={isTransitioning}
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
/>
<div className={"flex flex-col items-center"}>
<p
id={"nb-connection-status"}
role={"status"}
aria-live={"polite"}
className={
"wails-no-draggable mb-1 select-none text-sm font-medium tracking-wide text-nb-gray-200 transition-colors duration-300"
}
>
{t(STATUS_KEY[connState])}
</p>
<CopyToClipboard
message={fqdn}
variant={"bright"}
iconClassName={"-top-px"}
tabIndex={show && fqdn ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
"relative left-[0.55rem]",
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
className={
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
}
/>
</CopyToClipboard>
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
</div>
</div>
);
};
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const isFocusVisible = useFocusVisible();
const hasV6 = !!ipv6;
if (!hasV6) {
return (
<CopyToClipboard
message={ip}
variant={"bright"}
tabIndex={show && ip ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
);
}
return (
<div
className={cn(
"min-h-[1em] max-w-full transition-opacity duration-300",
"wails-no-draggable relative",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
tabIndex={show && ip ? 0 : -1}
aria-label={t("connect.localIp.label")}
aria-haspopup={"dialog"}
aria-expanded={open}
className={cn(
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"transition-colors",
)}
>
<span
className={cn(
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
>
{ip || " "}
</span>
<ChevronDownIcon
size={14}
aria-hidden={"true"}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"center"}
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 max-w-[280px] overflow-hidden",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-1 text-nb-gray-200 shadow-lg outline-none",
"flex flex-col",
)}
>
<IpRow value={ip} />
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
<IpRow value={ipv6} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
};
const IpRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isFocusVisible = useFocusVisible();
const handleClick = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (e) {
console.warn("copy IP to clipboard failed", e);
}
};
return (
<button
type={"button"}
onClick={handleClick}
tabIndex={0}
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
<span
aria-hidden={"true"}
className={"inline-flex shrink-0 items-center text-nb-gray-200"}
>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};
@@ -0,0 +1,256 @@
import { forwardRef, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes, toggleExitNode } = useNetworks();
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
const disabled = !isConnected || !hasAny;
const [open, setOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (open || disabled) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
}
};
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active)
toggleExitNode(active.id, true).catch((err: unknown) =>
console.error("toggle exit node failed", err),
);
return;
}
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 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}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ExitNodeTriggerCard
title={title}
description={description}
disabled={disabled}
active={!!active}
aria-label={t("exitNodes.dropdown.trigger")}
aria-haspopup={"listbox"}
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
side={"top"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => {
e.preventDefault();
listRef.current?.focus();
}}
style={{ width: "var(--radix-popover-trigger-width)" }}
className={cn(
"wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
"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()}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<Command.List
ref={listRef}
aria-label={t("exitNodes.dropdown.trigger")}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
key={n.id}
id={n.id}
label={n.id}
isActive={active?.id === n.id}
onSelect={() => handleSelect(n.id)}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
title: string;
description: string;
active?: boolean;
};
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
function ExitNodeTriggerCard(
{ title, description, disabled, active = false, className, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<button
ref={ref}
type={"button"}
tabIndex={0}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled
? "cursor-not-allowed opacity-60"
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
className,
)}
{...props}
>
<div
aria-hidden={"true"}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
active
? "bg-green-500/25 text-green-400"
: "bg-nb-gray-900 text-nb-gray-300",
)}
>
<ExitNodeIcon size={14} />
</div>
<div className={"min-w-0 flex-1"}>
<span className={"block truncate text-sm font-medium text-nb-gray-100"}>
{title}
</span>
<TruncatedText
text={description}
className={
"block max-w-full truncate text-[0.85rem] font-medium text-nb-gray-400"
}
/>
</div>
<ChevronsUpDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
);
},
);
type NoneRowProps = {
isActive: boolean;
onSelect: () => void;
};
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
const { t } = useTranslation();
return (
<Command.Item
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && (
<Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />
)}
</Command.Item>
);
};
type ExitNodeRowProps = {
id: string;
label: string;
isActive: boolean;
onSelect: () => void;
};
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
<Command.Item
value={id}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} aria-hidden={"true"} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);
@@ -0,0 +1,192 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUpCircleIcon,
Check,
MoreVertical,
PanelsRightBottom,
RectangleVertical,
Settings,
type LucideIcon,
} from "lucide-react";
import { WindowManager } from "@bindings/services";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { IconButton } from "@/components/buttons/IconButton";
import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { isWindows } from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
export const MainHeader = () => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const openSettings = useCallback(() => {
setMenuOpen(false);
WindowManager.OpenSettings("").catch((err: unknown) =>
console.error("open settings window failed", err),
);
}, []);
useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
const openAbout = () => {
setMenuOpen(false);
WindowManager.OpenSettings("about").catch((err: unknown) =>
console.error("open settings (about) window failed", err),
);
};
const openManageProfiles = () => {
WindowManager.OpenSettings("profiles").catch((err: unknown) =>
console.error("open settings (profiles) window failed", err),
);
};
const selectMode = (mode: ViewMode) => {
setMenuOpen(false);
setViewMode(mode);
};
const profileSlot = features.disableProfiles ? null : (
<ProfileDropdown onManageProfiles={openManageProfiles} />
);
const settingsSlot = (
<div className={"relative"}>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
<IconButton
icon={MoreVertical}
iconClassName={"text-nb-gray-200 wails-no-draggable"}
className={"select-none"}
aria-label={t("header.menu.open")}
aria-haspopup={"menu"}
aria-expanded={menuOpen}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
sideOffset={8}
className={
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
}
>
{updateAvailable && (
<>
<DropdownMenuItem onClick={openAbout}>
<div className={"flex items-center gap-2"}>
<ArrowUpCircleIcon
size={14}
className={"text-netbird"}
aria-hidden={"true"}
/>
<span className={"text-netbird"}>
{t("header.menu.updateAvailable")}
</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={openSettings}>
<div className={"flex w-full items-center gap-2"}>
<Settings size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("header.menu.settings")}</span>
<DropdownMenuShortcut>
{formatShortcut(SETTINGS_SHORTCUT)}
</DropdownMenuShortcut>
</div>
</DropdownMenuItem>
{!mdm.disableAdvancedView && (
<>
<DropdownMenuSeparator />
<ViewModeItem
icon={RectangleVertical}
label={t("header.menu.defaultView")}
selected={viewMode === "default"}
onSelect={() => selectMode("default")}
/>
<ViewModeItem
icon={PanelsRightBottom}
label={t("header.menu.advancedView")}
selected={viewMode === "advanced"}
onSelect={() => selectMode("advanced")}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{updateAvailable && (
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
>
<span
className={
"absolute inset-0 animate-ping rounded-full bg-netbird opacity-60"
}
/>
<span className={"relative h-1.5 w-1.5 rounded-full bg-netbird"} />
</span>
)}
</div>
);
return (
<header
className={cn(
"wails-draggable relative z-10 shrink-0 cursor-default",
"top-3 flex h-12 items-center",
)}
>
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
className={cn(
"grid shrink-0 grid-cols-3 items-center",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<div />
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
</header>
);
};
type ViewModeItemProps = {
icon: LucideIcon;
label: string;
selected: boolean;
onSelect: () => void;
};
const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
<DropdownMenuItem onClick={onSelect} role={"menuitemradio"} aria-checked={selected}>
<div className={"flex w-full items-center gap-2"}>
<Icon size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{label}</span>
{selected && <Check size={14} className={"text-netbird"} aria-hidden={"true"} />}
</div>
</DropdownMenuItem>
);
@@ -0,0 +1,114 @@
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
import { MainHeader } from "@/modules/main/MainHeader.tsx";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
import { cn } from "@/lib/cn";
import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
import { useEffect } from "react";
import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
import { isWindows } from "@/lib/platform.ts";
export const MainPage = () => {
return (
<ViewModeProvider>
<MainHeader />
<NetworksProvider>
<PeerDetailProvider>
<MainBody />
</PeerDetailProvider>
</NetworksProvider>
</ViewModeProvider>
);
};
const MainBody = () => {
const { viewMode, setViewMode } = useViewMode();
const { mdm, features } = useRestrictions();
// Force flip the view if MDM disabled advanced
useEffect(() => {
if (mdm.disableAdvancedView && viewMode === "advanced") {
setViewMode("default");
}
}, [mdm.disableAdvancedView, viewMode, setViewMode]);
const isAdvanced = viewMode === "advanced";
return (
<main className={"wails-draggable flex min-h-0 flex-1"}>
{/* 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 shrink-0 flex-col items-center",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<MainConnectionStatusSwitch />
{!features.disableNetworks && (
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
<MainExitNodeSwitcher />
</div>
)}
</div>
{isAdvanced && (
<NavSectionProvider>
<AdvancedAppRightPanel />
</NavSectionProvider>
)}
</main>
);
};
const AdvancedAppRightPanel = () => {
const { section } = useNavSection();
const { selected } = usePeerDetail();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
return (
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
>
<div
ref={(el) => {
if (!el) return;
if (isConnected) el.removeAttribute("inert");
else el.setAttribute("inert", "");
}}
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
!isConnected && "pointer-events-none select-none",
)}
aria-hidden={!isConnected}
>
<Navigation />
<div
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
className={"flex min-h-0 flex-1 flex-col"}
>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
</div>
</div>
{!isConnected && (
<div className={"pointer-events-auto absolute inset-0 z-20 flex bg-nb-gray-940"}>
<NotConnectedState />
</div>
)}
</AppRightPanel>
);
};
@@ -0,0 +1,134 @@
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
type TabEntry = {
value: NavSection;
label: string;
icon: ComponentType<LucideProps>;
};
export const Navigation = () => {
const { t } = useTranslation();
const { section, setSection } = useNavSection();
const { status } = useStatus();
const { features } = useRestrictions();
const isConnected = status?.status === "Connected";
// Reset back to peers tab if mdm or feature flag flipped it
useEffect(() => {
if (features.disableNetworks && section === "networks") {
setSection("peers");
}
}, [features.disableNetworks, section, setSection]);
const tabs: TabEntry[] = [
{
value: "peers",
label: t("nav.peers.title"),
icon: MonitorSmartphoneIcon,
},
];
if (!features.disableNetworks) {
tabs.push({
value: "networks",
label: t("nav.resources.title"),
icon: Layers3Icon,
});
}
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const focusTab = (value: NavSection) => {
setSection(value);
requestAnimationFrame(() => tabRefs.current[value]?.focus());
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const enabled = tabs.filter((t) => isConnected || t.value === section);
if (enabled.length < 2) return;
const currentIndex = enabled.findIndex((t) => t.value === section);
if (currentIndex === -1) return;
let nextIndex: number;
switch (e.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % enabled.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = enabled.length - 1;
break;
default:
return;
}
e.preventDefault();
focusTab(enabled[nextIndex].value);
};
return (
<div
role={"tablist"}
aria-orientation={"horizontal"}
aria-label={t("nav.peers.title")}
className={"wails-no-draggable flex shrink-0 items-stretch"}
>
{tabs.map((tab, index) => {
const isActive = tab.value === section;
const isDisabled = !isConnected && !isActive;
const isFirst = index === 0;
const isLast = index === tabs.length - 1;
const Icon = tab.icon;
return (
<button
key={tab.value}
ref={(el) => {
tabRefs.current[tab.value] = el;
}}
type={"button"}
role={"tab"}
aria-selected={isActive}
aria-controls={`nb-tabpanel-${tab.value}`}
id={`nb-tab-${tab.value}`}
tabIndex={isActive ? 0 : -1}
onClick={() => setSection(tab.value)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
className={cn(
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
>
<Icon size={14} aria-hidden={"true"} />
<span className={"text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden={"true"}
className={cn(
"absolute inset-x-0 bottom-0 h-px transition-all",
isActive
? "bg-netbird"
: "bg-nb-gray-910 group-hover:bg-nb-gray-700",
)}
/>
</button>
);
})}
</div>
);
};
export type { NavSection } from "@/contexts/NavSectionContext";
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type NetworkFilter = "all" | "active" | "overlapping";
type Props = {
value: NetworkFilter;
onChange: (value: NetworkFilter) => void;
counts: Record<NetworkFilter, number>;
disabled?: boolean;
};
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
{ value: "active", label: t("networks.filter.active") },
{ value: "overlapping", label: t("networks.filter.overlapping") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: NetworkFilter) => {
onChange(v);
setOpen(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
tabIndex={0}
aria-label={t("common.filter")}
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role={"menuitemradio"}
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
aria-hidden={"true"}
className={"flex w-4 shrink-0 items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,528 @@
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
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";
import { SearchInput } from "@/components/inputs/SearchInput";
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 { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
// 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);
type ResourceType = "host" | "subnet" | "domain";
const isHostCidr = (cidr: string): boolean => {
const [addr, bitsStr] = cidr.split("/");
if (!addr || !bitsStr) return false;
const bits = Number(bitsStr);
const isV6 = addr.includes(":");
return isV6 ? bits === 128 : bits === 32;
};
const resourceTypeOf = (n: Network): ResourceType => {
if (isDnsRoute(n)) return "domain";
const primary = n.range.split(",")[0].trim();
return isHostCidr(primary) ? "host" : "subnet";
};
const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
if (type === "host") return WorkflowIcon;
if (type === "domain") return GlobeIcon;
return NetworkIcon;
};
const buildOverlapMap = (
routes: { id: string; range: string; domains: string[] }[],
): Map<string, string[]> => {
const byRange = new Map<string, string[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r.id);
byRange.set(r.range, arr);
}
const out = new Map<string, string[]>();
for (const [range, ids] of byRange) {
if (ids.length > 1) out.set(range, ids);
}
return out;
};
export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
const overlapById = useMemo(() => {
const map = new Map<string, string[]>();
for (const ids of overlapGroups.values()) {
for (const id of ids) map.set(id, ids);
}
return map;
}, [overlapGroups]);
const counts = useMemo<Record<NetworkFilter, number>>(
() => ({
all: networkRoutes.length,
active: networkRoutes.filter((r) => r.selected).length,
overlapping: overlapById.size,
}),
[networkRoutes, overlapById],
);
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
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);
},
);
orderRef.current = order;
return items;
}, [networkRoutes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((r) => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [ordered, search, filter, overlapById]);
if (isConnected && networkRoutes.length === 0) {
return (
<EmptyState
icon={Layers3Icon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
/>
);
}
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
if (allSelected) {
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);
setNetworksSelected(ids, true).catch((err: unknown) =>
console.error("enable all networks failed", err),
);
}
};
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
data={filtered}
onToggle={toggleNetwork}
scrollParent={scrollParent}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
{filtered.length > 0 && (
<div
className={cn(
"flex items-center gap-3 px-6 py-3.5",
"border-t border-nb-gray-910",
)}
>
<span className={"flex-1 text-xs font-medium tabular-nums text-nb-gray-300"}>
{t("networks.bulk.selectionCount", {
selected: selectedInView,
total: filtered.length,
})}
</span>
<button
type={"button"}
tabIndex={0}
onClick={onBulkClick}
aria-label={t("networks.bulk.label")}
className={cn(
"inline-flex h-8 items-center rounded-md px-3",
"text-xs font-medium text-nb-gray-100",
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
"wails-no-draggable cursor-pointer outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{bulkLabel}
</button>
</div>
)}
</div>
);
};
type NetworksListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
scrollParent: HTMLElement;
};
const NetworksHeader = () => <div className={"h-2"} />;
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= data.length) return;
const row = data[index];
const tryFocus = () => {
const el = rowRefs.current.get(row.id);
if (el) {
el.focus();
return true;
}
return false;
};
if (!tryFocus()) {
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
requestAnimationFrame(() => {
if (!tryFocus()) requestAnimationFrame(tryFocus);
});
}
};
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, data.length - 1));
break;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "Home":
e.preventDefault();
focusRow(0);
break;
case "End":
e.preventDefault();
focusRow(data.length - 1);
break;
}
};
const setRowRef = (id: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(id, el);
else rowRefs.current.delete(id);
};
const ctx = useMemo<NetworkRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, onToggle],
);
return (
<Virtuoso<Network, NetworkRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, n) => n.id}
components={{ Header: NetworksHeader }}
context={ctx}
itemContent={renderNetworkRow}
/>
);
};
type NetworkRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => (
<NetworkRow
network={n}
index={index}
onKeyDown={ctx.onKeyDown}
onToggle={ctx.onToggle}
setRowRef={ctx.setRowRef}
/>
);
type NetworkRowProps = {
network: Network;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => {
const { t } = useTranslation();
// Same handler is attached to the overlay button and to the network-id copy
// button so arrow nav works wherever focus sits inside the row.
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(n.id, el)}
aria-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div
className={
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
}
>
<div>
<CopyToClipboard message={n.id} onKeyDown={handleKey}>
<TruncatedText
text={n.id}
className={
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
}
/>
</CopyToClipboard>
</div>
<Subtitle network={n} onKeyDown={handleKey} />
</div>
<div
aria-hidden={"true"}
className={"pointer-events-none relative shrink-0 self-center"}
>
<NetworkToggle checked={n.selected} />
</div>
</div>
);
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
<div
aria-hidden={"true"}
className={cn(
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
)}
>
<Icon size={14} />
</div>
);
};
type SubtitleProps = {
network: Network;
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
if (isDnsRoute(network)) {
const domain = network.domains[0];
const ips = network.resolvedIps[domain] ?? [];
return <DomainSubtitle domain={domain} ips={ips} onKeyDown={onKeyDown} />;
}
if (network.range && network.range !== INVALID_PREFIX) {
return (
<div>
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
className={
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
}
/>
</CopyToClipboard>
</div>
);
}
return null;
};
type DomainSubtitleProps = {
domain: string;
ips: string[];
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
const span = (
<span className={"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"}>
{domain}
</span>
);
return (
<div>
<CopyToClipboard message={domain} onKeyDown={onKeyDown}>
{ips.length > 0 ? (
<Tooltip
content={<ResolvedIpsTooltip ips={ips} />}
delayDuration={300}
closeDelay={300}
side={"right"}
align={"start"}
alignOffset={-8}
interactive
keepOpenOnClick
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 pr-4",
)}
>
{span}
</Tooltip>
) : (
span
)}
</CopyToClipboard>
</div>
);
};
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
const { t } = useTranslation();
return (
<>
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
<span
className={
"whitespace-nowrap font-mono text-[0.72rem] text-nb-gray-100"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</>
);
};
type ToggleProps = {
checked: boolean;
mixed?: boolean;
};
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return (
<span
className={cn(
"inline-flex h-5 w-9 shrink-0 items-center rounded-full",
"wails-no-draggable transition-colors",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
)}
/>
</span>
);
};
@@ -0,0 +1,575 @@
import {
type ComponentType,
Fragment,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowUpDownIcon,
ArrowUpIcon,
Check as CheckIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
ClockIcon,
Copy as CopyIcon,
GaugeIcon,
HandshakeIcon,
KeyRoundIcon,
Layers3Icon,
type LucideProps,
MapPinIcon,
MonitorIcon,
Radio,
RefreshCwIcon,
WaypointsIcon,
} from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { peerStatusLabelKey } from "./Peers";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
ease: [0.32, 0.72, 0, 1],
};
const DASH = "-";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
type Props = {
transition?: Transition;
};
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
useEffect(() => {
if (!selected) return;
const peers = status?.peers ?? [];
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
if (!fresh) {
setSelected(null);
return;
}
if (fresh !== selected) setSelected(fresh);
}, [status, selected, setSelected]);
// 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;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [selected]);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
const MIN_SPIN_MS = 600;
const minDelay = new Promise<void>((r) => setTimeout(r, MIN_SPIN_MS));
try {
await Promise.all([refresh(), minDelay]);
} finally {
setRefreshing(false);
}
}, [refresh, refreshing]);
useEffect(() => {
if (!selected) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSelected(null);
return;
}
if (e.key === "ArrowLeft") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
setSelected(null);
}
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
const dialogRef = useRef<HTMLDivElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!selected) return;
// Defer focus until the slide-in animation has started rendering.
// preventScroll avoids the browser scrolling the parent to chase the
// still-offscreen button, which lands as a stutter at the end of the slide.
requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.pubKey]);
const getFocusable = (): HTMLElement[] => {
const root = dialogRef.current;
if (!root) return [];
const sel =
"button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," +
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
};
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Tab") return;
const focusables = getFocusable();
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !active || !dialogRef.current?.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
return (
<AnimatePresence>
{selected && (
<motion.div
ref={dialogRef}
role={"dialog"}
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
onKeyDown={onDialogKeyDown}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={transition}
style={{ willChange: "transform" }}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
>
<div
className={cn(
"flex shrink-0 items-center gap-3",
"h-12 border-b border-nb-gray-910 px-3",
)}
>
<button
ref={backButtonRef}
type={"button"}
tabIndex={0}
onClick={() => setSelected(null)}
aria-label={t("common.close")}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} aria-hidden={"true"} />
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
aria-hidden={"true"}
className={cn(
"h-2 w-2 shrink-0 rounded-full",
dotClass(selected.connStatus),
)}
/>
</Tooltip>
<CopyToClipboard
message={selected.fqdn || selected.ip}
size={11}
className={"min-w-0 flex-1"}
iconClassName={"top-[2px]"}
>
<span
id={"nb-peer-detail-title"}
className={"truncate text-sm font-medium text-nb-gray-100"}
>
{shortenDns(selected.fqdn) || selected.ip}
</span>
</CopyToClipboard>
<Tooltip content={t("peers.details.refresh")}>
<button
type={"button"}
tabIndex={0}
onClick={onRefresh}
disabled={refreshing}
aria-label={t("peers.details.refresh")}
aria-busy={refreshing}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
"disabled:opacity-50 disabled:hover:bg-transparent",
)}
>
<RefreshCwIcon
size={14}
aria-hidden={"true"}
className={refreshing ? "animate-spin" : undefined}
/>
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</motion.div>
)}
</AnimatePresence>
);
};
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const { t } = useTranslation();
const formatAge = (unix: number, fallback: string): string => {
if (!Number.isFinite(unix) || unix <= 0) return fallback;
const diff = Math.floor(now / 1000 - unix);
if (diff < 1) return t("peers.details.justNow");
return formatRelative(unix, now) ?? fallback;
};
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
const isConnected = peer.connStatus === "Connected";
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
return (
<ul className={"flex flex-col divide-y divide-nb-gray-920"}>
<Row icon={MapPinIcon} label={t("peers.details.netbirdIp")}>
{peer.ip ? (
<CopyToClipboard
message={peer.ip}
alwaysShowIcon
className={"max-w-full"}
iconClassName={"top-0"}
>
<span className={"font-mono"}>{peer.ip}</span>
</CopyToClipboard>
) : (
DASH
)}
</Row>
{peer.ipv6 && (
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
<CopyToClipboard
message={peer.ipv6}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.ipv6} mono />
</CopyToClipboard>
</Row>
)}
{isConnected && (
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
</Row>
)}
{peer.relayed && (
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
)}
{peer.latencyMs > 0 && (
<Row icon={GaugeIcon} label={t("peers.details.latency")}>
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}>
{peer.latencyMs} ms
</span>
</Row>
)}
{(peer.bytesRx > 0 || peer.bytesTx > 0) && (
<Row icon={ArrowUpDownIcon} label={t("peers.details.bytes")}>
<div
className={
"flex items-center justify-end gap-3 font-medium text-nb-gray-300"
}
>
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
<ArrowDownIcon
size={13}
aria-hidden={"true"}
className={"text-sky-400"}
/>
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
</div>
<div className={"flex items-center gap-1.5 whitespace-nowrap"}>
<ArrowUpIcon
size={13}
aria-hidden={"true"}
className={"text-netbird"}
/>
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
</div>
</div>
</Row>
)}
<Row icon={HandshakeIcon} label={t("peers.details.lastHandshake")}>
{lastHandshake}
</Row>
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
{statusSince}
</Row>
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<IceRow
icon={MonitorIcon}
baseLabel={t("peers.details.localIce")}
type={peer.localIceCandidateType}
endpoint={peer.localIceCandidateEndpoint}
/>
<IceRow
icon={Radio}
baseLabel={t("peers.details.remoteIce")}
type={peer.remoteIceCandidateType}
endpoint={peer.remoteIceCandidateEndpoint}
/>
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
{peer.pubKey ? (
<CopyToClipboard
message={peer.pubKey}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.pubKey} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
</ul>
);
};
type RowProps = {
icon: ComponentType<LucideProps>;
iconClassName?: string;
label: string;
children: ReactNode;
};
type IceRowProps = {
icon: ComponentType<LucideProps>;
baseLabel: string;
type: string;
endpoint: string;
};
const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s);
const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
if (!type && !endpoint) return null;
const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel;
return (
<Row icon={icon} label={label}>
{endpoint ? (
<CopyToClipboard
message={endpoint}
alwaysShowIcon
className={"min-w-0 max-w-full"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={endpoint} mono />
</CopyToClipboard>
) : (
<span className={"truncate"}>{capitalize(type)}</span>
)}
</Row>
);
};
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
const [open, setOpen] = useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
tabIndex={0}
aria-haspopup={"dialog"}
aria-expanded={open}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{networks.length}
<ChevronDownIcon
size={12}
aria-hidden={"true"}
className={cn("transition-transform duration-150", open && "rotate-180")}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"end"}
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-1 text-nb-gray-200 shadow-lg outline-none",
"flex flex-col",
)}
>
{networks.map((n, i) => (
<Fragment key={n}>
{i > 0 && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
<ResourceRow value={n} />
</Fragment>
))}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ResourceRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isFocusVisible = useFocusVisible();
const handleClick = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (e) {
console.warn("copy resource to clipboard failed", e);
}
};
return (
<button
type={"button"}
onClick={handleClick}
tabIndex={0}
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/resourcerow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
<span
aria-hidden={"true"}
className={"inline-flex shrink-0 items-center text-nb-gray-200"}
>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
className={cn(
"inline-block min-w-0 max-w-[260px] truncate align-middle",
mono && "font-mono",
)}
/>
);
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<li className={"flex min-w-0 items-center gap-2 px-5 py-4 text-xs text-nb-gray-100"}>
<Icon
size={14}
aria-hidden={"true"}
className={cn("shrink-0 text-nb-gray-100", iconClassName)}
/>
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
<span
className={cn(
"min-w-0 flex-1 pl-8 text-right",
"font-medium text-nb-gray-350",
"flex items-center justify-end",
)}
>
{children}
</span>
</li>
);
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type StatusFilter = "all" | "online" | "offline";
type Props = {
value: StatusFilter;
onChange: (value: StatusFilter) => void;
counts: Record<StatusFilter, number>;
disabled?: boolean;
};
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
{ value: "online", label: t("peers.filter.online") },
{ value: "offline", label: t("peers.filter.offline") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: StatusFilter) => {
onChange(v);
setOpen(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
tabIndex={0}
aria-label={t("common.filter")}
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} aria-hidden={"true"} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role={"menuitemradio"}
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
aria-hidden={"true"}
className={"flex w-4 shrink-0 items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,353 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
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";
import { NoResults } from "@/components/empty-state/NoResults";
import { latencyColor, shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
default:
return "bg-nb-gray-500";
}
};
export const peerStatusLabelKey = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "peers.status.connected";
case "Connecting":
return "peers.status.connecting";
default:
return "peers.status.disconnected";
}
};
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const isConnected = status?.status === "Connected";
const peers = useMemo(() => status?.peers ?? [], [status?.peers]);
const counts = useMemo<Record<StatusFilter, number>>(() => {
const online = peers.filter((p) => isOnline(p.connStatus)).length;
return {
all: peers.length,
online,
offline: peers.length - online,
};
}, [peers]);
// 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 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);
};
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
return [];
}
if (!stickyRef.current) {
const sorted = [...peers].sort(compare);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
}
return sorted;
}
const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare);
orderRef.current = order;
return items;
}, [peers]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((p) => {
if (statusFilter === "online" && !isOnline(p.connStatus)) return false;
if (statusFilter === "offline" && isOnline(p.connStatus)) return false;
return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q);
});
}, [ordered, search, statusFilter]);
if (isConnected && peers.length === 0) {
return (
<EmptyState
icon={MonitorSmartphoneIcon}
title={t("peers.empty.title")}
description={t("peers.empty.description")}
/>
);
}
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("peers.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<PeerFilters value={statusFilter} onChange={setStatusFilter} counts={counts} />
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</div>
);
};
const ListTopSpacer = () => <div className={"h-2"} />;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
};
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= data.length) return;
const peer = data[index];
const tryFocus = () => {
const el = rowRefs.current.get(peer.pubKey);
if (el) {
el.focus();
return true;
}
return false;
};
if (!tryFocus()) {
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
// Row may not be mounted yet — retry after Virtuoso renders it.
requestAnimationFrame(() => {
if (!tryFocus()) requestAnimationFrame(tryFocus);
});
}
};
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, data.length - 1));
break;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "ArrowRight":
e.preventDefault();
setSelected(data[index]);
break;
case "Home":
e.preventDefault();
focusRow(0);
break;
case "End":
e.preventDefault();
focusRow(data.length - 1);
break;
}
};
const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(pubKey, el);
else rowRefs.current.delete(pubKey);
};
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
);
return (
<Virtuoso<PeerStatus, PeerRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, peer) => peer.pubKey}
components={{ Header: ListTopSpacer }}
context={ctx}
itemContent={renderPeerRow}
/>
);
};
type PeerRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
<PeerRow
peer={peer}
index={index}
onKeyDown={ctx.onKeyDown}
onSelect={ctx.onSelect}
setRowRef={ctx.setRowRef}
/>
);
type PeerRowProps = {
peer: PeerStatus;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(peer.pubKey, el)}
aria-label={t("peers.row.label", { name: peerName, status: statusLabel })}
onClick={() => onSelect(peer)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<Tooltip content={statusLabel} side={"left"}>
<span
aria-hidden={"true"}
className={cn(
"relative mt-2 h-2 w-2 shrink-0 rounded-full",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div
className={
"pointer-events-none relative flex min-w-0 flex-1 flex-col leading-tight"
}
>
<div>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
"block max-w-[300px] truncate text-[0.81rem] font-medium text-nb-gray-100"
}
/>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<span className={"truncate font-mono text-xs text-nb-gray-400"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"pointer-events-none relative shrink-0 self-center text-xs tabular-nums",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
aria-hidden={"true"}
className={cn(
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
"opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
</div>
);
};
@@ -0,0 +1,76 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import {
Briefcase,
Building,
Cloud,
Construction,
FlaskConical,
Gamepad2,
GraduationCap,
House,
Radio,
Server,
SquareCode,
Terminal,
UserCircle,
UserPlus,
Users,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/cn";
// 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],
[/(home|house|private)/i, House],
[/(dev|development|developer|code|coding|engineering)/i, SquareCode],
[/(local|localhost|loopback)/i, Terminal],
[/(stage|staging|preprod|pre-prod)/i, Construction],
[/(test|testing|qa)/i, FlaskConical],
[/(prod|production)/i, Cloud],
[/(live)/i, Radio],
[/(selfhosted|self-hosted|on-prem|onprem)/i, Server],
[/(school|university|edu|study|student)/i, GraduationCap],
[/(client|customer)/i, Building],
[/(family)/i, Users],
[/(gaming|game)/i, Gamepad2],
[/(guest)/i, UserPlus],
];
export const pickProfileIcon = (name: string | undefined): LucideIcon | null => {
if (!name) return null;
for (const [pattern, Icon] of ICON_MAP) {
if (pattern.test(name)) return Icon;
}
return null;
};
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
name?: string;
size?: number;
};
export const ProfileAvatar = forwardRef<HTMLButtonElement, Props>(function ProfileAvatar(
{ name = "", size = 28, className, type = "button", ...props },
ref,
) {
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type={type}
className={cn(
"inline-grid place-items-center rounded-full bg-nb-gray-900 p-0 text-center",
"cursor-default outline-none",
"transition-colors duration-150 hover:bg-nb-gray-850",
"data-[state=open]:bg-nb-gray-850",
className,
)}
style={{ width: size, height: size }}
{...props}
>
<Icon size={Math.round(size * 0.4)} className={"text-nb-gray-200"} />
</button>
);
});
@@ -0,0 +1,263 @@
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { Label } from "@/components/typography/Label";
import { HelpText } from "@/components/typography/HelpText";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export type ProfileFormInitial = {
name: string;
managementUrl: string;
};
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (name: string, managementUrl: string) => void | Promise<void>;
initial?: ProfileFormInitial;
};
const MAX_PROFILE_NAME_LEN = 128;
export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => {
const { t } = useTranslation();
const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL;
const nameId = useId();
const urlId = useId();
const isEdit = !!initial;
const initialModeFromUrl = (u: string): ManagementMode =>
u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud;
const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : "");
const [name, setName] = useState(initial?.name ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<ManagementMode>(
initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud,
);
const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
const [urlError, setUrlError] = useState<string | null>(null);
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setName(initial?.name ?? "");
setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud);
setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
setNameError(null);
setUrlError(null);
setUnreachable(false);
setChecking(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial?.name, initial?.managementUrl]);
const initialModeRef = useRef<ManagementMode>(ManagementMode.Cloud);
useEffect(() => {
if (!open) return;
initialModeRef.current = mode;
const id = globalThis.setTimeout(() => {
nameRef.current?.focus();
nameRef.current?.select();
}, 0);
return () => globalThis.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// When the user toggles to Self-hosted inside the dialog (not on initial
// open), move focus to the URL input so they can start typing immediately.
useEffect(() => {
if (!open) return;
if (mode === initialModeRef.current) return;
if (mode !== ManagementMode.SelfHosted) return;
urlRef.current?.focus();
}, [open, mode]);
useEffect(() => {
setUrlError(null);
setUnreachable(false);
}, [url, mode]);
const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => {
if (managedManagementUrl) {
return { url: managedManagementUrl, needsReachCheck: false };
}
if (mode === ManagementMode.Cloud) {
return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false };
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return null;
}
const target = normalizeManagementUrl(trimmed);
const unchanged = target === initial?.managementUrl;
return { url: target, needsReachCheck: !unchanged };
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (checking) return;
const sanitized = name.trim();
if (sanitized.length === 0) {
setNameError(t("profile.dialog.required"));
nameRef.current?.focus();
return;
}
const target = resolveTargetUrl();
if (!target) return;
if (target.needsReachCheck) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target.url);
setChecking(false);
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
}
await onSubmit(sanitized, target.url);
onOpenChange(false);
};
const handleNameChange = (value: string) => {
setName(value);
if (nameError) setNameError(null);
};
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted &&
trimmedUrl !== "" &&
!isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidthClass={"max-w-md"}
showClose={false}
className={"py-7"}
srTitle={isEdit ? t("profile.edit.title") : t("profile.dialog.title")}
srDescription={t("profile.dialog.description")}
onOpenAutoFocus={(e) => {
e.preventDefault();
// Focus + select-all so editing an existing name is one
// keystroke away from overwriting it.
nameRef.current?.focus();
nameRef.current?.select();
}}
>
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.description")}
</HelpText>
</div>
<Input
id={nameId}
ref={nameRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={nameError ?? undefined}
maxLength={MAX_PROFILE_NAME_LEN}
spellCheck={false}
autoComplete={"off"}
autoCapitalize={"off"}
/>
</div>
{!managedManagementUrl && (
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.managementHelp")}
</HelpText>
</div>
<div className={"flex flex-col gap-3"}>
<ManagementServerSwitch
value={mode}
onChange={setMode}
fullWidth
/>
{mode === ManagementMode.SelfHosted && (
<Input
id={urlId}
ref={urlRef}
aria-label={t("settings.general.management.label")}
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
)}
</div>
</div>
)}
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
<Button
type={"button"}
variant={"secondary"}
size={"sm"}
disabled={checking}
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type={"submit"}
variant={"primary"}
size={"sm"}
loading={checking}
>
{isEdit ? t("profile.edit.submit") : t("profile.dialog.submit")}
</Button>
</DialogActions>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
);
};
@@ -0,0 +1,293 @@
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import type { Profile } from "@bindings/services/models.js";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
};
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const { t } = useTranslation();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
}
};
const sortedProfiles = [...profiles].sort((a, b) => {
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
});
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
};
const handleSelect = (id: string) => {
setOpen(false);
if (id === activeProfileId) return;
void guarded(t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleManage = () => {
setOpen(false);
onManageProfiles?.();
};
if (!loaded) return <ProfileTriggerSkeleton />;
const hasProfile = !!activeProfileId;
const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name;
const displayName = hasProfile
? (activeFromList ?? activeProfile)
: t("profile.selector.noProfile");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"} disabled={!hasProfile}>
<ProfileTriggerButton
name={displayName}
disabled={!hasProfile}
onKeyDown={handleTriggerKeyDown}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => {
e.preventDefault();
listRef.current?.focus();
}}
className={cn(
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
"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]:origin-top data-[side=top]:origin-bottom",
"data-[side=left]:origin-right data-[side=right]:origin-left",
"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()}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<Command.List
ref={listRef}
aria-label={t("header.profile.switch")}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-60 px-1"}>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
onSelect={handleSelect}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</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",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
)}
>
<Settings2
size={14}
aria-hidden={"true"}
className={"shrink-0"}
/>
<span className={"flex-1 truncate"}>
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ProfileTriggerSkeleton = () => (
<div
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={"wails-no-draggable flex h-10 select-none items-center gap-2 rounded-lg px-3"}
>
<div
aria-hidden={"true"}
className={"size-4 shrink-0 animate-pulse rounded-full bg-nb-gray-900"}
/>
<div aria-hidden={"true"} className={"h-4 w-24 animate-pulse rounded bg-nb-gray-900"} />
</div>
);
type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
name: string;
};
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
const { t } = useTranslation();
const isFocusVisible = useFocusVisible();
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type={"button"}
disabled={disabled}
tabIndex={disabled ? -1 : 0}
aria-label={t("header.profile.switch")}
aria-haspopup={"listbox"}
className={cn(
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
"text-nb-gray-200 hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-900",
"disabled:opacity-50 disabled:hover:bg-transparent",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}
{...props}
>
<Icon
size={16}
aria-hidden={"true"}
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
/>
<span className={"wails-no-draggable max-w-[140px] truncate text-sm font-medium"}>
{name}
</span>
<ChevronDown
size={14}
aria-hidden={"true"}
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
/>
</button>
);
},
);
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSelect: (id: string) => void;
};
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
const showEmail = !!profile.email;
return (
<Command.Item
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
showEmail ? "items-start" : "items-center",
)}
>
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span className={"truncate"}>{profile.name}</span>
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
{isActive && (
<Check
size={16}
aria-hidden={"true"}
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
/>
)}
</Command.Item>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span ref={ref} className={"mt-0.5 max-w-[180px] truncate text-xs text-nb-gray-300"}>
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};
@@ -0,0 +1,679 @@
import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CircleMinus,
LogIn,
MoreVertical,
PencilLine,
PlusCircle,
Trash2,
UserCircle,
} from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/buttons/Button";
import HelpText from "@/components/typography/HelpText";
import {
ProfileCreationModal,
type ProfileFormInitial,
} from "@/modules/profiles/ProfileCreationModal";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext";
import { useConfirm } from "@/contexts/DialogContext";
import { Settings as SettingsSvc } from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE_ID = "default";
export function ProfilesTab() {
const { t } = useTranslation();
const {
profiles,
activeProfileId,
loaded,
username,
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [editTarget, setEditTarget] = useState<{
profile: Profile;
initial: ProfileFormInitial;
} | null>(null);
const [busy, setBusy] = useState(false);
// 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 { order, items } = reconcileOrder(
orderRef.current,
profiles,
(p) => p.id,
(a, b) => {
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
},
);
orderRef.current = order;
return items;
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
};
const handleSwitch = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleDeregister = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
});
if (!ok) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id));
};
const handleDelete = async (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
};
const handleCreate = async (name: string, managementUrl: string) => {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(id);
});
};
const handleEdit = async (id: string, name: string) => {
await guarded(i18next.t("profile.error.editTitle"), async () => {
const config = await SettingsSvc.GetConfig({ profileName: id, username });
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
setEditTarget({
profile,
initial: { name, managementUrl: config.managementUrl },
});
});
};
const handleSave = async (name: string, managementUrl: string) => {
if (!editTarget) return;
const { profile, initial } = editTarget;
await guarded(i18next.t("profile.error.editTitle"), async () => {
if (name !== initial.name) {
await renameProfile(profile.id, name);
}
if (managementUrl !== initial.managementUrl) {
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: profile.id,
username,
managementUrl,
}),
);
}
});
};
return (
<div>
<SectionGroup title={t("settings.profiles.section.profiles")}>
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
<div
className={cn(
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
)}
>
<ProfilesTable
ordered={ordered}
activeProfileId={activeProfileId}
onSwitch={handleSwitch}
onEdit={handleEdit}
onDeregister={handleDeregister}
onDelete={handleDelete}
/>
{loaded && ordered.length === 0 && (
<div
className={
"flex flex-col items-center justify-center py-10 text-center"
}
>
<UserCircle
size={28}
aria-hidden={"true"}
className={"mb-2 text-nb-gray-500"}
/>
<p className={"text-sm font-semibold text-nb-gray-200"}>
{t("settings.profiles.emptyTitle")}
</p>
<p className={"mt-1 max-w-sm text-balance text-xs text-nb-gray-400"}>
{t("settings.profiles.emptyDescription")}
</p>
</div>
)}
</div>
<SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
<PlusCircle size={14} aria-hidden={"true"} />
{t("settings.profiles.addProfile")}
</Button>
</SettingsBottomBar>
</SectionGroup>
<ProfileCreationModal
open={newOpen}
onOpenChange={setNewOpen}
onSubmit={handleCreate}
/>
<ProfileCreationModal
open={editTarget !== null}
onOpenChange={(o) => {
if (!o) setEditTarget(null);
}}
initial={editTarget?.initial}
onSubmit={handleSave}
/>
</div>
);
}
type ProfilesTableProps = {
ordered: Profile[];
activeProfileId: string | undefined;
onSwitch: (id: string, name: string) => void;
onEdit: (id: string, name: string) => void;
onDeregister: (id: string, name: string) => void;
onDelete: (id: string, name: string) => void;
};
const ProfilesTable = ({
ordered,
activeProfileId,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfilesTableProps) => {
const { t } = useTranslation();
const [focusedIndex, setFocusedIndex] = useState(0);
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= ordered.length) return;
setFocusedIndex(index);
const el = rowRefs.current.get(ordered[index].id);
el?.focus();
};
const actionButtonsIn = (row: HTMLTableRowElement | undefined) =>
Array.from(
row?.querySelectorAll<HTMLButtonElement>(
"button:not([aria-hidden='true']):not([aria-disabled='true'])",
) ?? [],
);
const handleRowKey = (e: KeyboardEvent<HTMLTableRowElement>, index: number): boolean => {
switch (e.key) {
case "ArrowDown":
focusRow(Math.min(index + 1, ordered.length - 1));
return true;
case "ArrowUp":
focusRow(Math.max(index - 1, 0));
return true;
case "Home":
focusRow(0);
return true;
case "End":
focusRow(ordered.length - 1);
return true;
}
return false;
};
const handleButtonKey = (
e: KeyboardEvent<HTMLTableRowElement>,
index: number,
row: HTMLTableRowElement,
): boolean => {
const buttons = actionButtonsIn(row);
const current = buttons.indexOf(e.target as HTMLButtonElement);
if (current === -1) return false;
switch (e.key) {
case "ArrowDown":
focusRow(Math.min(index + 1, ordered.length - 1));
return true;
case "ArrowUp":
focusRow(Math.max(index - 1, 0));
return true;
case "Escape":
row.focus();
return true;
case "Tab":
// At the last button: jump to the next row instead of exiting the table.
// At the first button with Shift+Tab: jump back to the row.
if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) {
focusRow(index + 1);
return true;
}
if (e.shiftKey && current === 0) {
row.focus();
return true;
}
return false;
}
return false;
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLTableRowElement>, index: number) => {
const row = rowRefs.current.get(ordered[index].id);
if (!row) return;
const onRow = e.target === row;
const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row);
if (handled) e.preventDefault();
};
const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1));
return (
<table
aria-label={t("settings.profiles.section.profiles")}
className={"w-full border-separate border-spacing-0 text-sm"}
>
<tbody className={"flex flex-col"}>
{ordered.map((profile, index) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
isFocused={index === safeFocusedIndex}
isFirst={index === 0}
isLast={index === ordered.length - 1}
rowRef={(el) => {
if (el) rowRefs.current.set(profile.id, el);
else rowRefs.current.delete(profile.id);
}}
onKeyDown={(e) => handleRowKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
onSwitch={() => onSwitch(profile.id, profile.name)}
onEdit={() => onEdit(profile.id, profile.name)}
onDeregister={() => onDeregister(profile.id, profile.name)}
onDelete={() => onDelete(profile.id, profile.name)}
/>
))}
</tbody>
</table>
);
};
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
isFocused: boolean;
isFirst: boolean;
isLast: boolean;
rowRef: (el: HTMLTableRowElement | null) => void;
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
onFocus: () => void;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({
profile,
isActive,
isFocused,
isFirst,
isLast,
rowRef,
onKeyDown,
onFocus,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfileRowProps) => {
const { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email;
return (
<tr
ref={rowRef}
tabIndex={isFocused ? 0 : -1}
onKeyDown={onKeyDown}
onFocus={onFocus}
aria-label={profile.name}
className={cn(
"flex items-center gap-4 px-4 py-2.5",
"border-b border-nb-gray-910 last:border-b-0",
"outline-none",
isFirst && "rounded-t-xl",
isLast && "rounded-b-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<td
className={cn(
"flex min-w-0 flex-1 gap-2 leading-tight",
showEmail ? "items-start" : "items-center",
)}
>
<Icon
size={15}
aria-hidden={"true"}
className={cn("shrink-0 text-nb-gray-200", showEmail ? "mt-0.5" : "")}
/>
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<div className={"flex min-w-0 items-center gap-2"}>
<span
className={
"cursor-text select-text truncate font-medium text-nb-gray-100"
}
>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div>
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</td>
<td className={"shrink-0 text-right"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
isDefault={profile.id === DEFAULT_PROFILE_ID}
isActive={isActive}
rowFocused={isFocused}
onSwitch={onSwitch}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</td>
</tr>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span
ref={ref}
className={"mt-0.5 cursor-text select-text truncate text-xs text-nb-gray-300"}
>
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};
type RowActionsProps = {
canSwitch: boolean;
canDeregister: boolean;
isDefault: boolean;
isActive: boolean;
rowFocused: boolean;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowActions = ({
canSwitch,
canDeregister,
isDefault,
isActive,
rowFocused,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
let deleteDisabledReason: string | null = null;
if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault");
else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive");
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
label={t("profile.selector.switchTo")}
icon={LogIn}
onClick={onSwitch}
hidden={!canSwitch}
tabbable={rowFocused}
/>
<RowMoreMenu
canDeregister={canDeregister}
deleteDisabled={deleteDisabled}
deleteDisabledReason={deleteDisabledReason}
rowFocused={rowFocused}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</div>
);
};
type RowMoreMenuProps = {
canDeregister: boolean;
deleteDisabled: boolean;
deleteDisabledReason: string | null;
rowFocused: boolean;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowMoreMenu = ({
canDeregister,
deleteDisabled,
deleteDisabledReason,
rowFocused,
onEdit,
onDeregister,
onDelete,
}: RowMoreMenuProps) => {
const { t } = useTranslation();
const moreLabel = t("profile.selector.moreOptions");
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={moreLabel}
tabIndex={rowFocused ? 0 : -1}
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
)}
>
<MoreVertical size={16} aria-hidden={"true"} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} sideOffset={4} className={"min-w-36 select-none"}>
<DropdownMenuItem onClick={onEdit}>
<div className={"flex w-full items-center gap-2"}>
<PencilLine size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.edit")}</span>
</div>
</DropdownMenuItem>
{canDeregister && (
<DropdownMenuItem onClick={onDeregister}>
<div className={"flex w-full items-center gap-2"}>
<CircleMinus size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.deregister")}</span>
</div>
</DropdownMenuItem>
)}
<DeleteMenuItem
disabled={deleteDisabled}
disabledReason={deleteDisabledReason}
onDelete={onDelete}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
type DeleteMenuItemProps = {
disabled: boolean;
disabledReason: string | null;
onDelete: () => void;
};
const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => {
const { t } = useTranslation();
const item = (
<DropdownMenuItem
disabled={disabled}
onClick={disabled ? undefined : onDelete}
className={cn(!disabled && "text-red-500 hover:!text-red-500 focus:text-red-500")}
>
<div className={"flex w-full items-center gap-2"}>
<Trash2 size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.delete")}</span>
</div>
</DropdownMenuItem>
);
if (!disabled || !disabledReason) return item;
return (
<Tooltip
content={<span className={"block max-w-[260px] leading-snug"}>{disabledReason}</span>}
side={"left"}
>
<span className={"block"}>{item}</span>
</Tooltip>
);
};
type ActionIconButtonProps = {
label: string;
icon: typeof CircleMinus;
onClick: () => void;
variant?: "default" | "danger";
/** Occupies space but invisible and non-interactive (preserves row layout). */
hidden?: boolean;
disabled?: boolean;
tabbable?: boolean;
};
const ActionIconButton = ({
label,
icon: Icon,
onClick,
variant = "default",
hidden = false,
disabled = false,
tabbable = true,
}: ActionIconButtonProps) => {
const button = (
<button
type={"button"}
onClick={disabled ? undefined : onClick}
aria-label={label}
aria-hidden={hidden || undefined}
aria-disabled={disabled || undefined}
tabIndex={hidden || !tabbable ? -1 : 0}
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
variant === "danger"
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
hidden && "pointer-events-none opacity-0",
disabled &&
"cursor-not-allowed opacity-40 hover:!bg-transparent hover:!text-nb-gray-400",
)}
>
<Icon size={16} aria-hidden={"true"} />
</button>
);
if (hidden) return button;
return (
<Tooltip
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
side={"top"}
>
{button}
</Tooltip>
);
};
@@ -0,0 +1,202 @@
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 { AlertCircleIcon, ClockIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
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 { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
const SOON_THRESHOLD_SECONDS = 60 * 60;
export default function SessionExpirationDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const initialSeconds = useMemo(() => {
const raw = params.get("seconds");
if (!raw) return DEFAULT_SECONDS;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS;
}, [params]);
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const busyRef = useRef(busy);
busyRef.current = busy;
const expired = remaining <= 0;
const expiredRef = useRef(expired);
expiredRef.current = expired;
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(() => {
const id = globalThis.setInterval(() => {
setRemaining((s) => (s <= 1 ? 0 : s - 1));
}, 1000);
return () => globalThis.clearInterval(id);
}, [initialSeconds]);
// Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state).
useEffect(() => {
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
if (busyRef.current || expiredRef.current) return;
if (ev?.data?.status === "Connected") {
WindowManager.CloseSessionExpiration().catch(console.error);
}
});
return () => {
off();
};
}, []);
const stay = useCallback(async () => {
if (busy) return;
setBusy(true);
let offCancel: (() => void) | undefined;
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
// The popup opens the URL and (Go-side) hides this window, restoring it on close.
if (uri) {
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
console.error(e);
}
}
const cancelPromise = new Promise<void>((resolve) => {
offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
resolve();
});
});
const waitPromise = Session.WaitExtend({
deviceCode: start.deviceCode,
userCode: start.userCode,
});
const outcome = await Promise.race([
waitPromise.then((r) => ({ kind: "done" as const, result: r })),
cancelPromise.then(() => ({ kind: "cancel" as const })),
]);
if (outcome.kind === "cancel") {
waitPromise.cancel?.();
waitPromise.catch(() => {});
return;
}
// Another surface owns this flow; keep the dialog open to retry.
if (outcome.result.preempted) {
return;
}
// Close before the popup so the restore can't flash this window back.
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
}
}, [busy, t]);
const logout = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const username = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
await Connection.Logout({
profileName: active.id || "default",
username,
});
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);
const close = useCallback(() => {
WindowManager.CloseSessionExpiration().catch(console.error);
}, []);
return (
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading id={"nb-session-expiration-title"}>
{expired ? t("sessionExpiration.expired") : activeTitle}
</DialogHeading>
<DialogDescription>
{expired ? t("sessionExpiration.expiredDescription") : activeDescription}
</DialogDescription>
</div>
{!expired && (
<div
className={
"font-mono text-2xl font-semibold tabular-nums tracking-wider text-nb-gray-50"
}
aria-live={"polite"}
>
{formatRemaining(remaining)}
</div>
)}
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={stay}
disabled={busy}
>
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
</Button>
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={expired ? close : logout}
disabled={busy}
>
{expired ? t("sessionExpiration.close") : t("sessionExpiration.logout")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}
@@ -0,0 +1,169 @@
import type { ComponentType, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
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) {
Browser.OpenURL(url).catch(() => {
window.open(url, "_blank");
});
}
export function SettingsAbout() {
const { t } = useTranslation();
const { status } = useStatus();
const { guiVersion } = useSettings();
const daemonVersion = status?.daemonVersion ?? "—";
const handleVersionClick = useAccentTrigger();
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: GithubIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.slack"),
url: "https://docs.netbird.io/slack-url",
Icon: SlackIcon,
iconClassName: "h-3 w-3",
},
{
label: t("settings.about.community.forum"),
url: "https://forum.netbird.io",
Icon: MessagesSquare,
},
{
label: t("settings.about.community.documentation"),
url: "https://docs.netbird.io",
Icon: BookOpen,
},
{
label: t("settings.about.community.feedback"),
url: "https://forms.gle/TeLw2zrXEdw6RcQ36",
Icon: MessageSquareText,
},
];
const LEGAL_LINKS: { label: string; url: string }[] = [
{ label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" },
{ label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" },
{ label: t("settings.about.links.cla"), url: "https://netbird.io/cla" },
{ label: t("settings.about.links.terms"), url: "https://netbird.io/terms" },
];
return (
<div
className={
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
}
>
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<button
type={"button"}
onClick={handleVersionClick}
className={
"cursor-text select-text bg-transparent text-sm font-semibold text-nb-gray-100 outline-none"
}
>
{daemonVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: daemonVersion })
)}
</button>
<p className={"cursor-text select-text text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<UpdateVersionCard />
<p className={"mt-2 text-center text-sm text-nb-gray-300"}>
{t("settings.about.copyright", { year: new Date().getFullYear() })}
</p>
<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, iconClassName }) => (
<button
key={url}
type={"button"}
tabIndex={0}
onClick={() => openUrl(url)}
className={
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
<span>{label}</span>
</button>
))}
</div>
<div
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
>
{LEGAL_LINKS.map((link) => (
<button
key={link.url}
type={"button"}
tabIndex={0}
onClick={() => openUrl(link.url)}
className={
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
{link.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
export function useAccentTrigger() {
const clicksRef = useRef(0);
const lastClickRef = useRef(0);
return useCallback(() => {
const now = performance.now();
if (now - lastClickRef.current > 400) {
clicksRef.current = 0;
}
lastClickRef.current = now;
clicksRef.current += 1;
if (clicksRef.current >= 10) {
clicksRef.current = 0;
triggerAccent();
}
}, []);
}
function triggerAccent() {
if (document.getElementById("nb-accent-root")) return;
const container = document.createElement("div");
container.id = "nb-accent-root";
document.body.appendChild(container);
const root = createRoot(container);
const cleanup = () => {
root.unmount();
container.remove();
};
root.render(<Accent onDone={cleanup} />);
}
function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
}
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
const timeout = globalThis.setTimeout(() => {
setVisible(false);
globalThis.setTimeout(onDone, 500);
}, 9000);
return () => {
cancelAnimationFrame(raf);
globalThis.clearTimeout(timeout);
window.removeEventListener("resize", resize);
};
}, [onDone]);
return (
<div
className={`pointer-events-none fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 ${visible ? "opacity-100" : "opacity-0"}`}
>
<canvas ref={canvasRef} className={"block"} />
</div>
);
}
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { System } from "@wailsio/runtime";
import Button from "@/components/buttons/Button";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
// 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 lets the daemon pick a random free port.
const PORT_MIN = 0;
const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
const MTU_MIN = 576;
const MTU_MAX = 8192;
const PSK_MASK = "**********";
export function SettingsAdvanced() {
const { t } = useTranslation();
const { config, saveFields } = useSettings();
const { mdm } = useRestrictions();
const initialPsk = config.preSharedKeySet ? PSK_MASK : "";
const [values, setValues] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
});
const [pskInputValue, setPskInputValue] = useState(initialPsk);
const [saving, setSaving] = useState(false);
useEffect(() => {
setValues({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
});
setPskInputValue(config.preSharedKeySet ? PSK_MASK : "");
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]);
const errors = useMemo(() => {
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
if (!INTERFACE_NAME_RE.test(values.interfaceName)) {
out.interfaceName = t(INTERFACE_NAME_ERROR_KEY);
}
if (
!Number.isInteger(values.wireguardPort) ||
values.wireguardPort < PORT_MIN ||
values.wireguardPort > PORT_MAX
) {
out.wireguardPort = t("settings.advanced.port.error", {
min: PORT_MIN,
max: PORT_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;
}, [values.interfaceName, values.wireguardPort, values.mtu, t]);
const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors;
const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined);
const pskChanged = pskInputValue !== initialPsk;
const hasChanges =
values.interfaceName !== config.interfaceName ||
(!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) ||
values.mtu !== config.mtu ||
(!mdm.preSharedKey && pskChanged);
const handleSave = async () => {
if (!hasChanges || saving || hasErrors) return;
setSaving(true);
try {
const partial: typeof values = { ...values };
if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort;
const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK;
const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined;
await saveFields(partial, pskOpts);
if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK);
} finally {
setSaving(false);
}
};
return (
<>
<SectionGroup title={t("settings.advanced.section.interface")}>
<Input
label={t("settings.advanced.interfaceName.label")}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
<div className={mdm.wireguardPort ? "" : "grid grid-cols-2 gap-4"}>
{!mdm.wireguardPort && (
<div>
<Input
label={t("settings.advanced.port.label")}
type={"number"}
min={PORT_MIN}
max={PORT_MAX}
value={values.wireguardPort}
error={errors.wireguardPort}
onChange={(e) =>
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
/>
<HelpText className={"mt-1.5"}>
{t("settings.advanced.port.help")}
</HelpText>
</div>
)}
<Input
label={t("settings.advanced.mtu.label")}
type={"number"}
min={MTU_MIN}
max={MTU_MAX}
value={values.mtu}
error={errors.mtu}
onChange={(e) => setValues((v) => ({ ...v, mtu: Number(e.target.value) }))}
/>
</div>
</SectionGroup>
{!mdm.preSharedKey && (
<SectionGroup title={t("settings.advanced.section.security")}>
<div>
<Label as={"div"}>{t("settings.advanced.psk.label")}</Label>
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
showPasswordToggle={pskInputValue !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={pskInputValue}
onChange={(e) => setPskInputValue(e.target.value)}
spellCheck={false}
autoComplete={"new-password"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
</div>
</SectionGroup>
)}
<SettingsBottomBar>
<Button
variant={"primary"}
size={"md"}
disabled={!hasChanges || saving || hasErrors}
onClick={handleSave}
>
{t("common.saveChanges")}
</Button>
</SettingsBottomBar>
</>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
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 { mdm, features } = useRestrictions();
const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
const prevMode = useRef(mode);
useEffect(() => {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
inputRef.current?.focus();
}
prevMode.current = mode;
}, [mode]);
return (
<>
<SectionGroup title={t("settings.general.section.general")}>
<LanguagePicker />
<FancyToggleSwitch
value={!config.disableNotifications}
onChange={(v) => setField("disableNotifications", !v)}
label={t("settings.general.notifications.label")}
helpText={t("settings.general.notifications.help")}
/>
{!mdm.disableAutoConnect && !features.disableUpdateSettings && (
<FancyToggleSwitch
value={!config.disableAutoConnect}
onChange={(v) => setField("disableAutoConnect", !v)}
label={t("settings.general.connectOnStartup.label")}
helpText={t("settings.general.connectOnStartup.help")}
/>
)}
{(autostart === null || autostart.supported) && (
<FancyToggleSwitch
value={autostart?.enabled ?? false}
onChange={setAutostartEnabled}
loading={autostart === null}
label={t("settings.general.autostart.label")}
helpText={t("settings.general.autostart.help")}
/>
)}
</SectionGroup>
{!mdm.managementURL && !features.disableUpdateSettings && (
<SectionGroup title={t("settings.general.section.connection")}>
<div>
<div className={"flex items-start gap-3"}>
<div className={"min-w-0 flex-1"}>
<Label htmlFor={managementUrlId}>
{t("settings.general.management.label")}
</Label>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"mt-2 flex items-start gap-3"}>
<Input
id={managementUrlId}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("settings.general.management.urlPlaceholder")}
error={
showError
? t("settings.general.management.urlError")
: undefined
}
warning={
unreachable
? t("settings.general.management.urlUnreachable")
: undefined
}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
loading={checking}
onClick={() => save()}
>
{t("common.save")}
</Button>
</div>
)}
</div>
</SectionGroup>
)}
</>
);
}
@@ -0,0 +1,87 @@
import { useTranslation } from "react-i18next";
import { Tooltip } from "@/components/Tooltip.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import {
BoltIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
UserCircleIcon,
} from "lucide-react";
export const SettingsNavigation = () => {
const { t } = useTranslation();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings;
const aboutAdornment = updateAvailable ? (
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
<UpdateBadge />
</Tooltip>
) : undefined;
return (
<div className={"flex w-52 shrink-0 select-none flex-col items-center"}>
<VerticalTabs.List aria-label={t("settings.nav.label")}>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
{!features.disableUpdateSettings && (
<>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
</>
)}
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
)}
{showSsh && (
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
)}
{!features.disableUpdateSettings && (
<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>
);
};
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export function SettingsNetwork() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { mdm } = useRestrictions();
return (
<>
<SectionGroup title={t("settings.network.section.connectivity")}>
<FancyToggleSwitch
value={config.networkMonitor}
onChange={(v) => setField("networkMonitor", v)}
label={t("settings.network.monitor.label")}
helpText={t("settings.network.monitor.help")}
/>
</SectionGroup>
<SectionGroup title={t("settings.network.section.routingDns")}>
<FancyToggleSwitch
value={!config.disableDns}
onChange={(v) => setField("disableDns", !v)}
label={t("settings.network.dns.label")}
helpText={t("settings.network.dns.help")}
/>
{!mdm.disableClientRoutes && (
<FancyToggleSwitch
value={!config.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", !v)}
label={t("settings.network.clientRoutes.label")}
helpText={t("settings.network.clientRoutes.help")}
/>
)}
{!mdm.disableServerRoutes && (
<FancyToggleSwitch
value={!config.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", !v)}
label={t("settings.network.serverRoutes.label")}
helpText={t("settings.network.serverRoutes.help")}
/>
)}
<FancyToggleSwitch
value={!config.disableIpv6}
onChange={(v) => setField("disableIpv6", !v)}
label={t("settings.network.ipv6.label")}
helpText={t("settings.network.ipv6.help")}
/>
</SectionGroup>
</>
);
}
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
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 { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
const enum Tab {
General = "general",
Network = "network",
Security = "security",
Profiles = "profiles",
SSH = "ssh",
Advanced = "advanced",
Troubleshooting = "troubleshooting",
About = "about",
}
const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
[Tab.Security]: <SettingsSecurity />,
[Tab.Profiles]: <ProfilesTab />,
[Tab.SSH]: <SettingsSSH />,
[Tab.Advanced]: <SettingsAdvanced />,
[Tab.Troubleshooting]: <SettingsTroubleshooting />,
[Tab.About]: <SettingsAbout />,
};
export const SettingsPage = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const { mdm, features } = useRestrictions();
const visibleTabs = useMemo<Tab[]>(() => {
const editable = !features.disableUpdateSettings;
const visibility: Record<Tab, boolean> = {
[Tab.General]: true,
[Tab.Network]: editable,
[Tab.Security]: editable,
[Tab.Profiles]: !features.disableProfiles,
[Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.Advanced]: editable,
[Tab.Troubleshooting]: true,
[Tab.About]: true,
};
return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]);
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
const defaultTab = visibleTabs[0];
const [active, setActive] = useState<string>(() => navState?.tab ?? defaultTab);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
}, [navState?.tab, location.key]);
useEffect(() => {
return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => {
setActive(e.data || defaultTab);
});
}, [defaultTab]);
// Reset active tab if it got disabled by any feature flag or mdm restrictions
useEffect(() => {
if (!visibleTabs.includes(active as Tab)) setActive(defaultTab);
}, [visibleTabs, active, defaultTab]);
return (
<>
{isMacOS() ? (
<div className={"wails-draggable h-12 shrink-0 cursor-default select-none"} />
) : (
<div className={"h-px shrink-0 bg-nb-gray-920/0"} />
)}
<main className={"flex min-h-0 flex-1"}>
<VerticalTabs value={active} onValueChange={setActive}>
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<SettingsProvider>
<ScrollArea.Root
key={active}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"px-7 py-6"}>
{visibleTabs.map((tab) => (
<VerticalTabs.Content key={tab} value={tab}>
{TAB_CONTENT[tab]}
</VerticalTabs.Content>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</SettingsProvider>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
</main>
</>
);
};
@@ -0,0 +1,119 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const isSSHServerEnabled = config.serverSshAllowed;
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
useEffect(() => {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}, [config.sshJwtCacheTtl]);
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setJwtTtlInput(v);
if (v === "") return;
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
setField("sshJwtCacheTtl", n);
}
};
const handleJwtTtlBlur = () => {
if (jwtTtlInput === "") {
setJwtTtlInput("0");
setField("sshJwtCacheTtl", 0);
return;
}
const n = Number(jwtTtlInput);
if (!Number.isFinite(n) || n < 0) {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}
};
return (
<>
<SectionGroup title={t("settings.ssh.section.server")}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
/>
</SectionGroup>
<SectionGroup
title={t("settings.ssh.section.capabilities")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
/>
<FancyToggleSwitch
value={config.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label={t("settings.ssh.sftp.label")}
helpText={t("settings.ssh.sftp.help")}
/>
<FancyToggleSwitch
value={config.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label={t("settings.ssh.localForward.label")}
helpText={t("settings.ssh.localForward.help")}
/>
<FancyToggleSwitch
value={config.enableSshRemotePortForwarding}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label={t("settings.ssh.remoteForward.label")}
helpText={t("settings.ssh.remoteForward.help")}
/>
</SectionGroup>
<SectionGroup
title={t("settings.ssh.section.authentication")}
disabled={!isSSHServerEnabled}
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
/>
<div
className={cn(
"flex items-center justify-between gap-6",
config.disableSshAuth && "pointer-events-none opacity-50",
)}
>
<div className={"max-w-md flex-1"}>
<Label htmlFor={jwtTtlId}>{t("settings.ssh.jwtTtl.label")}</Label>
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
id={jwtTtlId}
type={"number"}
min={0}
value={jwtTtlInput}
onChange={handleJwtTtlChange}
onBlur={handleJwtTtlBlur}
customSuffix={t("settings.ssh.jwtTtl.suffix")}
/>
</div>
</div>
</SectionGroup>
</>
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
export const SectionGroup = ({
title,
children,
disabled = false,
}: {
title: string;
children: ReactNode;
disabled?: boolean;
}) => (
<section
aria-label={title}
tabIndex={disabled ? -1 : 0}
{...(disabled ? { inert: "" } : {})}
className={cn(
"mb-8 rounded-md px-1 outline-none last:mb-1",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled && "pointer-events-none opacity-30",
)}
>
<h2 className={"mb-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-400"}>
{title}
</h2>
<div className={"flex flex-col gap-5"}>{children}</div>
</section>
);
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
}
>
{children}
</div>
</div>
</>
);
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export function SettingsSecurity() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { mdm } = useRestrictions();
const hideRosenpassEnabled = mdm.rosenpassEnabled;
const hideRosenpassPermissive =
mdm.rosenpassPermissive || (mdm.rosenpassEnabled && !config.rosenpassEnabled);
const showEncryptionSection = !(hideRosenpassEnabled && hideRosenpassPermissive);
return (
<>
<SectionGroup title={t("settings.security.section.firewall")}>
{!mdm.blockInbound && (
<FancyToggleSwitch
value={config.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label={t("settings.security.blockInbound.label")}
helpText={t("settings.security.blockInbound.help")}
/>
)}
<FancyToggleSwitch
value={config.blockLanAccess}
onChange={(v) => setField("blockLanAccess", v)}
label={t("settings.security.blockLan.label")}
helpText={t("settings.security.blockLan.help")}
/>
</SectionGroup>
{showEncryptionSection && (
<SectionGroup title={t("settings.security.section.encryption")}>
{!hideRosenpassEnabled && (
<FancyToggleSwitch
value={config.rosenpassEnabled}
onChange={(v) => {
setField("rosenpassEnabled", v);
if (!v) setField("rosenpassPermissive", false);
}}
label={t("settings.security.rosenpass.label")}
helpText={t("settings.security.rosenpass.help")}
/>
)}
{!hideRosenpassPermissive && (
<FancyToggleSwitch
value={config.rosenpassPermissive}
onChange={(v) => setField("rosenpassPermissive", v)}
label={t("settings.security.rosenpassPermissive.label")}
helpText={t("settings.security.rosenpassPermissive.help")}
disabled={!config.rosenpassEnabled}
/>
)}
</SectionGroup>
)}
</>
);
}
@@ -0,0 +1,27 @@
import Skeleton from "react-loading-skeleton";
export const SettingsSkeleton = () => {
return (
<div className={"flex flex-col gap-6"}>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
<div className={"mt-3"}>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
</div>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
<Skeleton width={300} height={10} />
</div>
</div>
</div>
);
};
@@ -0,0 +1,337 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon";
import { formatRemaining } from "@/lib/formatters";
import type { DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
const {
anonymize,
setAnonymize,
systemInfo,
setSystemInfo,
upload,
setUpload,
trace,
setTrace,
capture,
setCapture,
traceMinutes,
setTraceMinutes,
capturePackets,
setCapturePackets,
run,
stage,
cancel,
reset,
} = useDebugBundleContext();
if (stage.kind === "done") {
return (
<DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={reset} />
);
}
if (stage.kind !== "idle") {
return <ProgressSection stage={stage} onCancel={cancel} />;
}
return (
<SectionGroup title={t("settings.troubleshooting.section.title")}>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={t("settings.troubleshooting.anonymize.label")}
helpText={t("settings.troubleshooting.anonymize.help")}
/>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}
label={t("settings.troubleshooting.systemInfo.label")}
helpText={t("settings.troubleshooting.systemInfo.help")}
/>
<FancyToggleSwitch
value={upload}
onChange={setUpload}
label={t("settings.troubleshooting.upload.label")}
helpText={t("settings.troubleshooting.upload.help")}
/>
<FancyToggleSwitch
value={trace}
onChange={setTrace}
label={t("settings.troubleshooting.trace.label")}
helpText={t("settings.troubleshooting.trace.help")}
/>
<FancyToggleSwitch
value={capture}
onChange={setCapture}
label={t("settings.troubleshooting.capture.label")}
helpText={t("settings.troubleshooting.capture.help")}
/>
<div className={"flex flex-col gap-4"}>
<FancyToggleSwitch
value={capturePackets}
onChange={setCapturePackets}
label={t("settings.troubleshooting.packets.label")}
helpText={t("settings.troubleshooting.packets.help")}
disabled={!capture}
/>
<div
className={"flex items-center justify-between gap-6"}
{...(capture ? {} : { inert: "" })}
>
<div className={"max-w-md flex-1"}>
<Label htmlFor={durationId} disabled={!capture}>
{t("settings.troubleshooting.duration.label")}
</Label>
<HelpText margin={false} disabled={!capture}>
{t("settings.troubleshooting.duration.help")}
</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
id={durationId}
type={"number"}
min={1}
max={30}
value={traceMinutes}
onChange={(e) =>
setTraceMinutes(
Math.max(1, Math.min(30, Number(e.target.value) || 1)),
)
}
customSuffix={t("settings.troubleshooting.duration.suffix")}
disabled={!capture}
/>
</div>
</div>
</div>
<SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={run}>
{t("settings.troubleshooting.create")}
</Button>
</SettingsBottomBar>
</SectionGroup>
);
}
function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) {
return (
<div
className={
"absolute inset-0 flex flex-col items-center justify-center gap-5 p-8 text-center"
}
>
{children}
</div>
);
}
function ProgressSection({
stage,
onCancel,
}: Readonly<{ stage: DebugStage; onCancel: () => void }>) {
const { t } = useTranslation();
const cancelling = stage.kind === "cancelling";
return (
<CenteredPanel>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex max-w-sm flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>{stageLabel(stage, t)}</DialogHeading>
<DialogDescription>
{t("settings.troubleshooting.progress.description")}
</DialogDescription>
</div>
{stage.kind === "capturing" && (
<div
className={
"font-mono text-2xl font-semibold tabular-nums tracking-wider text-nb-gray-50"
}
aria-live={"polite"}
>
{formatRemaining(stage.remainingSec)}
</div>
)}
<DialogActions className={"max-w-[220px]"}>
<Button
autoFocus
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={onCancel}
disabled={cancelling}
>
{t("common.cancel")}
</Button>
</DialogActions>
</CenteredPanel>
);
}
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;
DebugSvc.RevealFile(result.path).catch((err: unknown) =>
console.error("reveal debug bundle file", err),
);
};
return (
<CenteredPanel>
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
<div className={"flex max-w-sm flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{showKey
? t("settings.troubleshooting.done.uploadedTitle")
: t("settings.troubleshooting.done.savedTitle")}
</DialogHeading>
<DialogDescription>
{showKey ? (
<Trans
i18nKey={"settings.troubleshooting.done.uploadedDescription"}
components={{
docs: (
<a
href={SUPPORT_DOCS_URL}
aria-label={t("settings.about.community.documentation")}
onClick={(e) => {
e.preventDefault();
Browser.OpenURL(SUPPORT_DOCS_URL).catch(() =>
globalThis.open(SUPPORT_DOCS_URL, "_blank"),
);
}}
className={"text-netbird hover:underline"}
>
{/* content is provided by <Trans> */}
<span />
</a>
),
}}
/>
) : (
t("settings.troubleshooting.done.savedDescription")
)}
</DialogDescription>
</div>
<div className={"flex w-full max-w-sm flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
customSuffix={
<button
type={"button"}
onClick={onRevealPath}
className={"pointer-events-auto transition-all hover:text-white"}
aria-label={t("settings.troubleshooting.done.openFileLocation")}
>
<FolderOpen size={16} aria-hidden={"true"} />
</button>
}
/>
)}
{uploadFailed && (
<div
role={"alert"}
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
}
>
{result.uploadFailureReason
? t("settings.troubleshooting.uploadFailedWithReason", {
reason: result.uploadFailureReason,
})
: t("settings.troubleshooting.uploadFailed")}
</div>
)}
</div>
<DialogActions className={"max-w-[220px]"}>
{showKey ? (
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
copy={result.uploadedKey}
>
{t("settings.troubleshooting.done.copyKey")}
</Button>
) : (
result.path && (
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={onRevealPath}
>
<FolderOpen size={14} aria-hidden={"true"} />
{t("settings.troubleshooting.done.openFolder")}
</Button>
)
)}
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={onClose}>
{t("common.close")}
</Button>
</DialogActions>
</CenteredPanel>
);
}
const stageLabel = (
stage: DebugStage,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
switch (stage.kind) {
case "reconnecting":
return t("settings.troubleshooting.stage.reconnecting");
case "capturing":
return t("settings.troubleshooting.stage.capturing");
case "bundling":
return t("settings.troubleshooting.stage.bundling");
case "uploading":
return t("settings.troubleshooting.stage.uploading");
case "cancelling":
return t("settings.troubleshooting.stage.cancelling");
default:
return "";
}
};
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Preferences,
Profiles as ProfilesSvc,
Settings as SettingsSvc,
WindowManager,
} from "@bindings/services";
import { Restrictions, SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isNetbirdCloud } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
type WelcomeStep = "tray" | "management";
function shouldShowManagementStep(
activeProfileId: string,
email: string,
managementUrl: string,
managedManagementUrl: string,
): boolean {
if (managedManagementUrl) return false;
// The default profile's ID equals the literal "default", so this check
// holds whether we pass an ID or the legacy name.
if (activeProfileId !== "default") return false;
if (email.trim() !== "") return false;
return isNetbirdCloud(managementUrl);
}
type InitialState = {
profileName: string;
username: string;
managementUrl: string;
needsManagementStep: boolean;
};
export default function WelcomeDialog() {
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
]);
const profileId = active.id || "default";
const [config, list, restrictions] = await Promise.all([
SettingsSvc.GetConfig({ profileName: profileId, username }),
ProfilesSvc.List(username),
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
]);
const profile = list.find((p) => p.id === profileId);
const email = profile?.email ?? "";
if (cancelled) return;
setInitial({
profileName: profileId,
username,
managementUrl: config.managementUrl,
needsManagementStep: shouldShowManagementStep(
profileId,
email,
config.managementUrl,
restrictions.mdm.managementURL,
),
});
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
setInitial({
profileName: "default",
username: "",
managementUrl: "",
needsManagementStep: false,
});
}
})();
return () => {
cancelled = true;
};
}, []);
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
try {
await Preferences.SetOnboardingCompleted(true);
} catch (e) {
console.error("persist onboarding flag:", e);
}
try {
await WindowManager.OpenMain();
} catch (e) {
console.error("open main window:", e);
}
try {
await WindowManager.CloseWelcome();
} catch (e) {
console.error("close welcome window:", e);
}
}, [closing]);
const handleTrayContinue = useCallback(async () => {
if (initial?.needsManagementStep) {
setStep("management");
} else {
await finish();
}
}, [initial, finish]);
const handleManagementContinue = useCallback(
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — undefined fields are preserved Go-side.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
username: initial.username,
managementUrl: url,
}),
);
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
throw e;
}
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
await finish();
},
[initial, finish],
);
const content = useMemo(() => {
if (!initial) {
return null;
}
switch (step) {
case "tray":
return <WelcomeStepTray onContinue={handleTrayContinue} />;
case "management":
return (
<WelcomeStepManagement
initialUrl={initial.managementUrl}
onContinue={handleManagementContinue}
/>
);
}
}, [initial, step, handleTrayContinue, handleManagementContinue]);
return (
<ConfirmDialog
ref={contentRef}
aria-labelledby={step === "tray" ? "nb-welcome-title" : "nb-welcome-management-title"}
>
{content}
</ConfirmDialog>
);
}
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { Input } from "@/components/inputs/Input";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isNetbirdCloud,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
initialUrl: string;
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({
initialUrl,
onContinue,
}: Readonly<WelcomeStepManagementProps>) {
const { t } = useTranslation();
const startsCloud = isNetbirdCloud(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
const inputRef = useRef<HTMLInputElement | null>(null);
const initialMountRef = useRef(true);
const initialSelfHostedRef = useRef(!startsCloud);
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
}, [url, mode]);
useEffect(() => {
if (initialMountRef.current && initialSelfHostedRef.current) {
inputRef.current?.focus();
}
initialMountRef.current = false;
}, []);
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
}
const target =
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
if (!reachable) {
setUnreachable(true);
return;
}
}
try {
await onContinue(target);
} catch (e) {
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
const inputError = syntaxError ?? undefined;
const inputWarning = useMemo(
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
[syntaxError, unreachable, t],
);
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
<div className={"wails-no-draggable w-full"}>
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<Input
ref={inputRef}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={inputError}
warning={inputWarning}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
</div>
)}
<DialogActions>
<Button
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={handleContinue}
disabled={checking}
>
{checking ? t("welcome.management.checking") : t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { isMacOS, isWindows } from "@/lib/platform";
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
return trayScreenshotLinux;
}
type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
return (
<>
<div className={"px-1"}>
<img
src={trayScreenshot}
alt={""}
className={"pointer-events-none h-auto w-full select-none rounded-2xl"}
draggable={false}
/>
</div>
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
tabIndex={0}
className={"w-full"}
onClick={onContinue}
>
{t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}