mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 13:49: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,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 "";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user