diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 0a8b2c269..9fbadcbf5 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -289,6 +289,11 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.AgentNetworkOnly != nil { returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly } + if req.Settings.DashboardFeatures != nil { + returnSettings.DashboardFeatures = &types.DashboardFeatures{ + AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork, + } + } return returnSettings, nil } @@ -434,6 +439,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A networkRangeV6Str := settings.NetworkRangeV6.String() apiSettings.NetworkRangeV6 = &networkRangeV6Str } + if settings.DashboardFeatures != nil { + apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{ + AgentNetwork: settings.DashboardFeatures.AgentNetwork, + } + } apiOnboarding := api.AccountOnboarding{ OnboardingFlowPending: onboarding.OnboardingFlowPending, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index d0bcbbc3b..49a9848c0 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -312,6 +312,38 @@ func TestAccounts_AccountsHandler(t *testing.T) { expectedArray: false, expectedID: accountID, }, + { + name: "PutAccount OK setting dashboard_features agent_network", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, { name: "PutAccount OK disabling agent_network_only again", expectedBody: true, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c8ded4e4e..f3e24298d 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1606,6 +1606,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_dashboard_features, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1630,6 +1631,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool + sDashboardFeatures sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1653,6 +1655,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sDashboardFeatures, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1724,6 +1727,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sAgentNetworkOnly.Valid { account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool } + if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { + if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { + log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) + } + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index faef8651e..58f62be32 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1270,6 +1270,36 @@ func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") } +func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") + + agentNetwork := true + account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") + require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") + require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") + + disabled := false + reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + reloadedDisabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") + require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 7c24f944b..815c93ce6 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -80,6 +80,11 @@ type Settings struct { // Set for accounts created via netbird.ai signups; users can disable it later. AgentNetworkOnly bool `gorm:"default:false"` + // DashboardFeatures holds per-account dashboard section visibility overrides. + // It serializes to a single JSON column so new sections can be added without + // a schema change. + DashboardFeatures *DashboardFeatures `gorm:"serializer:json"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -126,9 +131,31 @@ func (s *Settings) Copy() *Settings { if s.Extra != nil { settings.Extra = s.Extra.Copy() } + if s.DashboardFeatures != nil { + settings.DashboardFeatures = s.DashboardFeatures.Copy() + } return settings } +// DashboardFeatures holds per-account dashboard section visibility overrides. +// Nil fields are unset and follow the default dashboard behavior; an explicit +// value forces that section shown or hidden for the account. +type DashboardFeatures struct { + // AgentNetwork, when set, forces the Agent Network menu shown (true) or + // hidden (false) regardless of the deployment feature flag. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + +// Copy returns a deep copy of the DashboardFeatures struct. +func (d *DashboardFeatures) Copy() *DashboardFeatures { + c := &DashboardFeatures{} + if d.AgentNetwork != nil { + v := *d.AgentNetwork + c.AgentNetwork = &v + } + return c +} + type ExtraSettings struct { // PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator PeerApprovalEnabled bool diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d0c5aee8b..c61dbd2f8 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -379,6 +379,8 @@ components: description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. type: boolean example: false + dashboard_features: + $ref: '#/components/schemas/AccountDashboardFeatures' embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean @@ -407,6 +409,14 @@ components: - regular_users_view_blocked - peer_expose_enabled - peer_expose_groups + AccountDashboardFeatures: + description: Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + type: object + properties: + agent_network: + description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + type: boolean + example: true AccountExtraSettings: type: object properties: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6356aca18..6fc17ef60 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1612,6 +1612,12 @@ type Account struct { Settings AccountSettings `json:"settings"` } +// AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. +type AccountDashboardFeatures struct { + // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + // AccountExtraSettings defines model for AccountExtraSettings. type AccountExtraSettings struct { // NetworkTrafficLogsEnabled Enables or disables network traffic logging. If enabled, all network traffic events from peers will be stored. @@ -1656,6 +1662,9 @@ type AccountSettings struct { // AutoUpdateVersion Set Clients auto-update version. "latest", "disabled", or a specific version (e.g "0.50.1") AutoUpdateVersion *string `json:"auto_update_version,omitempty"` + // DashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + DashboardFeatures *AccountDashboardFeatures `json:"dashboard_features,omitempty"` + // DnsDomain Allows to define a custom dns domain for the account DnsDomain *string `json:"dns_domain,omitempty"`