From 49f833b2d4023be2d2b07b97d90abe9b62fca860 Mon Sep 17 00:00:00 2001 From: Bartek Gatz Date: Wed, 2 Sep 2026 11:29:35 +0200 Subject: [PATCH] feat: add deployment setting to disable the Codernauts game (#27662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `codernauts_enabled` runtime setting (default: enabled) that controls whether the Codernauts game link appears in the user dropdown menu. A toggle at the bottom of the Deployment > Appearance page controls it, and the value persists in `site_configs`, so it survives restarts and upgrades without any CLI flags or environment variables. The setting works on all licenses: `GET /api/v2/appearance` reports it from both the enterprise and the default (AGPL) appearance fetchers, and `PUT /api/v2/appearance` persists it gated only by the deployment-config RBAC permission, not by an entitlement. On the Appearance page the toggle renders outside the Premium paywall introduced in #27948; the other appearance fields keep their existing licensing behavior. Note: a pure AGPL-only build of coderd does not register the `/api/v2/appearance` routes at all, so the toggle cannot be saved there. The standard `coder server` binary is unaffected regardless of license state.
Implementation notes - `coderd/database/queries/siteconfig.sql`: new `GetCodernautsEnabled` / `UpsertCodernautsEnabled` queries backed by a `codernauts_enabled` key in `site_configs`; defaults to `true` when unset (no migration needed). - `coderd/database/dbauthz`: read has no authz checks (matching other appearance reads); write requires `ResourceDeploymentConfig` update. Coverage added to `dbauthz_test.go`. - `codersdk`: `codernauts_enabled` added to `AppearanceConfig` and `UpdateAppearanceConfig`. - `coderd/appearance`: the default fetcher takes a `database.Store` and reads the setting from the database so unlicensed deployments serve it too. - `enterprise/coderd/appearance.go`: fetches the value in `Fetch` and persists it in `putAppearance`. - Frontend: "Codernauts game" toggle section on Deployment > Appearance saving immediately via the existing appearance mutation; `codernauts_enabled` threaded from `useDashboard().appearance` through Navbar to `UserDropdownContent`, which renders the menu item only when enabled. Storybook stories with `play` functions cover toggling (entitled and not) and the dropdown hiding the link when disabled.
--- 🤖 This PR was generated by Coder Agents on behalf of @bartekgatzcoder. (cherry picked from commit 55340de360af2ca55bdb718ab395a6cb778a6708) --- coderd/apidoc/docs.go | 6 +++ coderd/apidoc/swagger.json | 6 +++ coderd/appearance/appearance.go | 18 +++++-- coderd/coderd.go | 2 +- coderd/database/dbauthz/dbauthz.go | 11 +++++ coderd/database/dbauthz/dbauthz_test.go | 8 ++++ coderd/database/dbmetrics/querymetrics.go | 16 +++++++ coderd/database/dbmock/dbmock.go | 29 +++++++++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 34 +++++++++++++ coderd/database/queries/siteconfig.sql | 20 ++++++++ codersdk/deployment.go | 2 + docs/reference/api/enterprise.md | 3 ++ docs/reference/api/schemas.md | 4 ++ enterprise/coderd/appearance.go | 18 +++++++ enterprise/coderd/appearance_test.go | 46 ++++++++++++++++++ enterprise/coderd/coderd.go | 2 +- site/site.go | 2 +- site/src/api/api.ts | 1 + site/src/api/typesGenerated.ts | 2 + site/src/modules/dashboard/Navbar/Navbar.tsx | 1 + .../modules/dashboard/Navbar/NavbarView.tsx | 3 ++ .../UserDropdown/UserDropdown.stories.tsx | 14 ++++++ .../Navbar/UserDropdown/UserDropdown.tsx | 3 ++ .../UserDropdown/UserDropdownContent.tsx | 48 ++++++++++--------- .../AppearanceSettingsPageView.stories.tsx | 43 ++++++++++++++++- .../AppearanceSettingsPageView.tsx | 22 +++++++++ site/src/testHelpers/entities.ts | 1 + 28 files changed, 337 insertions(+), 30 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c3cbeafd52fd7..49ff9a422cf57 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18840,6 +18840,9 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, @@ -28945,6 +28948,9 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0b5c840230ef4..094a1effedc46 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16909,6 +16909,9 @@ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, @@ -26612,6 +26615,9 @@ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index f63cd77a59ca2..e77edc90b41b7 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,29 @@ type Fetcher interface { } type AGPLFetcher struct { - docsURL string + database database.Store + docsURL string } -func (f AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) { +func (f AGPLFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) { + codernautsEnabled, err := f.database.GetCodernautsEnabled(ctx) + if err != nil { + return codersdk.AppearanceConfig{}, xerrors.Errorf("get codernauts enabled: %w", err) + } return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, + CodernautsEnabled: codernautsEnabled, }, 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 ae228b1a41651..e0fdb321b64fd 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -756,7 +756,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 6ab346395108f..0649fac4c5422 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3788,6 +3788,10 @@ 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) { + 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) @@ -9179,6 +9183,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 diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6fb47988f25f8..5830c3c7b6319 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5490,6 +5490,14 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().UpsertApplicationName(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + 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("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()} 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 2a2d2692f3cb3..c12e13a808209 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1920,6 +1920,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) @@ -6432,6 +6440,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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 251b1b303a15d..026bc37889f7a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3599,6 +3599,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() @@ -12102,6 +12117,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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4ff6615a417f1..3a149bbee04a9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -560,6 +560,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) @@ -1683,6 +1684,7 @@ type sqlcQuerier interface { UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatUserModelOverride(ctx context.Context, arg UpsertChatUserModelOverrideParams) 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. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3689b0daade3a..02b03eb516f9b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25866,6 +25866,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' ` @@ -26246,6 +26258,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 diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 52f28e7c5f1ca..51eadef0eda3b 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -57,6 +57,26 @@ 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: GetCodernautsEnabled :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'codernauts_enabled'), true) :: boolean AS codernauts_enabled; + +-- name: UpsertCodernautsEnabled :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'codernauts_enabled', + CASE + WHEN @enabled::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN @enabled::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'codernauts_enabled'; + -- 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 5b7ea5a061071..9700549ebb8df 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5280,6 +5280,7 @@ type AppearanceConfig struct { ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` SupportLinks []LinkConfig `json:"support_links,omitempty"` + CodernautsEnabled bool `json:"codernauts_enabled"` } type UpdateAppearanceConfig struct { @@ -5288,6 +5289,7 @@ type UpdateAppearanceConfig struct { // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` + 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 6fb958ea3c938..70707e53ee056 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -356,6 +356,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", "logo_url": "string", "service_banner": { @@ -408,6 +409,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -437,6 +439,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "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 4b1467bec0286..74018f79d3313 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1684,6 +1684,7 @@ None } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", "logo_url": "string", "service_banner": { @@ -1708,6 +1709,7 @@ None |------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | +| `codernauts_enabled` | boolean | false | | | | `docs_url` | string | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | @@ -15313,6 +15315,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W } ], "application_name": "string", + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -15328,6 +15331,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 | | | +| `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 db845fadea385..3bf2810787970 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 + codernautsEnabled 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) { + codernautsEnabled, err = f.database.GetCodernautsEnabled(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get codernauts enabled: %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, + CodernautsEnabled: codernautsEnabled, } if announcementBannersJSON != "" { @@ -206,5 +215,14 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { return } + err = api.Database.UpsertCodernautsEnabled(ctx, appearance.CodernautsEnabled) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Unable to set Codernauts enabled", + Detail: err.Error(), + }) + return + } + httpapi.Write(r.Context(), rw, http.StatusOK, appearance) } diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 8255dd4c8aa8c..8bfaae8b9b031 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -55,6 +55,52 @@ func TestCustomLogoAndCompanyName(t *testing.T) { require.Equal(t, uac.LogoURL, got.LogoURL) } +func TestCodernautsEnabled(t *testing.T) { + t.Parallel() + + 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. + 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() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index fcf9b8f09d7c5..ed40957d39f0f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1162,7 +1162,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 d607c02ce7a6b..a24954a7f39cf 100644 --- a/site/site.go +++ b/site/site.go @@ -89,7 +89,7 @@ type Options struct { func New(opts *Options) (*Handler, error) { if opts.AppearanceFetcher == nil { daf := atomic.Pointer[appearance.Fetcher]{} - f := appearance.NewDefaultFetcher(opts.DocsURL) + f := appearance.NewDefaultFetcher(opts.Database, opts.DocsURL) daf.Store(&f) opts.AppearanceFetcher = &daf } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2c98a77dcdbf7..7d6eba4b8cd9b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2532,6 +2532,7 @@ class ApiMethods { docs_url: "", logo_url: "", announcement_banners: [], + codernauts_enabled: true, service_banner: { enabled: false, }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5239f3fe02ea1..c12dc3b4bfe98 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1590,6 +1590,7 @@ export interface AppearanceConfig { readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; readonly support_links?: readonly LinkConfig[]; + readonly codernauts_enabled: boolean; } // From codersdk/templates.go @@ -9740,6 +9741,7 @@ export interface UpdateAppearanceConfig { */ readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; + 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 e21feecc6732c..3f37a9dac4c23 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -61,6 +61,7 @@ export const Navbar: React.FC = () => { user={me} buildInfo={buildInfoQuery.data} supportLinks={Array.from(uniqueLinks.values())} + 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 7167570c42477..e6296a0b25513 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[]; + codernautsEnabled?: boolean; onSignOut: () => void; adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; @@ -46,6 +47,7 @@ export const NavbarView: FC = ({ user, buildInfo, supportLinks, + codernautsEnabled, onSignOut, adminPermissions, canCreateChat, @@ -138,6 +140,7 @@ export const NavbarView: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks?.filter((link) => !isNavbarLink(link))} + 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 1879252b6d3a3..d65dae4fbd06e 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -420,4 +420,18 @@ export const InstallCoderDesktopHiddenOniPadOS: Story = { }, }; +export const CodernautsDisabled: Story = { + args: { + codernautsEnabled: false, + }, + play: async ({ canvasElement, step }) => { + await step("hides the Codernauts link", async () => { + await openDropdown(canvasElement); + expect( + screen.queryByRole("menuitem", { name: "Codernauts" }), + ).not.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 3a9f52a5c763b..8bfdfa2188b6e 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -36,6 +36,7 @@ interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -43,6 +44,7 @@ export const UserDropdown: FC = ({ buildInfo, user, supportLinks, + codernautsEnabled, onSignOut, }) => { const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -108,6 +110,7 @@ export const UserDropdown: FC = ({ ) } supportLinks={supportLinks} + 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 12250f6cb645e..6bf90c2deb405 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -32,6 +32,7 @@ interface UserDropdownContentProps { * (e.g. AI spend). The consumer supplies its own separator if needed. */ profileExtra?: ReactNode; supportLinks: readonly TypesGen.LinkConfig[]; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -40,6 +41,7 @@ export const UserDropdownContent: FC = ({ buildInfo, profileExtra, supportLinks, + codernautsEnabled = true, onSignOut, }) => { const { showCopiedSuccess, copyToClipboard } = useClipboard(); @@ -93,28 +95,30 @@ export const UserDropdownContent: FC = ({ ))} )} - - - - - - - - - - - - Codernauts - - {" "} + {codernautsEnabled && ( + + + + + + + + + + + + Codernauts + + + )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index dbdb2aba3ae89..3f004fa032c26 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockPermissions } from "#/testHelpers/entities"; import { docs } from "#/utils/docs"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; @@ -23,9 +23,11 @@ const meta: Meta = { background_color: "#ffaff3", }, ], + codernauts_enabled: true, }, isEntitled: false, canViewPremium: MockPermissions.viewAllLicenses, + onSaveAppearance: fn(), }, }; @@ -84,3 +86,42 @@ export const NotEntitledWithoutLicenseAccess: Story = { ).not.toBeInTheDocument(); }, }; + +export const CodernautsToggle: Story = { + args: { + isEntitled: true, + }, + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + 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({ + codernauts_enabled: false, + }), + ); + }); + }, +}; + +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: "Codernauts game", + }); + expect(switchEl).toBeEnabled(); + await userEvent.click(switchEl); + await waitFor(() => + expect(args.onSaveAppearance).toHaveBeenCalledWith({ + codernauts_enabled: false, + }), + ); + }); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index f17a113a983fb..2f10adca5e5e2 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -17,6 +17,7 @@ import { SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; +import { Switch } from "#/components/Switch/Switch"; import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; @@ -122,6 +123,27 @@ export const AppearanceSettingsPageView: FC< /> )} + +
+
+
+

+ +

+
+ A lunar-lander game where you rescue stranded teammates. Disable + if you're experiencing any productivity loss. +
+
+ + onSaveAppearance({ codernauts_enabled: checked }) + } + /> +
+
); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 184da7338d740..6f374943b27a9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3537,6 +3537,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = { }, announcement_banners: [], docs_url: "https://coder.com/docs/@main/", + codernauts_enabled: true, }; export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {