feat: add support for creating service accounts (#23140)

This commit is contained in:
Kayla はな
2026-03-17 15:36:20 -06:00
committed by GitHub
parent f9c265ca6e
commit 49e5547c22
23 changed files with 556 additions and 362 deletions
+9
View File
@@ -18310,6 +18310,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -19631,6 +19634,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -20477,6 +20483,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
+9
View File
@@ -16708,6 +16708,9 @@
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -17979,6 +17982,9 @@
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -18768,6 +18774,9 @@
"type": "string",
"format": "uuid"
},
"is_service_account": {
"type": "boolean"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
+8 -7
View File
@@ -195,13 +195,14 @@ func MinimalUserFromVisibleUser(user database.VisibleUser) codersdk.MinimalUser
func ReducedUser(user database.User) codersdk.ReducedUser {
return codersdk.ReducedUser{
MinimalUser: MinimalUser(user),
Email: user.Email,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
LastSeenAt: user.LastSeenAt,
Status: codersdk.UserStatus(user.Status),
LoginType: codersdk.LoginType(user.LoginType),
MinimalUser: MinimalUser(user),
Email: user.Email,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
LastSeenAt: user.LastSeenAt,
Status: codersdk.UserStatus(user.Status),
LoginType: codersdk.LoginType(user.LoginType),
IsServiceAccount: user.IsServiceAccount,
}
}
+3 -2
View File
@@ -57,8 +57,9 @@ type ReducedUser struct {
UpdatedAt time.Time `json:"updated_at" table:"updated at" format:"date-time"`
LastSeenAt time.Time `json:"last_seen_at,omitempty" format:"date-time"`
Status UserStatus `json:"status" table:"status" enums:"active,suspended"`
LoginType LoginType `json:"login_type"`
Status UserStatus `json:"status" table:"status" enums:"active,suspended"`
LoginType LoginType `json:"login_type"`
IsServiceAccount bool `json:"is_service_account,omitempty"`
// Deprecated: this value should be retrieved from
// `codersdk.UserPreferenceSettings` instead.
ThemePreference string `json:"theme_preference,omitempty"`
+1
View File
@@ -67,6 +67,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
+17
View File
@@ -263,6 +263,7 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -402,6 +403,7 @@ curl -X GET http://coder-server:8080/api/v2/groups?organization=string&has_membe
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -443,6 +445,7 @@ Status Code **200**
| `»» created_at` | string(date-time) | true | | |
| `»» email` | string(email) | true | | |
| `»» id` | string(uuid) | true | | |
| `»» is_service_account` | boolean | false | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»» name` | string | false | | |
@@ -502,6 +505,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -563,6 +567,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -643,6 +648,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1741,6 +1747,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1782,6 +1789,7 @@ Status Code **200**
| `»» created_at` | string(date-time) | true | | |
| `»» email` | string(email) | true | | |
| `»» id` | string(uuid) | true | | |
| `»» is_service_account` | boolean | false | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»» name` | string | false | | |
@@ -1854,6 +1862,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1916,6 +1925,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3230,6 +3240,7 @@ curl -X PUT http://coder-server:8080/api/v2/scim/v2/Users/{id} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3320,6 +3331,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3689,6 +3701,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3714,6 +3727,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3843,6 +3857,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3867,6 +3882,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -3902,6 +3918,7 @@ Status Code **200**
| `»»» created_at` | string(date-time) | true | | |
| `»»» email` | string(email) | true | | |
| `»»» id` | string(uuid) | true | | |
| `»»» is_service_account` | boolean | false | | |
| `»»» last_seen_at` | string(date-time) | false | | |
| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»»» name` | string | false | | |
+64 -44
View File
@@ -292,6 +292,7 @@
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -316,6 +317,7 @@
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1173,6 +1175,7 @@
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1263,6 +1266,7 @@
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1598,6 +1602,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1673,6 +1678,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1741,6 +1747,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -4370,6 +4377,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -4448,6 +4456,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -7369,6 +7378,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -7381,19 +7391,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|--------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|--------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `is_service_account` | boolean | false | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
#### Enumerated Values
@@ -8604,6 +8615,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -8629,6 +8641,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -8798,6 +8811,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -9076,6 +9090,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -9099,22 +9114,23 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `organization_ids` | array of string | false | | |
| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | |
| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `is_service_account` | boolean | false | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `organization_ids` | array of string | false | | |
| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | |
| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
#### Enumerated Values
@@ -9970,6 +9986,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -9992,21 +10009,22 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `organization_ids` | array of string | false | | |
| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|-------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `created_at` | string | true | | |
| `email` | string | true | | |
| `id` | string | true | | |
| `is_service_account` | boolean | false | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `name` | string | false | | |
| `organization_ids` | array of string | false | | |
| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |
| `theme_preference` | string | false | | Deprecated: this value should be retrieved from `codersdk.UserPreferenceSettings` instead. |
| `updated_at` | string | false | | |
| `username` | string | true | | |
#### Enumerated Values
@@ -10754,6 +10772,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -11979,6 +11998,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
+8
View File
@@ -35,6 +35,7 @@ curl -X GET http://coder-server:8080/api/v2/users \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -112,6 +113,7 @@ curl -X POST http://coder-server:8080/api/v2/users \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -423,6 +425,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1356,6 +1359,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1413,6 +1417,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1482,6 +1487,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1539,6 +1545,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
@@ -1596,6 +1603,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
+1
View File
@@ -1693,6 +1693,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
+2 -3
View File
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test";
import { API } from "api/api";
import { Language } from "pages/CreateUserPage/Language";
import { coderPort, license, premiumTestsRequired, users } from "../constants";
import { expectUrl } from "../expectUrl";
import { createUser } from "../helpers";
@@ -16,8 +15,8 @@ test("setup deployment", async ({ page }) => {
}
// Setup first user
await page.getByLabel(Language.emailLabel).fill(users.owner.email);
await page.getByLabel(Language.passwordLabel).fill(users.owner.password);
await page.getByLabel("Email").fill(users.owner.email);
await page.getByLabel("Password").fill(users.owner.password);
await page.getByTestId("create").click();
await expectUrl(page).toHavePathName("/templates");
+1
View File
@@ -5157,6 +5157,7 @@ export interface ReducedUser extends MinimalUser {
readonly last_seen_at?: string;
readonly status: UserStatus;
readonly login_type: LoginType;
readonly is_service_account?: boolean;
/**
* Deprecated: this value should be retrieved from
* `codersdk.UserPreferenceSettings` instead.
+1 -1
View File
@@ -78,7 +78,7 @@ export const Avatar: React.FC<AvatarProps> = ({
>
<AvatarPrimitive.Image
src={src}
className="aspect-square h-full w-full object-contain"
className="aspect-square size-full object-contain"
css={getExternalImageStylesFromUrl(theme.externalImages, src)}
/>
{fallback && (
@@ -0,0 +1,48 @@
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { type FC, type ReactNode, useId } from "react";
import { cn } from "utils/cn";
import type { FormHelpers } from "utils/formUtils";
type FormFieldProps = React.ComponentPropsWithRef<"input"> & {
field: FormHelpers;
label: ReactNode;
};
export const FormField: FC<FormFieldProps> = ({
field,
label,
className,
...inputProps
}) => {
const generatedId = useId();
const id = inputProps.id ?? generatedId;
const errorId = `${id}-error`;
const helperId = `${id}-helper`;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Input
{...inputProps}
id={id}
aria-invalid={field.error}
aria-describedby={
field.error ? errorId : field.helperText ? helperId : undefined
}
className={cn(field.error && "border-border-destructive", className)}
/>
{field.error ? (
<span id={errorId} className="text-xs text-content-destructive">
{field.helperText}
</span>
) : (
field.helperText && (
<span id={helperId} className="text-xs text-content-secondary">
{field.helperText}
</span>
)
)}
</div>
);
};
@@ -1,35 +1,44 @@
import { css } from "@emotion/css";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import { checkAuthorization } from "api/queries/authCheck";
import { organizations } from "api/queries/organizations";
import type { AuthorizationCheck, Organization } from "api/typesGenerated";
import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { type ComponentProps, type FC, useEffect, useState } from "react";
import { Button } from "components/Button/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "components/Command/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { Check } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
type OrganizationAutocompleteProps = {
onChange: (organization: Organization | null) => void;
label?: string;
className?: string;
size?: ComponentProps<typeof TextField>["size"];
id?: string;
required?: boolean;
check?: AuthorizationCheck;
};
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
onChange,
label,
className,
size = "small",
id,
required,
check,
}) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Organization | null>(null);
const organizationsQuery = useQuery(organizations());
const checks =
check &&
organizationsQuery.data &&
@@ -44,9 +53,7 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
);
const permissionsQuery = useQuery({
...checkAuthorization({
checks: checks ?? {},
}),
...checkAuthorization({ checks: checks ?? {} }),
enabled: Boolean(check && organizationsQuery.data),
});
@@ -73,74 +80,69 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
}, [options, selected, onChange]);
return (
<Autocomplete
noOptionsText="No organizations found"
className={className}
options={options}
disabled={options.length === 1}
value={selected}
loading={organizationsQuery.isLoading}
data-testid="organization-autocomplete"
open={open}
isOptionEqualToValue={(a, b) => a.id === b.id}
getOptionLabel={(option) => option.display_name}
onOpen={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
onChange={(_, newValue) => {
setSelected(newValue);
onChange(newValue);
}}
renderOption={({ key, ...props }, option) => (
<li key={key} {...props}>
<AvatarData
title={option.display_name}
subtitle={option.name}
src={option.icon}
/>
</li>
)}
renderInput={(params) => (
<TextField
{...params}
required={required}
fullWidth
size={size}
label={label}
placeholder="Organization name"
css={{
"&:not(:has(label))": {
margin: 0,
},
}}
InputProps={{
...params.InputProps,
startAdornment: selected && (
<Avatar size="sm" src={selected.icon} fallback={selected.name} />
),
endAdornment: (
<>
{organizationsQuery.isFetching && open && (
<CircularProgress size={16} />
)}
{params.InputProps.endAdornment}
</>
),
classes: { root },
}}
InputLabelProps={{
shrink: true,
}}
/>
)}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
aria-expanded={open}
aria-required={required}
data-testid="organization-autocomplete"
className="w-full justify-start gap-2 font-normal"
>
{selected ? (
<>
<Avatar
size="sm"
src={selected.icon}
fallback={selected.display_name}
/>
<span className="truncate">{selected.display_name}</span>
</>
) : (
<span className="text-content-secondary">
Select an organization{required ? "…" : " (optional)"}
</span>
)}
<ChevronDownIcon className="ml-auto !size-icon-sm shrink-0 text-content-secondary" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command loop>
<CommandInput placeholder="Find organization…" />
<CommandList>
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup>
{options.map((org) => (
<CommandItem
key={org.id}
value={`${org.display_name} ${org.name}`}
onSelect={() => {
setSelected(org);
onChange(org);
setOpen(false);
}}
>
<Avatar
size="sm"
src={org.icon}
fallback={org.display_name}
/>
<span className="truncate">
{org.display_name || org.name}
</span>
{selected?.id === org.id && (
<Check className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
const root = css`
padding-left: 14px !important; // Same padding left as input
gap: 4px;
`;
@@ -106,7 +106,7 @@ export const StarterTemplateWithProvisionerWarning: Story = {
showOrganizationPicker: true,
},
play: async () => {
const organizationPicker = screen.getByPlaceholderText("Organization name");
const organizationPicker = screen.getByTestId("organization-autocomplete");
await userEvent.click(organizationPicker);
const org2 = await screen.findByText(MockOrganization2.display_name);
await userEvent.click(org2);
@@ -146,7 +146,7 @@ export const StarterTemplatePermissionsCheck: Story = {
showOrganizationPicker: true,
},
play: async () => {
const organizationPicker = screen.getByPlaceholderText("Organization name");
const organizationPicker = screen.getByTestId("organization-autocomplete");
await userEvent.click(organizationPicker);
},
};
@@ -20,6 +20,7 @@ import {
HorizontalForm,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { Label } from "components/Label/Label";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
@@ -258,20 +259,26 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
{showOrganizationPicker && (
<>
{showProvisionerWarning && <ProvisionerWarning />}
<OrganizationAutocomplete
{...getFieldHelpers("organization")}
required
label="Belongs to"
onChange={(newValue) => {
setSelectedOrg(newValue);
void form.setFieldValue("organization", newValue?.name || "");
}}
size="medium"
check={{
object: { resource_type: "template" },
action: "create",
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="organization">Organization</Label>
<OrganizationAutocomplete
{...getFieldHelpers("organization")}
id="organization"
required
onChange={(newValue) => {
setSelectedOrg(newValue);
void form.setFieldValue(
"organization",
newValue?.name || "",
);
}}
check={{
object: { resource_type: "template" },
action: "create",
}}
/>
</div>
</>
)}
@@ -63,7 +63,7 @@ export const WithOrganizations: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Organization *"));
await userEvent.click(canvas.getByLabelText("Organization"));
},
};
+203 -134
View File
@@ -1,18 +1,24 @@
import Link from "@mui/material/Link";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import * as SelectPrimitive from "@radix-ui/react-select";
import { hasApiFieldErrors, isApiError } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import { FormField } from "components/FormField/FormField";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { Label } from "components/Label/Label";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { PasswordField } from "components/PasswordField/PasswordField";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik";
import { Check } from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
import {
displayNameValidator,
getFormHelpers,
@@ -20,52 +26,48 @@ import {
onChangeTrimmed,
} from "utils/formUtils";
import * as Yup from "yup";
import { Language } from "./Language";
export const authMethodLanguage = {
const loginTypeOptions = {
password: {
displayName: "Password",
description: "Use an email address and password to login",
label: "Password",
description: "Use an email address and password to log in.",
},
oidc: {
displayName: "OpenID Connect",
description: "Use an OpenID Connect provider for authentication",
label: "OpenID Connect",
description: "Use an OpenID Connect provider for authentication.",
},
github: {
displayName: "Github",
description: "Use Github OAuth for authentication",
label: "GitHub",
description: "Use GitHub OAuth for authentication.",
},
none: {
displayName: "None",
description: (
<>
Disable authentication for this user (See the{" "}
<Link
target="_blank"
rel="noopener"
href="https://coder.com/docs/admin/users/headless-auth"
>
documentation
</Link>{" "}
for more details)
</>
),
label: "Service account",
description:
"Cannot log in interactively. Intended for automated pipelines, bots, and other non-human access.",
},
};
} as const;
const validationSchema = Yup.object({
username: nameValidator("Username"),
name: displayNameValidator("Full name"),
email: Yup.string()
.trim()
.email(Language.emailInvalid)
.required(Language.emailRequired),
.when("service_account", {
is: false,
then: (schema) =>
schema
.email("Please enter a valid email address.")
.required("Please enter an email address."),
otherwise: (schema) => schema.optional(),
}),
login_type: Yup.string()
.oneOf(Object.keys(loginTypeOptions))
.required("Please select a login type."),
password: Yup.string().when("login_type", {
is: "password",
then: (schema) => schema.required(Language.passwordRequired),
then: (schema) => schema.required("Please enter a password."),
otherwise: (schema) => schema,
}),
username: nameValidator(Language.usernameLabel),
name: displayNameValidator(Language.nameLabel),
login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)),
});
type CreateUserFormData = {
@@ -75,6 +77,7 @@ type CreateUserFormData = {
readonly organization: string;
readonly login_type: TypesGen.LoginType;
readonly password: string;
readonly service_account: boolean;
};
interface CreateUserFormProps {
@@ -86,9 +89,7 @@ interface CreateUserFormProps {
showOrganizations: boolean;
}
export const CreateUserForm: FC<
React.PropsWithChildren<CreateUserFormProps>
> = ({
export const CreateUserForm: FC<CreateUserFormProps> = ({
error,
isLoading,
onSubmit,
@@ -96,134 +97,202 @@ export const CreateUserForm: FC<
showOrganizations,
authMethods,
}) => {
const availableLoginTypes = [
authMethods?.password.enabled && "password",
authMethods?.oidc.enabled && "oidc",
authMethods?.github.enabled && "github",
"none",
].filter(Boolean) as Array<keyof typeof loginTypeOptions>;
const defaultLoginType = availableLoginTypes[0];
const form = useFormik<CreateUserFormData>({
initialValues: {
email: "",
password: "",
username: "",
name: "",
// If organizations aren't enabled, use the fallback ID to add the user to
// the default organization.
organization: showOrganizations
? ""
: "00000000-0000-0000-0000-000000000000",
login_type: "",
login_type: defaultLoginType,
service_account: defaultLoginType === "none",
},
validationSchema,
onSubmit,
enableReinitialize: true,
});
const getFieldHelpers = getFormHelpers(form, error);
const methods = [
authMethods?.password.enabled && "password",
authMethods?.oidc.enabled && "oidc",
authMethods?.github.enabled && "github",
"none",
].filter(Boolean) as Array<keyof typeof authMethodLanguage>;
const isServiceAccount = form.values.login_type === "none";
const isPasswordLogin = form.values.login_type === "password";
const loginTypeField = getFieldHelpers("login_type", {
helperText: "Authentication method for this user.",
});
return (
<FullPageForm title="Create user">
{isApiError(error) && !hasApiFieldErrors(error) && (
<ErrorAlert error={error} css={{ marginBottom: 32 }} />
<ErrorAlert error={error} className="mb-8" />
)}
<form onSubmit={form.handleSubmit} autoComplete="off">
<Stack spacing={2.5}>
<TextField
{...getFieldHelpers("username")}
<div className="flex flex-col gap-6">
<FormField
field={getFieldHelpers("username")}
label="Username"
id="username"
name="username"
value={form.values.username}
onChange={onChangeTrimmed(form)}
onBlur={form.handleBlur}
autoComplete="username"
autoFocus
fullWidth
label={Language.usernameLabel}
/>
<TextField
{...getFieldHelpers("name")}
<FormField
field={getFieldHelpers("name")}
label={
<>
Full name{" "}
<span className="font-normal text-content-secondary">
(optional)
</span>
</>
}
id="name"
name="name"
value={form.values.name}
onChange={form.handleChange}
onBlur={form.handleBlur}
autoComplete="name"
fullWidth
label={Language.nameLabel}
/>
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
/>
{showOrganizations && (
<OrganizationAutocomplete
{...getFieldHelpers("organization")}
required
label="Organization"
onChange={(newValue) => {
void form.setFieldValue("organization", newValue?.id ?? "");
}}
check={{
object: { resource_type: "organization_member" },
action: "create",
<div className="flex flex-col gap-2">
<Label htmlFor="organization">Organization</Label>
<OrganizationAutocomplete
{...getFieldHelpers("organization")}
id="organization"
required
onChange={(newValue) => {
void form.setFieldValue("organization", newValue?.id ?? "");
}}
check={{
object: { resource_type: "organization_member" },
action: "create",
}}
/>
</div>
)}
{/* Login type — "none" is presented as "Service account" */}
<div className="flex flex-col gap-2">
<Label htmlFor="login_type">Login type</Label>
<Select
value={form.values.login_type}
onValueChange={async (value) => {
const isServiceAccount = value === "none";
await Promise.all([
form.setFieldValue("login_type", value),
form.setFieldValue("service_account", isServiceAccount),
value !== "email"
? form.setFieldValue("email", "")
: Promise.resolve(),
value !== "password"
? form.setFieldValue("password", "")
: Promise.resolve(),
]);
}}
>
<SelectTrigger
id="login_type"
data-testid="login-type-input"
aria-invalid={loginTypeField.error}
aria-describedby={
loginTypeField.error
? "login_type-error"
: "login_type-helper"
}
className={cn(
loginTypeField.error && "border-border-destructive",
)}
>
<SelectValue placeholder="Select a login type…" />
</SelectTrigger>
<SelectContent className="max-w-sm">
{availableLoginTypes.map((key) => {
const opt = loginTypeOptions[key];
return (
<SelectPrimitive.Item
key={key}
value={key}
className="relative flex w-full cursor-default select-none items-start rounded-sm py-1.5 pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute right-2 top-2 flex items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-icon-sm" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col py-0.5">
<SelectPrimitive.ItemText>
{opt.label}
</SelectPrimitive.ItemText>
<span className="text-xs text-content-secondary whitespace-normal break-words">
{opt.description}
</span>
</div>
</SelectPrimitive.Item>
);
})}
</SelectContent>
</Select>
{loginTypeField.helperText && (
<span
id="login_type-helper"
className="text-xs text-content-secondary"
>
{loginTypeField.helperText}
</span>
)}
</div>
{!isServiceAccount && (
<FormField
field={getFieldHelpers("email")}
label={
<>
Email{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
}
id="email"
name="email"
value={form.values.email}
onChange={onChangeTrimmed(form)}
onBlur={form.handleBlur}
autoComplete="email"
type="email"
/>
)}
<TextField
{...getFieldHelpers("login_type", {
helperText: "Authentication method for this user",
})}
select
id="login_type"
data-testid="login-type-input"
value={form.values.login_type}
label="Login Type"
onChange={async (e) => {
if (e.target.value !== "password") {
await form.setFieldValue("password", "");
}
await form.setFieldValue("login_type", e.target.value);
}}
SelectProps={{
renderValue: (selected: unknown) =>
authMethodLanguage[selected as keyof typeof authMethodLanguage]
?.displayName ?? "",
}}
>
{methods.map((value) => {
const language = authMethodLanguage[value];
return (
<MenuItem key={value} id={`item-${value}`} value={value}>
<Stack
spacing={0}
css={{
maxWidth: 400,
}}
>
{language.displayName}
<span
css={(theme) => ({
fontSize: 14,
color: theme.palette.text.secondary,
wordWrap: "normal",
whiteSpace: "break-spaces",
})}
>
{language.description}
</span>
</Stack>
</MenuItem>
);
})}
</TextField>
<PasswordField
{...getFieldHelpers("password", {
helperText:
form.values.login_type !== "password" &&
"No password required for this login type",
})}
autoComplete="current-password"
fullWidth
id="password"
data-testid="password-input"
disabled={form.values.login_type !== "password"}
label={Language.passwordLabel}
/>
</Stack>
{isPasswordLogin && (
<FormField
field={getFieldHelpers("password")}
label="Password"
id="password"
name="password"
value={form.values.password}
onChange={form.handleChange}
onBlur={form.handleBlur}
autoComplete="new-password"
type="password"
data-testid="password-input"
/>
)}
</div>
<FormFooter className="mt-8">
<Button onClick={onCancel} variant="outline">
@@ -1,54 +0,0 @@
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CreateUserPage from "./CreateUserPage";
import { Language as FormLanguage } from "./Language";
const renderCreateUserPage = async () => {
renderWithAuth(<CreateUserPage />, {
extraRoutes: [
{ path: "/deployment/users", element: <div>Users Page</div> },
],
});
await waitForLoaderToBeRemoved();
};
const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "SomeSecurePassword!",
}: {
username?: string;
email?: string;
password?: string;
}) => {
const usernameField = screen.getByLabelText(FormLanguage.usernameLabel);
const emailField = screen.getByLabelText(FormLanguage.emailLabel);
const passwordField = screen
.getByTestId("password-input")
.querySelector("input");
const loginTypeField = screen.getByTestId("login-type-input");
await userEvent.type(usernameField, username);
await userEvent.type(emailField, email);
await userEvent.type(loginTypeField, "password");
await userEvent.type(passwordField as HTMLElement, password);
const submitButton = screen.getByRole("button", {
name: /save/i,
});
fireEvent.click(submitButton);
};
describe("Create User Page", () => {
it("shows success notification and redirects to users page", async () => {
await renderCreateUserPage();
await fillForm({});
const successMessage = await screen.findByText(
'User "someuser" created successfully.',
);
expect(successMessage).toBeDefined();
});
});
@@ -0,0 +1,67 @@
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import CreateUserPage from "./CreateUserPage";
const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "SomeSecurePassword!",
}: {
username?: string;
email?: string;
password?: string;
} = {}) => {
await userEvent.type(screen.getByLabelText("Username"), username);
await userEvent.type(screen.getByLabelText(/email/i), email);
await userEvent.click(screen.getByTestId("login-type-input"));
await userEvent.click(screen.getByRole("option", { name: /password/i }));
await userEvent.type(screen.getByTestId("password-input"), password);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
};
describe("CreateUserPage", () => {
it("shows a success notification and redirects to the users page on submit", async () => {
renderWithAuth(<CreateUserPage />, {
extraRoutes: [
{ path: "/deployment/users", element: <div>Users Page</div> },
],
});
await waitForLoaderToBeRemoved();
await fillForm();
await expect(
screen.findByText('User "someuser" created successfully.'),
).resolves.toBeInTheDocument();
});
it("shows an error alert when user creation fails", async () => {
server.use(
http.post("/api/v2/users", () => {
return HttpResponse.json(
{ message: "Username already in use." },
{ status: 400 },
);
}),
);
renderWithAuth(<CreateUserPage />, {
extraRoutes: [
{ path: "/deployment/users", element: <div>Users Page</div> },
],
});
await waitForLoaderToBeRemoved();
await fillForm();
await expect(
screen.findByText("Username already in use."),
).resolves.toBeInTheDocument();
});
});
@@ -9,10 +9,6 @@ import { toast } from "sonner";
import { pageTitle } from "utils/page";
import { CreateUserForm } from "./CreateUserForm";
const _Language = {
unknownError: "Oops, an unknown error occurred.",
};
const CreateUserPage: FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -37,6 +33,7 @@ const CreateUserPage: FC = () => {
login_type: user.login_type,
password: user.password,
user_status: null,
service_account: user.service_account,
},
{
onSuccess: () => {
-11
View File
@@ -1,11 +0,0 @@
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
usernameLabel: "Username",
nameLabel: "Full name",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
createUser: "Create",
cancel: "Cancel",
};
@@ -149,7 +149,9 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
subtitle={
user.is_service_account ? "Service Account" : user.email
}
src={user.avatar_url}
/>
</TableCell>