This commit is contained in:
Eduard Gert
2026-06-12 18:00:28 +02:00
parent c4d4208ea6
commit 6cc6d68b37
18 changed files with 487 additions and 317 deletions

View File

@@ -70,7 +70,7 @@ Page-specific chrome and providers live in the page, not the layout:
- `session/``SessionExpirationDialog.tsx`.
- `auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`.
- `error/``ErrorDialog.tsx`.
- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`."
- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `MdmContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`."
- `components/` — presentational primitives, no daemon RPCs, no router:
- `buttons/``Button`, `IconButton`.
- `inputs/``Input`, `SearchInput`.
@@ -106,6 +106,7 @@ State that crosses screens/windows lives in context, each provider mounted exact
- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag.
- **`ProfileContext`** — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` + a `refresh()`.
- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null`. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect — `save` drops the field when it equals the mask.
- **`MdmContext`** — `useMdm()` returns `config.managedFields` as `Record<string, boolean>`, **keyed by the daemon's `mdm.Key*` names exactly as written in the policy source** (`managementURL`, `allowServerSSH`, `preSharedKey`, `wireguardPort`, `rosenpassEnabled`/`Permissive`, `disableClientRoutes`/`disableServerRoutes`, `disableAutoConnect`, `blockInbound`). No GUI-side renaming — what the Group Policy admin writes is what the lookup key is. Mounted in `AppLayout` (under `ProfileProvider`); fetches `Settings.GetConfig` once, re-fetches on the daemon's `netbird:event` `metadata.type=config_changed` push so policy flips paint live. No second copy of the locked *values* — MDM is a global override, so the active profile's resolved `useSettings().config.<field>` already carries the MDM-enforced value. Consumers: Settings tabs hide individual toggles/sections (both rosenpass keys managed ⇒ whole encryption section hidden); `SettingsNavigation` + `SettingsPage` hide the SSH tab when `managed.allowServerSSH` is set and bounce `active="ssh"` back to General; `ProfileCreationModal` skips the Cloud/self-hosted picker when `managed.managementURL` is set and submits the resolved URL verbatim; `WelcomeDialog` reads `config.managedFields.managementURL` directly (sits outside `AppLayout`) to skip the management step on a fresh install.
- **`DebugBundleContext`** — stages `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches:
1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases.

View File

@@ -0,0 +1,45 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { Events } from "@wailsio/runtime";
import { Settings as SettingsSvc } from "@bindings/services";
import { Restrictions } from "@bindings/services/models.js";
const EVENT_SYSTEM = "netbird:event";
const EMPTY = new Restrictions();
const RestrictionsContext = createContext<Restrictions>(EMPTY);
export const useRestrictions = () => useContext(RestrictionsContext);
export const RestrictionsProvider = ({ children }: { children: ReactNode }) => {
const [restrictions, setRestrictions] = useState<Restrictions>(EMPTY);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const r = await SettingsSvc.GetRestrictions();
if (!cancelled) setRestrictions(r);
} catch (e) {
console.error("[RestrictionsContext] refresh failed", e);
}
};
refresh();
const off = Events.On(
EVENT_SYSTEM,
(e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
if (e.data?.metadata?.type === "config_changed") refresh();
},
);
return () => {
cancelled = true;
off();
};
}, []);
return (
<RestrictionsContext.Provider value={restrictions}>{children}</RestrictionsContext.Provider>
);
};

View File

