mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: add support for creating service accounts (#23140)
This commit is contained in:
Generated
+9
@@ -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"
|
||||
|
||||
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
Generated
+17
@@ -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 | | |
|
||||
|
||||
Generated
+64
-44
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+1
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user