diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index fa82286acebbf..61b17e026d290 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -10,7 +10,6 @@ "last_seen_at": "====[timestamp]=====", "status": "active", "login_type": "password", - "theme_preference": "", "organization_ids": [ "===========[first org ID]===========" ], @@ -32,7 +31,6 @@ "last_seen_at": "====[timestamp]=====", "status": "dormant", "login_type": "password", - "theme_preference": "", "organization_ids": [ "===========[first org ID]===========" ], diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2612083ba74dc..8f90cd5c205a2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6395,6 +6395,38 @@ const docTemplate = `{ } }, "/users/{user}/appearance": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user appearance settings", + "operationId": "get-user-appearance-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAppearanceSettings" + } + } + } + }, "put": { "security": [ { @@ -6434,7 +6466,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.User" + "$ref": "#/definitions/codersdk.UserAppearanceSettings" } } } @@ -13857,6 +13889,7 @@ const docTemplate = `{ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n` + "`" + `codersdk.UserPreferenceSettings` + "`" + ` instead.", "type": "string" }, "updated_at": { @@ -14724,6 +14757,7 @@ const docTemplate = `{ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n` + "`" + `codersdk.UserPreferenceSettings` + "`" + ` instead.", "type": "string" }, "updated_at": { @@ -15334,6 +15368,7 @@ const docTemplate = `{ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n` + "`" + `codersdk.UserPreferenceSettings` + "`" + ` instead.", "type": "string" }, "updated_at": { @@ -15406,6 +15441,14 @@ const docTemplate = `{ } } }, + "codersdk.UserAppearanceSettings": { + "type": "object", + "properties": { + "theme_preference": { + "type": "string" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 27fea243afdd9..fcfe56d3fc4aa 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5647,6 +5647,34 @@ } }, "/users/{user}/appearance": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get user appearance settings", + "operationId": "get-user-appearance-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAppearanceSettings" + } + } + } + }, "put": { "security": [ { @@ -5680,7 +5708,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.User" + "$ref": "#/definitions/codersdk.UserAppearanceSettings" } } } @@ -12538,6 +12566,7 @@ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n`codersdk.UserPreferenceSettings` instead.", "type": "string" }, "updated_at": { @@ -13380,6 +13409,7 @@ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n`codersdk.UserPreferenceSettings` instead.", "type": "string" }, "updated_at": { @@ -13942,6 +13972,7 @@ ] }, "theme_preference": { + "description": "Deprecated: this value should be retrieved from\n`codersdk.UserPreferenceSettings` instead.", "type": "string" }, "updated_at": { @@ -14014,6 +14045,14 @@ } } }, + "codersdk.UserAppearanceSettings": { + "type": "object", + "properties": { + "theme_preference": { + "type": "string" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/audit.go b/coderd/audit.go index ce932c9143a98..75b711bf74ec9 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -204,7 +204,6 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs Deleted: dblog.UserDeleted.Bool, LastSeenAt: dblog.UserLastSeenAt.Time, QuietHoursSchedule: dblog.UserQuietHoursSchedule.String, - ThemePreference: dblog.UserThemePreference.String, Name: dblog.UserName.String, }, []uuid.UUID{}) user = &sdkUser diff --git a/coderd/coderd.go b/coderd/coderd.go index d4c948e346265..ab8e99d29dea8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1145,6 +1145,7 @@ func New(options *Options) *API { r.Put("/suspend", api.putSuspendUserAccount()) r.Put("/activate", api.putActivateUserAccount()) }) + r.Get("/appearance", api.userAppearanceSettings) r.Put("/appearance", api.putUserAppearanceSettings) r.Route("/password", func(r chi.Router) { r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute)) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 53cd272b3235e..41691c5a1d3f1 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -150,14 +150,13 @@ func ReducedUser(user database.User) codersdk.ReducedUser { Username: user.Username, AvatarURL: user.AvatarURL, }, - Email: user.Email, - Name: user.Name, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - LastSeenAt: user.LastSeenAt, - Status: codersdk.UserStatus(user.Status), - LoginType: codersdk.LoginType(user.LoginType), - ThemePreference: user.ThemePreference, + Email: user.Email, + Name: user.Name, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + LastSeenAt: user.LastSeenAt, + Status: codersdk.UserStatus(user.Status), + LoginType: codersdk.LoginType(user.LoginType), } } @@ -176,7 +175,6 @@ func UserFromGroupMember(member database.GroupMember) database.User { Deleted: member.UserDeleted, LastSeenAt: member.UserLastSeenAt, QuietHoursSchedule: member.UserQuietHoursSchedule, - ThemePreference: member.UserThemePreference, Name: member.UserName, GithubComUserID: member.UserGithubComUserID, } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 037acb3c5914f..a4d76fa0198ed 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2510,6 +2510,17 @@ func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetU return q.db.GetUserActivityInsights(ctx, arg) } +func (q *querier) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (string, error) { + u, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return "", err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { + return "", err + } + return q.db.GetUserAppearanceSettings(ctx, userID) +} + func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg) } @@ -4021,13 +4032,13 @@ func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg da return fetchAndExec(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg) } -func (q *querier) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) { - u, err := q.db.GetUserByID(ctx, arg.ID) +func (q *querier) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.UserConfig, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { - return database.User{}, err + return database.UserConfig{}, err } if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { - return database.User{}, err + return database.UserConfig{}, err } return q.db.UpdateUserAppearanceSettings(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a2ac739042366..614a357efcbc5 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1522,13 +1522,26 @@ func (s *MethodTestSuite) TestUser() { []database.GetUserWorkspaceBuildParametersRow{}, ) })) + s.Run("GetUserAppearanceSettings", s.Subtest(func(db database.Store, check *expects) { + ctx := context.Background() + u := dbgen.User(s.T(), db, database.User{}) + db.UpdateUserAppearanceSettings(ctx, database.UpdateUserAppearanceSettingsParams{ + UserID: u.ID, + ThemePreference: "light", + }) + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("light") + })) s.Run("UpdateUserAppearanceSettings", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) + uc := database.UserConfig{ + UserID: u.ID, + Key: "theme_preference", + Value: "dark", + } check.Args(database.UpdateUserAppearanceSettingsParams{ - ID: u.ID, - ThemePreference: u.ThemePreference, - UpdatedAt: u.UpdatedAt, - }).Asserts(u, policy.ActionUpdatePersonal).Returns(u) + UserID: u.ID, + ThemePreference: uc.Value, + }).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) })) s.Run("UpdateUserStatus", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 3810fcb5052cf..97940c1a4b76f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -528,7 +528,6 @@ func GroupMember(t testing.TB, db database.Store, member database.GroupMemberTab UserDeleted: user.Deleted, UserLastSeenAt: user.LastSeenAt, UserQuietHoursSchedule: user.QuietHoursSchedule, - UserThemePreference: user.ThemePreference, UserName: user.Name, UserGithubComUserID: user.GithubComUserID, OrganizationID: group.OrganizationID, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 5a530c1db6e38..7f7ff987ff544 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -55,44 +55,45 @@ func New() database.Store { mutex: &sync.RWMutex{}, data: &data{ apiKeys: make([]database.APIKey, 0), - organizationMembers: make([]database.OrganizationMember, 0), - organizations: make([]database.Organization, 0), - users: make([]database.User, 0), + auditLogs: make([]database.AuditLog, 0), + customRoles: make([]database.CustomRole, 0), dbcryptKeys: make([]database.DBCryptKey, 0), externalAuthLinks: make([]database.ExternalAuthLink, 0), - groups: make([]database.Group, 0), - groupMembers: make([]database.GroupMemberTable, 0), - auditLogs: make([]database.AuditLog, 0), files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), + groups: make([]database.Group, 0), + groupMembers: make([]database.GroupMemberTable, 0), + licenses: make([]database.License, 0), + locks: map[int64]struct{}{}, notificationMessages: make([]database.NotificationMessage, 0), notificationPreferences: make([]database.NotificationPreference, 0), - InboxNotification: make([]database.InboxNotification, 0), + organizationMembers: make([]database.OrganizationMember, 0), + organizations: make([]database.Organization, 0), + inboxNotifications: make([]database.InboxNotification, 0), parameterSchemas: make([]database.ParameterSchema, 0), + presets: make([]database.TemplateVersionPreset, 0), + presetParameters: make([]database.TemplateVersionPresetParameter, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), + provisionerJobs: make([]database.ProvisionerJob, 0), + provisionerJobLogs: make([]database.ProvisionerJobLog, 0), provisionerKeys: make([]database.ProvisionerKey, 0), + runtimeConfig: map[string]string{}, + telemetryItems: make([]database.TelemetryItem, 0), + templateVersions: make([]database.TemplateVersionTable, 0), + templates: make([]database.TemplateTable, 0), + users: make([]database.User, 0), + userConfigs: make([]database.UserConfig, 0), + userStatusChanges: make([]database.UserStatusChange, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), - provisionerJobLogs: make([]database.ProvisionerJobLog, 0), workspaceResources: make([]database.WorkspaceResource, 0), workspaceModules: make([]database.WorkspaceModule, 0), workspaceResourceMetadata: make([]database.WorkspaceResourceMetadatum, 0), - provisionerJobs: make([]database.ProvisionerJob, 0), - templateVersions: make([]database.TemplateVersionTable, 0), - templates: make([]database.TemplateTable, 0), workspaceAgentStats: make([]database.WorkspaceAgentStat, 0), workspaceAgentLogs: make([]database.WorkspaceAgentLog, 0), workspaceBuilds: make([]database.WorkspaceBuild, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.WorkspaceTable, 0), - licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), - customRoles: make([]database.CustomRole, 0), - locks: map[int64]struct{}{}, - runtimeConfig: map[string]string{}, - userStatusChanges: make([]database.UserStatusChange, 0), - telemetryItems: make([]database.TelemetryItem, 0), - presets: make([]database.TemplateVersionPreset, 0), - presetParameters: make([]database.TemplateVersionPresetParameter, 0), }, } // Always start with a default org. Matching migration 198. @@ -207,7 +208,7 @@ type data struct { notificationMessages []database.NotificationMessage notificationPreferences []database.NotificationPreference notificationReportGeneratorLogs []database.NotificationReportGeneratorLog - InboxNotification []database.InboxNotification + inboxNotifications []database.InboxNotification oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret oauth2ProviderAppCodes []database.OAuth2ProviderAppCode @@ -224,6 +225,7 @@ type data struct { templateVersionWorkspaceTags []database.TemplateVersionWorkspaceTag templates []database.TemplateTable templateUsageStats []database.TemplateUsageStat + userConfigs []database.UserConfig workspaceAgents []database.WorkspaceAgent workspaceAgentMetadata []database.WorkspaceAgentMetadatum workspaceAgentLogs []database.WorkspaceAgentLog @@ -899,7 +901,6 @@ func (q *FakeQuerier) getGroupMemberNoLock(ctx context.Context, userID, groupID UserDeleted: user.Deleted, UserLastSeenAt: user.LastSeenAt, UserQuietHoursSchedule: user.QuietHoursSchedule, - UserThemePreference: user.ThemePreference, UserName: user.Name, UserGithubComUserID: user.GithubComUserID, OrganizationID: orgID, @@ -1725,7 +1726,7 @@ func (q *FakeQuerier) CountUnreadInboxNotificationsByUserID(_ context.Context, u defer q.mutex.RUnlock() var count int64 - for _, notification := range q.InboxNotification { + for _, notification := range q.inboxNotifications { if notification.UserID != userID { continue } @@ -3295,7 +3296,7 @@ func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, a defer q.mutex.RUnlock() notifications := make([]database.InboxNotification, 0) - for _, notification := range q.InboxNotification { + for _, notification := range q.inboxNotifications { if notification.UserID == arg.UserID { for _, template := range arg.Templates { templateFound := false @@ -3531,7 +3532,7 @@ func (q *FakeQuerier) GetInboxNotificationByID(_ context.Context, id uuid.UUID) q.mutex.RLock() defer q.mutex.RUnlock() - for _, notification := range q.InboxNotification { + for _, notification := range q.inboxNotifications { if notification.ID == id { return notification, nil } @@ -3545,7 +3546,7 @@ func (q *FakeQuerier) GetInboxNotificationsByUserID(_ context.Context, params da defer q.mutex.RUnlock() notifications := make([]database.InboxNotification, 0) - for _, notification := range q.InboxNotification { + for _, notification := range q.inboxNotifications { if notification.UserID == params.UserID { notifications = append(notifications, notification) } @@ -6162,6 +6163,20 @@ func (q *FakeQuerier) GetUserActivityInsights(_ context.Context, arg database.Ge return rows, nil } +func (q *FakeQuerier) GetUserAppearanceSettings(_ context.Context, userID uuid.UUID) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, uc := range q.userConfigs { + if uc.UserID != userID || uc.Key != "theme_preference" { + continue + } + return uc.Value, nil + } + + return "", sql.ErrNoRows +} + func (q *FakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err @@ -8211,7 +8226,7 @@ func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.In CreatedAt: time.Now(), } - q.InboxNotification = append(q.InboxNotification, notification) + q.inboxNotifications = append(q.inboxNotifications, notification) return notification, nil } @@ -9938,9 +9953,9 @@ func (q *FakeQuerier) UpdateInboxNotificationReadStatus(_ context.Context, arg d q.mutex.Lock() defer q.mutex.Unlock() - for i := range q.InboxNotification { - if q.InboxNotification[i].ID == arg.ID { - q.InboxNotification[i].ReadAt = arg.ReadAt + for i := range q.inboxNotifications { + if q.inboxNotifications[i].ID == arg.ID { + q.inboxNotifications[i].ReadAt = arg.ReadAt } } @@ -10454,24 +10469,31 @@ func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg return nil } -func (q *FakeQuerier) UpdateUserAppearanceSettings(_ context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) { +func (q *FakeQuerier) UpdateUserAppearanceSettings(_ context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.UserConfig, error) { err := validateDatabaseType(arg) if err != nil { - return database.User{}, err + return database.UserConfig{}, err } q.mutex.Lock() defer q.mutex.Unlock() - for index, user := range q.users { - if user.ID != arg.ID { + for i, uc := range q.userConfigs { + if uc.UserID != arg.UserID || uc.Key != "theme_preference" { continue } - user.ThemePreference = arg.ThemePreference - q.users[index] = user - return user, nil + uc.Value = arg.ThemePreference + q.userConfigs[i] = uc + return uc, nil } - return database.User{}, sql.ErrNoRows + + uc := database.UserConfig{ + UserID: arg.UserID, + Key: "theme_preference", + Value: arg.ThemePreference, + } + q.userConfigs = append(q.userConfigs, uc) + return uc, nil } func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, id uuid.UUID) error { @@ -12862,7 +12884,6 @@ func (q *FakeQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg data UserLastSeenAt: sql.NullTime{Time: user.LastSeenAt, Valid: userValid}, UserLoginType: database.NullLoginType{LoginType: user.LoginType, Valid: userValid}, UserDeleted: sql.NullBool{Bool: user.Deleted, Valid: userValid}, - UserThemePreference: sql.NullString{String: user.ThemePreference, Valid: userValid}, UserQuietHoursSchedule: sql.NullString{String: user.QuietHoursSchedule, Valid: userValid}, UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, UserRoles: user.RBACRoles, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f6c2f35d22b61..0d021f978151b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1403,6 +1403,13 @@ func (m queryMetricsStore) GetUserActivityInsights(ctx context.Context, arg data return r0, r1 } +func (m queryMetricsStore) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (string, error) { + start := time.Now() + r0, r1 := m.s.GetUserAppearanceSettings(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserAppearanceSettings").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { start := time.Now() user, err := m.s.GetUserByEmailOrUsername(ctx, arg) @@ -2551,7 +2558,7 @@ func (m queryMetricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Contex return r0 } -func (m queryMetricsStore) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) { +func (m queryMetricsStore) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.UserConfig, error) { start := time.Now() r0, r1 := m.s.UpdateUserAppearanceSettings(ctx, arg) m.queryLatencies.WithLabelValues("UpdateUserAppearanceSettings").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 46e4dbbf4ea2a..6e07614f4cb3f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2932,6 +2932,21 @@ func (mr *MockStoreMockRecorder) GetUserActivityInsights(ctx, arg any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserActivityInsights", reflect.TypeOf((*MockStore)(nil).GetUserActivityInsights), ctx, arg) } +// GetUserAppearanceSettings mocks base method. +func (m *MockStore) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAppearanceSettings", ctx, userID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserAppearanceSettings indicates an expected call of GetUserAppearanceSettings. +func (mr *MockStoreMockRecorder) GetUserAppearanceSettings(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAppearanceSettings", reflect.TypeOf((*MockStore)(nil).GetUserAppearanceSettings), ctx, userID) +} + // GetUserByEmailOrUsername mocks base method. func (m *MockStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { m.ctrl.T.Helper() @@ -5399,10 +5414,10 @@ func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(ctx, arg any } // UpdateUserAppearanceSettings mocks base method. -func (m *MockStore) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) { +func (m *MockStore) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.UserConfig, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateUserAppearanceSettings", ctx, arg) - ret0, _ := ret[0].(database.User) + ret0, _ := ret[0].(database.UserConfig) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e206b3ea7c136..900e05c209101 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -849,7 +849,6 @@ CREATE TABLE users ( deleted boolean DEFAULT false NOT NULL, last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, quiet_hours_schedule text DEFAULT ''::text NOT NULL, - theme_preference text DEFAULT ''::text NOT NULL, name text DEFAULT ''::text NOT NULL, github_com_user_id bigint, hashed_one_time_passcode bytea, @@ -859,8 +858,6 @@ CREATE TABLE users ( COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; -COMMENT ON COLUMN users.theme_preference IS '"" can be interpreted as "the user does not care", falling back to the default theme'; - COMMENT ON COLUMN users.name IS 'Name of the Coder user'; COMMENT ON COLUMN users.github_com_user_id IS 'The GitHub.com numerical user ID. At time of implementation, this is used to check if the user has starred the Coder repository.'; @@ -892,7 +889,6 @@ CREATE VIEW group_members_expanded AS users.deleted AS user_deleted, users.last_seen_at AS user_last_seen_at, users.quiet_hours_schedule AS user_quiet_hours_schedule, - users.theme_preference AS user_theme_preference, users.name AS user_name, users.github_com_user_id AS user_github_com_user_id, groups.organization_id, @@ -1547,6 +1543,12 @@ CREATE VIEW template_with_names AS COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; +CREATE TABLE user_configs ( + user_id uuid NOT NULL, + key character varying(256) NOT NULL, + value text NOT NULL +); + CREATE TABLE user_deleted ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -2199,6 +2201,9 @@ ALTER TABLE ONLY template_versions ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_configs + ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); + ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); @@ -2613,6 +2618,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_configs + ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 525d240f25267..f7044815852cd 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -51,6 +51,7 @@ const ( ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyUserConfigsUserID ForeignKeyConstraint = "user_configs_user_id_fkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserDeletedUserID ForeignKeyConstraint = "user_deleted_user_id_fkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000299_user_configs.down.sql b/coderd/database/migrations/000299_user_configs.down.sql new file mode 100644 index 0000000000000..c3ca42798ef98 --- /dev/null +++ b/coderd/database/migrations/000299_user_configs.down.sql @@ -0,0 +1,57 @@ +-- Put back "theme_preference" column +ALTER TABLE users ADD COLUMN IF NOT EXISTS + theme_preference text DEFAULT ''::text NOT NULL; + +-- Copy "theme_preference" back to "users" +UPDATE users + SET theme_preference = (SELECT value + FROM user_configs + WHERE user_configs.user_id = users.id + AND user_configs.key = 'theme_preference'); + +-- Drop the "user_configs" table. +DROP TABLE user_configs; + +-- Replace "group_members_expanded", and bring back with "theme_preference" +DROP VIEW group_members_expanded; +-- Taken from 000242_group_members_view.up.sql +CREATE VIEW + group_members_expanded +AS +-- If the group is a user made group, then we need to check the group_members table. +-- If it is the "Everyone" group, then we need to check the organization_members table. +WITH all_members AS ( + SELECT user_id, group_id FROM group_members + UNION + SELECT user_id, organization_id AS group_id FROM organization_members +) +SELECT + users.id AS user_id, + users.email AS user_email, + users.username AS user_username, + users.hashed_password AS user_hashed_password, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.status AS user_status, + users.rbac_roles AS user_rbac_roles, + users.login_type AS user_login_type, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.last_seen_at AS user_last_seen_at, + users.quiet_hours_schedule AS user_quiet_hours_schedule, + users.theme_preference AS user_theme_preference, + users.name AS user_name, + users.github_com_user_id AS user_github_com_user_id, + groups.organization_id AS organization_id, + groups.name AS group_name, + all_members.group_id AS group_id +FROM + all_members +JOIN + users ON users.id = all_members.user_id +JOIN + groups ON groups.id = all_members.group_id +WHERE + users.deleted = 'false'; + +COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).'; diff --git a/coderd/database/migrations/000299_user_configs.up.sql b/coderd/database/migrations/000299_user_configs.up.sql new file mode 100644 index 0000000000000..fb5db1d8e5f6e --- /dev/null +++ b/coderd/database/migrations/000299_user_configs.up.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS user_configs ( + user_id uuid NOT NULL, + key varchar(256) NOT NULL, + value text NOT NULL, + + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + + +-- Copy "theme_preference" from "users" table +INSERT INTO user_configs (user_id, key, value) + SELECT id, 'theme_preference', theme_preference + FROM users + WHERE users.theme_preference IS NOT NULL; + + +-- Replace "group_members_expanded" without "theme_preference" +DROP VIEW group_members_expanded; +-- Taken from 000242_group_members_view.up.sql +CREATE VIEW + group_members_expanded +AS +-- If the group is a user made group, then we need to check the group_members table. +-- If it is the "Everyone" group, then we need to check the organization_members table. +WITH all_members AS ( + SELECT user_id, group_id FROM group_members + UNION + SELECT user_id, organization_id AS group_id FROM organization_members +) +SELECT + users.id AS user_id, + users.email AS user_email, + users.username AS user_username, + users.hashed_password AS user_hashed_password, + users.created_at AS user_created_at, + users.updated_at AS user_updated_at, + users.status AS user_status, + users.rbac_roles AS user_rbac_roles, + users.login_type AS user_login_type, + users.avatar_url AS user_avatar_url, + users.deleted AS user_deleted, + users.last_seen_at AS user_last_seen_at, + users.quiet_hours_schedule AS user_quiet_hours_schedule, + users.name AS user_name, + users.github_com_user_id AS user_github_com_user_id, + groups.organization_id AS organization_id, + groups.name AS group_name, + all_members.group_id AS group_id +FROM + all_members +JOIN + users ON users.id = all_members.user_id +JOIN + groups ON groups.id = all_members.group_id +WHERE + users.deleted = 'false'; + +COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).'; + +-- Drop the "theme_preference" column now that the view no longer depends on it. +ALTER TABLE users DROP COLUMN theme_preference; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d9013b1f08c0c..fe782bdd14170 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -406,20 +406,19 @@ func ConvertUserRows(rows []GetUsersRow) []User { users := make([]User, len(rows)) for i, r := range rows { users[i] = User{ - ID: r.ID, - Email: r.Email, - Username: r.Username, - Name: r.Name, - HashedPassword: r.HashedPassword, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - Status: r.Status, - RBACRoles: r.RBACRoles, - LoginType: r.LoginType, - AvatarURL: r.AvatarURL, - Deleted: r.Deleted, - LastSeenAt: r.LastSeenAt, - ThemePreference: r.ThemePreference, + ID: r.ID, + Email: r.Email, + Username: r.Username, + Name: r.Name, + HashedPassword: r.HashedPassword, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + Status: r.Status, + RBACRoles: r.RBACRoles, + LoginType: r.LoginType, + AvatarURL: r.AvatarURL, + Deleted: r.Deleted, + LastSeenAt: r.LastSeenAt, } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 4c323fd91c1de..cc19de5132f37 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -417,7 +417,6 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -505,7 +504,6 @@ func (q *sqlQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg GetAu &i.UserRoles, &i.UserAvatarUrl, &i.UserDeleted, - &i.UserThemePreference, &i.UserQuietHoursSchedule, &i.OrganizationName, &i.OrganizationDisplayName, diff --git a/coderd/database/models.go b/coderd/database/models.go index 3e0f59e6e9391..eadaabf89c2c4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2605,7 +2605,6 @@ type GroupMember struct { UserDeleted bool `db:"user_deleted" json:"user_deleted"` UserLastSeenAt time.Time `db:"user_last_seen_at" json:"user_last_seen_at"` UserQuietHoursSchedule string `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` - UserThemePreference string `db:"user_theme_preference" json:"user_theme_preference"` UserName string `db:"user_name" json:"user_name"` UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` @@ -3176,8 +3175,6 @@ type User struct { LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` // Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user's quiet hours. If empty, the default quiet hours on the instance is used instead. QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` - // "" can be interpreted as "the user does not care", falling back to the default theme - ThemePreference string `db:"theme_preference" json:"theme_preference"` // Name of the Coder user Name string `db:"name" json:"name"` // The GitHub.com numerical user ID. At time of implementation, this is used to check if the user has starred the Coder repository. @@ -3188,6 +3185,12 @@ type User struct { OneTimePasscodeExpiresAt sql.NullTime `db:"one_time_passcode_expires_at" json:"one_time_passcode_expires_at"` } +type UserConfig struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} + // Tracks when users were deleted type UserDeleted struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4fe20f3fcd806..28227797c7e3f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -306,6 +306,7 @@ type sqlcQuerier interface { // produces a bloated value if a user has used multiple templates // simultaneously. GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) + GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (string, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) @@ -522,7 +523,7 @@ type sqlcQuerier interface { UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error - UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (User, error) + UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (UserConfig, error) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e3e0445360bc4..a55d50e1d2127 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -457,7 +457,6 @@ SELECT users.rbac_roles AS user_roles, users.avatar_url AS user_avatar_url, users.deleted AS user_deleted, - users.theme_preference AS user_theme_preference, users.quiet_hours_schedule AS user_quiet_hours_schedule, COALESCE(organizations.name, '') AS organization_name, COALESCE(organizations.display_name, '') AS organization_display_name, @@ -608,7 +607,6 @@ type GetAuditLogsOffsetRow struct { UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` UserDeleted sql.NullBool `db:"user_deleted" json:"user_deleted"` - UserThemePreference sql.NullString `db:"user_theme_preference" json:"user_theme_preference"` UserQuietHoursSchedule sql.NullString `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` OrganizationName string `db:"organization_name" json:"organization_name"` OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` @@ -669,7 +667,6 @@ func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOff &i.UserRoles, &i.UserAvatarUrl, &i.UserDeleted, - &i.UserThemePreference, &i.UserQuietHoursSchedule, &i.OrganizationName, &i.OrganizationDisplayName, @@ -1582,7 +1579,7 @@ func (q *sqlQuerier) DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteG } const getGroupMembers = `-- name: GetGroupMembers :many -SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded +SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded ` func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error) { @@ -1608,7 +1605,6 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error) &i.UserDeleted, &i.UserLastSeenAt, &i.UserQuietHoursSchedule, - &i.UserThemePreference, &i.UserName, &i.UserGithubComUserID, &i.OrganizationID, @@ -1629,7 +1625,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error) } const getGroupMembersByGroupID = `-- name: GetGroupMembersByGroupID :many -SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE group_id = $1 +SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE group_id = $1 ` func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GroupMember, error) { @@ -1655,7 +1651,6 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid. &i.UserDeleted, &i.UserLastSeenAt, &i.UserQuietHoursSchedule, - &i.UserThemePreference, &i.UserName, &i.UserGithubComUserID, &i.OrganizationID, @@ -7777,7 +7772,7 @@ FROM ( -- Select all groups this user is a member of. This will also include -- the "Everyone" group for organizations the user is a member of. - SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded + SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE $1 = user_id AND $2 = group_members_expanded.organization_id @@ -11359,9 +11354,26 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. return i, err } +const getUserAppearanceSettings = `-- name: GetUserAppearanceSettings :one +SELECT + value as theme_preference +FROM + user_configs +WHERE + user_id = $1 + AND key = 'theme_preference' +` + +func (q *sqlQuerier) GetUserAppearanceSettings(ctx context.Context, userID uuid.UUID) (string, error) { + row := q.db.QueryRowContext(ctx, getUserAppearanceSettings, userID) + var theme_preference string + err := row.Scan(&theme_preference) + return theme_preference, err +} + const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at FROM users WHERE @@ -11393,7 +11405,6 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11404,7 +11415,7 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at FROM users WHERE @@ -11430,7 +11441,6 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11457,7 +11467,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, COUNT(*) OVER() AS count + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, COUNT(*) OVER() AS count FROM users WHERE @@ -11567,7 +11577,6 @@ type GetUsersRow struct { Deleted bool `db:"deleted" json:"deleted"` LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` - ThemePreference string `db:"theme_preference" json:"theme_preference"` Name string `db:"name" json:"name"` GithubComUserID sql.NullInt64 `db:"github_com_user_id" json:"github_com_user_id"` HashedOneTimePasscode []byte `db:"hashed_one_time_passcode" json:"hashed_one_time_passcode"` @@ -11610,7 +11619,6 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11631,7 +11639,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at FROM users WHERE id = ANY($1 :: uuid [ ]) +SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at FROM users WHERE id = ANY($1 :: uuid [ ]) ` // This shouldn't check for deleted, because it's frequently used @@ -11660,7 +11668,6 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11698,7 +11705,7 @@ VALUES -- if the status passed in is empty, fallback to dormant, which is what -- we were doing before. COALESCE(NULLIF($10::text, '')::user_status, 'dormant'::user_status) - ) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + ) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type InsertUserParams struct { @@ -11742,7 +11749,6 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11804,45 +11810,29 @@ func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg Updat } const updateUserAppearanceSettings = `-- name: UpdateUserAppearanceSettings :one -UPDATE - users +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'theme_preference', $2) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE SET - theme_preference = $2, - updated_at = $3 -WHERE - id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'theme_preference' +RETURNING user_id, key, value ` type UpdateUserAppearanceSettingsParams struct { - ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` ThemePreference string `db:"theme_preference" json:"theme_preference"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -func (q *sqlQuerier) UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (User, error) { - row := q.db.QueryRowContext(ctx, updateUserAppearanceSettings, arg.ID, arg.ThemePreference, arg.UpdatedAt) - var i User - err := row.Scan( - &i.ID, - &i.Email, - &i.Username, - &i.HashedPassword, - &i.CreatedAt, - &i.UpdatedAt, - &i.Status, - &i.RBACRoles, - &i.LoginType, - &i.AvatarURL, - &i.Deleted, - &i.LastSeenAt, - &i.QuietHoursSchedule, - &i.ThemePreference, - &i.Name, - &i.GithubComUserID, - &i.HashedOneTimePasscode, - &i.OneTimePasscodeExpiresAt, - ) +func (q *sqlQuerier) UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (UserConfig, error) { + row := q.db.QueryRowContext(ctx, updateUserAppearanceSettings, arg.UserID, arg.ThemePreference) + var i UserConfig + err := row.Scan(&i.UserID, &i.Key, &i.Value) return i, err } @@ -11928,7 +11918,7 @@ SET last_seen_at = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserLastSeenAtParams struct { @@ -11954,7 +11944,6 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -11976,7 +11965,7 @@ SET '':: bytea END WHERE - id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserLoginTypeParams struct { @@ -12001,7 +11990,6 @@ func (q *sqlQuerier) UpdateUserLoginType(ctx context.Context, arg UpdateUserLogi &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -12021,7 +12009,7 @@ SET name = $6 WHERE id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserProfileParams struct { @@ -12057,7 +12045,6 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -12073,7 +12060,7 @@ SET quiet_hours_schedule = $2 WHERE id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserQuietHoursScheduleParams struct { @@ -12098,7 +12085,6 @@ func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg Updat &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -12115,7 +12101,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserRolesParams struct { @@ -12140,7 +12126,6 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, @@ -12156,7 +12141,7 @@ SET status = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type UpdateUserStatusParams struct { @@ -12182,7 +12167,6 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.Deleted, &i.LastSeenAt, &i.QuietHoursSchedule, - &i.ThemePreference, &i.Name, &i.GithubComUserID, &i.HashedOneTimePasscode, diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index 52efc40c73738..9016908a75feb 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -16,7 +16,6 @@ SELECT users.rbac_roles AS user_roles, users.avatar_url AS user_avatar_url, users.deleted AS user_deleted, - users.theme_preference AS user_theme_preference, users.quiet_hours_schedule AS user_quiet_hours_schedule, COALESCE(organizations.name, '') AS organization_name, COALESCE(organizations.display_name, '') AS organization_display_name, diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 1f30a2c2c1d24..79f19c1784155 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -98,14 +98,27 @@ SET WHERE id = $1; +-- name: GetUserAppearanceSettings :one +SELECT + value as theme_preference +FROM + user_configs +WHERE + user_id = @user_id + AND key = 'theme_preference'; + -- name: UpdateUserAppearanceSettings :one -UPDATE - users +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'theme_preference', @theme_preference) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE SET - theme_preference = $2, - updated_at = $3 -WHERE - id = $1 + value = @theme_preference +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'theme_preference' RETURNING *; -- name: UpdateUserRoles :one diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index eb61e2f39a2c8..b2c814241d55a 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -65,6 +65,7 @@ const ( UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); diff --git a/coderd/users.go b/coderd/users.go index bf5b1db763fe9..bbb10c4787a27 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -959,6 +959,38 @@ func (api *API) notifyUserStatusChanged(ctx context.Context, actingUserName stri return nil } +// @Summary Get user appearance settings +// @ID get-user-appearance-settings +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.UserAppearanceSettings +// @Router /users/{user}/appearance [get] +func (api *API) userAppearanceSettings(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + themePreference, err := api.Database.GetUserAppearanceSettings(ctx, user.ID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading user settings.", + Detail: err.Error(), + }) + return + } + + themePreference = "" + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAppearanceSettings{ + ThemePreference: themePreference, + }) +} + // @Summary Update user appearance settings // @ID update-user-appearance-settings // @Security CoderSessionToken @@ -967,7 +999,7 @@ func (api *API) notifyUserStatusChanged(ctx context.Context, actingUserName stri // @Tags Users // @Param user path string true "User ID, name, or me" // @Param request body codersdk.UpdateUserAppearanceSettingsRequest true "New appearance settings" -// @Success 200 {object} codersdk.User +// @Success 200 {object} codersdk.UserAppearanceSettings // @Router /users/{user}/appearance [put] func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Request) { var ( @@ -980,10 +1012,9 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques return } - updatedUser, err := api.Database.UpdateUserAppearanceSettings(ctx, database.UpdateUserAppearanceSettingsParams{ - ID: user.ID, + updatedSettings, err := api.Database.UpdateUserAppearanceSettings(ctx, database.UpdateUserAppearanceSettingsParams{ + UserID: user.ID, ThemePreference: params.ThemePreference, - UpdatedAt: dbtime.Now(), }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -993,16 +1024,9 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques return } - organizationIDs, err := userOrganizationIDs(ctx, api, user) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching user's organizations.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs)) + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAppearanceSettings{ + ThemePreference: updatedSettings.Value, + }) } // @Summary Update user password diff --git a/codersdk/users.go b/codersdk/users.go index 7177a1bc3e76d..31854731a0ae1 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -54,9 +54,11 @@ type ReducedUser struct { UpdatedAt time.Time `json:"updated_at" table:"updated at" format:"date-time"` LastSeenAt time.Time `json:"last_seen_at" format:"date-time"` - Status UserStatus `json:"status" table:"status" enums:"active,suspended"` - LoginType LoginType `json:"login_type"` - ThemePreference string `json:"theme_preference"` + Status UserStatus `json:"status" table:"status" enums:"active,suspended"` + LoginType LoginType `json:"login_type"` + // Deprecated: this value should be retrieved from + // `codersdk.UserPreferenceSettings` instead. + ThemePreference string `json:"theme_preference,omitempty"` } // User represents a user in Coder. @@ -187,6 +189,10 @@ type ValidateUserPasswordResponse struct { Details string `json:"details"` } +type UserAppearanceSettings struct { + ThemePreference string `json:"theme_preference"` +} + type UpdateUserAppearanceSettingsRequest struct { ThemePreference string `json:"theme_preference" validate:"required"` } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 4817ea03f4bc5..778e9f9c2e26e 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -28,7 +28,7 @@ We track the following resources: | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | WorkspaceAgent
connect, disconnect | |
FieldTracked
api_versionfalse
architecturefalse
auth_instance_idfalse
auth_tokenfalse
connection_timeout_secondsfalse
created_atfalse
directoryfalse
disconnected_atfalse
display_appsfalse
display_orderfalse
environment_variablesfalse
expanded_directoryfalse
first_connected_atfalse
idfalse
instance_metadatafalse
last_connected_atfalse
last_connected_replica_idfalse
lifecycle_statefalse
logs_lengthfalse
logs_overflowedfalse
motd_filefalse
namefalse
operating_systemfalse
ready_atfalse
resource_idfalse
resource_metadatafalse
started_atfalse
subsystemsfalse
troubleshooting_urlfalse
updated_atfalse
versionfalse
| | WorkspaceApp
open, close | |
FieldTracked
agent_idfalse
commandfalse
created_atfalse
display_namefalse
display_orderfalse
externalfalse
healthfalse
healthcheck_intervalfalse
healthcheck_thresholdfalse
healthcheck_urlfalse
hiddenfalse
iconfalse
idfalse
open_infalse
sharing_levelfalse
slugfalse
subdomainfalse
urlfalse
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 282cf20ab252d..152f331fc81d5 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -260,7 +260,7 @@ Status Code **200** | `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | | `»» name` | string | false | | | | `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» theme_preference` | string | false | | | +| `»» theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | | `»» updated_at` | string(date-time) | false | | | | `»» username` | string | true | | | | `» name` | string | false | | | @@ -1271,7 +1271,7 @@ Status Code **200** | `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | | `»» name` | string | false | | | | `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» theme_preference` | string | false | | | +| `»» theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | | `»» updated_at` | string(date-time) | false | | | | `»» username` | string | true | | | | `» name` | string | false | | | @@ -3126,26 +3126,26 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|----------------------------------------------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» avatar_url` | string(uri) | false | | | -| `» created_at` | string(date-time) | true | | | -| `» email` | string(email) | true | | | -| `» id` | string(uuid) | true | | | -| `» last_seen_at` | string(date-time) | false | | | -| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `» name` | string | false | | | -| `» organization_ids` | array | false | | | -| `» role` | [codersdk.TemplateRole](schemas.md#codersdktemplaterole) | false | | | -| `» roles` | array | false | | | -| `»» display_name` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string | false | | | -| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `» theme_preference` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string(uri) | false | | | +| `» created_at` | string(date-time) | true | | | +| `» email` | string(email) | true | | | +| `» id` | string(uuid) | true | | | +| `» last_seen_at` | string(date-time) | false | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `» name` | string | false | | | +| `» organization_ids` | array | false | | | +| `» role` | [codersdk.TemplateRole](schemas.md#codersdktemplaterole) | false | | | +| `» roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `» theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `» updated_at` | string(date-time) | false | | | +| `» username` | string | true | | | #### Enumerated Values @@ -3325,7 +3325,7 @@ Status Code **200** | `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | | `»»» name` | string | false | | | | `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»»» theme_preference` | string | false | | | +| `»»» theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | | `»»» updated_at` | string(date-time) | false | | | | `»»» username` | string | true | | | | `»» name` | string | false | | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ffb440675cb21..9fa22af7356ae 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5195,19 +5195,19 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------|----------|--------------|-------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -6180,22 +6180,22 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|-------------------------------------------------|----------|--------------|-------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -6880,21 +6880,21 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|-------------------------------------------------|----------|--------------|-------------| -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `updated_at` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. | +| `updated_at` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -6990,6 +6990,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| |----------|----------------------------------------------------------------------------|----------|--------------|-------------| | `report` | [codersdk.UserActivityInsightsReport](#codersdkuseractivityinsightsreport) | false | | | +## codersdk.UserAppearanceSettings + +```json +{ + "theme_preference": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------|----------|--------------|-------------| +| `theme_preference` | string | false | | | + ## codersdk.UserLatency ```json diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index df0a8ca094df2..3f0c38571f7c4 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -476,6 +476,43 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user appearance settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/appearance \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/appearance` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "theme_preference": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAppearanceSettings](schemas.md#codersdkuserappearancesettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update user appearance settings ### Code samples @@ -511,35 +548,15 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \ ```json { - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "updated_at": "2019-08-24T14:15:22Z", - "username": "string" + "theme_preference": "string" } ``` ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAppearanceSettings](schemas.md#codersdkuserappearancesettings) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 53f03dd60ae63..6fd3f46308975 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -147,7 +147,6 @@ var auditableResourcesTypes = map[any]map[string]Action{ "last_seen_at": ActionIgnore, "deleted": ActionTrack, "quiet_hours_schedule": ActionTrack, - "theme_preference": ActionIgnore, "name": ActionTrack, "github_com_user_id": ActionIgnore, "hashed_one_time_passcode": ActionIgnore, diff --git a/site/index.html b/site/index.html index fff26338b21aa..b953abe052923 100644 --- a/site/index.html +++ b/site/index.html @@ -9,53 +9,54 @@ --> - - Coder - - - - - - - - - - - - - - - - - - + + Coder + + + + + + + + + + + + + + + + + + + -
- +
+ diff --git a/site/site.go b/site/site.go index e0e9a1328508b..f4d5509479db5 100644 --- a/site/site.go +++ b/site/site.go @@ -292,13 +292,14 @@ type htmlState struct { ApplicationName string LogoURL string - BuildInfo string - User string - Entitlements string - Appearance string - Experiments string - Regions string - DocsURL string + BuildInfo string + User string + Entitlements string + Appearance string + UserAppearance string + Experiments string + Regions string + DocsURL string } type csrfState struct { @@ -426,12 +427,22 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht var eg errgroup.Group var user database.User + var themePreference string orgIDs := []uuid.UUID{} eg.Go(func() error { var err error user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID) return err }) + eg.Go(func() error { + var err error + themePreference, err = h.opts.Database.GetUserAppearanceSettings(ctx, apiKey.UserID) + if errors.Is(err, sql.ErrNoRows) { + themePreference = "" + return nil + } + return err + }) eg.Go(func() error { memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID}) if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 { @@ -455,6 +466,17 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht } }() + wg.Add(1) + go func() { + defer wg.Done() + userAppearance, err := json.Marshal(codersdk.UserAppearanceSettings{ + ThemePreference: themePreference, + }) + if err == nil { + state.UserAppearance = html.EscapeString(string(userAppearance)) + } + }() + if h.Entitlements != nil { wg.Add(1) go func() { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ede6f90a0133b..627ede80976c6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1340,14 +1340,16 @@ class ApiMethods { return response.data; }; + getAppearanceSettings = + async (): Promise => { + const response = await this.axios.get("/api/v2/users/me/appearance"); + return response.data; + }; + updateAppearanceSettings = async ( - userId: string, data: TypesGen.UpdateUserAppearanceSettingsRequest, - ): Promise => { - const response = await this.axios.put( - `/api/v2/users/${userId}/appearance`, - data, - ); + ): Promise => { + const response = await this.axios.put("/api/v2/users/me/appearance", data); return response.data; }; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 77d879abe3258..5de828b6eac22 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -8,8 +8,8 @@ import type { UpdateUserPasswordRequest, UpdateUserProfileRequest, User, + UserAppearanceSettings, UsersRequest, - ValidateUserPasswordRequest, } from "api/typesGenerated"; import { type MetadataState, @@ -224,35 +224,39 @@ export const updateProfile = (userId: string) => { }; }; +const myAppearanceKey = ["me", "appearance"]; + +export const appearanceSettings = ( + metadata: MetadataState, +) => { + return cachedQuery({ + metadata, + queryKey: myAppearanceKey, + queryFn: API.getAppearanceSettings, + }); +}; + export const updateAppearanceSettings = ( - userId: string, queryClient: QueryClient, ): UseMutationOptions< - User, + UserAppearanceSettings, unknown, UpdateUserAppearanceSettingsRequest, unknown > => { return { - mutationFn: (req) => API.updateAppearanceSettings(userId, req), + mutationFn: (req) => API.updateAppearanceSettings(req), onMutate: async (patch) => { // Mutate the `queryClient` optimistically to make the theme switcher // more responsive. - const me: User | undefined = queryClient.getQueryData(meKey); - if (userId === "me" && me) { - queryClient.setQueryData(meKey, { - ...me, - theme_preference: patch.theme_preference, - }); - } + queryClient.setQueryData(myAppearanceKey, { + theme_preference: patch.theme_preference, + }); }, - onSuccess: async () => { + onSuccess: async () => // Could technically invalidate more, but we only ever care about the // `theme_preference` for the `me` query. - if (userId === "me") { - await queryClient.invalidateQueries(meKey); - } - }, + await queryClient.invalidateQueries(myAppearanceKey), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0535b2b8b50de..222c07575b969 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1970,7 +1970,7 @@ export interface ReducedUser extends MinimalUser { readonly last_seen_at: string; readonly status: UserStatus; readonly login_type: LoginType; - readonly theme_preference: string; + readonly theme_preference?: string; } // From codersdk/workspaceproxy.go @@ -2805,6 +2805,11 @@ export interface UserActivityInsightsResponse { readonly report: UserActivityInsightsReport; } +// From codersdk/users.go +export interface UserAppearanceSettings { + readonly theme_preference: string; +} + // From codersdk/insights.go export interface UserLatency { readonly template_ids: readonly string[]; diff --git a/site/src/components/FileUpload/FileUpload.test.tsx b/site/src/components/FileUpload/FileUpload.test.tsx index 2ff94f355bcfe..6292bc200a517 100644 --- a/site/src/components/FileUpload/FileUpload.test.tsx +++ b/site/src/components/FileUpload/FileUpload.test.tsx @@ -1,20 +1,18 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { ThemeProvider } from "contexts/ThemeProvider"; +import { fireEvent, screen } from "@testing-library/react"; +import { renderComponent } from "testHelpers/renderHelpers"; import { FileUpload } from "./FileUpload"; test("accepts files with the correct extension", async () => { const onUpload = jest.fn(); - render( - - - , + renderComponent( + , ); const dropZone = screen.getByTestId("drop-zone"); diff --git a/site/src/contexts/ThemeProvider.tsx b/site/src/contexts/ThemeProvider.tsx index 8367e96e3cc64..4521ab71d7a74 100644 --- a/site/src/contexts/ThemeProvider.tsx +++ b/site/src/contexts/ThemeProvider.tsx @@ -7,26 +7,27 @@ import { StyledEngineProvider, // biome-ignore lint/nursery/noRestrictedImports: we extend the MUI theme } from "@mui/material/styles"; +import { appearanceSettings } from "api/queries/users"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { type FC, type PropsWithChildren, type ReactNode, - useContext, useEffect, useMemo, useState, } from "react"; +import { useQuery } from "react-query"; import themes, { DEFAULT_THEME, type Theme } from "theme"; -import { AuthContext } from "./auth/AuthProvider"; /** * */ export const ThemeProvider: FC = ({ children }) => { - // We need to use the `AuthContext` directly, rather than the `useAuth` hook, - // because Storybook and many tests depend on this component, but do not provide - // an `AuthProvider`, and `useAuth` will throw in that case. - const user = useContext(AuthContext)?.user; + const { metadata } = useEmbeddedMetadata(); + const appearanceSettingsQuery = useQuery( + appearanceSettings(metadata.userAppearance), + ); const themeQuery = useMemo( () => window.matchMedia?.("(prefers-color-scheme: light)"), [], @@ -53,7 +54,8 @@ export const ThemeProvider: FC = ({ children }) => { }, [themeQuery]); // We might not be logged in yet, or the `theme_preference` could be an empty string. - const themePreference = user?.theme_preference || DEFAULT_THEME; + const themePreference = + appearanceSettingsQuery.data?.theme_preference || DEFAULT_THEME; // The janky casting here is find because of the much more type safe fallback // We need to support `themePreference` being wrong anyway because the database // value could be anything, like an empty string. diff --git a/site/src/hooks/useClipboard.test.tsx b/site/src/hooks/useClipboard.test.tsx index f98c1d1154b86..1d4d2eb702a81 100644 --- a/site/src/hooks/useClipboard.test.tsx +++ b/site/src/hooks/useClipboard.test.tsx @@ -11,7 +11,8 @@ */ import { act, renderHook, screen } from "@testing-library/react"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; -import { ThemeProvider } from "contexts/ThemeProvider"; +import { ThemeOverride } from "contexts/ThemeProvider"; +import themes, { DEFAULT_THEME } from "theme"; import { COPY_FAILED_MESSAGE, HTTP_FALLBACK_DATA_ID, @@ -121,10 +122,10 @@ function renderUseClipboard(inputs: TInput) { initialProps: inputs, wrapper: ({ children }) => ( // Need ThemeProvider because GlobalSnackbar uses theme - + {children} - + ), }, ); diff --git a/site/src/hooks/useEmbeddedMetadata.test.ts b/site/src/hooks/useEmbeddedMetadata.test.ts index 75dd4eed8f235..aacb635ada3bf 100644 --- a/site/src/hooks/useEmbeddedMetadata.test.ts +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -6,6 +6,7 @@ import { MockEntitlements, MockExperiments, MockUser, + MockUserAppearanceSettings, } from "testHelpers/entities"; import { DEFAULT_METADATA_KEY, @@ -38,6 +39,7 @@ const mockDataForTags = { entitlements: MockEntitlements, experiments: MockExperiments, user: MockUser, + userAppearance: MockUserAppearanceSettings, regions: MockRegions, } as const satisfies Record; @@ -66,6 +68,10 @@ const emptyMetadata: RuntimeHtmlMetadata = { available: false, value: undefined, }, + userAppearance: { + available: false, + value: undefined, + }, }; const populatedMetadata: RuntimeHtmlMetadata = { @@ -93,6 +99,10 @@ const populatedMetadata: RuntimeHtmlMetadata = { available: true, value: MockUser, }, + userAppearance: { + available: true, + value: MockUserAppearanceSettings, + }, }; function seedInitialMetadata(metadataKey: string): () => void { diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index ac4fd50037ed3..35cd8614f408e 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -5,6 +5,7 @@ import type { Experiments, Region, User, + UserAppearanceSettings, } from "api/typesGenerated"; import { useMemo, useSyncExternalStore } from "react"; @@ -25,6 +26,7 @@ type AvailableMetadata = Readonly<{ user: User; experiments: Experiments; appearance: AppearanceConfig; + userAppearance: UserAppearanceSettings; entitlements: Entitlements; regions: readonly Region[]; "build-info": BuildInfoResponse; @@ -83,6 +85,8 @@ export class MetadataManager implements MetadataManagerApi { this.metadata = { user: this.registerValue("user"), appearance: this.registerValue("appearance"), + userAppearance: + this.registerValue("userAppearance"), entitlements: this.registerValue("entitlements"), experiments: this.registerValue("experiments"), "build-info": this.registerValue("build-info"), diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx index e3eb0d9c12367..c48c265460a4e 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx @@ -34,7 +34,7 @@ describe("appearance page", () => { // Check if the API was called correctly expect(API.updateAppearanceSettings).toBeCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", { + expect(API.updateAppearanceSettings).toHaveBeenCalledWith({ theme_preference: "light", }); }); diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx index dfa4519ab2d58..1379e42d0e909 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx @@ -1,19 +1,34 @@ import CircularProgress from "@mui/material/CircularProgress"; import { updateAppearanceSettings } from "api/queries/users"; +import { appearanceSettings } from "api/queries/users"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import type { FC } from "react"; -import { useMutation, useQueryClient } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { Section } from "../Section"; import { AppearanceForm } from "./AppearanceForm"; export const AppearancePage: FC = () => { - const { user: me } = useAuthenticated(); const queryClient = useQueryClient(); const updateAppearanceSettingsMutation = useMutation( - updateAppearanceSettings("me", queryClient), + updateAppearanceSettings(queryClient), ); + const { metadata } = useEmbeddedMetadata(); + const appearanceSettingsQuery = useQuery( + appearanceSettings(metadata.userAppearance), + ); + + if (appearanceSettingsQuery.isLoading) { + return ; + } + + if (!appearanceSettingsQuery.data) { + return ; + } + return ( <>
{
diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 3d2f44602bd31..225db7c8a44c0 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -1,15 +1,13 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { API } from "api/api"; import { workspaceByOwnerAndName } from "api/queries/workspaces"; -import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; -import { ThemeProvider } from "contexts/ThemeProvider"; import dayjs from "dayjs"; import { http, HttpResponse } from "msw"; import type { FC } from "react"; -import { QueryClient, QueryClientProvider, useQuery } from "react-query"; -import { RouterProvider, createMemoryRouter } from "react-router-dom"; +import { useQuery } from "react-query"; import { MockTemplate, MockWorkspace } from "testHelpers/entities"; +import { render } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; @@ -45,16 +43,7 @@ const renderScheduleControls = async () => { }); }), ); - render( - - - }])} - /> - - - , - ); + render(); await screen.findByTestId("schedule-controls"); expect(screen.getByText("Stop in 3 hours")).toBeInTheDocument(); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index aa87ac7fbf6fc..dd7974bf5fe9a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -495,7 +495,6 @@ export const MockUser: TypesGen.User = { avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", last_seen_at: "", login_type: "password", - theme_preference: "", name: "", }; @@ -516,7 +515,6 @@ export const MockUser2: TypesGen.User = { avatar_url: "", last_seen_at: "2022-09-14T19:12:21Z", login_type: "oidc", - theme_preference: "", name: "Mock User The Second", }; @@ -532,10 +530,13 @@ export const SuspendedMockUser: TypesGen.User = { avatar_url: "", last_seen_at: "", login_type: "password", - theme_preference: "", name: "", }; +export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = { + theme_preference: "dark", +}; + export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { organization_id: MockOrganization.id, user_id: MockUser.id, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 71e67697572e2..1e08937593aec 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -162,6 +162,9 @@ export const handlers = [ http.get("/api/v2/users/me", () => { return HttpResponse.json(M.MockUser); }), + http.get("/api/v2/users/me/appearance", () => { + return HttpResponse.json(M.MockUserAppearanceSettings); + }), http.get("/api/v2/users/me/keys", () => { return HttpResponse.json(M.MockAPIKey); }), diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 330919c7ef7f6..eb76b481783da 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -5,7 +5,7 @@ import { } from "@testing-library/react"; import { AppProviders } from "App"; import type { ProxyProvider } from "contexts/ProxyContext"; -import { ThemeProvider } from "contexts/ThemeProvider"; +import { ThemeOverride } from "contexts/ThemeProvider"; import { RequireAuth } from "contexts/auth/RequireAuth"; import { DashboardLayout } from "modules/dashboard/DashboardLayout"; import type { DashboardProvider } from "modules/dashboard/DashboardProvider"; @@ -19,6 +19,7 @@ import { RouterProvider, createMemoryRouter, } from "react-router-dom"; +import themes, { DEFAULT_THEME } from "theme"; import { MockUser } from "./entities"; export function createTestQueryClient() { @@ -245,6 +246,8 @@ export const waitForLoaderToBeRemoved = async (): Promise => { export const renderComponent = (component: React.ReactElement) => { return testingLibraryRender(component, { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + {children} + ), }); };