@@ -4,6 +4,7 @@ import { StatusProvider } from "@/contexts/StatusContext.tsx";
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
export const AppLayout = () => {
return (
@@ -11,11 +12,13 @@ export const AppLayout = () => {
<DialogProvider>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
<RestrictionsProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</RestrictionsProvider>
</ProfileProvider>
</StatusProvider>
</DialogProvider>

View File

@@ -24,6 +24,7 @@ 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;
@@ -33,6 +34,7 @@ export const MainHeader = () => {
const [menuOpen, setMenuOpen] = useState(false);
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const openSettings = useCallback(() => {
setMenuOpen(false);
@@ -55,7 +57,9 @@ export const MainHeader = () => {
setViewMode(mode);
};
const profileSlot = <ProfileDropdown onManageProfiles={openManageProfiles} />;
const profileSlot = features.disableProfiles ? null : (
<ProfileDropdown onManageProfiles={openManageProfiles} />
);
const settingsSlot = (
<div className={"relative"}>
@@ -94,19 +98,23 @@ export const MainHeader = () => {
</DropdownMenuShortcut>
</div>
</DropdownMenuItem>
<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")}
/>
{!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 && (

View File

@@ -6,12 +6,14 @@ 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";
@@ -29,7 +31,16 @@ export const MainPage = () => {
};
const MainBody = () => {
const { viewMode } = useViewMode();
const { viewMode, setViewMode } = useViewMode();
const { mdm, features } = useRestrictions();
// Force flip the view if mdm changed it
useEffect(() => {
if (mdm.disableAdvancedView && viewMode === "advanced") {
setViewMode("default");
}
}, [mdm.disableAdvancedView, viewMode, setViewMode]);
const isAdvanced = viewMode === "advanced";
return (
@@ -43,9 +54,11 @@ const MainBody = () => {
)}
>
<MainConnectionStatusSwitch />
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
</div>
{!features.disableNetworks && (
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
</div>
)}
</div>
{isAdvanced && (
<NavSectionProvider>

View File

@@ -4,6 +4,8 @@ import { Layers3Icon, 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";
import { useEffect } from "react";
type TabEntry = {
value: NavSection;
@@ -15,20 +17,30 @@ 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,
},
];
});
}
return (
<div className={"wails-no-draggable shrink-0 flex items-stretch "}>

View File

@@ -14,6 +14,7 @@ import {
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
type Props = {
open: boolean;
@@ -31,6 +32,8 @@ const sanitizeProfileInput = (value: string): string =>
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation();
const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL;
const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
@@ -70,6 +73,12 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
return;
}
if (managedManagementUrl) {
onCreate(sanitized, managedManagementUrl);
onOpenChange(false);
return;
}
if (mode === ManagementMode.Cloud) {
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
onOpenChange(false);
@@ -145,35 +154,41 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
/>
</div>
<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
ref={urlRef}
autoFocus
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
{!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
ref={urlRef}
autoFocus
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
)}
</div>
</div>
</div>
)}
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
<Button

View File

@@ -7,6 +7,7 @@ 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();
@@ -14,9 +15,11 @@ 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;
@@ -24,15 +27,14 @@ const MTU_MAX = 8192;
export function SettingsAdvanced() {
const { t } = useTranslation();
const { config, saveFields } = useSettings();
const { mdm } = useRestrictions();
const [values, setValues] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
});
// PSK is write-only from the UI: the daemon returns only preSharedKeySet,
// never the value. Empty means "leave unchanged"; a typed value is sent on
// save. Reset on every config reload (e.g. after a successful save).
const [psk, setPsk] = useState("");
const [saving, setSaving] = useState(false);
@@ -66,18 +68,22 @@ export function SettingsAdvanced() {
return out;
}, [values.interfaceName, values.wireguardPort, values.mtu, t]);
const hasErrors = Object.keys(errors).length > 0;
const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors;
const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined);
const hasChanges =
values.interfaceName !== config.interfaceName ||
values.wireguardPort !== config.wireguardPort ||
(!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) ||
values.mtu !== config.mtu ||
psk !== "";
(!mdm.preSharedKey && psk !== "");
const handleSave = async () => {
if (!hasChanges || saving || hasErrors) return;
setSaving(true);
try {
await saveFields(values, psk ? { preSharedKey: psk } : undefined);
const partial: typeof values = { ...values };
if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort;
const pskOpts = !mdm.preSharedKey && psk ? { preSharedKey: psk } : undefined;
await saveFields(partial, pskOpts);
} finally {
setSaving(false);
}
@@ -92,24 +98,28 @@ export function SettingsAdvanced() {
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
/>
<div className={"grid grid-cols-2 gap-4"}>
<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>
<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"}
@@ -122,23 +132,25 @@ export function SettingsAdvanced() {
</div>
</SectionGroup>
<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={psk !== ""}
placeholder={
config.preSharedKeySet
? t("settings.advanced.psk.configured")
: "kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="
}
value={psk}
onChange={(e) => setPsk(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={psk !== ""}
placeholder={
config.preSharedKeySet
? t("settings.advanced.psk.configured")
: "kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="
}
value={psk}
onChange={(e) => setPsk(e.target.value)}
/>
</div>
</SectionGroup>
)}
<SettingsBottomBar>
<Button

View File

@@ -10,6 +10,7 @@ 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();
@@ -17,6 +18,7 @@ export function SettingsGeneral() {
const { autostart, setAutostartEnabled } = useAutostartSetting();
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl();
const { mdm } = useRestrictions();
const inputRef = useRef<HTMLInputElement>(null);
const prevMode = useRef(mode);
@@ -37,12 +39,14 @@ export function SettingsGeneral() {
label={t("settings.general.notifications.label")}
helpText={t("settings.general.notifications.help")}
/>
<FancyToggleSwitch
value={!config.disableAutoConnect}
onChange={(v) => setField("disableAutoConnect", !v)}
label={t("settings.general.connectOnStartup.label")}
helpText={t("settings.general.connectOnStartup.help")}
/>
{!mdm.disableAutoConnect && (
<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}
@@ -54,46 +58,48 @@ export function SettingsGeneral() {
)}
</SectionGroup>
<SectionGroup title={t("settings.general.section.connection")}>
<div>
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>{t("settings.general.management.label")}</Label>
<HelpText>{t("settings.general.management.help")}</HelpText>
{!mdm.managementURL && (
<SectionGroup title={t("settings.general.section.connection")}>
<div>
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>{t("settings.general.management.label")}</Label>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
{mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}>
<Input
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
}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
loading={checking}
onClick={() => save()}
>
{t("common.save")}
</Button>
</div>
)}
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}>
<Input
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
}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
loading={checking}
onClick={() => save()}
>
{t("common.save")}
</Button>
</div>
)}
</div>
</SectionGroup>
</SectionGroup>
)}
</>
);
}

View File

@@ -3,6 +3,7 @@ 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,
@@ -17,6 +18,7 @@ import {
export const SettingsNavigation = () => {
const { t } = useTranslation();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const aboutAdornment = updateAvailable ? (
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
@@ -27,36 +29,44 @@ export const SettingsNavigation = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
{!features.disableUpdateSettings && (
<>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={t("settings.tabs.general")}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={t("settings.tabs.network")}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
)}
{!mdm.allowServerSSH && (
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={t("settings.tabs.ssh")}
/>
)}
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={t("settings.tabs.advanced")}
/>
</>
)}
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}

View File

@@ -2,10 +2,12 @@ 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 (
<>
@@ -31,18 +33,22 @@ export function SettingsNetwork() {
label={t("settings.network.dns.label")}
helpText={t("settings.network.dns.help")}
/>
<FancyToggleSwitch
value={!config.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", !v)}
label={t("settings.network.clientRoutes.label")}
helpText={t("settings.network.clientRoutes.help")}
/>
<FancyToggleSwitch
value={!config.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", !v)}
label={t("settings.network.serverRoutes.label")}
helpText={t("settings.network.serverRoutes.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)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
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";
@@ -16,13 +16,54 @@ 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 [active, setActive] = useState(() => navState?.tab ?? "general");
const { mdm, features } = useRestrictions();
const visibleTabs = useMemo<Tab[]>(() => {
const editable = !features.disableUpdateSettings;
const visibility: Record<Tab, boolean> = {
[Tab.General]: editable,
[Tab.Network]: editable,
[Tab.Security]: editable,
[Tab.Profiles]: editable && !features.disableProfiles,
[Tab.SSH]: editable && !mdm.allowServerSSH,
[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);
@@ -30,9 +71,14 @@ export const SettingsPage = () => {
useEffect(() => {
return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => {
setActive(e.data || "general");
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 (
<>
@@ -53,30 +99,11 @@ export const SettingsPage = () => {
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-6 px-7"}>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<ProfilesTab />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
{visibleTabs.map((tab) => (
<VerticalTabs.Content key={tab} value={tab}>
{TAB_CONTENT[tab]}
</VerticalTabs.Content>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar

View File

@@ -2,19 +2,25 @@ 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 showEncryptionSection = !(mdm.rosenpassEnabled && mdm.rosenpassPermissive);
return (
<>
<SectionGroup title={t("settings.security.section.firewall")}>
<FancyToggleSwitch
value={config.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label={t("settings.security.blockInbound.label")}
helpText={t("settings.security.blockInbound.help")}
/>
{!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)}
@@ -23,24 +29,30 @@ export function SettingsSecurity() {
/>
</SectionGroup>
<SectionGroup title={t("settings.security.section.encryption")}>
<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")}
/>
<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>
{showEncryptionSection && (
<SectionGroup title={t("settings.security.section.encryption")}>
{!mdm.rosenpassEnabled && (
<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")}
/>
)}
{!mdm.rosenpassPermissive && (
<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>
)}
</>
);
}

View File

@@ -5,7 +5,7 @@ import {
Settings as SettingsSvc,
WindowManager,
} from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
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";
@@ -22,7 +22,9 @@ function shouldShowManagementStep(
activeProfile: string,
email: string,
managementUrl: string,
managedManagementUrl: string,
): boolean {
if (managedManagementUrl) return false;
if (activeProfile !== "default") return false;
if (email.trim() !== "") return false;
return isNetbirdCloud(managementUrl);
@@ -50,9 +52,10 @@ export default function WelcomeDialog() {
ProfilesSvc.GetActive(),
]);
const profileName = active.profileName || "default";
const [config, list] = await Promise.all([
const [config, list, restrictions] = await Promise.all([
SettingsSvc.GetConfig({ profileName, username }),
ProfilesSvc.List(username),
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
]);
const profile = list.find((p) => p.name === profileName);
const email = profile?.email ?? "";
@@ -65,6 +68,7 @@ export default function WelcomeDialog() {
profileName,
email,
config.managementUrl,
restrictions.mdm.managementURL,
),
});
} catch (e) {

View File

@@ -4,48 +4,52 @@ package services
import (
"context"
"reflect"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
// mdmKeyToConfigField maps an MDM policy key (mdm.Key*) to the JSON field name
// of the matching Config field, so GetConfig can translate the daemon's key
// names to the frontend's field names in exactly one place. Mirrors the
// conflict set the daemon enforces on SetConfig/Login (mdmManagedFieldConflicts);
// keys with no settings field are absent.
var mdmKeyToConfigField = map[string]string{
mdm.KeyManagementURL: "managementUrl",
mdm.KeyPreSharedKey: "preSharedKey",
mdm.KeyWireguardPort: "wireguardPort",
mdm.KeyRosenpassEnabled: "rosenpassEnabled",
mdm.KeyRosenpassPermissive: "rosenpassPermissive",
mdm.KeyDisableClientRoutes: "disableClientRoutes",
mdm.KeyDisableServerRoutes: "disableServerRoutes",
mdm.KeyAllowServerSSH: "serverSshAllowed",
mdm.KeyDisableAutoConnect: "disableAutoConnect",
mdm.KeyBlockInbound: "blockInbound",
type MDMFields struct {
ManagementURL string `json:"managementURL"`
PreSharedKey bool `json:"preSharedKey"`
WireguardPort bool `json:"wireguardPort"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH bool `json:"allowServerSSH"`
DisableAutoConnect bool `json:"disableAutoConnect"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct {
DisableProfiles bool `json:"disableProfiles"`
DisableNetworks bool `json:"disableNetworks"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
}
type Restrictions struct {
MDM MDMFields `json:"mdm"`
Features Features `json:"features"`
}
// ConfigParams selects which profile/user to read or write config for.
type ConfigParams struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
// Config is the daemon configuration the UI exposes in the settings window.
// Pointer fields mark "set" vs "unset" so the UI can omit a value to keep the
// daemon's current setting (matching SetConfigRequest's optional semantics).
type Config struct {
ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
ConfigFile string `json:"configFile"`
LogFile string `json:"logFile"`
// PreSharedKeySet reports whether a pre-shared key is configured, without
// exposing its value (the daemon redacts the PSK). The settings form shows
// its own "configured" / "managed by MDM" placeholder when true and sends a
// new PSK only when the user actually types one — the redaction sentinel
// never crosses to the UI.
PreSharedKeySet bool `json:"preSharedKeySet"`
InterfaceName string `json:"interfaceName"`
WireguardPort int64 `json:"wireguardPort"`
@@ -69,19 +73,6 @@ type Config struct {
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
DisableSSHAuth bool `json:"disableSshAuth"`
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
// MDMManagedFields is the raw list of MDM-managed policy keys exactly as
// the daemon reports them (mdm.Key* names, e.g. "managementURL",
// "preSharedKey", "splitTunnelMode"). Includes keys with no settings
// field (split-tunnel, metrics, the Disable* feature flags). The faithful
// full set; prefer ManagedFields for per-field gating.
MDMManagedFields []string `json:"mdmManagedFields"`
// ManagedFields is the MDM-managed set normalised to Config JSON field
// names (e.g. "managementUrl", "serverSshAllowed", "preSharedKey"), so the
// settings form can gate a control with managedFields[fieldName] without
// translating the daemon's mdm.Key* names. Only managed fields are present
// (value true); keys with no settings field are omitted (the Disable*
// feature flags come via GetFeatures instead).
ManagedFields map[string]bool `json:"managedFields"`
}
// SetConfigParams is a partial update — only fields with non-nil pointers
@@ -117,14 +108,6 @@ type SetConfigParams struct {
SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"`
}
// Features reports which UI surfaces the daemon has disabled. The Fyne UI uses
// these flags to grey out menu items the operator turned off server-side.
type Features struct {
DisableProfiles bool `json:"disableProfiles"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
DisableNetworks bool `json:"disableNetworks"`
}
// Settings groups the daemon RPCs that read and write the daemon config.
type Settings struct {
conn DaemonConn
@@ -174,8 +157,6 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
DisableSSHAuth: resp.GetDisableSSHAuth(),
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
MDMManagedFields: resp.GetMDMManagedFields(),
ManagedFields: configManagedFields(resp.GetMDMManagedFields()),
}, nil
}
@@ -218,32 +199,47 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
return err
}
func (s *Settings) GetFeatures(ctx context.Context) (Features, error) {
// MDM + Features Restrictions
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
cli, err := s.conn.Client()
if err != nil {
return Features{}, err
return Restrictions{}, err
}
resp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{})
cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{})
if err != nil {
return Features{}, err
return Restrictions{}, err
}
return Features{
DisableProfiles: resp.GetDisableProfiles(),
DisableUpdateSettings: resp.GetDisableUpdateSettings(),
DisableNetworks: resp.GetDisableNetworks(),
}, nil
}
// configManagedFields normalises the daemon's MDM-managed key list (mdm.Key*
// names) to a set keyed by Config JSON field names, so the settings form can
// look up a field's locked state directly. Returns a non-nil (possibly empty)
// map so it marshals to {} rather than null.
func configManagedFields(managed []string) map[string]bool {
out := make(map[string]bool, len(managed))
for _, k := range managed {
if field, ok := mdmKeyToConfigField[k]; ok {
out[field] = true
featResp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{})
if err != nil {
return Restrictions{}, err
}
r := Restrictions{
Features: Features{
DisableProfiles: featResp.GetDisableProfiles(),
DisableNetworks: featResp.GetDisableNetworks(),
DisableUpdateSettings: featResp.GetDisableUpdateSettings(),
},
}
managed := cfgResp.GetMDMManagedFields()
if len(managed) > 0 {
set := make(map[string]struct{}, len(managed))
for _, k := range managed {
set[k] = struct{}{}
}
v := reflect.ValueOf(&r.MDM).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
if v.Field(i).Kind() != reflect.Bool {
continue
}
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
v.Field(i).SetBool(true)
}
}
if _, ok := set["managementURL"]; ok {
r.MDM.ManagementURL = cfgResp.GetManagementUrl()
}
}
return out
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
return r, nil
}

View File

@@ -276,7 +276,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// Seed the feature kill switches so a DisableProfiles / DisableNetworks
// policy already greys out the matching menus on the first paint
// (config_changed events refresh them afterwards).
go t.refreshFeatures()
go t.refreshRestrictions()
go t.runSessionExpiryTicker()
// Notification-category registration must run after the Wails
// notifications service Startup has populated wn.appName /

View File

@@ -32,8 +32,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
// changes reflect in the tray without a periodic poll. This replaces the
// legacy Fyne UI's 2s GetFeatures poll.
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
log.Infof("config_changed event received (source=%s); refreshing tray features", se.Metadata[proto.MetadataSourceKey])
go t.refreshFeatures()
log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey])
go t.refreshRestrictions()
go t.loadConfig()
// An MDM-driven config change gets a user-facing toast so the
// operator knows their IT policy was applied. The daemon also

View File

@@ -8,23 +8,23 @@ import (
log "github.com/sirupsen/logrus"
)
// refreshFeatures pulls the daemon's operator-disabled UI surfaces
// refreshRestrictions pulls the daemon's operator-disabled UI surfaces
// (DisableProfiles / DisableNetworks) and re-applies the tray menu gating.
// Called once at startup (ApplicationStarted) and on every config_changed
// system event — the daemon re-applies its MDM policy on each engine spawn
// and emits that event, so this is the tray's signal to re-sync the kill
// switches. It replaces the legacy Fyne UI's 2s GetFeatures poll.
func (t *Tray) refreshFeatures() {
features, err := t.svc.Settings.GetFeatures(context.Background())
// switches.
func (t *Tray) refreshRestrictions() {
r, err := t.svc.Settings.GetRestrictions(context.Background())
if err != nil {
log.Debugf("get features: %v", err)
log.Debugf("get restrictions: %v", err)
return
}
t.featureMu.Lock()
changed := t.disableProfiles != features.DisableProfiles ||
t.disableNetworks != features.DisableNetworks
t.disableProfiles = features.DisableProfiles
t.disableNetworks = features.DisableNetworks
changed := t.disableProfiles != r.Features.DisableProfiles ||
t.disableNetworks != r.Features.DisableNetworks
t.disableProfiles = r.Features.DisableProfiles
t.disableNetworks = r.Features.DisableNetworks
t.featureMu.Unlock()
// Repaint only when a flag actually flipped: relayoutMenu rebuilds the
// whole menu tree, so a no-op refresh (the common case) must not churn