diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6ec51ff20f..85f4586105 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15411,6 +15411,13 @@ const docTemplate = `{ "password": { "type": "string" }, + "roles": { + "description": "Roles is an optional list of site-level roles to assign at creation.", + "type": "array", + "items": { + "type": "string" + } + }, "service_account": { "description": "Service accounts are admin-managed accounts that cannot login.", "type": "boolean" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 348b8bf2cd..323440a5e0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13890,6 +13890,13 @@ "password": { "type": "string" }, + "roles": { + "description": "Roles is an optional list of site-level roles to assign at creation.", + "type": "array", + "items": { + "type": "string" + } + }, "service_account": { "description": "Service accounts are admin-managed accounts that cannot login.", "type": "boolean" diff --git a/coderd/users.go b/coderd/users.go index 2283efaaa7..79fb10902a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -615,6 +615,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { CreateUserRequestWithOrgs: req, LoginType: loginType, accountCreatorName: accountCreator.Name, + RBACRoles: req.Roles, }) if dbauthz.IsNotAuthorizedError(err) { diff --git a/codersdk/users.go b/codersdk/users.go index 2bf4a8ce50..f90e10c763 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -189,6 +189,8 @@ type CreateUserRequestWithOrgs struct { OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"` // Service accounts are admin-managed accounts that cannot login. ServiceAccount bool `json:"service_account,omitempty"` + // Roles is an optional list of site-level roles to assign at creation. + Roles []string `json:"roles,omitempty"` } // UnmarshalJSON implements the unmarshal for the legacy param "organization_id". diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e5788ba9d6..3182eb676f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2747,6 +2747,9 @@ This is required on creation to enable a user-flow of validating a template work "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "password": "string", + "roles": [ + "string" + ], "service_account": true, "user_status": "active", "username": "string" @@ -2762,6 +2765,7 @@ This is required on creation to enable a user-flow of validating a template work | `name` | string | false | | | | `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. | | `password` | string | false | | | +| `roles` | array of string | false | | Roles is an optional list of site-level roles to assign at creation. | | `service_account` | boolean | false | | Service accounts are admin-managed accounts that cannot login. | | `user_status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | User status defaults to UserStatusDormant. | | `username` | string | true | | | diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 01187a8cfb..dadb5878a3 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -92,6 +92,9 @@ curl -X POST http://coder-server:8080/api/v2/users \ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "password": "string", + "roles": [ + "string" + ], "service_account": true, "user_status": "active", "username": "string" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 19e9b9d58b..5bee48151b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3108,6 +3108,10 @@ export interface CreateUserRequestWithOrgs { * Service accounts are admin-managed accounts that cannot login. */ readonly service_account?: boolean; + /** + * Roles is an optional list of site-level roles to assign at creation. + */ + readonly roles?: readonly string[]; } // From codersdk/usersecrets.go diff --git a/site/src/components/FullPageForm/FullPageForm.tsx b/site/src/components/FullPageForm/FullPageForm.tsx index 8cc802303b..03b657cd7f 100644 --- a/site/src/components/FullPageForm/FullPageForm.tsx +++ b/site/src/components/FullPageForm/FullPageForm.tsx @@ -1,5 +1,5 @@ import type { FC, ReactNode } from "react"; -import { Margins } from "#/components/Margins/Margins"; +import { Margins, type Size } from "#/components/Margins/Margins"; import { PageHeader, PageHeaderSubtitle, @@ -9,15 +9,17 @@ export interface FullPageFormProps { title: string; detail?: ReactNode; children?: ReactNode; + size?: Size; } export const FullPageForm: FC = ({ title, detail, children, + size = "small", }) => { return ( - + {title} {detail && {detail}} diff --git a/site/src/components/Margins/Margins.tsx b/site/src/components/Margins/Margins.tsx index ea42d45af2..1cb4f981e9 100644 --- a/site/src/components/Margins/Margins.tsx +++ b/site/src/components/Margins/Margins.tsx @@ -6,11 +6,12 @@ import { } from "#/theme/constants"; import { cn } from "#/utils/cn"; -type Size = "regular" | "medium" | "small"; +export type Size = "regular" | "medium" | "condensed" | "small"; const widthBySize: Record = { regular: containerWidth, medium: containerWidthMedium, + condensed: containerWidth / 2, small: containerWidth / 3, }; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx index d551b6b4a3..f620356337 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx @@ -2,8 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { userEvent, within } from "storybook/test"; import { + assignableRole, + MockAuditorRole, + MockAuthMethodsPasswordOnly, MockOrganization, MockOrganization2, + MockOwnerRole, + MockTemplateAdminRole, + MockUserAdminRole, mockApiError, } from "#/testHelpers/entities"; import { CreateUserForm } from "./CreateUserForm"; @@ -70,3 +76,45 @@ export const Loading: Story = { isLoading: true, }, }; + +const mockAvailableRoles = [ + assignableRole(MockOwnerRole, true), + assignableRole(MockUserAdminRole, true), + assignableRole(MockTemplateAdminRole, true), + assignableRole(MockAuditorRole, true), +]; + +export const RolesLoading: Story = { + args: { + rolesLoading: true, + authMethods: MockAuthMethodsPasswordOnly, + }, +}; + +export const RolesError: Story = { + args: { + rolesError: mockApiError({ + message: "Failed to fetch assignable roles.", + }), + authMethods: MockAuthMethodsPasswordOnly, + }, +}; + +export const WithRoles: Story = { + args: { + availableRoles: mockAvailableRoles, + authMethods: MockAuthMethodsPasswordOnly, + }, +}; + +export const WithRolesSelected: Story = { + args: { + availableRoles: mockAvailableRoles, + authMethods: MockAuthMethodsPasswordOnly, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("checkbox", { name: /owner/i })); + await userEvent.click(canvas.getByRole("checkbox", { name: /auditor/i })); + }, +}; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 90e85a64d8..10c3a84985 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -28,6 +28,7 @@ import { nameValidator, onChangeTrimmed, } from "#/utils/formUtils"; +import { RoleSelector } from "./RoleSelector"; const loginTypeOptions = { password: { @@ -80,6 +81,7 @@ type CreateUserFormData = { readonly login_type: TypesGen.LoginType; readonly password: string; readonly service_account: boolean; + readonly roles: string[]; }; interface CreateUserFormProps { @@ -90,6 +92,9 @@ interface CreateUserFormProps { authMethods?: TypesGen.AuthMethods; showOrganizations: boolean; serviceAccountsEnabled: boolean; + availableRoles?: TypesGen.AssignableRoles[]; + rolesLoading?: boolean; + rolesError?: unknown; } // Stable reference for empty org options to avoid re-render loops @@ -104,6 +109,9 @@ export const CreateUserForm: FC = ({ showOrganizations, authMethods, serviceAccountsEnabled, + availableRoles, + rolesLoading, + rolesError, }) => { const availableLoginTypes = [ authMethods?.password.enabled && "password", @@ -125,6 +133,7 @@ export const CreateUserForm: FC = ({ : "00000000-0000-0000-0000-000000000000", login_type: defaultLoginType, service_account: defaultLoginType === "none", + roles: [], }, validationSchema, onSubmit, @@ -172,44 +181,18 @@ export const CreateUserForm: FC = ({ }); return ( - + {isApiError(error) && !hasApiFieldErrors(error) && ( )} -
+
- - - - Full name{" "} - - (optional) - - - } - id="name" - name="name" - value={form.values.name} - onChange={form.handleChange} - onBlur={form.handleBlur} - autoComplete="name" - /> - {showOrganizations && ( -
+
= ({ )} {/* Login type — "none" is presented as "Service account" */} -
+