chore: add "user_configs" db table (#16564)

This commit is contained in:
ケイラ
2025-03-05 10:46:03 -07:00
committed by GitHub
parent cc946f199d
commit 9041646b81
47 changed files with 786 additions and 394 deletions
-2
View File
@@ -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]==========="
],
+44 -1
View File
@@ -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": {
+40 -1
View File
@@ -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": {
-1
View File
@@ -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
+1
View File
@@ -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))
+7 -9
View File
@@ -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,
}
+15 -4
View File
@@ -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)
}
+17 -4
View File
@@ -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{})
-1
View File
@@ -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,
+60 -39
View File
@@ -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),
provisionerKeys: make([]database.ProvisionerKey, 0),
workspaceAgents: make([]database.WorkspaceAgent, 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),
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,
+8 -1
View File
@@ -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())
+17 -2
View File
@@ -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
}
+12 -4
View File
@@ -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);
@@ -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);
@@ -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).';
@@ -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;
+13 -14
View File
@@ -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,
}
}
-2
View File
@@ -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,
+6 -3
View File
@@ -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"`
+2 -1
View File
@@ -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
+47 -63
View File
@@ -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,
-1
View File
@@ -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,
+20 -7
View File
@@ -98,14 +98,27 @@ SET
WHERE
id = $1;
-- name: UpdateUserAppearanceSettings :one
UPDATE
users
SET
theme_preference = $2,
updated_at = $3
-- name: GetUserAppearanceSettings :one
SELECT
value as theme_preference
FROM
user_configs
WHERE
id = $1
user_id = @user_id
AND key = 'theme_preference';
-- name: UpdateUserAppearanceSettings :one
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
value = @theme_preference
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'theme_preference'
RETURNING *;
-- name: UpdateUserRoles :one
+1
View File
@@ -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);
+38 -14
View File
@@ -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
+9 -3
View File
@@ -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"`
}
+1 -1
View File
@@ -28,7 +28,7 @@ We track the following resources:
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| WorkspaceAgent<br><i>connect, disconnect</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>api_version</td><td>false</td></tr><tr><td>architecture</td><td>false</td></tr><tr><td>auth_instance_id</td><td>false</td></tr><tr><td>auth_token</td><td>false</td></tr><tr><td>connection_timeout_seconds</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>directory</td><td>false</td></tr><tr><td>disconnected_at</td><td>false</td></tr><tr><td>display_apps</td><td>false</td></tr><tr><td>display_order</td><td>false</td></tr><tr><td>environment_variables</td><td>false</td></tr><tr><td>expanded_directory</td><td>false</td></tr><tr><td>first_connected_at</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>instance_metadata</td><td>false</td></tr><tr><td>last_connected_at</td><td>false</td></tr><tr><td>last_connected_replica_id</td><td>false</td></tr><tr><td>lifecycle_state</td><td>false</td></tr><tr><td>logs_length</td><td>false</td></tr><tr><td>logs_overflowed</td><td>false</td></tr><tr><td>motd_file</td><td>false</td></tr><tr><td>name</td><td>false</td></tr><tr><td>operating_system</td><td>false</td></tr><tr><td>ready_at</td><td>false</td></tr><tr><td>resource_id</td><td>false</td></tr><tr><td>resource_metadata</td><td>false</td></tr><tr><td>started_at</td><td>false</td></tr><tr><td>subsystems</td><td>false</td></tr><tr><td>troubleshooting_url</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>version</td><td>false</td></tr></tbody></table> |
| WorkspaceApp<br><i>open, close</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>agent_id</td><td>false</td></tr><tr><td>command</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>false</td></tr><tr><td>display_order</td><td>false</td></tr><tr><td>external</td><td>false</td></tr><tr><td>health</td><td>false</td></tr><tr><td>healthcheck_interval</td><td>false</td></tr><tr><td>healthcheck_threshold</td><td>false</td></tr><tr><td>healthcheck_url</td><td>false</td></tr><tr><td>hidden</td><td>false</td></tr><tr><td>icon</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>open_in</td><td>false</td></tr><tr><td>sharing_level</td><td>false</td></tr><tr><td>slug</td><td>false</td></tr><tr><td>subdomain</td><td>false</td></tr><tr><td>url</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
+23 -23
View File
@@ -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 | | |
+58 -44
View File
@@ -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
+41 -24
View File
@@ -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).
-1
View File
@@ -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,
+47 -46
View File
@@ -9,53 +9,54 @@
-->
<head>
<meta charset="utf-8" />
<title>Coder</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="{{ .ApplicationName }}" />
<meta property="og:type" content="website" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="build-info" content="{{ .BuildInfo }}" />
<meta property="user" content="{{ .User }}" />
<meta property="entitlements" content="{{ .Entitlements }}" />
<meta property="appearance" content="{{ .Appearance }}" />
<meta property="experiments" content="{{ .Experiments }}" />
<meta property="regions" content="{{ .Regions }}" />
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-light.png"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-light.svg"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-dark.png"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-dark.svg"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<meta charset="utf-8" />
<title>Coder</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="{{ .ApplicationName }}" />
<meta property="og:type" content="website" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="build-info" content="{{ .BuildInfo }}" />
<meta property="user" content="{{ .User }}" />
<meta property="entitlements" content="{{ .Entitlements }}" />
<meta property="appearance" content="{{ .Appearance }}" />
<meta property="userAppearance" content="{{ .UserAppearance }}" />
<meta property="experiments" content="{{ .Experiments }}" />
<meta property="regions" content="{{ .Regions }}" />
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-light.png"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-light.svg"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-dark.png"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-dark.svg"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
+29 -7
View File
@@ -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() {
+8 -6
View File
@@ -1340,14 +1340,16 @@ class ApiMethods {
return response.data;
};
getAppearanceSettings =
async (): Promise<TypesGen.UserAppearanceSettings> => {
const response = await this.axios.get("/api/v2/users/me/appearance");
return response.data;
};
updateAppearanceSettings = async (
userId: string,
data: TypesGen.UpdateUserAppearanceSettingsRequest,
): Promise<TypesGen.User> => {
const response = await this.axios.put(
`/api/v2/users/${userId}/appearance`,
data,
);
): Promise<TypesGen.UserAppearanceSettings> => {
const response = await this.axios.put("/api/v2/users/me/appearance", data);
return response.data;
};
+20 -16
View File
@@ -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<UserAppearanceSettings>,
) => {
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),
};
};
+6 -1
View File
@@ -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[];
@@ -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(
<ThemeProvider>
<FileUpload
isUploading={false}
onUpload={onUpload}
removeLabel="Remove file"
title="Upload file"
extensions={["tar", "zip"]}
/>
</ThemeProvider>,
renderComponent(
<FileUpload
isUploading={false}
onUpload={onUpload}
removeLabel="Remove file"
title="Upload file"
extensions={["tar", "zip"]}
/>,
);
const dropZone = screen.getByTestId("drop-zone");
+9 -7
View File
@@ -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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ 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.
+4 -3
View File
@@ -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<TInput extends UseClipboardInput>(inputs: TInput) {
initialProps: inputs,
wrapper: ({ children }) => (
// Need ThemeProvider because GlobalSnackbar uses theme
<ThemeProvider>
<ThemeOverride theme={themes[DEFAULT_THEME]}>
{children}
<GlobalSnackbar />
</ThemeProvider>
</ThemeOverride>
),
},
);
@@ -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<MetadataKey, MetadataValue>;
@@ -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 {
+4
View File
@@ -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>("user"),
appearance: this.registerValue<AppearanceConfig>("appearance"),
userAppearance:
this.registerValue<UserAppearanceSettings>("userAppearance"),
entitlements: this.registerValue<Entitlements>("entitlements"),
experiments: this.registerValue<Experiments>("experiments"),
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
@@ -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",
});
});
@@ -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 <Loader />;
}
if (!appearanceSettingsQuery.data) {
return <ErrorAlert error={appearanceSettingsQuery.error} />;
}
return (
<>
<Section
@@ -30,7 +45,9 @@ export const AppearancePage: FC = () => {
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isLoading}
error={updateAppearanceSettingsMutation.error}
initialValues={{ theme_preference: me.theme_preference }}
initialValues={{
theme_preference: appearanceSettingsQuery.data.theme_preference,
}}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
/>
</Section>
@@ -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(
<ThemeProvider>
<QueryClientProvider client={new QueryClient()}>
<RouterProvider
router={createMemoryRouter([{ path: "/", element: <Wrapper /> }])}
/>
</QueryClientProvider>
<GlobalSnackbar />
</ThemeProvider>,
);
render(<Wrapper />);
await screen.findByTestId("schedule-controls");
expect(screen.getByText("Stop in 3 hours")).toBeInTheDocument();
};
+4 -3
View File
@@ -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,
+3
View File
@@ -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);
}),
+5 -2
View File
@@ -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<void> => {
export const renderComponent = (component: React.ReactElement) => {
return testingLibraryRender(component, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
wrapper: ({ children }) => (
<ThemeOverride theme={themes[DEFAULT_THEME]}>{children}</ThemeOverride>
),
});
};