mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 03:39:07 +02:00
[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:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user