mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
add mdm
This commit is contained in:
@@ -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.
|
||||
|
||||
45
client/ui/frontend/src/contexts/RestrictionsContext.tsx
Normal file
45
client/ui/frontend/src/contexts/RestrictionsContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user