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 |
|
| Template
write, 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 |
|
| TemplateVersion
create, 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 |
|
-| User
create, 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 |
|
+| User
create, 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 |
|
| WorkspaceAgent
connect, 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 |
|
| WorkspaceApp
open, 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 |
|
| WorkspaceBuild
start, 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}
+ ),
});
};