diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 19a94d5dc..dc7d84e7e 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -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 `` 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`, **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.` 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. diff --git a/client/ui/frontend/src/contexts/RestrictionsContext.tsx b/client/ui/frontend/src/contexts/RestrictionsContext.tsx new file mode 100644 index 000000000..a88b55440 --- /dev/null +++ b/client/ui/frontend/src/contexts/RestrictionsContext.tsx @@ -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(EMPTY); + +export const useRestrictions = () => useContext(RestrictionsContext); + +export const RestrictionsProvider = ({ children }: { children: ReactNode }) => { + const [restrictions, setRestrictions] = useState(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 ( + {children} + ); +}; diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index a13b02bfb..1588d9d08 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -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 = () => { - - - - - + + + + + + + diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index 1550167a4..0b3e9eedb 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -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 = ; + const profileSlot = features.disableProfiles ? null : ( + + ); const settingsSlot = (
@@ -94,19 +98,23 @@ export const MainHeader = () => {
- - selectMode("default")} - /> - selectMode("advanced")} - /> + {!mdm.disableAdvancedView && ( + <> + + selectMode("default")} + /> + selectMode("advanced")} + /> + + )} {updateAvailable && ( diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index e9af6e5fe..805674783 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -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 = () => { )} > -
- -
+ {!features.disableNetworks && ( +
+ +
+ )} {isAdvanced && ( diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index d839f04d4..9e434f88a 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -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 (
diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index dfcf2712b..20a62346b 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -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(null); const nameRef = useRef(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) => />
-
-
- - - {t("profile.dialog.managementHelp")} - -
-
- - {mode === ManagementMode.SelfHosted && ( - setUrl(e.target.value)} - error={urlInputError} - warning={urlInputWarning} - spellCheck={false} - autoComplete="off" - autoCapitalize="off" + {!managedManagementUrl && ( +
+
+ + + {t("profile.dialog.managementHelp")} + +
+
+ - )} + {mode === ManagementMode.SelfHosted && ( + setUrl(e.target.value)} + error={urlInputError} + warning={urlInputWarning} + spellCheck={false} + autoComplete="off" + autoCapitalize="off" + /> + )} +
-
+ )} +
+ )} - {mode === ManagementMode.SelfHosted && ( -
- 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 - } - /> - -
- )} - - + + )} ); } diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index ed0817d59..4a218d97c 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -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 ? ( @@ -27,36 +29,44 @@ export const SettingsNavigation = () => { return (
- - - - - - + {!features.disableUpdateSettings && ( + <> + + + + {!features.disableProfiles && ( + + )} + {!mdm.allowServerSSH && ( + + )} + + + )} @@ -31,18 +33,22 @@ export function SettingsNetwork() { label={t("settings.network.dns.label")} helpText={t("settings.network.dns.help")} /> - setField("disableClientRoutes", !v)} - label={t("settings.network.clientRoutes.label")} - helpText={t("settings.network.clientRoutes.help")} - /> - setField("disableServerRoutes", !v)} - label={t("settings.network.serverRoutes.label")} - helpText={t("settings.network.serverRoutes.help")} - /> + {!mdm.disableClientRoutes && ( + setField("disableClientRoutes", !v)} + label={t("settings.network.clientRoutes.label")} + helpText={t("settings.network.clientRoutes.help")} + /> + )} + {!mdm.disableServerRoutes && ( + setField("disableServerRoutes", !v)} + label={t("settings.network.serverRoutes.label")} + helpText={t("settings.network.serverRoutes.help")} + /> + )} setField("disableIpv6", !v)} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index fb0661bc2..be17940b2 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -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.General]: , + [Tab.Network]: , + [Tab.Security]: , + [Tab.Profiles]: , + [Tab.SSH]: , + [Tab.Advanced]: , + [Tab.Troubleshooting]: , + [Tab.About]: , +}; + 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(() => { + const editable = !features.disableUpdateSettings; + const visibility: Record = { + [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(() => 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 = () => { >
- - - - - - - - - - - - - - - - - - - - - - - - + {visibleTabs.map((tab) => ( + + {TAB_CONTENT[tab]} + + ))}
- setField("blockInbound", v)} - label={t("settings.security.blockInbound.label")} - helpText={t("settings.security.blockInbound.help")} - /> + {!mdm.blockInbound && ( + setField("blockInbound", v)} + label={t("settings.security.blockInbound.label")} + helpText={t("settings.security.blockInbound.help")} + /> + )} setField("blockLanAccess", v)} @@ -23,24 +29,30 @@ export function SettingsSecurity() { /> - - { - setField("rosenpassEnabled", v); - if (!v) setField("rosenpassPermissive", false); - }} - label={t("settings.security.rosenpass.label")} - helpText={t("settings.security.rosenpass.help")} - /> - setField("rosenpassPermissive", v)} - label={t("settings.security.rosenpassPermissive.label")} - helpText={t("settings.security.rosenpassPermissive.help")} - disabled={!config.rosenpassEnabled} - /> - + {showEncryptionSection && ( + + {!mdm.rosenpassEnabled && ( + { + setField("rosenpassEnabled", v); + if (!v) setField("rosenpassPermissive", false); + }} + label={t("settings.security.rosenpass.label")} + helpText={t("settings.security.rosenpass.help")} + /> + )} + {!mdm.rosenpassPermissive && ( + setField("rosenpassPermissive", v)} + label={t("settings.security.rosenpassPermissive.label")} + helpText={t("settings.security.rosenpassPermissive.help")} + disabled={!config.rosenpassEnabled} + /> + )} + + )} ); } diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx index 5ded5e6bd..8f2cefb72 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -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) { diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index ca105d62b..48744784d 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -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 } diff --git a/client/ui/tray.go b/client/ui/tray.go index 426cd5a4b..cbe91ed42 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -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 / diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index d1e818209..7dad9cb0d 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -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 diff --git a/client/ui/tray_features.go b/client/ui/tray_features.go index f127c68aa..f2df4a5ff 100644 --- a/client/ui/tray_features.go +++ b/client/ui/tray_features.go @@ -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