diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 36f00e4bd..69f17ebc7 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1795,6 +1795,18 @@ "message": "The NetBird daemon is not responding. Please check that the service is running.", "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." }, + "error.settings_locked": { + "message": "Settings cannot be changed on this device: an administrator has locked them.", + "description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes." + }, + "error.settings_managed_by_mdm": { + "message": "This setting is managed by your organization and cannot be changed.", + "description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system." + }, + "error.change_refused": { + "message": "The NetBird service refused this change.", + "description": "Error: the daemon answered the request and declined it. Generic fallback for a refusal with no more specific cause." + }, "error.unknown": { "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0c6f2f20f..2b77548f5 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -134,6 +134,10 @@ func (c errorClassifier) classify(err error) *ClientError { strings.Contains(lower, "connection refused"), strings.Contains(lower, "context deadline exceeded"): code = "daemon_unreachable" + case strings.Contains(lower, "update settings are disabled"): + code = "settings_locked" + case strings.Contains(lower, "managed by mdm"): + code = "settings_managed_by_mdm" } // Fall back to the gRPC status code when the message didn't match a known @@ -145,6 +149,11 @@ func (c errorClassifier) classify(err error) *ClientError { code = "permission_denied" case gcodes.Unavailable, gcodes.DeadlineExceeded: code = "daemon_unreachable" + case gcodes.FailedPrecondition: + // The daemon answered and refused. The two refusals it composes + // are matched above; anything else that reaches here is still a + // refusal, so say that rather than "operation failed". + code = "change_refused" } } diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 2f8f3d039..c1f4badb9 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -34,6 +34,28 @@ func TestErrorClassifier_Classify(t *testing.T) { require.Equal(t, "session_expired", ce.Code) }) + t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "update settings are disabled, you cannot use this feature without update settings enabled") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "settings_locked", ce.Code) + }) + + t.Run("an MDM-managed field is named as such", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "fields managed by MDM cannot be modified: [managementURL]") + + require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code) + }) + + t.Run("any other refusal is still a refusal", func(t *testing.T) { + // FailedPrecondition means the daemon answered and declined; falling + // through to "unknown" showed "Operation failed" instead. + require.Equal(t, "change_refused", c.classify(gstatus.Error(gcodes.FailedPrecondition, "something else")).Code) + }) + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) require.Equal(t, "daemon_unreachable", ce.Code)