From f52d23d6309f1b33c580997cafdd3b1c2babfa77 Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Wed, 29 Jul 2026 20:34:53 +0000 Subject: [PATCH 1/5] feat: add deployment setting to hide the Codernauts game Adds a hide_codernauts runtime setting persisted in site_configs and exposed through the appearance API. A new toggle at the bottom of the Deployment > Appearance page controls it, and the user dropdown skips the Codernauts menu item when it is set. The setting works on all licenses: the default appearance fetcher reads it from the database too, so no license entitlement is required. --- coderd/apidoc/docs.go | 8 ++++ coderd/apidoc/swagger.json | 8 ++++ coderd/appearance/appearance.go | 24 ++++++++-- coderd/coderd.go | 2 +- coderd/database/dbauthz/dbauthz.go | 12 +++++ coderd/database/dbauthz/dbauthz_test.go | 8 ++++ coderd/database/dbmetrics/querymetrics.go | 16 +++++++ coderd/database/dbmock/dbmock.go | 29 +++++++++++ coderd/database/querier.go | 4 ++ coderd/database/queries.sql.go | 36 ++++++++++++++ coderd/database/queries/siteconfig.sql | 22 +++++++++ codersdk/deployment.go | 4 ++ docs/reference/api/enterprise.md | 3 ++ docs/reference/api/schemas.md | 32 +++++++------ enterprise/coderd/appearance.go | 18 +++++++ enterprise/coderd/coderd.go | 2 +- site/site.go | 4 +- site/src/api/api.ts | 1 + site/src/api/typesGenerated.ts | 8 ++++ site/src/modules/dashboard/Navbar/Navbar.tsx | 1 + .../modules/dashboard/Navbar/NavbarView.tsx | 3 ++ .../UserDropdown/UserDropdown.stories.tsx | 25 ++++++++++ .../Navbar/UserDropdown/UserDropdown.tsx | 3 ++ .../UserDropdown/UserDropdownContent.tsx | 48 ++++++++++--------- .../AppearanceSettingsPageView.stories.tsx | 40 ++++++++++++++++ .../AppearanceSettingsPageView.tsx | 22 +++++++++ site/src/testHelpers/entities.ts | 1 + 27 files changed, 341 insertions(+), 43 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 388c35f1144..c02052c43e3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16744,6 +16744,10 @@ const docTemplate = `{ "docs_url": { "type": "string" }, + "hide_codernauts": { + "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", + "type": "boolean" + }, "logo_url": { "type": "string" }, @@ -25796,6 +25800,10 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "hide_codernauts": { + "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4553c63fc92..efe0bed6746 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15014,6 +15014,10 @@ "docs_url": { "type": "string" }, + "hide_codernauts": { + "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", + "type": "boolean" + }, "logo_url": { "type": "string" }, @@ -23692,6 +23696,10 @@ "application_name": { "type": "string" }, + "hide_codernauts": { + "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index f63cd77a59c..f74a1dfc516 100644 --- a/coderd/appearance/appearance.go +++ b/coderd/appearance/appearance.go @@ -3,6 +3,9 @@ package appearance import ( "context" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" ) @@ -11,22 +14,35 @@ type Fetcher interface { } type AGPLFetcher struct { - docsURL string + // database may be nil when no store is available, in which case + // runtime settings fall back to their zero values. + database database.Store + docsURL string } -func (f AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) { +func (f AGPLFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) { + hideCodernauts := false + if f.database != nil { + var err error + hideCodernauts, err = f.database.GetHideCodernauts(ctx) + if err != nil { + return codersdk.AppearanceConfig{}, xerrors.Errorf("get hide codernauts: %w", err) + } + } return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, + HideCodernauts: hideCodernauts, }, nil } -func NewDefaultFetcher(docsURL string) Fetcher { +func NewDefaultFetcher(db database.Store, docsURL string) Fetcher { if docsURL == "" { docsURL = codersdk.DefaultDocsURL() } return &AGPLFetcher{ - docsURL: docsURL, + database: db, + docsURL: docsURL, } } diff --git a/coderd/coderd.go b/coderd/coderd.go index 45bff9e2b6e..da5b8adff72 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -730,7 +730,7 @@ func New(options *Options) *API { options.AppSigningKeyCache, ) - f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String()) + f := appearance.NewDefaultFetcher(options.Database, api.DeploymentValues.DocsURL.String()) api.AppearanceFetcher.Store(&f) api.PortSharer.Store(&portsharing.DefaultPortSharer) api.PrebuildsClaimer.Store(&prebuilds.DefaultClaimer) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index de153b24c2e..324042bab85 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3984,6 +3984,11 @@ func (q *querier) GetHealthSettings(ctx context.Context) (string, error) { return q.db.GetHealthSettings(ctx) } +func (q *querier) GetHideCodernauts(ctx context.Context) (bool, error) { + // No authz checks + return q.db.GetHideCodernauts(ctx) +} + func (q *querier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID)); err != nil { return database.GetHighestGroupAIBudgetByUserRow{}, err @@ -9061,6 +9066,13 @@ func (q *querier) UpsertHealthSettings(ctx context.Context, value string) error return q.db.UpsertHealthSettings(ctx, value) } +func (q *querier) UpsertHideCodernauts(ctx context.Context, hide bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertHideCodernauts(ctx, hide) +} + func (q *querier) UpsertLastUpdateCheck(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 07278858db4..d67fa9cd4b1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5421,6 +5421,14 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().UpsertApplicationName(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetHideCodernauts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetHideCodernauts(gomock.Any()).Return(false, nil).AnyTimes() + check.Args().Asserts() + })) + s.Run("UpsertHideCodernauts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertHideCodernauts(gomock.Any(), true).Return(nil).AnyTimes() + check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertBoundaryUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.UpsertBoundaryUsageStatsParams{ReplicaID: uuid.New()} dbm.EXPECT().UpsertBoundaryUsageStats(gomock.Any(), arg).Return(false, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 26f47ff51dd..055680debdb 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2241,6 +2241,14 @@ func (m queryMetricsStore) GetHealthSettings(ctx context.Context) (string, error return r0, r1 } +func (m queryMetricsStore) GetHideCodernauts(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetHideCodernauts(ctx) + m.queryLatencies.WithLabelValues("GetHideCodernauts").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetHideCodernauts").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { start := time.Now() r0, r1 := m.s.GetHighestGroupAIBudgetByUser(ctx, userID) @@ -6465,6 +6473,14 @@ func (m queryMetricsStore) UpsertHealthSettings(ctx context.Context, value strin return r0 } +func (m queryMetricsStore) UpsertHideCodernauts(ctx context.Context, hide bool) error { + start := time.Now() + r0 := m.s.UpsertHideCodernauts(ctx, hide) + m.queryLatencies.WithLabelValues("UpsertHideCodernauts").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertHideCodernauts").Inc() + return r0 +} + func (m queryMetricsStore) UpsertLastUpdateCheck(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertLastUpdateCheck(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ac848e0f993..a9a8b34e2c7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4153,6 +4153,21 @@ func (mr *MockStoreMockRecorder) GetHealthSettings(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHealthSettings", reflect.TypeOf((*MockStore)(nil).GetHealthSettings), ctx) } +// GetHideCodernauts mocks base method. +func (m *MockStore) GetHideCodernauts(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetHideCodernauts", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetHideCodernauts indicates an expected call of GetHideCodernauts. +func (mr *MockStoreMockRecorder) GetHideCodernauts(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHideCodernauts", reflect.TypeOf((*MockStore)(nil).GetHideCodernauts), ctx) +} + // GetHighestGroupAIBudgetByUser mocks base method. func (m *MockStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { m.ctrl.T.Helper() @@ -12097,6 +12112,20 @@ func (mr *MockStoreMockRecorder) UpsertHealthSettings(ctx, value any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertHealthSettings", reflect.TypeOf((*MockStore)(nil).UpsertHealthSettings), ctx, value) } +// UpsertHideCodernauts mocks base method. +func (m *MockStore) UpsertHideCodernauts(ctx context.Context, hide bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertHideCodernauts", ctx, hide) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertHideCodernauts indicates an expected call of UpsertHideCodernauts. +func (mr *MockStoreMockRecorder) UpsertHideCodernauts(ctx, hide any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertHideCodernauts", reflect.TypeOf((*MockStore)(nil).UpsertHideCodernauts), ctx, hide) +} + // UpsertLastUpdateCheck mocks base method. func (m *MockStore) UpsertLastUpdateCheck(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eb46ff6a8f8..b7cffc029ca 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -629,6 +629,9 @@ type sqlcQuerier interface { // A limit of 0 means "no limit". GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) GetHealthSettings(ctx context.Context) (string, error) + // GetHideCodernauts returns whether the Codernauts game link is hidden + // from the user dropdown menu. Defaults to false when unset. + GetHideCodernauts(ctx context.Context) (bool, error) // Returns the highest group AI budget across the groups the user belongs to, // breaking ties by the earliest organization membership. Implements the // "highest" budget policy. group_members_expanded is a UNION of group_members @@ -1654,6 +1657,7 @@ type sqlcQuerier interface { UpsertDefaultProxy(ctx context.Context, arg UpsertDefaultProxyParams) error UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIBudgetParams) (GroupAIBudget, error) UpsertHealthSettings(ctx context.Context, value string) error + UpsertHideCodernauts(ctx context.Context, hide bool) error UpsertLastUpdateCheck(ctx context.Context, value string) error UpsertLogoURL(ctx context.Context, value string) error UpsertMCPServerUserToken(ctx context.Context, arg UpsertMCPServerUserTokenParams) (MCPServerUserToken, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3fb67221f0f..f6d8c451eb9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25528,6 +25528,20 @@ func (q *sqlQuerier) GetHealthSettings(ctx context.Context) (string, error) { return health_settings, err } +const getHideCodernauts = `-- name: GetHideCodernauts :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts +` + +// GetHideCodernauts returns whether the Codernauts game link is hidden +// from the user dropdown menu. Defaults to false when unset. +func (q *sqlQuerier) GetHideCodernauts(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getHideCodernauts) + var hide_codernauts bool + err := row.Scan(&hide_codernauts) + return hide_codernauts, err +} + const getLastUpdateCheck = `-- name: GetLastUpdateCheck :one SELECT value FROM site_configs WHERE key = 'last_update_check' ` @@ -25939,6 +25953,28 @@ func (q *sqlQuerier) UpsertHealthSettings(ctx context.Context, value string) err return err } +const upsertHideCodernauts = `-- name: UpsertHideCodernauts :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'hide_codernauts', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'hide_codernauts' +` + +func (q *sqlQuerier) UpsertHideCodernauts(ctx context.Context, hide bool) error { + _, err := q.db.ExecContext(ctx, upsertHideCodernauts, hide) + return err +} + const upsertLastUpdateCheck = `-- name: UpsertLastUpdateCheck :exec INSERT INTO site_configs (key, value) VALUES ('last_update_check', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update_check' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index fbadbef2235..04046c1be18 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -57,6 +57,28 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application -- name: GetApplicationName :one SELECT value FROM site_configs WHERE key = 'application_name'; +-- GetHideCodernauts returns whether the Codernauts game link is hidden +-- from the user dropdown menu. Defaults to false when unset. +-- name: GetHideCodernauts :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts; + +-- name: UpsertHideCodernauts :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'hide_codernauts', + CASE + WHEN sqlc.arg(hide)::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN sqlc.arg(hide)::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'hide_codernauts'; + -- name: GetHealthSettings :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'health_settings'), '{}') :: text AS health_settings diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5d6c624d200..15b9847d2d2 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5239,6 +5239,8 @@ type AppearanceConfig struct { ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` SupportLinks []LinkConfig `json:"support_links,omitempty"` + // HideCodernauts hides the Codernauts game link in the user dropdown menu. + HideCodernauts bool `json:"hide_codernauts"` } type UpdateAppearanceConfig struct { @@ -5247,6 +5249,8 @@ type UpdateAppearanceConfig struct { // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` + // HideCodernauts hides the Codernauts game link in the user dropdown menu. + HideCodernauts bool `json:"hide_codernauts"` } // Deprecated: ServiceBannerConfig has been renamed to BannerConfig. diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 37e1d852ab5..08e93094621 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -352,6 +352,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ ], "application_name": "string", "docs_url": "string", + "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -403,6 +404,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -432,6 +434,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f8295c7d1ba..9b82b6de22a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1503,6 +1503,7 @@ None ], "application_name": "string", "docs_url": "string", + "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -1522,14 +1523,15 @@ None ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| -| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | -| `application_name` | string | false | | | -| `docs_url` | string | false | | | -| `logo_url` | string | false | | | -| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | -| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------| +| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | +| `application_name` | string | false | | | +| `docs_url` | string | false | | | +| `hide_codernauts` | boolean | false | | Hide codernauts hides the Codernauts game link in the user dropdown menu. | +| `logo_url` | string | false | | | +| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | +| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | ## codersdk.ArchiveTemplateVersionsRequest @@ -13727,6 +13729,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W } ], "application_name": "string", + "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -13738,12 +13741,13 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| -| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | -| `application_name` | string | false | | | -| `logo_url` | string | false | | | -| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | +| Name | Type | Required | Restrictions | Description | +|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------| +| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | +| `application_name` | string | false | | | +| `hide_codernauts` | boolean | false | | Hide codernauts hides the Codernauts game link in the user dropdown menu. | +| `logo_url` | string | false | | | +| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | ## codersdk.UpdateChatACL diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index db845fadea3..250b7fdfa0d 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -66,6 +66,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi applicationName string logoURL string announcementBannersJSON string + hideCodernauts bool ) eg.Go(func() (err error) { applicationName, err = f.database.GetApplicationName(ctx) @@ -88,6 +89,13 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi } return nil }) + eg.Go(func() (err error) { + hideCodernauts, err = f.database.GetHideCodernauts(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get hide codernauts: %w", err) + } + return nil + }) err := eg.Wait() if err != nil { return codersdk.AppearanceConfig{}, err @@ -99,6 +107,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, + HideCodernauts: hideCodernauts, } if announcementBannersJSON != "" { @@ -206,5 +215,14 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { return } + err = api.Database.UpsertHideCodernauts(ctx, appearance.HideCodernauts) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Unable to set hide Codernauts", + Detail: err.Error(), + }) + return + } + httpapi.Write(r.Context(), rw, http.StatusOK, appearance) } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 8df7476400e..c2dc2ee8eed 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1136,7 +1136,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { ) api.AGPL.AppearanceFetcher.Store(&f) } else { - f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String()) + f := appearance.NewDefaultFetcher(api.Database, api.DeploymentValues.DocsURL.String()) api.AGPL.AppearanceFetcher.Store(&f) } } diff --git a/site/site.go b/site/site.go index 0c49a122144..e1d11f85f66 100644 --- a/site/site.go +++ b/site/site.go @@ -89,7 +89,9 @@ type Options struct { func New(opts *Options) (*Handler, error) { if opts.AppearanceFetcher == nil { daf := atomic.Pointer[appearance.Fetcher]{} - f := appearance.NewDefaultFetcher(opts.DocsURL) + // No database is available here; coderd replaces this fetcher + // with a database-backed one during startup. + f := appearance.NewDefaultFetcher(nil, opts.DocsURL) daf.Store(&f) opts.AppearanceFetcher = &daf } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b87d08a9f2b..1b48a114345 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2482,6 +2482,7 @@ class ApiMethods { docs_url: "", logo_url: "", announcement_banners: [], + hide_codernauts: false, service_banner: { enabled: false, }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5dadf27b2b2..c2e09306078 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1493,6 +1493,10 @@ export interface AppearanceConfig { readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; readonly support_links?: readonly LinkConfig[]; + /** + * HideCodernauts hides the Codernauts game link in the user dropdown menu. + */ + readonly hide_codernauts: boolean; } // From codersdk/templates.go @@ -9570,6 +9574,10 @@ export interface UpdateAppearanceConfig { */ readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; + /** + * HideCodernauts hides the Codernauts game link in the user dropdown menu. + */ + readonly hide_codernauts: boolean; } // From codersdk/chats.go diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 75417d5e9c6..639f251ef76 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -43,6 +43,7 @@ export const Navbar: React.FC = () => { user={me} buildInfo={buildInfoQuery.data} supportLinks={Array.from(uniqueLinks.values())} + hideCodernauts={appearance.hide_codernauts} onSignOut={signOut} adminPermissions={{ canViewDeployment, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 66345219701..9003afd2433 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -30,6 +30,7 @@ interface NavbarViewProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; + hideCodernauts?: boolean; onSignOut: () => void; adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; @@ -46,6 +47,7 @@ export const NavbarView: FC = ({ user, buildInfo, supportLinks, + hideCodernauts, onSignOut, adminPermissions, canCreateChat, @@ -138,6 +140,7 @@ export const NavbarView: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks?.filter((link) => !isNavbarLink(link))} + hideCodernauts={hideCodernauts} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 6260b96dc71..c44733dbe5f 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -298,4 +298,29 @@ export const AISpendHiddenOnNegativeLimit: Story = { }, }; +export const CodernautsHidden: Story = { + args: { + hideCodernauts: true, + }, + play: async ({ canvasElement, step }) => { + await step("hides the Codernauts link", async () => { + await openDropdown(canvasElement); + expect( + screen.queryByRole("menuitem", { name: "Codernauts" }), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const CodernautsShown: Story = { + play: async ({ canvasElement, step }) => { + await step("shows the Codernauts link by default", async () => { + await openDropdown(canvasElement); + expect( + screen.getByRole("menuitem", { name: "Codernauts" }), + ).toBeInTheDocument(); + }); + }, +}; + export { Example as UserDropdown }; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 2cc33a8fa03..841d8e034e3 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -24,6 +24,7 @@ interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; + hideCodernauts?: boolean; onSignOut: () => void; } @@ -31,6 +32,7 @@ export const UserDropdown: FC = ({ buildInfo, user, supportLinks, + hideCodernauts, onSignOut, }) => { const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -86,6 +88,7 @@ export const UserDropdown: FC = ({ ) } supportLinks={supportLinks} + hideCodernauts={hideCodernauts} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index ac262232684..1e580be2d52 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -28,6 +28,7 @@ interface UserDropdownContentProps { * (e.g. AI spend). The consumer supplies its own separator if needed. */ profileExtra?: ReactNode; supportLinks: readonly TypesGen.LinkConfig[]; + hideCodernauts?: boolean; onSignOut: () => void; } @@ -36,6 +37,7 @@ export const UserDropdownContent: FC = ({ buildInfo, profileExtra, supportLinks, + hideCodernauts, onSignOut, }) => { const { showCopiedSuccess, copyToClipboard } = useClipboard(); @@ -81,28 +83,30 @@ export const UserDropdownContent: FC = ({ ))} )} - - - - - - - - - - - - Codernauts - - {" "} + {!hideCodernauts && ( + + + + + + + + + + + + Codernauts + + + )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index 72c5bd4f59d..9fe473888da 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; const meta: Meta = { @@ -20,8 +21,10 @@ const meta: Meta = { background_color: "#ffaff3", }, ], + hide_codernauts: false, }, isEntitled: false, + onSaveAppearance: fn(), }, }; @@ -35,3 +38,40 @@ export const Entitled: Story = { }; export const NotEntitled: Story = {}; + +export const HideCodernautsToggle: Story = { + args: { + isEntitled: true, + }, + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + await step("toggling the switch saves the setting", async () => { + await userEvent.click( + canvas.getByRole("switch", { name: "Hide Codernauts game" }), + ); + await waitFor(() => + expect(args.onSaveAppearance).toHaveBeenCalledWith({ + hide_codernauts: true, + }), + ); + }); + }, +}; + +export const HideCodernautsNotEntitled: Story = { + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + await step("the switch saves even without entitlement", async () => { + const switchEl = canvas.getByRole("switch", { + name: "Hide Codernauts game", + }); + expect(switchEl).toBeEnabled(); + await userEvent.click(switchEl); + await waitFor(() => + expect(args.onSaveAppearance).toHaveBeenCalledWith({ + hide_codernauts: true, + }), + ); + }); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 74acf048df3..85a12f0f7ff 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -19,6 +19,7 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; import { Tooltip, TooltipContent, @@ -154,6 +155,27 @@ export const AppearanceSettingsPageView: FC< onSaveAppearance({ announcement_banners: announcementBanners }) } /> + +
+
+
+

+ +

+
+ Remove the Codernauts game link from the user dropdown menu. + Available on all licenses. +
+
+ + onSaveAppearance({ hide_codernauts: checked }) + } + /> +
+
); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bc3ef896c5d..ac1f76d1c5a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3442,6 +3442,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = { }, announcement_banners: [], docs_url: "https://coder.com/docs/@main/", + hide_codernauts: false, }; export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = { From adeb84dfd52576c61efae6e116c4c836afc24f7e Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Thu, 30 Jul 2026 11:32:43 +0000 Subject: [PATCH 2/5] chore: apply PR review feedback Use the @arg sqlc shorthand, drop comments that restate the code, require a database store in the default appearance fetcher by passing site.Options.Database, and remove the redundant CodernautsShown story. --- coderd/apidoc/docs.go | 2 -- coderd/apidoc/swagger.json | 2 -- coderd/appearance/appearance.go | 12 ++----- coderd/database/querier.go | 2 -- coderd/database/queries.sql.go | 2 -- coderd/database/queries/siteconfig.sql | 6 ++-- codersdk/deployment.go | 6 ++-- docs/reference/api/schemas.md | 32 +++++++++---------- site/site.go | 4 +-- site/src/api/typesGenerated.ts | 6 ---- .../UserDropdown/UserDropdown.stories.tsx | 11 ------- 11 files changed, 24 insertions(+), 61 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c02052c43e3..dda20edf198 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16745,7 +16745,6 @@ const docTemplate = `{ "type": "string" }, "hide_codernauts": { - "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", "type": "boolean" }, "logo_url": { @@ -25801,7 +25800,6 @@ const docTemplate = `{ "type": "string" }, "hide_codernauts": { - "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", "type": "boolean" }, "logo_url": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index efe0bed6746..bfd62823f02 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15015,7 +15015,6 @@ "type": "string" }, "hide_codernauts": { - "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", "type": "boolean" }, "logo_url": { @@ -23697,7 +23696,6 @@ "type": "string" }, "hide_codernauts": { - "description": "HideCodernauts hides the Codernauts game link in the user dropdown menu.", "type": "boolean" }, "logo_url": { diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index f74a1dfc516..e97dbc8dd4b 100644 --- a/coderd/appearance/appearance.go +++ b/coderd/appearance/appearance.go @@ -14,20 +14,14 @@ type Fetcher interface { } type AGPLFetcher struct { - // database may be nil when no store is available, in which case - // runtime settings fall back to their zero values. database database.Store docsURL string } func (f AGPLFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) { - hideCodernauts := false - if f.database != nil { - var err error - hideCodernauts, err = f.database.GetHideCodernauts(ctx) - if err != nil { - return codersdk.AppearanceConfig{}, xerrors.Errorf("get hide codernauts: %w", err) - } + hideCodernauts, err := f.database.GetHideCodernauts(ctx) + if err != nil { + return codersdk.AppearanceConfig{}, xerrors.Errorf("get hide codernauts: %w", err) } return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b7cffc029ca..3254ed90bb9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -629,8 +629,6 @@ type sqlcQuerier interface { // A limit of 0 means "no limit". GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) GetHealthSettings(ctx context.Context) (string, error) - // GetHideCodernauts returns whether the Codernauts game link is hidden - // from the user dropdown menu. Defaults to false when unset. GetHideCodernauts(ctx context.Context) (bool, error) // Returns the highest group AI budget across the groups the user belongs to, // breaking ties by the earliest organization membership. Implements the diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f6d8c451eb9..c5b683820a2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25533,8 +25533,6 @@ SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts ` -// GetHideCodernauts returns whether the Codernauts game link is hidden -// from the user dropdown menu. Defaults to false when unset. func (q *sqlQuerier) GetHideCodernauts(ctx context.Context) (bool, error) { row := q.db.QueryRowContext(ctx, getHideCodernauts) var hide_codernauts bool diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 04046c1be18..54d1d48f4a0 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -57,8 +57,6 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application -- name: GetApplicationName :one SELECT value FROM site_configs WHERE key = 'application_name'; --- GetHideCodernauts returns whether the Codernauts game link is hidden --- from the user dropdown menu. Defaults to false when unset. -- name: GetHideCodernauts :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts; @@ -68,13 +66,13 @@ INSERT INTO site_configs (key, value) VALUES ( 'hide_codernauts', CASE - WHEN sqlc.arg(hide)::bool THEN 'true' + WHEN @hide::bool THEN 'true' ELSE 'false' END ) ON CONFLICT (key) DO UPDATE SET value = CASE - WHEN sqlc.arg(hide)::bool THEN 'true' + WHEN @hide::bool THEN 'true' ELSE 'false' END WHERE site_configs.key = 'hide_codernauts'; diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 15b9847d2d2..0bda34efdc8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5239,8 +5239,7 @@ type AppearanceConfig struct { ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` SupportLinks []LinkConfig `json:"support_links,omitempty"` - // HideCodernauts hides the Codernauts game link in the user dropdown menu. - HideCodernauts bool `json:"hide_codernauts"` + HideCodernauts bool `json:"hide_codernauts"` } type UpdateAppearanceConfig struct { @@ -5249,8 +5248,7 @@ type UpdateAppearanceConfig struct { // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` - // HideCodernauts hides the Codernauts game link in the user dropdown menu. - HideCodernauts bool `json:"hide_codernauts"` + HideCodernauts bool `json:"hide_codernauts"` } // Deprecated: ServiceBannerConfig has been renamed to BannerConfig. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9b82b6de22a..bb9648434e1 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1523,15 +1523,15 @@ None ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------| -| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | -| `application_name` | string | false | | | -| `docs_url` | string | false | | | -| `hide_codernauts` | boolean | false | | Hide codernauts hides the Codernauts game link in the user dropdown menu. | -| `logo_url` | string | false | | | -| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | -| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| +| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | +| `application_name` | string | false | | | +| `docs_url` | string | false | | | +| `hide_codernauts` | boolean | false | | | +| `logo_url` | string | false | | | +| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | +| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | ## codersdk.ArchiveTemplateVersionsRequest @@ -13741,13 +13741,13 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------| -| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | -| `application_name` | string | false | | | -| `hide_codernauts` | boolean | false | | Hide codernauts hides the Codernauts game link in the user dropdown menu. | -| `logo_url` | string | false | | | -| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | +| Name | Type | Required | Restrictions | Description | +|------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| +| `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | +| `application_name` | string | false | | | +| `hide_codernauts` | boolean | false | | | +| `logo_url` | string | false | | | +| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | ## codersdk.UpdateChatACL diff --git a/site/site.go b/site/site.go index e1d11f85f66..f3fcf5505b3 100644 --- a/site/site.go +++ b/site/site.go @@ -89,9 +89,7 @@ type Options struct { func New(opts *Options) (*Handler, error) { if opts.AppearanceFetcher == nil { daf := atomic.Pointer[appearance.Fetcher]{} - // No database is available here; coderd replaces this fetcher - // with a database-backed one during startup. - f := appearance.NewDefaultFetcher(nil, opts.DocsURL) + f := appearance.NewDefaultFetcher(opts.Database, opts.DocsURL) daf.Store(&f) opts.AppearanceFetcher = &daf } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c2e09306078..aea16999e77 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1493,9 +1493,6 @@ export interface AppearanceConfig { readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; readonly support_links?: readonly LinkConfig[]; - /** - * HideCodernauts hides the Codernauts game link in the user dropdown menu. - */ readonly hide_codernauts: boolean; } @@ -9574,9 +9571,6 @@ export interface UpdateAppearanceConfig { */ readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; - /** - * HideCodernauts hides the Codernauts game link in the user dropdown menu. - */ readonly hide_codernauts: boolean; } diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index c44733dbe5f..08f7cc0f962 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -312,15 +312,4 @@ export const CodernautsHidden: Story = { }, }; -export const CodernautsShown: Story = { - play: async ({ canvasElement, step }) => { - await step("shows the Codernauts link by default", async () => { - await openDropdown(canvasElement); - expect( - screen.getByRole("menuitem", { name: "Codernauts" }), - ).toBeInTheDocument(); - }); - }, -}; - export { Example as UserDropdown }; From d7ae752b5dabb35b851f953e6e41a92db93fa3b9 Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Wed, 19 Aug 2026 14:32:16 +0000 Subject: [PATCH 3/5] refactor: make the Codernauts setting affirmative Rename hide_codernauts to codernauts_enabled across the API, database queries, and frontend. The toggle now reads "Codernauts game" with a neutral description, defaults to on, and switching it off disables the game. --- coderd/apidoc/docs.go | 8 +-- coderd/apidoc/swagger.json | 8 +-- coderd/appearance/appearance.go | 6 +- coderd/database/dbauthz/dbauthz.go | 24 +++---- coderd/database/dbauthz/dbauthz_test.go | 10 +-- coderd/database/dbmetrics/querymetrics.go | 32 ++++----- coderd/database/dbmock/dbmock.go | 58 ++++++++-------- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 68 +++++++++---------- coderd/database/queries/siteconfig.sql | 14 ++-- codersdk/deployment.go | 4 +- docs/reference/api/enterprise.md | 6 +- docs/reference/api/schemas.md | 8 +-- enterprise/coderd/appearance.go | 12 ++-- site/src/api/api.ts | 2 +- site/src/api/typesGenerated.ts | 4 +- site/src/modules/dashboard/Navbar/Navbar.tsx | 2 +- .../modules/dashboard/Navbar/NavbarView.tsx | 6 +- .../UserDropdown/UserDropdown.stories.tsx | 4 +- .../Navbar/UserDropdown/UserDropdown.tsx | 6 +- .../UserDropdown/UserDropdownContent.tsx | 6 +- .../AppearanceSettingsPageView.stories.tsx | 22 +++--- .../AppearanceSettingsPageView.tsx | 12 ++-- site/src/testHelpers/entities.ts | 2 +- 24 files changed, 165 insertions(+), 163 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d8faa92b48d..60b8148f711 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17085,12 +17085,12 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, - "hide_codernauts": { - "type": "boolean" - }, "logo_url": { "type": "string" }, @@ -26256,7 +26256,7 @@ const docTemplate = `{ "application_name": { "type": "string" }, - "hide_codernauts": { + "codernauts_enabled": { "type": "boolean" }, "logo_url": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5ca876b6738..ea1bb05a6e7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15337,12 +15337,12 @@ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, - "hide_codernauts": { - "type": "boolean" - }, "logo_url": { "type": "string" }, @@ -24134,7 +24134,7 @@ "application_name": { "type": "string" }, - "hide_codernauts": { + "codernauts_enabled": { "type": "boolean" }, "logo_url": { diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index e97dbc8dd4b..e77edc90b41 100644 --- a/coderd/appearance/appearance.go +++ b/coderd/appearance/appearance.go @@ -19,15 +19,15 @@ type AGPLFetcher struct { } func (f AGPLFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) { - hideCodernauts, err := f.database.GetHideCodernauts(ctx) + codernautsEnabled, err := f.database.GetCodernautsEnabled(ctx) if err != nil { - return codersdk.AppearanceConfig{}, xerrors.Errorf("get hide codernauts: %w", err) + return codersdk.AppearanceConfig{}, xerrors.Errorf("get codernauts enabled: %w", err) } return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, - HideCodernauts: hideCodernauts, + CodernautsEnabled: codernautsEnabled, }, nil } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6a1ccbe3eff..9376372f19c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3650,6 +3650,11 @@ func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.Get return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChildChatsByParentIDs)(ctx, arg) } +func (q *querier) GetCodernautsEnabled(ctx context.Context) (bool, error) { + // No authz checks + return q.db.GetCodernautsEnabled(ctx) +} + func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { // Just like with the audit logs query, shortcut if the user is an owner. err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog) @@ -3940,11 +3945,6 @@ func (q *querier) GetHealthSettings(ctx context.Context) (string, error) { return q.db.GetHealthSettings(ctx) } -func (q *querier) GetHideCodernauts(ctx context.Context) (bool, error) { - // No authz checks - return q.db.GetHideCodernauts(ctx) -} - func (q *querier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID)); err != nil { return database.GetHighestGroupAIBudgetByUserRow{}, err @@ -8987,6 +8987,13 @@ func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl strin return q.db.UpsertChatWorkspaceTTL(ctx, workspaceTtl) } +func (q *querier) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertCodernautsEnabled(ctx, enabled) +} + func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err @@ -9013,13 +9020,6 @@ func (q *querier) UpsertHealthSettings(ctx context.Context, value string) error return q.db.UpsertHealthSettings(ctx, value) } -func (q *querier) UpsertHideCodernauts(ctx context.Context, hide bool) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertHideCodernauts(ctx, hide) -} - func (q *querier) UpsertLastUpdateCheck(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 54862f73d5a..96e488afdf8 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5242,13 +5242,13 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().UpsertApplicationName(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetHideCodernauts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetHideCodernauts(gomock.Any()).Return(false, nil).AnyTimes() + s.Run("GetCodernautsEnabled", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetCodernautsEnabled(gomock.Any()).Return(true, nil).AnyTimes() check.Args().Asserts() })) - s.Run("UpsertHideCodernauts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertHideCodernauts(gomock.Any(), true).Return(nil).AnyTimes() - check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + s.Run("UpsertCodernautsEnabled", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertCodernautsEnabled(gomock.Any(), false).Return(nil).AnyTimes() + check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) s.Run("UpsertBoundaryUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.UpsertBoundaryUsageStatsParams{ReplicaID: uuid.New()} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 86f8ac1d0d5..2802fd06ec7 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1873,6 +1873,14 @@ func (m queryMetricsStore) GetChildChatsByParentIDs(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) GetCodernautsEnabled(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetCodernautsEnabled(ctx) + m.queryLatencies.WithLabelValues("GetCodernautsEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetCodernautsEnabled").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { start := time.Now() r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg) @@ -2193,14 +2201,6 @@ func (m queryMetricsStore) GetHealthSettings(ctx context.Context) (string, error return r0, r1 } -func (m queryMetricsStore) GetHideCodernauts(ctx context.Context) (bool, error) { - start := time.Now() - r0, r1 := m.s.GetHideCodernauts(ctx) - m.queryLatencies.WithLabelValues("GetHideCodernauts").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetHideCodernauts").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { start := time.Now() r0, r1 := m.s.GetHighestGroupAIBudgetByUser(ctx, userID) @@ -6385,6 +6385,14 @@ func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspace return r0 } +func (m queryMetricsStore) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + start := time.Now() + r0 := m.s.UpsertCodernautsEnabled(ctx, enabled) + m.queryLatencies.WithLabelValues("UpsertCodernautsEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertCodernautsEnabled").Inc() + return r0 +} + func (m queryMetricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { start := time.Now() r0 := m.s.UpsertDefaultProxy(ctx, arg) @@ -6409,14 +6417,6 @@ func (m queryMetricsStore) UpsertHealthSettings(ctx context.Context, value strin return r0 } -func (m queryMetricsStore) UpsertHideCodernauts(ctx context.Context, hide bool) error { - start := time.Now() - r0 := m.s.UpsertHideCodernauts(ctx, hide) - m.queryLatencies.WithLabelValues("UpsertHideCodernauts").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertHideCodernauts").Inc() - return r0 -} - func (m queryMetricsStore) UpsertLastUpdateCheck(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertLastUpdateCheck(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9ca242a36df..43b3d9b8ac7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3480,6 +3480,21 @@ func (mr *MockStoreMockRecorder) GetChildChatsByParentIDs(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChildChatsByParentIDs", reflect.TypeOf((*MockStore)(nil).GetChildChatsByParentIDs), ctx, arg) } +// GetCodernautsEnabled mocks base method. +func (m *MockStore) GetCodernautsEnabled(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCodernautsEnabled", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCodernautsEnabled indicates an expected call of GetCodernautsEnabled. +func (mr *MockStoreMockRecorder) GetCodernautsEnabled(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCodernautsEnabled", reflect.TypeOf((*MockStore)(nil).GetCodernautsEnabled), ctx) +} + // GetConnectionLogsOffset mocks base method. func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { m.ctrl.T.Helper() @@ -4080,21 +4095,6 @@ func (mr *MockStoreMockRecorder) GetHealthSettings(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHealthSettings", reflect.TypeOf((*MockStore)(nil).GetHealthSettings), ctx) } -// GetHideCodernauts mocks base method. -func (m *MockStore) GetHideCodernauts(ctx context.Context) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetHideCodernauts", ctx) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetHideCodernauts indicates an expected call of GetHideCodernauts. -func (mr *MockStoreMockRecorder) GetHideCodernauts(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHideCodernauts", reflect.TypeOf((*MockStore)(nil).GetHideCodernauts), ctx) -} - // GetHighestGroupAIBudgetByUser mocks base method. func (m *MockStore) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) { m.ctrl.T.Helper() @@ -11982,6 +11982,20 @@ func (mr *MockStoreMockRecorder) UpsertChatWorkspaceTTL(ctx, workspaceTtl any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpsertChatWorkspaceTTL), ctx, workspaceTtl) } +// UpsertCodernautsEnabled mocks base method. +func (m *MockStore) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertCodernautsEnabled", ctx, enabled) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertCodernautsEnabled indicates an expected call of UpsertCodernautsEnabled. +func (mr *MockStoreMockRecorder) UpsertCodernautsEnabled(ctx, enabled any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCodernautsEnabled", reflect.TypeOf((*MockStore)(nil).UpsertCodernautsEnabled), ctx, enabled) +} + // UpsertDefaultProxy mocks base method. func (m *MockStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { m.ctrl.T.Helper() @@ -12025,20 +12039,6 @@ func (mr *MockStoreMockRecorder) UpsertHealthSettings(ctx, value any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertHealthSettings", reflect.TypeOf((*MockStore)(nil).UpsertHealthSettings), ctx, value) } -// UpsertHideCodernauts mocks base method. -func (m *MockStore) UpsertHideCodernauts(ctx context.Context, hide bool) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertHideCodernauts", ctx, hide) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertHideCodernauts indicates an expected call of UpsertHideCodernauts. -func (mr *MockStoreMockRecorder) UpsertHideCodernauts(ctx, hide any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertHideCodernauts", reflect.TypeOf((*MockStore)(nil).UpsertHideCodernauts), ctx, hide) -} - // UpsertLastUpdateCheck mocks base method. func (m *MockStore) UpsertLastUpdateCheck(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a1121f6ec47..5fee11b9b28 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -532,6 +532,7 @@ type sqlcQuerier interface { // invariant (parent archived implies child archived) is enforced // at write time, not here. GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error) + GetCodernautsEnabled(ctx context.Context) (bool, error) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -612,7 +613,6 @@ type sqlcQuerier interface { GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) GetGroupsByOrganizationIDPaginated(ctx context.Context, arg GetGroupsByOrganizationIDPaginatedParams) ([]GetGroupsByOrganizationIDPaginatedRow, error) GetHealthSettings(ctx context.Context) (string, error) - GetHideCodernauts(ctx context.Context) (bool, error) // Returns the highest group AI budget across the groups the user belongs to, // breaking ties by the earliest organization membership. Implements the // "highest" budget policy. group_members_expanded is a UNION of group_members @@ -1656,13 +1656,13 @@ type sqlcQuerier interface { UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error + UpsertCodernautsEnabled(ctx context.Context, enabled bool) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. // The functional values are immutable and controlled implicitly. UpsertDefaultProxy(ctx context.Context, arg UpsertDefaultProxyParams) error UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIBudgetParams) (GroupAIBudget, error) UpsertHealthSettings(ctx context.Context, value string) error - UpsertHideCodernauts(ctx context.Context, hide bool) error UpsertLastUpdateCheck(ctx context.Context, value string) error UpsertLogoURL(ctx context.Context, value string) error UpsertMCPServerUserToken(ctx context.Context, arg UpsertMCPServerUserTokenParams) (MCPServerUserToken, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b0d732809a3..5753a22c5eb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25150,6 +25150,18 @@ func (q *sqlQuerier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { return workspace_ttl, err } +const getCodernautsEnabled = `-- name: GetCodernautsEnabled :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'codernauts_enabled'), true) :: boolean AS codernauts_enabled +` + +func (q *sqlQuerier) GetCodernautsEnabled(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getCodernautsEnabled) + var codernauts_enabled bool + err := row.Scan(&codernauts_enabled) + return codernauts_enabled, err +} + const getDERPMeshKey = `-- name: GetDERPMeshKey :one SELECT value FROM site_configs WHERE key = 'derp_mesh_key' ` @@ -25202,18 +25214,6 @@ func (q *sqlQuerier) GetHealthSettings(ctx context.Context) (string, error) { return health_settings, err } -const getHideCodernauts = `-- name: GetHideCodernauts :one -SELECT - COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts -` - -func (q *sqlQuerier) GetHideCodernauts(ctx context.Context) (bool, error) { - row := q.db.QueryRowContext(ctx, getHideCodernauts) - var hide_codernauts bool - err := row.Scan(&hide_codernauts) - return hide_codernauts, err -} - const getLastUpdateCheck = `-- name: GetLastUpdateCheck :one SELECT value FROM site_configs WHERE key = 'last_update_check' ` @@ -25582,6 +25582,28 @@ func (q *sqlQuerier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl st return err } +const upsertCodernautsEnabled = `-- name: UpsertCodernautsEnabled :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'codernauts_enabled', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'codernauts_enabled' +` + +func (q *sqlQuerier) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + _, err := q.db.ExecContext(ctx, upsertCodernautsEnabled, enabled) + return err +} + const upsertDefaultProxy = `-- name: UpsertDefaultProxy :exec INSERT INTO site_configs (key, value) VALUES @@ -25615,28 +25637,6 @@ func (q *sqlQuerier) UpsertHealthSettings(ctx context.Context, value string) err return err } -const upsertHideCodernauts = `-- name: UpsertHideCodernauts :exec -INSERT INTO site_configs (key, value) -VALUES ( - 'hide_codernauts', - CASE - WHEN $1::bool THEN 'true' - ELSE 'false' - END -) -ON CONFLICT (key) DO UPDATE -SET value = CASE - WHEN $1::bool THEN 'true' - ELSE 'false' -END -WHERE site_configs.key = 'hide_codernauts' -` - -func (q *sqlQuerier) UpsertHideCodernauts(ctx context.Context, hide bool) error { - _, err := q.db.ExecContext(ctx, upsertHideCodernauts, hide) - return err -} - const upsertLastUpdateCheck = `-- name: UpsertLastUpdateCheck :exec INSERT INTO site_configs (key, value) VALUES ('last_update_check', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update_check' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index d472b339a44..bdac675538a 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -57,25 +57,25 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application -- name: GetApplicationName :one SELECT value FROM site_configs WHERE key = 'application_name'; --- name: GetHideCodernauts :one +-- name: GetCodernautsEnabled :one SELECT - COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'hide_codernauts'), false) :: boolean AS hide_codernauts; + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'codernauts_enabled'), true) :: boolean AS codernauts_enabled; --- name: UpsertHideCodernauts :exec +-- name: UpsertCodernautsEnabled :exec INSERT INTO site_configs (key, value) VALUES ( - 'hide_codernauts', + 'codernauts_enabled', CASE - WHEN @hide::bool THEN 'true' + WHEN @enabled::bool THEN 'true' ELSE 'false' END ) ON CONFLICT (key) DO UPDATE SET value = CASE - WHEN @hide::bool THEN 'true' + WHEN @enabled::bool THEN 'true' ELSE 'false' END -WHERE site_configs.key = 'hide_codernauts'; +WHERE site_configs.key = 'codernauts_enabled'; -- name: GetHealthSettings :one SELECT diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 34cd77367b2..9d7f99a1c5c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5277,7 +5277,7 @@ type AppearanceConfig struct { ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` SupportLinks []LinkConfig `json:"support_links,omitempty"` - HideCodernauts bool `json:"hide_codernauts"` + CodernautsEnabled bool `json:"codernauts_enabled"` } type UpdateAppearanceConfig struct { @@ -5286,7 +5286,7 @@ type UpdateAppearanceConfig struct { // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` - HideCodernauts bool `json:"hide_codernauts"` + CodernautsEnabled bool `json:"codernauts_enabled"` } // Deprecated: ServiceBannerConfig has been renamed to BannerConfig. diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index e78675a2be8..35bce2bb385 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -356,8 +356,8 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", - "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -409,7 +409,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", - "hide_codernauts": true, + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -439,7 +439,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", - "hide_codernauts": true, + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 14dd2e40be9..f0c9148bf04 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1605,8 +1605,8 @@ None } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", - "hide_codernauts": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -1630,8 +1630,8 @@ None |------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | +| `codernauts_enabled` | boolean | false | | | | `docs_url` | string | false | | | -| `hide_codernauts` | boolean | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | | `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | @@ -13989,7 +13989,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W } ], "application_name": "string", - "hide_codernauts": true, + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -14005,7 +14005,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | -| `hide_codernauts` | boolean | false | | | +| `codernauts_enabled` | boolean | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index 250b7fdfa0d..3bf28107879 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -66,7 +66,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi applicationName string logoURL string announcementBannersJSON string - hideCodernauts bool + codernautsEnabled bool ) eg.Go(func() (err error) { applicationName, err = f.database.GetApplicationName(ctx) @@ -90,9 +90,9 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi return nil }) eg.Go(func() (err error) { - hideCodernauts, err = f.database.GetHideCodernauts(ctx) + codernautsEnabled, err = f.database.GetCodernautsEnabled(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return xerrors.Errorf("get hide codernauts: %w", err) + return xerrors.Errorf("get codernauts enabled: %w", err) } return nil }) @@ -107,7 +107,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, - HideCodernauts: hideCodernauts, + CodernautsEnabled: codernautsEnabled, } if announcementBannersJSON != "" { @@ -215,10 +215,10 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { return } - err = api.Database.UpsertHideCodernauts(ctx, appearance.HideCodernauts) + err = api.Database.UpsertCodernautsEnabled(ctx, appearance.CodernautsEnabled) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Unable to set hide Codernauts", + Message: "Unable to set Codernauts enabled", Detail: err.Error(), }) return diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cab06b58bb9..09c90a3bd2d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2510,7 +2510,7 @@ class ApiMethods { docs_url: "", logo_url: "", announcement_banners: [], - hide_codernauts: false, + codernauts_enabled: true, service_banner: { enabled: false, }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2932e5a03f8..8802c97688c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1560,7 +1560,7 @@ export interface AppearanceConfig { readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; readonly support_links?: readonly LinkConfig[]; - readonly hide_codernauts: boolean; + readonly codernauts_enabled: boolean; } // From codersdk/templates.go @@ -9562,7 +9562,7 @@ export interface UpdateAppearanceConfig { */ readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; - readonly hide_codernauts: boolean; + readonly codernauts_enabled: boolean; } // From codersdk/chats.go diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 639f251ef76..031b1e600d6 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -43,7 +43,7 @@ export const Navbar: React.FC = () => { user={me} buildInfo={buildInfoQuery.data} supportLinks={Array.from(uniqueLinks.values())} - hideCodernauts={appearance.hide_codernauts} + codernautsEnabled={appearance.codernauts_enabled} onSignOut={signOut} adminPermissions={{ canViewDeployment, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 9003afd2433..baaa75069b7 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -30,7 +30,7 @@ interface NavbarViewProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; - hideCodernauts?: boolean; + codernautsEnabled?: boolean; onSignOut: () => void; adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; @@ -47,7 +47,7 @@ export const NavbarView: FC = ({ user, buildInfo, supportLinks, - hideCodernauts, + codernautsEnabled, onSignOut, adminPermissions, canCreateChat, @@ -140,7 +140,7 @@ export const NavbarView: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks?.filter((link) => !isNavbarLink(link))} - hideCodernauts={hideCodernauts} + codernautsEnabled={codernautsEnabled} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 50354aed1fd..d65dae4fbd0 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -420,9 +420,9 @@ export const InstallCoderDesktopHiddenOniPadOS: Story = { }, }; -export const CodernautsHidden: Story = { +export const CodernautsDisabled: Story = { args: { - hideCodernauts: true, + codernautsEnabled: false, }, play: async ({ canvasElement, step }) => { await step("hides the Codernauts link", async () => { diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 6334e5992cc..8bfdfa2188b 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -36,7 +36,7 @@ interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; - hideCodernauts?: boolean; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -44,7 +44,7 @@ export const UserDropdown: FC = ({ buildInfo, user, supportLinks, - hideCodernauts, + codernautsEnabled, onSignOut, }) => { const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -110,7 +110,7 @@ export const UserDropdown: FC = ({ ) } supportLinks={supportLinks} - hideCodernauts={hideCodernauts} + codernautsEnabled={codernautsEnabled} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 149b770c5a0..6bf90c2deb4 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -32,7 +32,7 @@ interface UserDropdownContentProps { * (e.g. AI spend). The consumer supplies its own separator if needed. */ profileExtra?: ReactNode; supportLinks: readonly TypesGen.LinkConfig[]; - hideCodernauts?: boolean; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -41,7 +41,7 @@ export const UserDropdownContent: FC = ({ buildInfo, profileExtra, supportLinks, - hideCodernauts, + codernautsEnabled = true, onSignOut, }) => { const { showCopiedSuccess, copyToClipboard } = useClipboard(); @@ -95,7 +95,7 @@ export const UserDropdownContent: FC = ({ ))} )} - {!hideCodernauts && ( + {codernautsEnabled && ( = { background_color: "#ffaff3", }, ], - hide_codernauts: false, + codernauts_enabled: true, }, isEntitled: false, canViewPremium: MockPermissions.viewAllLicenses, @@ -83,37 +83,39 @@ export const NotEntitledWithoutLicenseAccess: Story = { }, }; -export const HideCodernautsToggle: Story = { +export const CodernautsToggle: Story = { args: { isEntitled: true, }, play: async ({ canvasElement, args, step }) => { const canvas = within(canvasElement); - await step("toggling the switch saves the setting", async () => { - await userEvent.click( - canvas.getByRole("switch", { name: "Hide Codernauts game" }), - ); + await step("switching off saves the game as disabled", async () => { + const switchEl = canvas.getByRole("switch", { + name: "Codernauts game", + }); + expect(switchEl).toBeChecked(); + await userEvent.click(switchEl); await waitFor(() => expect(args.onSaveAppearance).toHaveBeenCalledWith({ - hide_codernauts: true, + codernauts_enabled: false, }), ); }); }, }; -export const HideCodernautsNotEntitled: Story = { +export const CodernautsToggleNotEntitled: Story = { play: async ({ canvasElement, args, step }) => { const canvas = within(canvasElement); await step("the switch saves even without entitlement", async () => { const switchEl = canvas.getByRole("switch", { - name: "Hide Codernauts game", + name: "Codernauts game", }); expect(switchEl).toBeEnabled(); await userEvent.click(switchEl); await waitFor(() => expect(args.onSaveAppearance).toHaveBeenCalledWith({ - hide_codernauts: true, + codernauts_enabled: false, }), ); }); diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index d9d2a3108be..4961ee4c097 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -118,18 +118,18 @@ export const AppearanceSettingsPageView: FC<

- +

- Remove the Codernauts game link from the user dropdown menu. - Available on all licenses. + A lunar-lander game where you rescue stranded teammates. Disable + if you're experiencing any productivity loss.
- onSaveAppearance({ hide_codernauts: checked }) + onSaveAppearance({ codernauts_enabled: checked }) } />
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ca601dca3bd..67ced079030 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3465,7 +3465,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = { }, announcement_banners: [], docs_url: "https://coder.com/docs/@main/", - hide_codernauts: false, + codernauts_enabled: true, }; export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = { From adae3e86d62a2b83c1efc1015b523a6b6bff6205 Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Wed, 19 Aug 2026 15:31:51 +0000 Subject: [PATCH 4/5] test(enterprise/coderd): verify Codernauts defaults to enabled Covers the upgrade path: with no stored site config the game is enabled, disabling works without a license, and the stored value survives switching to the licensed appearance fetcher. --- enterprise/coderd/appearance_test.go | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 8255dd4c8aa..daac39c28a9 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -55,6 +55,53 @@ func TestCustomLogoAndCompanyName(t *testing.T) { require.Equal(t, uac.LogoURL, got.LogoURL) } +func TestCodernautsEnabled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + basicUserClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + + // With no stored setting, as after an upgrade, the game defaults to + // enabled. This deployment has no license, so the default appearance + // fetcher serves the value. + got, err := basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.True(t, got.CodernautsEnabled) + + // The setting can be disabled without any license entitlement. + err = adminClient.UpdateAppearance(ctx, codersdk.UpdateAppearanceConfig{ + CodernautsEnabled: false, + }) + require.NoError(t, err) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.False(t, got.CodernautsEnabled) + + // The stored value survives switching to the licensed fetcher. + coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAppearance: 1, + }, + }) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.False(t, got.CodernautsEnabled) + + err = adminClient.UpdateAppearance(ctx, codersdk.UpdateAppearanceConfig{ + CodernautsEnabled: true, + }) + require.NoError(t, err) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.True(t, got.CodernautsEnabled) +} + func TestAnnouncementBanners(t *testing.T) { t.Parallel() From 51a9b272139f2edca81b4607961b26659db329bd Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Wed, 19 Aug 2026 18:43:34 +0000 Subject: [PATCH 5/5] chore: apply PR review feedback Drop the no-authz comment from GetCodernautsEnabled and use testutil.Context created right before first use in the Codernauts test to avoid eating into the timeout during setup. --- coderd/database/dbauthz/dbauthz.go | 1 - enterprise/coderd/appearance_test.go | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9376372f19c..1a1a4be1bfa 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3651,7 +3651,6 @@ func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.Get } func (q *querier) GetCodernautsEnabled(ctx context.Context) (bool, error) { - // No authz checks return q.db.GetCodernautsEnabled(ctx) } diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index daac39c28a9..8bfaae8b9b0 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -58,12 +58,11 @@ func TestCustomLogoAndCompanyName(t *testing.T) { func TestCodernautsEnabled(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) basicUserClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + // With no stored setting, as after an upgrade, the game defaults to // enabled. This deployment has no license, so the default appearance // fetcher serves the value.