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 |
Field Tracked | field true mapping true
|
| Templatewrite, delete | Field Tracked | active_version_id true activity_bump true allow_user_autostart true allow_user_autostop true allow_user_cancel_workspace_jobs true autostart_block_days_of_week true autostop_requirement_days_of_week true autostop_requirement_weeks true created_at false created_by true created_by_avatar_url false created_by_username false default_ttl true deleted false deprecated true description true display_name true failure_ttl true group_acl true icon true id true max_port_sharing_level true name true organization_display_name false organization_icon false organization_id false organization_name false provisioner true require_active_version true time_til_dormant true time_til_dormant_autodelete true updated_at false user_acl true
|
| TemplateVersioncreate, write | Field Tracked | archived true created_at false created_by true created_by_avatar_url false created_by_username false external_auth_providers false id true job_id false message false name true organization_id false readme true source_example_id false template_id true updated_at false
|
-| Usercreate, write, delete | Field Tracked | avatar_url false created_at false deleted true email true github_com_user_id false hashed_one_time_passcode false hashed_password true id true last_seen_at false login_type true name true one_time_passcode_expires_at true quiet_hours_schedule true rbac_roles true status true theme_preference false updated_at false username true
|
+| Usercreate, write, delete | Field Tracked | avatar_url false created_at false deleted true email true github_com_user_id false hashed_one_time_passcode false hashed_password true id true last_seen_at false login_type true name true one_time_passcode_expires_at true quiet_hours_schedule true rbac_roles true status true updated_at false username true
|
| WorkspaceAgentconnect, disconnect | Field Tracked | api_version false architecture false auth_instance_id false auth_token false connection_timeout_seconds false created_at false directory false disconnected_at false display_apps false display_order false environment_variables false expanded_directory false first_connected_at false id false instance_metadata false last_connected_at false last_connected_replica_id false lifecycle_state false logs_length false logs_overflowed false motd_file false name false operating_system false ready_at false resource_id false resource_metadata false started_at false subsystems false troubleshooting_url false updated_at false version false
|
| WorkspaceAppopen, close | Field Tracked | agent_id false command false created_at false display_name false display_order false external false health false healthcheck_interval false healthcheck_threshold false healthcheck_url false hidden false icon false id false open_in false sharing_level false slug false subdomain false url false
|
| WorkspaceBuildstart, stop | Field Tracked | build_number false created_at false daily_cost false deadline false id false initiator_by_avatar_url false initiator_by_username false initiator_id false job_id false max_deadline false provisioner_state false reason false template_version_id true template_version_preset_id false transition false updated_at false workspace_id false
|
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}
+ ),
});
};
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy