feat: add role selector in the create user form (#24711)

Adds a role selector to the create user form so admins can assign
site-level roles at creation time rather than navigating to the user
afterward.

The `POST /api/v2/users` endpoint now accepts an optional `roles` field,
wiring it through to the existing `RBACRoles` field on the internal
`CreateUserRequest`. No database changes are needed since roles are
already stored inline on the user row.

On the frontend, a `RoleSelector` component renders the assignable roles
as a scrollable multiselect checklist with the non-assignable Member
role pinned as a non-interactive footer. The selector appears once a
login type is chosen.

Also adds a `condensed` size (690px) to `Margins` between the existing
`small` (460px) and `medium` (1080px), and exposes a `size` prop on
`FullPageForm`. The create user form uses `condensed` to give the role
selector more breathing room. Also fixes `MockUserAdminRole` and
`MockTemplateAdminRole` in test helpers to use hyphenated names
(`user-admin`, `template-admin`) matching the canonical names in the Go
RBAC layer.

Fixes `sortRolesByAccessLevel` in `UserRoleCell` to sort unranked roles
(e.g. `member`) after all known roles. Previously, `indexOf` returned -1
for unknown names, placing them first; now they receive
`POSITIVE_INFINITY` as their rank.

🤖 Generated with [Claude Code](<https://claude.ai/claude-code>)

---


https://github.com/user-attachments/assets/75e7c8c5-d0d2-481d-86e8-1fcfb574517c

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-04-29 10:57:10 -04:00
committed by GitHub
parent ab75e46f1d
commit 0754016512
15 changed files with 302 additions and 42 deletions
+7
View File
@@ -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"
+7
View File
@@ -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"
+1
View File
@@ -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) {
+2
View File
@@ -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".
+4
View File
@@ -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 | | |
+3
View File
@@ -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"
+4
View File
@@ -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
@@ -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<FullPageFormProps> = ({
title,
detail,
children,
size = "small",
}) => {
return (
<Margins size="small">
<Margins size={size}>
<PageHeader className="pb-6">
<PageHeaderTitle>{title}</PageHeaderTitle>
{detail && <PageHeaderSubtitle>{detail}</PageHeaderSubtitle>}
+2 -1
View File
@@ -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<Size, number> = {
regular: containerWidth,
medium: containerWidthMedium,
condensed: containerWidth / 2,
small: containerWidth / 3,
};
@@ -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 }));
},
};
@@ -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<CreateUserFormProps> = ({
showOrganizations,
authMethods,
serviceAccountsEnabled,
availableRoles,
rolesLoading,
rolesError,
}) => {
const availableLoginTypes = [
authMethods?.password.enabled && "password",
@@ -125,6 +133,7 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
: "00000000-0000-0000-0000-000000000000",
login_type: defaultLoginType,
service_account: defaultLoginType === "none",
roles: [],
},
validationSchema,
onSubmit,
@@ -172,44 +181,18 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
});
return (
<FullPageForm title="Create user">
<FullPageForm title="Create user" size="condensed">
{isApiError(error) && !hasApiFieldErrors(error) && (
<ErrorAlert error={error} className="mb-8" />
)}
<form onSubmit={form.handleSubmit} autoComplete="off">
<form
onSubmit={form.handleSubmit}
autoComplete="off"
className="border border-border border-solid rounded-md p-4"
>
<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
/>
<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"
/>
{showOrganizations && (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 max-w-sm">
<Label htmlFor="organization">Organization</Label>
<OrganizationAutocomplete
id="organization"
@@ -225,7 +208,7 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
)}
{/* Login type — "none" is presented as "Service account" */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 max-w-sm">
<Label htmlFor="login_type">Login type</Label>
<Select
value={form.values.login_type}
@@ -295,6 +278,37 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
)}
</div>
<FormField
field={getFieldHelpers("username")}
label="Username"
id="username"
name="username"
value={form.values.username}
onChange={onChangeTrimmed(form)}
onBlur={form.handleBlur}
autoComplete="username"
autoFocus
className="max-w-sm"
/>
<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"
/>
{!isServiceAccount && (
<FormField
field={getFieldHelpers("email")}
@@ -330,6 +344,31 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
data-testid="password-input"
/>
)}
{rolesLoading ? (
<RoleSelector
loading
roles={[]}
selectedRoles={[]}
onChange={() => {}}
/>
) : rolesError ? (
<RoleSelector
error={rolesError}
roles={[]}
selectedRoles={[]}
onChange={() => {}}
/>
) : (
availableRoles &&
availableRoles.length > 0 && (
<RoleSelector
roles={availableRoles}
selectedRoles={form.values.roles}
onChange={(roles) => form.setFieldValue("roles", roles)}
/>
)
)}
</div>
<FormFooter className="mt-8">
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { roles } from "#/api/queries/roles";
import { authMethods, createUser } from "#/api/queries/users";
import { Margins } from "#/components/Margins/Margins";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -15,6 +16,7 @@ const CreateUserPage: FC = () => {
const queryClient = useQueryClient();
const createUserMutation = useMutation(createUser(queryClient));
const authMethodsQuery = useQuery(authMethods());
const rolesQuery = useQuery(roles());
const { showOrganizations } = useDashboard();
const { service_accounts: serviceAccountsEnabled } = useFeatureVisibility();
@@ -36,6 +38,7 @@ const CreateUserPage: FC = () => {
password: user.password,
user_status: null,
service_account: user.service_account,
roles: user.roles,
},
{
onSuccess: () => {
@@ -61,6 +64,9 @@ const CreateUserPage: FC = () => {
authMethods={authMethodsQuery.data}
showOrganizations={showOrganizations}
serviceAccountsEnabled={serviceAccountsEnabled}
availableRoles={rolesQuery.data}
rolesLoading={rolesQuery.isLoading}
rolesError={rolesQuery.error}
/>
</Margins>
);
@@ -0,0 +1,132 @@
import { UserIcon } from "lucide-react";
import { type FC, useId } from "react";
import { getErrorMessage } from "#/api/errors";
import type { AssignableRoles } from "#/api/typesGenerated";
import { Alert, AlertTitle } from "#/components/Alert/Alert";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import { Skeleton } from "#/components/Skeleton/Skeleton";
const roleDescriptions: Record<string, string> = {
owner:
"Owner can manage all resources, including users, groups, templates, and workspaces.",
"user-admin": "User admin can manage all users and groups.",
"template-admin": "Template admin can manage all templates and workspaces.",
auditor: "Auditor can access the audit logs.",
"agents-access": "Grants access to Coder Agents chat.",
member:
"Everybody is a member. This is a shared and default role for all users.",
};
interface RoleSelectorProps {
roles: AssignableRoles[];
selectedRoles: string[];
onChange: (roles: string[]) => void;
loading?: boolean;
error?: unknown;
}
export const RoleSelector: FC<RoleSelectorProps> = ({
roles,
selectedRoles,
onChange,
loading,
error,
}) => {
const baseId = useId();
const selectableRoles = roles.filter(
(r) => r.assignable && r.name !== "member",
);
const handleToggle = (roleName: string) => {
if (selectedRoles.includes(roleName)) {
onChange(selectedRoles.filter((r) => r !== roleName));
} else {
onChange([...selectedRoles, roleName]);
}
};
if (loading) {
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
<div className="border border-border border-solid rounded-md">
<div className="p-3 flex flex-col gap-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-start gap-2">
<Skeleton className="mt-1 shrink-0 size-4 rounded" />
<div className="flex flex-col gap-1 flex-1">
<Skeleton variant="text" className="w-24" />
<Skeleton variant="text" className="w-48" />
</div>
</div>
))}
</div>
</div>
<div className="border-t border-border py-2 flex items-start gap-2 text-content-disabled">
<UserIcon className="size-4 mt-1 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Member</span>
<span className="text-sm">{roleDescriptions.member}</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
<Alert severity="error">
<AlertTitle>
{getErrorMessage(error, "Failed to load roles.")}
</AlertTitle>
</Alert>
</div>
);
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
{selectableRoles.length > 0 && (
<div className="border border-border border-solid rounded-md">
<div className="overflow-y-auto max-h-72 p-3 flex flex-col gap-2">
{selectableRoles.map((role) => {
const checkboxId = `${baseId}-${role.name}`;
return (
<label
key={role.name}
htmlFor={checkboxId}
className="flex items-start gap-2 cursor-pointer"
>
<Checkbox
id={checkboxId}
checked={selectedRoles.includes(role.name)}
onCheckedChange={() => handleToggle(role.name)}
className="mt-1 shrink-0"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{role.display_name || role.name}
</span>
<span className="text-sm text-content-secondary">
{roleDescriptions[role.name] ?? ""}
</span>
</div>
</label>
);
})}
</div>
</div>
)}
<div className="border-t border-border py-2 flex items-start gap-2 text-content-disabled">
<UserIcon className="size-4 mt-1 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Member</span>
<span className="text-sm">{roleDescriptions.member}</span>
</div>
</div>
</div>
);
};
@@ -180,6 +180,12 @@ const roleNamesByAccessLevel: readonly string[] = [
"agents-access",
];
// Roles not in the priority list should sort after all known roles.
const roleSortComparator = (name: string) =>
roleNamesByAccessLevel.includes(name)
? roleNamesByAccessLevel.indexOf(name)
: Number.POSITIVE_INFINITY;
function sortRolesByAccessLevel<T extends SlimRole>(
roles: readonly T[],
): readonly T[] {
@@ -188,9 +194,7 @@ function sortRolesByAccessLevel<T extends SlimRole>(
}
return [...roles].sort(
(r1, r2) =>
roleNamesByAccessLevel.indexOf(r1.name) -
roleNamesByAccessLevel.indexOf(r2.name),
(r1, r2) => roleSortComparator(r1.name) - roleSortComparator(r2.name),
);
}
+2 -2
View File
@@ -288,7 +288,7 @@ export const MockOwnerRole: TypesGen.Role = {
};
export const MockUserAdminRole: TypesGen.Role = {
name: "user_admin",
name: "user-admin",
display_name: "User Admin",
site_permissions: [],
user_permissions: [],
@@ -298,7 +298,7 @@ export const MockUserAdminRole: TypesGen.Role = {
};
export const MockTemplateAdminRole: TypesGen.Role = {
name: "template_admin",
name: "template-admin",
display_name: "Template Admin",
site_permissions: [],
user_permissions: [],