diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md
index dc7d84e7e..c341b55a6 100644
--- a/client/ui/frontend/CLAUDE.md
+++ b/client/ui/frontend/CLAUDE.md
@@ -106,7 +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.
+- **`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); `mdm.*` fields are **is-managed flags** — `true` when the field name appears in `MDMManagedFields`, `false` otherwise. Consumers check truthiness (`!mdm.x` to mean "not managed"). The *resolved value* of a managed field is already enforced into `Config`, so consumers read it via `useSettings().config.` and hide the toggle when `mdm.` is true. **Three carve-outs** that carry the resolved value instead, populated outside the reflective is-managed loop in `services/settings.go` `GetRestrictions`: `mdm.managementURL` (string — used by `ProfileCreationModal` / `WelcomeDialog`, which need the URL to render the form); `mdm.allowServerSSH` (`*bool` / `boolean | null` — used by `SettingsNavigation` + `SettingsPage` to gate the SSH tab on `mdm.allowServerSSH ?? !features.disableUpdateSettings`; tri-state needed because the tab gate lives outside `SettingsProvider` and can't read `config.serverSshAllowed`, and MDM-managed-with-value-`false` must hide the tab — falsy fallthrough would leak it); and `mdm.disableAdvancedView` (`bool` — MDM-only on the daemon, no CLI fallback; plumbed via the `GetFeatures` RPC rather than `MDMManagedFields`, so set directly from `featResp.GetDisableAdvancedView()`. Value semantic: `true` iff MDM explicitly disables; the nil/false collapse is fine because both mean "advanced view available"). The three `features.*` gates (`disableProfiles`/`Networks`/`UpdateSettings`) live in `Features` because they accept a CLI-flag fallback in addition to MDM (`--disable-profiles` etc.) — `disableAdvancedView` doesn't, which is why it sits with the MDM-only carve-outs. `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/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx
index 805674783..b87d38ca6 100644
--- a/client/ui/frontend/src/modules/main/MainPage.tsx
+++ b/client/ui/frontend/src/modules/main/MainPage.tsx
@@ -34,7 +34,7 @@ const MainBody = () => {
const { viewMode, setViewMode } = useViewMode();
const { mdm, features } = useRestrictions();
- // Force flip the view if mdm changed it
+ // Force flip the view if MDM disabled advanced
useEffect(() => {
if (mdm.disableAdvancedView && viewMode === "advanced") {
setViewMode("default");
diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
index 1907c2114..13dbbe30f 100644
--- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx
@@ -19,7 +19,7 @@ export const SettingsNavigation = () => {
const { t } = useTranslation();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
- const showSsh = mdm.allowServerSSH || !features.disableUpdateSettings;
+ const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings;
const aboutAdornment = updateAvailable ? (
diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx
index ce3924647..49ba99754 100644
--- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx
@@ -54,7 +54,7 @@ export const SettingsPage = () => {
[Tab.Network]: editable,
[Tab.Security]: editable,
[Tab.Profiles]: !features.disableProfiles,
- [Tab.SSH]: mdm.allowServerSSH || editable,
+ [Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.Advanced]: editable,
[Tab.Troubleshooting]: true,
[Tab.About]: true,
diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go
index eb01b1e8a..6bcc8a21b 100644
--- a/client/ui/services/settings.go
+++ b/client/ui/services/settings.go
@@ -18,13 +18,13 @@ type MDMFields struct {
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
- AllowServerSSH bool `json:"allowServerSSH"`
+ 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"`
+ DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct {
@@ -222,6 +222,9 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
DisableNetworks: featResp.GetDisableNetworks(),
DisableUpdateSettings: featResp.GetDisableUpdateSettings(),
},
+ MDM: MDMFields{
+ DisableAdvancedView: featResp.GetDisableAdvancedView(),
+ },
}
managed := cfgResp.GetMDMManagedFields()
if len(managed) > 0 {
@@ -235,10 +238,6 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
if v.Field(i).Kind() != reflect.Bool {
continue
}
- // AllowServerSSH carries the resolved value (like ManagementURL), not the is-managed flag.
- if t.Field(i).Name == "AllowServerSSH" {
- continue
- }
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
v.Field(i).SetBool(true)
}
@@ -247,9 +246,9 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
r.MDM.ManagementURL = cfgResp.GetManagementUrl()
}
if _, ok := set["allowServerSSH"]; ok {
- r.MDM.AllowServerSSH = cfgResp.GetServerSSHAllowed()
+ allowed := cfgResp.GetServerSSHAllowed()
+ r.MDM.AllowServerSSH = &allowed
}
}
- r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
return r, nil
}