feat: add a theme picker (#11140)

This commit is contained in:
Kayla Washburn
2023-12-14 10:38:44 -07:00
committed by GitHub
parent 0cd4842d18
commit 133dc66143
63 changed files with 1283 additions and 317 deletions
+4 -2
View File
@@ -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": ""
}
]
+63
View File
@@ -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"
}
+55
View File
@@ -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"
}
+1
View File
@@ -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)
})
+1
View File
@@ -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 {
+11
View File
@@ -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.
+8
View File
@@ -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{
+20
View File
@@ -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
+7
View File
@@ -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)
+15
View File
@@ -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()
+1
View File
@@ -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)
+41 -1
View File
@@ -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 {
+12 -2
View File
@@ -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
View File
@@ -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, &params) {
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
View File
@@ -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 {
+1
View File
@@ -71,6 +71,7 @@ curl -X GET http://coder-server:8080/api/v2/audit \
}
],
"status": "active",
"theme_preference": "string",
"username": "string"
},
"user_agent": "string"
+13
View File
@@ -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 | | |
+24
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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 />} />
+8
View File
@@ -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> => {
+46 -11
View File
@@ -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);
}
},
};
};
+6
View File
@@ -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();
+26 -19
View File
@@ -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>
);
};
@@ -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,
+67
View File
@@ -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>
);
};
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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();
+3 -2
View File
@@ -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));
}),
);
+29 -24
View File
@@ -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;
+19 -12
View File
@@ -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>
+2 -2
View File
@@ -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", () => {
+18 -8
View File
@@ -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;
@@ -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;
@@ -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 = {};
@@ -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,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,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 =
| {
@@ -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.";
+6 -2
View File
@@ -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 -1
View File
@@ -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";
+7 -6
View File
@@ -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
+3
View File
@@ -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;