mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat: add a theme picker (#11140)
This commit is contained in:
+4
-2
@@ -16,7 +16,8 @@
|
||||
}
|
||||
],
|
||||
"avatar_url": "",
|
||||
"login_type": "password"
|
||||
"login_type": "password",
|
||||
"theme_preference": ""
|
||||
},
|
||||
{
|
||||
"id": "[second user ID]",
|
||||
@@ -30,6 +31,7 @@
|
||||
],
|
||||
"roles": [],
|
||||
"avatar_url": "",
|
||||
"login_type": "password"
|
||||
"login_type": "password",
|
||||
"theme_preference": ""
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+63
@@ -3742,6 +3742,52 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/appearance": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Update user appearance settings",
|
||||
"operationId": "update-user-appearance-settings",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New appearance settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateUserAppearanceSettingsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/convert-login": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -10672,6 +10718,9 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -11009,6 +11058,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserAppearanceSettingsRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"theme_preference"
|
||||
],
|
||||
"properties": {
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -11151,6 +11211,9 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Generated
+55
@@ -3286,6 +3286,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/appearance": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Users"],
|
||||
"summary": "Update user appearance settings",
|
||||
"operationId": "update-user-appearance-settings",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "New appearance settings",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateUserAppearanceSettingsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/convert-login": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -9653,6 +9693,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -9970,6 +10013,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserAppearanceSettingsRequest": {
|
||||
"type": "object",
|
||||
"required": ["theme_preference"],
|
||||
"properties": {
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": ["password"],
|
||||
@@ -10098,6 +10150,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"theme_preference": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -800,6 +800,7 @@ func New(options *Options) *API {
|
||||
r.Put("/suspend", api.putSuspendUserAccount())
|
||||
r.Put("/activate", api.putActivateUserAccount())
|
||||
})
|
||||
r.Put("/appearance", api.putUserAppearanceSettings)
|
||||
r.Route("/password", func(r chi.Router) {
|
||||
r.Put("/", api.putUserPassword)
|
||||
})
|
||||
|
||||
@@ -122,6 +122,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
|
||||
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
|
||||
AvatarURL: user.AvatarURL,
|
||||
LoginType: codersdk.LoginType(user.LoginType),
|
||||
ThemePreference: user.ThemePreference,
|
||||
}
|
||||
|
||||
for _, roleName := range user.RBACRoles {
|
||||
|
||||
@@ -2699,6 +2699,17 @@ func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg da
|
||||
return fetchAndExec(q.log, q.auth, rbac.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)
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, u.UserDataRBACObject()); err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
return q.db.UpdateUserAppearanceSettings(ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserDeletedByID
|
||||
// Deprecated: Delete this function in favor of 'SoftDeleteUserByID'. Deletes are
|
||||
// irreversible.
|
||||
|
||||
@@ -962,6 +962,14 @@ func (s *MethodTestSuite) TestUser() {
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}).Asserts(u.UserDataRBACObject(), rbac.ActionUpdate).Returns(u)
|
||||
}))
|
||||
s.Run("UpdateUserAppearanceSettings", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
check.Args(database.UpdateUserAppearanceSettingsParams{
|
||||
ID: u.ID,
|
||||
ThemePreference: u.ThemePreference,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}).Asserts(u.UserDataRBACObject(), rbac.ActionUpdate).Returns(u)
|
||||
}))
|
||||
s.Run("UpdateUserStatus", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
check.Args(database.UpdateUserStatusParams{
|
||||
|
||||
@@ -6222,6 +6222,26 @@ func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateUserAppearanceSettings(_ context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for index, user := range q.users {
|
||||
if user.ID != arg.ID {
|
||||
continue
|
||||
}
|
||||
user.ThemePreference = arg.ThemePreference
|
||||
q.users[index] = user
|
||||
return user, nil
|
||||
}
|
||||
return database.User{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error {
|
||||
if err := validateDatabaseType(params); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1691,6 +1691,13 @@ func (m metricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, ar
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) UpdateUserAppearanceSettings(ctx context.Context, arg database.UpdateUserAppearanceSettingsParams) (database.User, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserAppearanceSettings(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateUserAppearanceSettings").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) UpdateUserDeletedByID(ctx context.Context, arg database.UpdateUserDeletedByIDParams) error {
|
||||
start := time.Now()
|
||||
err := m.s.UpdateUserDeletedByID(ctx, arg)
|
||||
|
||||
@@ -3559,6 +3559,21 @@ func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(arg0, arg1 i
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateUserAppearanceSettings mocks base method.
|
||||
func (m *MockStore) UpdateUserAppearanceSettings(arg0 context.Context, arg1 database.UpdateUserAppearanceSettingsParams) (database.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUserAppearanceSettings", arg0, arg1)
|
||||
ret0, _ := ret[0].(database.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUserAppearanceSettings indicates an expected call of UpdateUserAppearanceSettings.
|
||||
func (mr *MockStoreMockRecorder) UpdateUserAppearanceSettings(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserAppearanceSettings", reflect.TypeOf((*MockStore)(nil).UpdateUserAppearanceSettings), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateUserDeletedByID mocks base method.
|
||||
func (m *MockStore) UpdateUserDeletedByID(arg0 context.Context, arg1 database.UpdateUserDeletedByIDParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -332,6 +332,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)
|
||||
UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error
|
||||
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
|
||||
UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error)
|
||||
|
||||
@@ -7414,6 +7414,45 @@ func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg Updat
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserAppearanceSettings = `-- name: UpdateUserAppearanceSettings :one
|
||||
UPDATE
|
||||
users
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserAppearanceSettingsParams struct {
|
||||
ID uuid.UUID `db:"id" json:"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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec
|
||||
UPDATE
|
||||
users
|
||||
@@ -7542,7 +7581,8 @@ SET
|
||||
avatar_url = $4,
|
||||
updated_at = $5
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
|
||||
@@ -80,7 +80,18 @@ SET
|
||||
avatar_url = $4,
|
||||
updated_at = $5
|
||||
WHERE
|
||||
id = $1 RETURNING *;
|
||||
id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserAppearanceSettings :one
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
theme_preference = $2,
|
||||
updated_at = $3
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserRoles :one
|
||||
UPDATE
|
||||
@@ -266,4 +277,3 @@ RETURNING id, email, last_seen_at;
|
||||
-- AllUserIDs returns all UserIDs regardless of user status or deletion.
|
||||
-- name: AllUserIDs :many
|
||||
SELECT DISTINCT id FROM USERS;
|
||||
|
||||
|
||||
+51
-8
@@ -626,15 +626,12 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) {
|
||||
isDifferentUser := existentUser.ID != user.ID
|
||||
|
||||
if err == nil && isDifferentUser {
|
||||
responseErrors := []codersdk.ValidationError{}
|
||||
if existentUser.Username == params.Username {
|
||||
responseErrors = append(responseErrors, codersdk.ValidationError{
|
||||
Field: "username",
|
||||
Detail: "this value is already in use and should be unique",
|
||||
})
|
||||
}
|
||||
responseErrors := []codersdk.ValidationError{{
|
||||
Field: "username",
|
||||
Detail: "This username is already in use.",
|
||||
}}
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "User already exists.",
|
||||
Message: "A user with this username already exists.",
|
||||
Validations: responseErrors,
|
||||
})
|
||||
return
|
||||
@@ -764,6 +761,52 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Update user appearance settings
|
||||
// @ID update-user-appearance-settings
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @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
|
||||
// @Router /users/{user}/appearance [put]
|
||||
func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
user = httpmw.UserParam(r)
|
||||
)
|
||||
|
||||
var params codersdk.UpdateUserAppearanceSettingsRequest
|
||||
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := api.Database.UpdateUserAppearanceSettings(ctx, database.UpdateUserAppearanceSettingsParams{
|
||||
ID: user.ID,
|
||||
ThemePreference: params.ThemePreference,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating user.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
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))
|
||||
}
|
||||
|
||||
// @Summary Update user password
|
||||
// @ID update-user-password
|
||||
// @Security CoderSessionToken
|
||||
|
||||
+20
-1
@@ -55,6 +55,7 @@ type User struct {
|
||||
Roles []Role `json:"roles"`
|
||||
AvatarURL string `json:"avatar_url" format:"uri"`
|
||||
LoginType LoginType `json:"login_type"`
|
||||
ThemePreference string `json:"theme_preference"`
|
||||
}
|
||||
|
||||
type GetUsersResponse struct {
|
||||
@@ -92,6 +93,10 @@ type UpdateUserProfileRequest struct {
|
||||
Username string `json:"username" validate:"required,username"`
|
||||
}
|
||||
|
||||
type UpdateUserAppearanceSettingsRequest struct {
|
||||
ThemePreference string `json:"theme_preference" validate:"required"`
|
||||
}
|
||||
|
||||
type UpdateUserPasswordRequest struct {
|
||||
OldPassword string `json:"old_password" validate:""`
|
||||
Password string `json:"password" validate:"required"`
|
||||
@@ -249,7 +254,7 @@ func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserProfile enables callers to update profile information
|
||||
// UpdateUserProfile updates the username of a user.
|
||||
func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateUserProfileRequest) (User, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req)
|
||||
if err != nil {
|
||||
@@ -288,6 +293,20 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateUserAppearanceSettings updates the appearance settings for a user.
|
||||
func (c *Client) UpdateUserAppearanceSettings(ctx context.Context, user string, req UpdateUserAppearanceSettingsRequest) (User, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/appearance", user), req)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return User{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp User
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates a user password.
|
||||
// It calls PUT /users/{user}/password
|
||||
func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error {
|
||||
|
||||
Generated
+1
@@ -71,6 +71,7 @@ curl -X GET http://coder-server:8080/api/v2/audit \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
},
|
||||
"user_agent": "string"
|
||||
|
||||
Generated
+13
@@ -196,6 +196,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -258,6 +259,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -335,6 +337,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -472,6 +475,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -511,6 +515,7 @@ Status Code **200**
|
||||
| `»»» display_name` | string | false | | |
|
||||
| `»»» name` | string | false | | |
|
||||
| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»» theme_preference` | string | false | | |
|
||||
| `»» username` | string | true | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
@@ -591,6 +596,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -654,6 +660,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -1014,6 +1021,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1067,6 +1075,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
@@ -1097,6 +1106,7 @@ Status Code **200**
|
||||
| `»» display_name` | string | false | | |
|
||||
| `»» name` | string | false | | |
|
||||
| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `» theme_preference` | string | false | | |
|
||||
| `» username` | string | true | | |
|
||||
|
||||
#### Enumerated Values
|
||||
@@ -1224,6 +1234,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -1249,6 +1260,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
@@ -1285,6 +1297,7 @@ Status Code **200**
|
||||
| `»»»» display_name` | string | false | | |
|
||||
| `»»»» name` | string | false | | |
|
||||
| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»»» theme_preference` | string | false | | |
|
||||
| `»»» username` | string | true | | |
|
||||
| `»» name` | string | false | | |
|
||||
| `»» organization_id` | string(uuid) | false | | |
|
||||
|
||||
Generated
+24
@@ -865,6 +865,7 @@ _None_
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -890,6 +891,7 @@ _None_
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
@@ -1175,6 +1177,7 @@ _None_
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
},
|
||||
"user_agent": "string"
|
||||
@@ -1252,6 +1255,7 @@ _None_
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
},
|
||||
"user_agent": "string"
|
||||
@@ -3106,6 +3110,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
@@ -3162,6 +3167,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
@@ -4882,6 +4888,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -4900,6 +4907,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | |
|
||||
| `roles` | array of [codersdk.Role](#codersdkrole) | false | | |
|
||||
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
|
||||
| `theme_preference` | string | false | | |
|
||||
| `username` | string | true | | |
|
||||
|
||||
#### Enumerated Values
|
||||
@@ -5278,6 +5286,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `user_perms` | object | false | | User perms should be a mapping of user ID to role. The user ID must be the uuid of the user, not a username or email address. |
|
||||
| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | |
|
||||
|
||||
## codersdk.UpdateUserAppearanceSettingsRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"theme_preference": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------ | ------ | -------- | ------------ | ----------- |
|
||||
| `theme_preference` | string | true | | |
|
||||
|
||||
## codersdk.UpdateUserPasswordRequest
|
||||
|
||||
```json
|
||||
@@ -5427,6 +5449,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -5444,6 +5467,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
| `organization_ids` | array of string | false | | |
|
||||
| `roles` | array of [codersdk.Role](#codersdkrole) | false | | |
|
||||
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
|
||||
| `theme_preference` | string | false | | |
|
||||
| `username` | string | true | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
Generated
+71
@@ -45,6 +45,7 @@ curl -X GET http://coder-server:8080/api/v2/users \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
@@ -112,6 +113,7 @@ curl -X POST http://coder-server:8080/api/v2/users \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -370,6 +372,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -421,6 +424,69 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Update user appearance settings
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`PUT /users/{user}/appearance`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"theme_preference": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
| ------ | ---- | ------------------------------------------------------------------------------------------------------ | -------- | ----------------------- |
|
||||
| `user` | path | string | true | User ID, name, or me |
|
||||
| `body` | body | [codersdk.UpdateUserAppearanceSettingsRequest](schemas.md#codersdkupdateuserappearancesettingsrequest) | true | New appearance settings |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```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": "",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string"
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1015,6 +1081,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1066,6 +1133,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1127,6 +1195,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1178,6 +1247,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
@@ -1229,6 +1299,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"theme_preference": "string",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
+14
-33
@@ -1,18 +1,12 @@
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { AuthProvider } from "components/AuthProvider/AuthProvider";
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { AppRouter } from "./AppRouter";
|
||||
import { ThemeProviders } from "./contexts/ThemeProviders";
|
||||
import { AuthProvider } from "./contexts/AuthProvider/AuthProvider";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
|
||||
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
|
||||
import theme from "./theme";
|
||||
import "./theme/globalFonts";
|
||||
import {
|
||||
StyledEngineProvider,
|
||||
ThemeProvider as MuiThemeProvider,
|
||||
} from "@mui/material/styles";
|
||||
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
|
||||
|
||||
const defaultQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -23,19 +17,6 @@ const defaultQueryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
export const ThemeProviders: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
<MuiThemeProvider theme={theme.dark}>
|
||||
<EmotionThemeProvider theme={theme.dark}>
|
||||
<CssBaseline enableColorScheme />
|
||||
{children}
|
||||
</EmotionThemeProvider>
|
||||
</MuiThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
queryClient?: QueryClient;
|
||||
@@ -47,16 +28,14 @@ export const AppProviders: FC<AppProvidersProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<ThemeProviders>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<GlobalSnackbar />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProviders>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<ThemeProviders>
|
||||
{children}
|
||||
<GlobalSnackbar />
|
||||
</ThemeProviders>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +43,9 @@ export const AppProviders: FC<AppProvidersProps> = ({
|
||||
export const App: FC = () => {
|
||||
return (
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
<ErrorBoundary>
|
||||
<AppRouter />
|
||||
</ErrorBoundary>
|
||||
</AppProviders>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-17
@@ -1,13 +1,3 @@
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { UsersLayout } from "components/UsersLayout/UsersLayout";
|
||||
import AuditPage from "pages/AuditPage/AuditPage";
|
||||
import LoginPage from "pages/LoginPage/LoginPage";
|
||||
import { SetupPage } from "pages/SetupPage/SetupPage";
|
||||
import { TemplateLayout } from "pages/TemplatePage/TemplateLayout";
|
||||
import { HealthLayout } from "pages/HealthPage/HealthLayout";
|
||||
import TemplatesPage from "pages/TemplatesPage/TemplatesPage";
|
||||
import UsersPage from "pages/UsersPage/UsersPage";
|
||||
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage";
|
||||
import { FC, lazy, Suspense } from "react";
|
||||
import {
|
||||
Route,
|
||||
@@ -16,11 +6,21 @@ import {
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { DashboardLayout } from "./components/Dashboard/DashboardLayout";
|
||||
import { DeploySettingsLayout } from "./components/DeploySettingsLayout/DeploySettingsLayout";
|
||||
import { FullScreenLoader } from "./components/Loader/FullScreenLoader";
|
||||
import { RequireAuth } from "./components/RequireAuth/RequireAuth";
|
||||
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout";
|
||||
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout";
|
||||
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
import { UsersLayout } from "./components/UsersLayout/UsersLayout";
|
||||
import AuditPage from "./pages/AuditPage/AuditPage";
|
||||
import LoginPage from "./pages/LoginPage/LoginPage";
|
||||
import { SetupPage } from "./pages/SetupPage/SetupPage";
|
||||
import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
|
||||
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
|
||||
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage";
|
||||
import UsersPage from "./pages/UsersPage/UsersPage";
|
||||
import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
|
||||
import UserSettingsLayout from "./pages/UserSettingsPage/Layout";
|
||||
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
|
||||
// Lazy load pages
|
||||
// - Pages that are secondary, not in the main navigation or not usually accessed
|
||||
@@ -32,6 +32,9 @@ const CliAuthenticationPage = lazy(
|
||||
const AccountPage = lazy(
|
||||
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
|
||||
);
|
||||
const AppearancePage = lazy(
|
||||
() => import("./pages/UserSettingsPage/AppearancePage/AppearancePage"),
|
||||
);
|
||||
const SchedulePage = lazy(
|
||||
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
|
||||
);
|
||||
@@ -133,8 +136,7 @@ const ExternalAuthPage = lazy(
|
||||
() => import("./pages/ExternalAuthPage/ExternalAuthPage"),
|
||||
);
|
||||
const UserExternalAuthSettingsPage = lazy(
|
||||
() =>
|
||||
import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"),
|
||||
() => import("./pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage"),
|
||||
);
|
||||
const TemplateVersionPage = lazy(
|
||||
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
|
||||
@@ -319,8 +321,9 @@ export const AppRouter: FC = () => {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route path="/settings" element={<UserSettingsLayout />}>
|
||||
<Route path="account" element={<AccountPage />} />
|
||||
<Route path="appearance" element={<AppearancePage />} />
|
||||
<Route path="schedule" element={<SchedulePage />} />
|
||||
<Route path="security" element={<SecurityPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
|
||||
@@ -712,6 +712,14 @@ export const updateProfile = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAppearanceSettings = async (
|
||||
userId: string,
|
||||
data: TypesGen.UpdateUserAppearanceSettingsRequest,
|
||||
): Promise<TypesGen.User> => {
|
||||
const response = await axios.put(`/api/v2/users/${userId}/appearance`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getUserQuietHoursSchedule = async (
|
||||
userId: TypesGen.User["id"],
|
||||
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { QueryClient, type UseQueryOptions } from "react-query";
|
||||
import {
|
||||
type UseMutationOptions,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
} from "react-query";
|
||||
import * as API from "api/api";
|
||||
import type {
|
||||
AuthorizationRequest,
|
||||
GetUsersResponse,
|
||||
UpdateUserPasswordRequest,
|
||||
UpdateUserProfileRequest,
|
||||
UpdateUserAppearanceSettingsRequest,
|
||||
UsersRequest,
|
||||
User,
|
||||
} from "api/typesGenerated";
|
||||
@@ -116,11 +122,13 @@ export const authMethods = () => {
|
||||
|
||||
const initialUserData = getMetadataAsJSON<User>("user");
|
||||
|
||||
const meKey = ["me"];
|
||||
|
||||
export const me = (): UseQueryOptions<User> & {
|
||||
queryKey: NonNullable<UseQueryOptions<User>["queryKey"]>;
|
||||
queryKey: QueryKey;
|
||||
} => {
|
||||
return {
|
||||
queryKey: ["me"],
|
||||
queryKey: meKey,
|
||||
initialData: initialUserData,
|
||||
queryFn: API.getAuthenticatedUser,
|
||||
};
|
||||
@@ -179,14 +187,41 @@ export const logout = (queryClient: QueryClient) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const updateProfile = () => {
|
||||
export const updateProfile = (userId: string) => {
|
||||
return {
|
||||
mutationFn: ({
|
||||
userId,
|
||||
req,
|
||||
}: {
|
||||
userId: string;
|
||||
req: UpdateUserProfileRequest;
|
||||
}) => API.updateProfile(userId, req),
|
||||
mutationFn: (req: UpdateUserProfileRequest) =>
|
||||
API.updateProfile(userId, req),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateAppearanceSettings = (
|
||||
userId: string,
|
||||
queryClient: QueryClient,
|
||||
): UseMutationOptions<
|
||||
User,
|
||||
unknown,
|
||||
UpdateUserAppearanceSettingsRequest,
|
||||
unknown
|
||||
> => {
|
||||
return {
|
||||
mutationFn: (req) => API.updateAppearanceSettings(userId, 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+6
@@ -1229,6 +1229,11 @@ export interface UpdateTemplateMeta {
|
||||
readonly deprecation_message?: string;
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
export interface UpdateUserAppearanceSettingsRequest {
|
||||
readonly theme_preference: string;
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
export interface UpdateUserPasswordRequest {
|
||||
readonly old_password: string;
|
||||
@@ -1293,6 +1298,7 @@ export interface User {
|
||||
readonly roles: Role[];
|
||||
readonly avatar_url: string;
|
||||
readonly login_type: LoginType;
|
||||
readonly theme_preference: string;
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { type FC } from "react";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { FC } from "react";
|
||||
import { NavbarView } from "./NavbarView";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
|
||||
export const Navbar: FC = () => {
|
||||
const { appearance, buildInfo } = useDashboard();
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import axios from "axios";
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { FC, useEffect } from "react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { embedRedirect } from "utils/redirect";
|
||||
import { FullScreenLoader } from "../Loader/FullScreenLoader";
|
||||
import { DashboardProvider } from "components/Dashboard/DashboardProvider";
|
||||
import { ProxyProvider } from "contexts/ProxyContext";
|
||||
import { isApiError } from "api/errors";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import { ProxyProvider } from "contexts/ProxyContext";
|
||||
import { DashboardProvider } from "../Dashboard/DashboardProvider";
|
||||
import { FullScreenLoader } from "../Loader/FullScreenLoader";
|
||||
|
||||
export const RequireAuth: FC = () => {
|
||||
const { signOut, isSigningOut, isSignedOut } = useAuth();
|
||||
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
|
||||
useAuth();
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === "/";
|
||||
const navigateTo = isHomePage
|
||||
@@ -17,6 +18,10 @@ export const RequireAuth: FC = () => {
|
||||
: embedRedirect(`${location.pathname}${location.search}`);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isSigningOut || !isSignedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interceptorHandle = axios.interceptors.response.use(
|
||||
(okResponse) => okResponse,
|
||||
(error: unknown) => {
|
||||
@@ -35,23 +40,25 @@ export const RequireAuth: FC = () => {
|
||||
return () => {
|
||||
axios.interceptors.response.eject(interceptorHandle);
|
||||
};
|
||||
}, [signOut]);
|
||||
}, [isLoading, isSigningOut, isSignedIn, signOut]);
|
||||
|
||||
if (isLoading || isSigningOut) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
if (isSignedOut) {
|
||||
return (
|
||||
<Navigate to={navigateTo} state={{ isRedirect: !isHomePage }} replace />
|
||||
);
|
||||
} else if (isSigningOut) {
|
||||
return <FullScreenLoader />;
|
||||
} else {
|
||||
// Authenticated pages have access to some contexts for knowing enabled experiments
|
||||
// and where to route workspace connections.
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<ProxyProvider>
|
||||
<Outlet />
|
||||
</ProxyProvider>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticated pages have access to some contexts for knowing enabled experiments
|
||||
// and where to route workspace connections.
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<ProxyProvider>
|
||||
<Outlet />
|
||||
</ProxyProvider>
|
||||
</DashboardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+33
-26
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { checkAuthorization } from "api/queries/authCheck";
|
||||
import {
|
||||
authMethods,
|
||||
@@ -7,25 +15,17 @@ import {
|
||||
me,
|
||||
updateProfile as updateProfileOptions,
|
||||
} from "api/queries/users";
|
||||
import {
|
||||
import { isApiError } from "api/errors";
|
||||
import type {
|
||||
AuthMethods,
|
||||
UpdateUserProfileRequest,
|
||||
User,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { permissionsToCheck, Permissions } from "./permissions";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { isApiError } from "api/errors";
|
||||
import { permissionsToCheck, type Permissions } from "./permissions";
|
||||
|
||||
type AuthContextValue = {
|
||||
export type AuthContextValue = {
|
||||
isLoading: boolean;
|
||||
isSignedOut: boolean;
|
||||
isSigningOut: boolean;
|
||||
isConfiguringTheFirstUser: boolean;
|
||||
@@ -42,7 +42,9 @@ type AuthContextValue = {
|
||||
updateProfile: (data: UpdateUserProfileRequest) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
export const AuthContext = createContext<AuthContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -61,7 +63,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
);
|
||||
const logoutMutation = useMutation(logout(queryClient));
|
||||
const updateProfileMutation = useMutation({
|
||||
...updateProfileOptions(),
|
||||
...updateProfileOptions("me"),
|
||||
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(meOptions.queryKey, user);
|
||||
displaySuccess("Updated settings.");
|
||||
@@ -78,7 +81,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
userQuery.isLoading ||
|
||||
hasFirstUserQuery.isLoading ||
|
||||
(userQuery.isSuccess && permissionsQuery.isLoading);
|
||||
const isConfiguringTheFirstUser = !hasFirstUserQuery.data;
|
||||
const isConfiguringTheFirstUser =
|
||||
!hasFirstUserQuery.isLoading && !hasFirstUserQuery.data;
|
||||
const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined;
|
||||
const isSigningIn = loginMutation.isLoading;
|
||||
const isUpdatingProfile = updateProfileMutation.isLoading;
|
||||
@@ -87,21 +91,24 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
logoutMutation.mutate();
|
||||
}, [logoutMutation]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
await loginMutation.mutateAsync({ email, password });
|
||||
};
|
||||
const signIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
await loginMutation.mutateAsync({ email, password });
|
||||
},
|
||||
[loginMutation],
|
||||
);
|
||||
|
||||
const updateProfile = (req: UpdateUserProfileRequest) => {
|
||||
updateProfileMutation.mutate({ userId: userQuery.data!.id, req });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
const updateProfile = useCallback(
|
||||
(req: UpdateUserProfileRequest) => {
|
||||
updateProfileMutation.mutate(req);
|
||||
},
|
||||
[updateProfileMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
isSignedOut,
|
||||
isSigningOut,
|
||||
isConfiguringTheFirstUser,
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import {
|
||||
StyledEngineProvider,
|
||||
ThemeProvider as MuiThemeProvider,
|
||||
} from "@mui/material/styles";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import themes, { DEFAULT_THEME } from "theme";
|
||||
import { AuthContext } from "./AuthProvider/AuthProvider";
|
||||
|
||||
export const ThemeProviders: 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 themeQuery = useMemo(
|
||||
() => window.matchMedia?.("(prefers-color-scheme: light)"),
|
||||
[],
|
||||
);
|
||||
const [preferredColorScheme, setPreferredColorScheme] = useState<
|
||||
"dark" | "light"
|
||||
>(themeQuery?.matches ? "light" : "dark");
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setPreferredColorScheme(event.matches ? "light" : "dark");
|
||||
};
|
||||
|
||||
// `addEventListener` here is a recent API that only _very_ up-to-date
|
||||
// browsers support, and that isn't mocked in Jest.
|
||||
themeQuery.addEventListener?.("change", listener);
|
||||
return () => {
|
||||
themeQuery.removeEventListener?.("change", listener);
|
||||
};
|
||||
}, [themeQuery]);
|
||||
|
||||
// We might not be logged in yet, or the `theme_preference` could be an empty string.
|
||||
const themePreference = user?.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.
|
||||
const theme =
|
||||
themes[themePreference as keyof typeof themes] ??
|
||||
themes[preferredColorScheme];
|
||||
|
||||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<EmotionThemeProvider theme={theme}>
|
||||
<CssBaseline enableColorScheme />
|
||||
{children}
|
||||
</EmotionThemeProvider>
|
||||
</MuiThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User } from "api/typesGenerated";
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import type { User } from "api/typesGenerated";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
|
||||
export const useMe = (): User => {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { Permissions } from "components/AuthProvider/permissions";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import type { Permissions } from "contexts/AuthProvider/permissions";
|
||||
|
||||
export const usePermissions = (): Permissions => {
|
||||
const { permissions } = useAuth();
|
||||
|
||||
@@ -13,8 +13,8 @@ import { LoginPage } from "./LoginPage";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
// appear logged out
|
||||
server.use(
|
||||
// Appear logged out
|
||||
rest.get("/api/v2/users/me", (req, res, ctx) => {
|
||||
return res(ctx.status(401), ctx.json({ message: "no user here" }));
|
||||
}),
|
||||
@@ -50,7 +50,8 @@ describe("LoginPage", () => {
|
||||
it("redirects to the setup page if there is no first user", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/users/first", async (req, res, ctx) => {
|
||||
// No first user
|
||||
rest.get("/api/v2/users/first", (req, res, ctx) => {
|
||||
return res(ctx.status(404));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { FC } from "react";
|
||||
import { type FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import { getApplicationName } from "utils/appearance";
|
||||
import { retrieveRedirect } from "utils/redirect";
|
||||
import { LoginPageView } from "./LoginPageView";
|
||||
import { getApplicationName } from "utils/appearance";
|
||||
|
||||
export const LoginPage: FC = () => {
|
||||
const location = useLocation();
|
||||
const {
|
||||
isLoading,
|
||||
isSignedIn,
|
||||
isConfiguringTheFirstUser,
|
||||
signIn,
|
||||
@@ -33,7 +34,7 @@ export const LoginPage: FC = () => {
|
||||
const redirectURL = new URL(redirectTo);
|
||||
if (redirectURL.host !== window.location.host) {
|
||||
window.location.href = redirectTo;
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
@@ -41,30 +42,34 @@ export const LoginPage: FC = () => {
|
||||
// Path based apps.
|
||||
if (redirectTo.includes("/apps/")) {
|
||||
window.location.href = redirectTo;
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
} else if (isConfiguringTheFirstUser) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Sign in to {applicationName}</title>
|
||||
</Helmet>
|
||||
<LoginPageView
|
||||
authMethods={authMethods}
|
||||
error={signInError}
|
||||
isSigningIn={isSigningIn}
|
||||
onSignIn={async ({ email, password }) => {
|
||||
await signIn(email, password);
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConfiguringTheFirstUser) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Sign in to {applicationName}</title>
|
||||
</Helmet>
|
||||
<LoginPageView
|
||||
authMethods={authMethods}
|
||||
error={signInError}
|
||||
isLoading={isLoading}
|
||||
isSigningIn={isSigningIn}
|
||||
onSignIn={async ({ email, password }) => {
|
||||
await signIn(email, password);
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import { type FC } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { retrieveRedirect } from "utils/redirect";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { getApplicationName, getLogoURL } from "utils/appearance";
|
||||
import type { AuthMethods } from "api/typesGenerated";
|
||||
import { getApplicationName, getLogoURL } from "utils/appearance";
|
||||
import { retrieveRedirect } from "utils/redirect";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
|
||||
export interface LoginPageViewProps {
|
||||
authMethods: AuthMethods | undefined;
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
isSigningIn: boolean;
|
||||
onSignIn: (credentials: { email: string; password: string }) => void;
|
||||
}
|
||||
@@ -17,6 +19,7 @@ export interface LoginPageViewProps {
|
||||
export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
authMethods,
|
||||
error,
|
||||
isLoading,
|
||||
isSigningIn,
|
||||
onSignIn,
|
||||
}) => {
|
||||
@@ -47,14 +50,18 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
<div css={styles.root}>
|
||||
<div css={styles.container}>
|
||||
{applicationLogo}
|
||||
<SignInForm
|
||||
authMethods={authMethods}
|
||||
redirectTo={redirectTo}
|
||||
isSigningIn={isSigningIn}
|
||||
error={error}
|
||||
message={message}
|
||||
onSubmit={onSignIn}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<SignInForm
|
||||
authMethods={authMethods}
|
||||
redirectTo={redirectTo}
|
||||
isSigningIn={isSigningIn}
|
||||
error={error}
|
||||
message={message}
|
||||
onSubmit={onSignIn}
|
||||
/>
|
||||
)}
|
||||
<footer css={styles.footer}>
|
||||
Copyright © {new Date().getFullYear()} Coder Technologies, Inc.
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { rest } from "msw";
|
||||
import { createMemoryRouter } from "react-router-dom";
|
||||
@@ -29,7 +29,7 @@ const fillForm = async ({
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: PageViewLanguage.create,
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
await userEvent.click(submitButton);
|
||||
};
|
||||
|
||||
describe("Setup Page", () => {
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { FC } from "react";
|
||||
import { type FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { SetupPageView } from "./SetupPageView";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useMutation } from "react-query";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { createFirstUser } from "api/queries/users";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { SetupPageView } from "./SetupPageView";
|
||||
|
||||
export const SetupPage: FC = () => {
|
||||
const { signIn, isConfiguringTheFirstUser, isSignedIn, isSigningIn } =
|
||||
useAuth();
|
||||
const {
|
||||
isLoading,
|
||||
signIn,
|
||||
isConfiguringTheFirstUser,
|
||||
isSignedIn,
|
||||
isSigningIn,
|
||||
} = useAuth();
|
||||
const createFirstUserMutation = useMutation(createFirstUser());
|
||||
const setupIsComplete = !isConfiguringTheFirstUser;
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
// If the user is logged in, navigate to the app
|
||||
if (isSignedIn) {
|
||||
return <Navigate to="/" state={{ isRedirect: true }} replace />;
|
||||
@@ -30,7 +40,7 @@ export const SetupPage: FC = () => {
|
||||
<title>{pageTitle("Set up your account")}</title>
|
||||
</Helmet>
|
||||
<SetupPageView
|
||||
isLoading={createFirstUserMutation.isLoading || isSigningIn}
|
||||
isLoading={isSigningIn || createFirstUserMutation.isLoading}
|
||||
error={createFirstUserMutation.error}
|
||||
onSubmit={async (firstUser) => {
|
||||
await createFirstUserMutation.mutateAsync(firstUser);
|
||||
|
||||
@@ -51,40 +51,39 @@ export const AccountForm: FC<AccountFormProps> = ({
|
||||
const getFieldHelpers = getFormHelpers(form, updateProfileError);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={form.handleSubmit}>
|
||||
<FormFields>
|
||||
{Boolean(updateProfileError) && (
|
||||
<ErrorAlert error={updateProfileError} />
|
||||
)}
|
||||
<TextField
|
||||
disabled
|
||||
fullWidth
|
||||
label={Language.emailLabel}
|
||||
value={email}
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("username")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
aria-disabled={!editable}
|
||||
autoComplete="username"
|
||||
disabled={!editable}
|
||||
fullWidth
|
||||
label={Language.usernameLabel}
|
||||
/>
|
||||
<Form onSubmit={form.handleSubmit}>
|
||||
<FormFields>
|
||||
{Boolean(updateProfileError) && (
|
||||
<ErrorAlert error={updateProfileError} />
|
||||
)}
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
disabled={!editable}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
{Language.updateSettings}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FormFields>
|
||||
</Form>
|
||||
</>
|
||||
<TextField
|
||||
disabled
|
||||
fullWidth
|
||||
label={Language.emailLabel}
|
||||
value={email}
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("username")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
aria-disabled={!editable}
|
||||
autoComplete="username"
|
||||
disabled={!editable}
|
||||
fullWidth
|
||||
label={Language.usernameLabel}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
disabled={!editable}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
{Language.updateSettings}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FormFields>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,6 @@ import { renderWithAuth } from "testHelpers/renderHelpers";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
|
||||
const renderPage = () => {
|
||||
return renderWithAuth(<AccountPage />);
|
||||
};
|
||||
|
||||
const newData = {
|
||||
username: "user",
|
||||
};
|
||||
@@ -35,16 +31,17 @@ describe("AccountPage", () => {
|
||||
avatar_url: "",
|
||||
last_seen_at: new Date().toString(),
|
||||
login_type: "password",
|
||||
theme_preference: "",
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
const { user } = renderPage();
|
||||
renderWithAuth(<AccountPage />);
|
||||
await fillAndSubmitForm();
|
||||
|
||||
const successMessage = await screen.findByText("Updated settings.");
|
||||
expect(successMessage).toBeDefined();
|
||||
expect(API.updateProfile).toBeCalledTimes(1);
|
||||
expect(API.updateProfile).toBeCalledWith(user.id, newData);
|
||||
expect(API.updateProfile).toBeCalledWith("me", newData);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +56,7 @@ describe("AccountPage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { user } = renderPage();
|
||||
renderWithAuth(<AccountPage />);
|
||||
await fillAndSubmitForm();
|
||||
|
||||
const errorMessage = await screen.findByText(
|
||||
@@ -67,7 +64,7 @@ describe("AccountPage", () => {
|
||||
);
|
||||
expect(errorMessage).toBeDefined();
|
||||
expect(API.updateProfile).toBeCalledTimes(1);
|
||||
expect(API.updateProfile).toBeCalledWith(user.id, newData);
|
||||
expect(API.updateProfile).toBeCalledWith("me", newData);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,13 +74,13 @@ describe("AccountPage", () => {
|
||||
data: "unknown error",
|
||||
});
|
||||
|
||||
const { user } = renderPage();
|
||||
renderWithAuth(<AccountPage />);
|
||||
await fillAndSubmitForm();
|
||||
|
||||
const errorMessage = await screen.findByText("Something went wrong.");
|
||||
expect(errorMessage).toBeDefined();
|
||||
expect(API.updateProfile).toBeCalledTimes(1);
|
||||
expect(API.updateProfile).toBeCalledWith(user.id, newData);
|
||||
expect(API.updateProfile).toBeCalledWith("me", newData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useOrganizationId } from "hooks";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { groupsForUser } from "api/queries/groups";
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { useAuth } from "contexts/AuthProvider/AuthProvider";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { Section } from "../Section";
|
||||
import { AccountUserGroups } from "./AccountUserGroups";
|
||||
import { AccountForm } from "./AccountForm";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Group } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { AvatarCard } from "components/AvatarCard/AvatarCard";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { Section } from "../Section";
|
||||
|
||||
type AccountGroupsProps = {
|
||||
groups: readonly Group[] | undefined;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AppearanceForm } from "./AppearanceForm";
|
||||
|
||||
const onUpdateTheme = action("update");
|
||||
|
||||
const meta: Meta<typeof AppearanceForm> = {
|
||||
title: "pages/UserSettingsPage/AppearanceForm",
|
||||
component: AppearanceForm,
|
||||
args: {
|
||||
onSubmit: (update) =>
|
||||
Promise.resolve(onUpdateTheme(update.theme_preference)),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppearanceForm>;
|
||||
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
enableAuto: true,
|
||||
initialValues: { theme_preference: "" },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { type Interpolation } from "@emotion/react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import type { UpdateUserAppearanceSettingsRequest } from "api/typesGenerated";
|
||||
import themes, { DEFAULT_THEME, type Theme } from "theme";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
|
||||
export interface AppearanceFormProps {
|
||||
isUpdating?: boolean;
|
||||
error?: unknown;
|
||||
initialValues: UpdateUserAppearanceSettingsRequest;
|
||||
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => Promise<unknown>;
|
||||
|
||||
// temporary, so that storybook can test the right thing without showing
|
||||
// a semi-broken auto theme to users. will be removed when light mode is done.
|
||||
enableAuto?: boolean;
|
||||
}
|
||||
|
||||
export const AppearanceForm: FC<AppearanceFormProps> = ({
|
||||
isUpdating,
|
||||
error,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
enableAuto,
|
||||
}) => {
|
||||
const currentTheme = initialValues.theme_preference || DEFAULT_THEME;
|
||||
|
||||
const onChangeTheme = async (theme: string) => {
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit({ theme_preference: theme });
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
<Stack direction="row" wrap="wrap">
|
||||
{enableAuto && (
|
||||
<AutoThemePreviewButton
|
||||
displayName="Auto"
|
||||
active={currentTheme === "auto"}
|
||||
themes={[themes.dark, themes.light]}
|
||||
onSelect={() => onChangeTheme("auto")}
|
||||
/>
|
||||
)}
|
||||
<ThemePreviewButton
|
||||
displayName="Dark"
|
||||
active={currentTheme === "dark"}
|
||||
theme={themes.dark}
|
||||
onSelect={() => onChangeTheme("dark")}
|
||||
/>
|
||||
<ThemePreviewButton
|
||||
displayName="Dark blue"
|
||||
active={currentTheme === "darkBlue"}
|
||||
theme={themes.darkBlue}
|
||||
onSelect={() => onChangeTheme("darkBlue")}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface AutoThemePreviewButtonProps {
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
displayName: string;
|
||||
themes: [Theme, Theme];
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const AutoThemePreviewButton: FC<AutoThemePreviewButtonProps> = ({
|
||||
active,
|
||||
className,
|
||||
displayName,
|
||||
themes,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [leftTheme, rightTheme] = themes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
id={displayName}
|
||||
value={displayName}
|
||||
checked={active}
|
||||
onChange={onSelect}
|
||||
css={{ ...visuallyHidden }}
|
||||
/>
|
||||
<label htmlFor={displayName} className={className}>
|
||||
<ThemePreview
|
||||
css={{
|
||||
// This half is absolute to not advance the layout (which would offset the second half)
|
||||
position: "absolute",
|
||||
// Slightly past the bounding box to avoid cutting off the outline
|
||||
clipPath: "polygon(-5% -5%, 50% -5%, 50% 105%, -5% 105%)",
|
||||
}}
|
||||
active={active}
|
||||
displayName={displayName}
|
||||
theme={leftTheme}
|
||||
/>
|
||||
<ThemePreview
|
||||
active={active}
|
||||
displayName={displayName}
|
||||
theme={rightTheme}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThemePreviewButtonProps {
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
displayName: string;
|
||||
theme: Theme;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const ThemePreviewButton: FC<ThemePreviewButtonProps> = ({
|
||||
active,
|
||||
className,
|
||||
displayName,
|
||||
theme,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
id={displayName}
|
||||
value={displayName}
|
||||
checked={active}
|
||||
onChange={onSelect}
|
||||
css={{ ...visuallyHidden }}
|
||||
/>
|
||||
<label htmlFor={displayName} className={className}>
|
||||
<ThemePreview active={active} displayName={displayName} theme={theme} />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThemePreviewProps {
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
displayName: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const ThemePreview: FC<ThemePreviewProps> = ({
|
||||
active,
|
||||
className,
|
||||
displayName,
|
||||
theme,
|
||||
}) => {
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
({
|
||||
container: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
width: 220,
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: 6,
|
||||
overflow: "clip",
|
||||
userSelect: "none",
|
||||
},
|
||||
containerActive: {
|
||||
outline: `2px solid ${theme.experimental.roles.active.outline}`,
|
||||
},
|
||||
page: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "6px 10px",
|
||||
marginBottom: 8,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
headerLinks: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
headerLink: {
|
||||
backgroundColor: theme.palette.text.secondary,
|
||||
height: 6,
|
||||
width: 20,
|
||||
borderRadius: 3,
|
||||
},
|
||||
activeHeaderLink: {
|
||||
backgroundColor: theme.palette.text.primary,
|
||||
},
|
||||
proxy: {
|
||||
backgroundColor: theme.palette.success.light,
|
||||
height: 6,
|
||||
width: 12,
|
||||
borderRadius: 3,
|
||||
},
|
||||
user: {
|
||||
backgroundColor: theme.palette.text.primary,
|
||||
height: 8,
|
||||
width: 8,
|
||||
borderRadius: 4,
|
||||
float: "right",
|
||||
},
|
||||
body: {
|
||||
width: 120,
|
||||
margin: "auto",
|
||||
},
|
||||
title: {
|
||||
backgroundColor: theme.palette.text.primary,
|
||||
height: 8,
|
||||
width: 45,
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
},
|
||||
table: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderBottom: "none",
|
||||
borderTopLeftRadius: 3,
|
||||
borderTopRightRadius: 3,
|
||||
overflow: "clip",
|
||||
},
|
||||
tableHeader: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
height: 10,
|
||||
margin: -1,
|
||||
},
|
||||
label: {
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
padding: "4px 12px",
|
||||
fontSize: 14,
|
||||
},
|
||||
workspace: {
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
height: 15,
|
||||
|
||||
"&::after": {
|
||||
content: '""',
|
||||
display: "block",
|
||||
marginTop: 4,
|
||||
marginLeft: 4,
|
||||
backgroundColor: theme.palette.text.disabled,
|
||||
height: 6,
|
||||
width: 30,
|
||||
borderRadius: 3,
|
||||
},
|
||||
},
|
||||
}) satisfies Record<string, Interpolation<never>>,
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={[styles.container, active && styles.containerActive]}
|
||||
className={className}
|
||||
>
|
||||
<div css={styles.page}>
|
||||
<div css={styles.header}>
|
||||
<div css={styles.headerLinks}>
|
||||
<div css={[styles.headerLink, styles.activeHeaderLink]}></div>
|
||||
<div css={styles.headerLink}></div>
|
||||
<div css={styles.headerLink}></div>
|
||||
</div>
|
||||
<div css={styles.headerLinks}>
|
||||
<div css={styles.proxy}></div>
|
||||
<div css={styles.user}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div css={styles.body}>
|
||||
<div css={styles.title}></div>
|
||||
<div css={styles.table}>
|
||||
<div css={styles.tableHeader}></div>
|
||||
<div css={styles.workspace}></div>
|
||||
<div css={styles.workspace}></div>
|
||||
<div css={styles.workspace}></div>
|
||||
<div css={styles.workspace}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div css={styles.label}>{displayName}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import * as API from "api/api";
|
||||
import { renderWithAuth } from "testHelpers/renderHelpers";
|
||||
import { AppearancePage } from "./AppearancePage";
|
||||
import { MockUser } from "testHelpers/entities";
|
||||
|
||||
describe("appearance page", () => {
|
||||
it("changes theme to dark", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
|
||||
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
|
||||
...MockUser,
|
||||
theme_preference: "dark",
|
||||
});
|
||||
|
||||
const dark = await screen.findByText("Dark");
|
||||
await userEvent.click(dark);
|
||||
|
||||
// Check if the API was called correctly
|
||||
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", {
|
||||
theme_preference: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("changes theme to dark blue", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
|
||||
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
|
||||
...MockUser,
|
||||
theme_preference: "darkBlue",
|
||||
});
|
||||
|
||||
const darkBlue = await screen.findByText("Dark blue");
|
||||
await userEvent.click(darkBlue);
|
||||
|
||||
// Check if the API was called correctly
|
||||
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", {
|
||||
theme_preference: "darkBlue",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { type FC } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { updateAppearanceSettings } from "api/queries/users";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useMe } from "hooks";
|
||||
import { Section } from "../Section";
|
||||
import { AppearanceForm } from "./AppearanceForm";
|
||||
|
||||
export const AppearancePage: FC = () => {
|
||||
const me = useMe();
|
||||
const queryClient = useQueryClient();
|
||||
const updateAppearanceSettingsMutation = useMutation(
|
||||
updateAppearanceSettings("me", queryClient),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section
|
||||
title={
|
||||
<Stack direction="row" alignItems="center">
|
||||
<span>Theme</span>
|
||||
{updateAppearanceSettingsMutation.isLoading && (
|
||||
<CircularProgress size={16} />
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
layout="fluid"
|
||||
>
|
||||
<AppearanceForm
|
||||
isUpdating={updateAppearanceSettingsMutation.isLoading}
|
||||
error={updateAppearanceSettingsMutation.error}
|
||||
initialValues={{ theme_preference: me.theme_preference }}
|
||||
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearancePage;
|
||||
+9
-9
@@ -1,17 +1,17 @@
|
||||
import { FC, useState } from "react";
|
||||
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
|
||||
import { type FC, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
externalAuths,
|
||||
unlinkExternalAuths,
|
||||
validateExternalAuth,
|
||||
} from "api/queries/externalAuth";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Section } from "../Section";
|
||||
import { ExternalAuthPageView } from "./ExternalAuthPageView";
|
||||
|
||||
const UserExternalAuthSettingsPage: FC = () => {
|
||||
const ExternalAuthPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
// This is used to tell the child components something was unlinked and things
|
||||
// need to be refetched
|
||||
@@ -24,7 +24,7 @@ const UserExternalAuthSettingsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Section title="External Authentication" layout="fluid">
|
||||
<UserExternalAuthSettingsPageView
|
||||
<ExternalAuthPageView
|
||||
isLoading={externalAuthsQuery.isLoading}
|
||||
getAuthsError={externalAuthsQuery.error}
|
||||
auths={externalAuthsQuery.data}
|
||||
@@ -83,4 +83,4 @@ const UserExternalAuthSettingsPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserExternalAuthSettingsPage;
|
||||
export default ExternalAuthPage;
|
||||
+5
-5
@@ -2,12 +2,12 @@ import {
|
||||
MockGithubAuthLink,
|
||||
MockGithubExternalProvider,
|
||||
} from "testHelpers/entities";
|
||||
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
|
||||
import { ExternalAuthPageView } from "./ExternalAuthPageView";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
|
||||
title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView",
|
||||
component: UserExternalAuthSettingsPageView,
|
||||
const meta: Meta<typeof ExternalAuthPageView> = {
|
||||
title: "pages/UserSettingsPage/ExternalAuthPageView",
|
||||
component: ExternalAuthPageView,
|
||||
args: {
|
||||
isLoading: false,
|
||||
getAuthsError: undefined,
|
||||
@@ -22,7 +22,7 @@ const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UserExternalAuthSettingsPageView>;
|
||||
type Story = StoryObj<typeof ExternalAuthPageView>;
|
||||
|
||||
export const NoProviders: Story = {};
|
||||
|
||||
+11
-11
@@ -1,9 +1,13 @@
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import { type FC, useState, useCallback, useEffect } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { externalAuthProvider } from "api/queries/externalAuth";
|
||||
import type {
|
||||
ListUserExternalAuthResponse,
|
||||
ExternalAuthLinkProvider,
|
||||
@@ -12,8 +16,7 @@ import type {
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import {
|
||||
MoreMenu,
|
||||
MoreMenuContent,
|
||||
@@ -21,13 +24,10 @@ import {
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth";
|
||||
import { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { externalAuthProvider } from "api/queries/externalAuth";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
|
||||
export type UserExternalAuthSettingsPageViewProps = {
|
||||
export type ExternalAuthPageViewProps = {
|
||||
isLoading: boolean;
|
||||
getAuthsError?: unknown;
|
||||
unlinked: number;
|
||||
@@ -36,14 +36,14 @@ export type UserExternalAuthSettingsPageViewProps = {
|
||||
onValidateExternalAuth: (provider: string) => void;
|
||||
};
|
||||
|
||||
export const UserExternalAuthSettingsPageView = ({
|
||||
export const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
|
||||
isLoading,
|
||||
getAuthsError,
|
||||
auths,
|
||||
unlinked,
|
||||
onUnlinkExternalAuth,
|
||||
onValidateExternalAuth,
|
||||
}: UserExternalAuthSettingsPageViewProps): JSX.Element => {
|
||||
}) => {
|
||||
if (getAuthsError) {
|
||||
// Nothing to show if there is an error
|
||||
return <ErrorAlert error={getAuthsError} />;
|
||||
@@ -105,13 +105,13 @@ interface ExternalAuthRowProps {
|
||||
onValidateExternalAuth: () => void;
|
||||
}
|
||||
|
||||
const ExternalAuthRow = ({
|
||||
const ExternalAuthRow: FC<ExternalAuthRowProps> = ({
|
||||
app,
|
||||
unlinked,
|
||||
link,
|
||||
onUnlinkExternalAuth,
|
||||
onValidateExternalAuth,
|
||||
}: ExternalAuthRowProps): JSX.Element => {
|
||||
}) => {
|
||||
const name = app.id || app.type;
|
||||
const authURL = "/external-auth/" + app.id;
|
||||
|
||||
+4
-2
@@ -4,11 +4,11 @@ import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Margins } from "../Margins/Margins";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
export const SettingsLayout: FC = () => {
|
||||
const Layout: FC = () => {
|
||||
const me = useMe();
|
||||
|
||||
return (
|
||||
@@ -30,3 +30,5 @@ export const SettingsLayout: FC = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PropsWithChildren, FC, useState } from "react";
|
||||
import { type FC, useState } from "react";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { Section } from "../Section";
|
||||
import { SSHKeysPageView } from "./SSHKeysPageView";
|
||||
import { regenerateUserSSHKey, userSSHKey } from "api/queries/sshKeys";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -18,7 +18,7 @@ export const Language = {
|
||||
cancelLabel: "Cancel",
|
||||
};
|
||||
|
||||
export const SSHKeysPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
export const SSHKeysPage: FC = () => {
|
||||
const [isConfirmingRegeneration, setIsConfirmingRegeneration] =
|
||||
useState(false);
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { FC } from "react";
|
||||
import { type FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { ScheduleForm } from "./ScheduleForm";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
updateUserQuietHoursSchedule,
|
||||
userQuietHoursSchedule,
|
||||
} from "api/queries/settings";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Section } from "../Section";
|
||||
import { ScheduleForm } from "./ScheduleForm";
|
||||
|
||||
export const SchedulePage: FC = () => {
|
||||
const me = useMe();
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SecurityForm } from "./SettingsSecurityForm";
|
||||
import { SecurityForm } from "./SecurityForm";
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
|
||||
const meta: Meta<typeof SecurityForm> = {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import * as API from "api/api";
|
||||
import * as SecurityForm from "./SettingsSecurityForm";
|
||||
import { Language } from "./SecurityForm";
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
@@ -33,7 +33,7 @@ const fillAndSubmitSecurityForm = () => {
|
||||
fireEvent.change(screen.getByLabelText("Confirm Password"), {
|
||||
target: { value: newSecurityFormValues.confirm_password },
|
||||
});
|
||||
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword));
|
||||
fireEvent.click(screen.getByText(Language.updatePassword));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { ComponentProps, FC } from "react";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { SecurityForm } from "./SettingsSecurityForm";
|
||||
import { type ComponentProps, type FC } from "react";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import { getUserLoginType } from "api/api";
|
||||
import { authMethods, updatePassword } from "api/queries/users";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Section } from "../Section";
|
||||
import { SecurityForm } from "./SecurityForm";
|
||||
import {
|
||||
SingleSignOnSection,
|
||||
useSingleSignOnSection,
|
||||
} from "./SingleSignOnSection";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { authMethods, updatePassword } from "api/queries/users";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
|
||||
export const SecurityPage: FC = () => {
|
||||
const me = useMe();
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { useMutation } from "react-query";
|
||||
import Button from "@mui/material/Button";
|
||||
import Link from "@mui/material/Link";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
|
||||
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||
import KeyIcon from "@mui/icons-material/VpnKey";
|
||||
import Button from "@mui/material/Button";
|
||||
import { convertToOAUTH } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import type {
|
||||
AuthMethods,
|
||||
LoginType,
|
||||
OIDCAuthMethod,
|
||||
UserLoginType,
|
||||
} from "api/typesGenerated";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useMutation } from "react-query";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import Link from "@mui/material/Link";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { docs } from "utils/docs";
|
||||
import { Section } from "../Section";
|
||||
|
||||
type LoginTypeConfirmation =
|
||||
| {
|
||||
|
||||
+10
-1
@@ -1,6 +1,8 @@
|
||||
import { type FC } from "react";
|
||||
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
|
||||
import FingerprintOutlinedIcon from "@mui/icons-material/FingerprintOutlined";
|
||||
import AccountIcon from "@mui/icons-material/Person";
|
||||
import AppearanceIcon from "@mui/icons-material/Brush";
|
||||
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
|
||||
import SecurityIcon from "@mui/icons-material/LockOutlined";
|
||||
import type { User } from "api/typesGenerated";
|
||||
@@ -13,7 +15,11 @@ import {
|
||||
} from "components/Sidebar/Sidebar";
|
||||
import { GitIcon } from "components/Icons/GitIcon";
|
||||
|
||||
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||
interface SidebarProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ user }) => {
|
||||
const { entitlements } = useDashboard();
|
||||
const allowAutostopRequirement =
|
||||
entitlements.features.template_autostop_requirement.enabled;
|
||||
@@ -30,6 +36,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||
<SidebarNavItem href="account" icon={AccountIcon}>
|
||||
Account
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem href="appearance" icon={AppearanceIcon}>
|
||||
Appearance
|
||||
</SidebarNavItem>
|
||||
{allowAutostopRequirement && (
|
||||
<SidebarNavItem href="schedule" icon={ScheduleIcon}>
|
||||
Schedule
|
||||
@@ -1,26 +1,18 @@
|
||||
import { FC, PropsWithChildren, useState } from "react";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { TokensPageView } from "./TokensPageView";
|
||||
import { useTokensData } from "./hooks";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import { css, type Interpolation, type Theme } from "@emotion/react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Button from "@mui/material/Button";
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import { APIKeyWithOwner } from "api/typesGenerated";
|
||||
import { css } from "@emotion/react";
|
||||
import type { APIKeyWithOwner } from "api/typesGenerated";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Section } from "../Section";
|
||||
import { useTokensData } from "./hooks";
|
||||
import { TokensPageView } from "./TokensPageView";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
|
||||
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
const cliCreateCommand = "coder tokens create";
|
||||
|
||||
const TokenActions = () => (
|
||||
<Stack direction="row" justifyContent="end" css={{ marginBottom: 8 }}>
|
||||
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
|
||||
Add token
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
const cliCreateCommand = "coder tokens create";
|
||||
|
||||
export const TokensPage: FC = () => {
|
||||
const [tokenToDelete, setTokenToDelete] = useState<
|
||||
APIKeyWithOwner | undefined
|
||||
>(undefined);
|
||||
@@ -41,15 +33,7 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
<>
|
||||
<Section
|
||||
title="Tokens"
|
||||
css={(theme) => css`
|
||||
& code {
|
||||
background: ${theme.palette.divider};
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
color: ${theme.palette.text.primary};
|
||||
border-radius: 2px;
|
||||
}
|
||||
`}
|
||||
css={styles.section}
|
||||
description={
|
||||
<>
|
||||
Tokens are used to authenticate with the Coder API. You can create a
|
||||
@@ -79,4 +63,24 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const TokenActions: FC = () => (
|
||||
<Stack direction="row" justifyContent="end" css={{ marginBottom: 8 }}>
|
||||
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
|
||||
Add token
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const styles = {
|
||||
section: (theme) => css`
|
||||
& code {
|
||||
background: ${theme.palette.divider};
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
color: ${theme.palette.text.primary};
|
||||
border-radius: 2px;
|
||||
}
|
||||
`,
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default TokensPage;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { WorkspaceProxyView } from "./WorkspaceProxyView";
|
||||
import { type FC } from "react";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { Section } from "../Section";
|
||||
import { WorkspaceProxyView } from "./WorkspaceProxyView";
|
||||
|
||||
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
export const WorkspaceProxyPage: FC = () => {
|
||||
const description =
|
||||
"Workspace proxies improve terminal and web app connections to workspaces.";
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
type DeploymentConfig,
|
||||
} from "api/api";
|
||||
import { FieldError } from "api/errors";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import range from "lodash/range";
|
||||
import { Permissions } from "components/AuthProvider/permissions";
|
||||
import { Permissions } from "contexts/AuthProvider/permissions";
|
||||
import { TemplateVersionFiles } from "utils/templateVersion";
|
||||
import { FileTree } from "utils/filetree";
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency";
|
||||
@@ -284,6 +284,7 @@ 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: "",
|
||||
};
|
||||
|
||||
export const MockUserAdmin: TypesGen.User = {
|
||||
@@ -297,6 +298,7 @@ export const MockUserAdmin: TypesGen.User = {
|
||||
avatar_url: "",
|
||||
last_seen_at: "",
|
||||
login_type: "password",
|
||||
theme_preference: "",
|
||||
};
|
||||
|
||||
export const MockUser2: TypesGen.User = {
|
||||
@@ -310,6 +312,7 @@ export const MockUser2: TypesGen.User = {
|
||||
avatar_url: "",
|
||||
last_seen_at: "2022-09-14T19:12:21Z",
|
||||
login_type: "oidc",
|
||||
theme_preference: "",
|
||||
};
|
||||
|
||||
export const SuspendedMockUser: TypesGen.User = {
|
||||
@@ -323,6 +326,7 @@ export const SuspendedMockUser: TypesGen.User = {
|
||||
avatar_url: "",
|
||||
last_seen_at: "",
|
||||
login_type: "password",
|
||||
theme_preference: "",
|
||||
};
|
||||
|
||||
export const MockProvisioner: TypesGen.ProvisionerDaemon = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { rest } from "msw";
|
||||
import { CreateWorkspaceBuildRequest } from "api/typesGenerated";
|
||||
import { permissionsToCheck } from "components/AuthProvider/permissions";
|
||||
import { permissionsToCheck } from "contexts/AuthProvider/permissions";
|
||||
import * as M from "./entities";
|
||||
import { MockGroup, MockWorkspaceQuota } from "./entities";
|
||||
import fs from "fs";
|
||||
|
||||
@@ -4,19 +4,20 @@ import {
|
||||
waitFor,
|
||||
renderHook,
|
||||
} from "@testing-library/react";
|
||||
import { AppProviders, ThemeProviders } from "App";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { QueryClient } from "react-query";
|
||||
import { AppProviders } from "App";
|
||||
import { ThemeProviders } from "contexts/ThemeProviders";
|
||||
import { DashboardLayout } from "components/Dashboard/DashboardLayout";
|
||||
import { RequireAuth } from "components/RequireAuth/RequireAuth";
|
||||
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
import {
|
||||
RouterProvider,
|
||||
createMemoryRouter,
|
||||
RouteObject,
|
||||
RouterProvider,
|
||||
type RouteObject,
|
||||
} from "react-router-dom";
|
||||
import { RequireAuth } from "../components/RequireAuth/RequireAuth";
|
||||
import { MockUser } from "./entities";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { QueryClient } from "react-query";
|
||||
|
||||
function createTestQueryClient() {
|
||||
// Helps create one query client for each test case, to make sure that tests
|
||||
|
||||
@@ -9,9 +9,12 @@ export interface Theme extends MuiTheme {
|
||||
experimental: NewTheme;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME = "auto";
|
||||
|
||||
const theme = {
|
||||
dark,
|
||||
darkBlue,
|
||||
light: darkBlue,
|
||||
} satisfies Record<string, Theme>;
|
||||
|
||||
export default theme;
|
||||
|
||||
Reference in New Issue
Block a user