From 55791332f32873b71a09b0b8ab4b777fcd6f0fc3 Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 11 Sep 2026 14:54:53 +0200 Subject: [PATCH] [client] Classify the daemon's refusals in the GUI (review item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FailedPrecondition reached the classifier unmatched, so a refusal showed as "Operation failed". It is the code both of the daemon's deliberate refusals carry: the update-settings kill switch, and a field an MDM policy manages. Both are now named — settings_locked and settings_managed_by_mdm, matched on the message the daemon composes — and FailedPrecondition itself falls back to change_refused, so a refusal the daemon grows later still reads as a refusal rather than a failure. Only the English strings are added. Bundle.Translate falls back to the default language for a missing key, so other locales show English until the usual translation pass, rather than the bare "error." the classifier would otherwise surface. Note: the package needs GTK4/WebKit to build, which this machine has not, so the test is type-checked (go vet, GOOS=windows) but was not executed locally; CI's Linux job runs it. --- client/ui/i18n/locales/en/common.json | 12 ++++++++++++ client/ui/services/errors.go | 9 +++++++++ client/ui/services/errors_test.go | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+) 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)