From 49e5547c22ca5d5f325a561997d6ff96ac6ad1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Tue, 17 Mar 2026 15:36:20 -0600 Subject: [PATCH] feat: add support for creating service accounts (#23140) --- coderd/apidoc/docs.go | 9 + coderd/apidoc/swagger.json | 9 + coderd/database/db2sdk/db2sdk.go | 15 +- codersdk/users.go | 5 +- docs/reference/api/audit.md | 1 + docs/reference/api/enterprise.md | 17 + docs/reference/api/schemas.md | 108 +++--- docs/reference/api/users.md | 8 + docs/reference/api/workspaces.md | 1 + site/e2e/setup/addUsersAndLicense.spec.ts | 5 +- site/src/api/typesGenerated.ts | 1 + site/src/components/Avatar/Avatar.tsx | 2 +- site/src/components/FormField/FormField.tsx | 48 +++ .../OrganizationAutocomplete.tsx | 170 ++++----- .../CreateTemplateForm.stories.tsx | 4 +- .../CreateTemplatePage/CreateTemplateForm.tsx | 35 +- .../CreateUserPage/CreateUserForm.stories.tsx | 2 +- .../pages/CreateUserPage/CreateUserForm.tsx | 337 +++++++++++------- .../CreateUserPage/CreateUserPage.jest.tsx | 54 --- .../CreateUserPage/CreateUserPage.test.tsx | 67 ++++ .../pages/CreateUserPage/CreateUserPage.tsx | 5 +- site/src/pages/CreateUserPage/Language.ts | 11 - .../UsersPage/UsersTable/UsersTableBody.tsx | 4 +- 23 files changed, 556 insertions(+), 362 deletions(-) create mode 100644 site/src/components/FormField/FormField.tsx delete mode 100644 site/src/pages/CreateUserPage/CreateUserPage.jest.tsx create mode 100644 site/src/pages/CreateUserPage/CreateUserPage.test.tsx delete mode 100644 site/src/pages/CreateUserPage/Language.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c67919e3be..d441b185b1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c00b610772..5bc51ca337 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index b06defb119..d9d2c638b5 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, } } diff --git a/codersdk/users.go b/codersdk/users.go index 6e123b0558..1bffc1beac 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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"` diff --git a/docs/reference/api/audit.md b/docs/reference/api/audit.md index c717a75d51..4a648e07b6 100644 --- a/docs/reference/api/audit.md +++ b/docs/reference/api/audit.md @@ -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", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 51d14a815f..a1d7a21dc1 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -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 | | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 02a579d46b..3a197078bf 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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", diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 3595544f63..aee912f7ed 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -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", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 9d54ee018d..6e232f6ac7 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -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", diff --git a/site/e2e/setup/addUsersAndLicense.spec.ts b/site/e2e/setup/addUsersAndLicense.spec.ts index 79f5a2edf7..1d7de905a4 100644 --- a/site/e2e/setup/addUsersAndLicense.spec.ts +++ b/site/e2e/setup/addUsersAndLicense.spec.ts @@ -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"); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1d0ff5e18c..54eea1ddd8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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. diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx index 28fb4bcdb8..ac11bf0a06 100644 --- a/site/src/components/Avatar/Avatar.tsx +++ b/site/src/components/Avatar/Avatar.tsx @@ -78,7 +78,7 @@ export const Avatar: React.FC = ({ > {fallback && ( diff --git a/site/src/components/FormField/FormField.tsx b/site/src/components/FormField/FormField.tsx new file mode 100644 index 0000000000..b0cb8ed9c2 --- /dev/null +++ b/site/src/components/FormField/FormField.tsx @@ -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 = ({ + field, + label, + className, + ...inputProps +}) => { + const generatedId = useId(); + const id = inputProps.id ?? generatedId; + const errorId = `${id}-error`; + const helperId = `${id}-helper`; + + return ( +
+ + + {field.error ? ( + + {field.helperText} + + ) : ( + field.helperText && ( + + {field.helperText} + + ) + )} +
+ ); +}; diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 5b29a8a22c..03b4bcb0ec 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -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["size"]; + id?: string; required?: boolean; check?: AuthorizationCheck; }; export const OrganizationAutocomplete: FC = ({ onChange, - label, - className, - size = "small", + id, required, check, }) => { const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); + const organizationsQuery = useQuery(organizations()); + const checks = check && organizationsQuery.data && @@ -44,9 +53,7 @@ export const OrganizationAutocomplete: FC = ({ ); const permissionsQuery = useQuery({ - ...checkAuthorization({ - checks: checks ?? {}, - }), + ...checkAuthorization({ checks: checks ?? {} }), enabled: Boolean(check && organizationsQuery.data), }); @@ -73,74 +80,69 @@ export const OrganizationAutocomplete: FC = ({ }, [options, selected, onChange]); return ( - 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) => ( -
  • - -
  • - )} - renderInput={(params) => ( - - ), - endAdornment: ( - <> - {organizationsQuery.isFetching && open && ( - - )} - {params.InputProps.endAdornment} - - ), - classes: { root }, - }} - InputLabelProps={{ - shrink: true, - }} - /> - )} - /> + + + + + + + + + No organizations found. + + {options.map((org) => ( + { + setSelected(org); + onChange(org); + setOpen(false); + }} + > + + + {org.display_name || org.name} + + {selected?.id === org.id && ( + + )} + + ))} + + + + + ); }; - -const root = css` - padding-left: 14px !important; // Same padding left as input - gap: 4px; -`; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index 17167ef79f..7a9599e2d3 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -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); }, }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index b14db2fadc..6a01875949 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -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 = (props) => { {showOrganizationPicker && ( <> {showProvisionerWarning && } - { - setSelectedOrg(newValue); - void form.setFieldValue("organization", newValue?.name || ""); - }} - size="medium" - check={{ - object: { resource_type: "template" }, - action: "create", - }} - /> + +
    + + { + setSelectedOrg(newValue); + void form.setFieldValue( + "organization", + newValue?.name || "", + ); + }} + check={{ + object: { resource_type: "template" }, + action: "create", + }} + /> +
    )} diff --git a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx index d112fbae47..0c6d79701c 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx @@ -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")); }, }; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 3cad2f2a71..1420994357 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -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{" "} - - documentation - {" "} - 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 -> = ({ +export const CreateUserForm: FC = ({ 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; + + const defaultLoginType = availableLoginTypes[0]; + const form = useFormik({ 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; + 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 ( {isApiError(error) && !hasApiFieldErrors(error) && ( - + )}
    - - + - + Full name{" "} + + (optional) + + + } + id="name" + name="name" + value={form.values.name} + onChange={form.handleChange} + onBlur={form.handleBlur} autoComplete="name" - fullWidth - label={Language.nameLabel} - /> - + {showOrganizations && ( - { - void form.setFieldValue("organization", newValue?.id ?? ""); - }} - check={{ - object: { resource_type: "organization_member" }, - action: "create", +
    + + { + void form.setFieldValue("organization", newValue?.id ?? ""); + }} + check={{ + object: { resource_type: "organization_member" }, + action: "create", + }} + /> +
    + )} + + {/* Login type — "none" is presented as "Service account" */} +
    + + + {loginTypeField.helperText && ( + + {loginTypeField.helperText} + + )} +
    + + {!isServiceAccount && ( + + Email{" "} + + * + + + } + id="email" + name="email" + value={form.values.email} + onChange={onChangeTrimmed(form)} + onBlur={form.handleBlur} + autoComplete="email" + type="email" /> )} - { - 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 ( - - - {language.displayName} - ({ - fontSize: 14, - color: theme.palette.text.secondary, - wordWrap: "normal", - whiteSpace: "break-spaces", - })} - > - {language.description} - - - - ); - })} - - -
    + + {isPasswordLogin && ( + + )} +