mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: allow selecting the initial organization for new users (#16829)
This commit is contained in:
@ -1062,6 +1062,7 @@ type UserValues = {
|
||||
export async function createUser(
|
||||
page: Page,
|
||||
userValues: Partial<UserValues> = {},
|
||||
orgName = defaultOrganizationName,
|
||||
): Promise<UserValues> {
|
||||
const returnTo = page.url();
|
||||
|
||||
@ -1082,6 +1083,16 @@ export async function createUser(
|
||||
await page.getByLabel("Full name").fill(name);
|
||||
}
|
||||
await page.getByLabel("Email").fill(email);
|
||||
|
||||
// If the organization picker is present on the page, select the default
|
||||
// organization.
|
||||
const orgPicker = page.getByLabel("Organization *");
|
||||
const organizationsEnabled = await orgPicker.isVisible();
|
||||
if (organizationsEnabled) {
|
||||
await orgPicker.click();
|
||||
await page.getByText(orgName, { exact: true }).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Login Type").click();
|
||||
await page.getByRole("option", { name: "Password", exact: false }).click();
|
||||
// Using input[name=password] due to the select element utilizing 'password'
|
||||
|
@ -7,17 +7,10 @@ import { organizations } from "api/queries/organizations";
|
||||
import type { AuthorizationCheck, Organization } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type ComponentProps, type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
export type OrganizationAutocompleteProps = {
|
||||
value: Organization | null;
|
||||
onChange: (organization: Organization | null) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
@ -27,7 +20,6 @@ export type OrganizationAutocompleteProps = {
|
||||
};
|
||||
|
||||
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
@ -35,13 +27,9 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
required,
|
||||
check,
|
||||
}) => {
|
||||
const [autoComplete, setAutoComplete] = useState<{
|
||||
value: string;
|
||||
open: boolean;
|
||||
}>({
|
||||
value: value?.name ?? "",
|
||||
open: false,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Organization | null>(null);
|
||||
|
||||
const organizationsQuery = useQuery(organizations());
|
||||
|
||||
const permissionsQuery = useQuery(
|
||||
@ -60,16 +48,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
: { enabled: false },
|
||||
);
|
||||
|
||||
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setAutoComplete((state) => ({
|
||||
...state,
|
||||
value: event.target.value,
|
||||
}));
|
||||
},
|
||||
750,
|
||||
);
|
||||
|
||||
// If an authorization check was provided, filter the organizations based on
|
||||
// the results of that check.
|
||||
let options = organizationsQuery.data ?? [];
|
||||
@ -85,24 +63,18 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
className={className}
|
||||
options={options}
|
||||
loading={organizationsQuery.isLoading}
|
||||
value={value}
|
||||
data-testid="organization-autocomplete"
|
||||
open={autoComplete.open}
|
||||
isOptionEqualToValue={(a, b) => a.name === b.name}
|
||||
open={open}
|
||||
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||
getOptionLabel={(option) => option.display_name}
|
||||
onOpen={() => {
|
||||
setAutoComplete((state) => ({
|
||||
...state,
|
||||
open: true,
|
||||
}));
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setAutoComplete({
|
||||
value: value?.name ?? "",
|
||||
open: false,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
setSelected(newValue);
|
||||
onChange(newValue);
|
||||
}}
|
||||
renderOption={({ key, ...props }, option) => (
|
||||
@ -130,13 +102,12 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
onChange: debouncedInputOnChange,
|
||||
startAdornment: value && (
|
||||
<Avatar size="sm" src={value.icon} fallback={value.name} />
|
||||
startAdornment: selected && (
|
||||
<Avatar size="sm" src={selected.icon} fallback={selected.name} />
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{organizationsQuery.isFetching && autoComplete.open && (
|
||||
{organizationsQuery.isFetching && open && (
|
||||
<CircularProgress size={16} />
|
||||
)}
|
||||
{params.InputProps.endAdornment}
|
||||
|
@ -266,7 +266,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
{...getFieldHelpers("organization")}
|
||||
required
|
||||
label="Belongs to"
|
||||
value={selectedOrg}
|
||||
onChange={(newValue) => {
|
||||
setSelectedOrg(newValue);
|
||||
void form.setFieldValue("organization", newValue?.name || "");
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
import { userEvent, within } from "@storybook/test";
|
||||
import { organizationsKey } from "api/queries/organizations";
|
||||
import type { Organization } from "api/typesGenerated";
|
||||
import {
|
||||
MockOrganization,
|
||||
MockOrganization2,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import { CreateUserForm } from "./CreateUserForm";
|
||||
|
||||
const meta: Meta<typeof CreateUserForm> = {
|
||||
@ -18,6 +25,48 @@ type Story = StoryObj<typeof CreateUserForm>;
|
||||
|
||||
export const Ready: Story = {};
|
||||
|
||||
const permissionCheckQuery = (organizations: Organization[]) => {
|
||||
return {
|
||||
key: [
|
||||
"authorization",
|
||||
{
|
||||
checks: Object.fromEntries(
|
||||
organizations.map((org) => [
|
||||
org.id,
|
||||
{
|
||||
action: "create",
|
||||
object: {
|
||||
resource_type: "organization_member",
|
||||
organization_id: org.id,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
data: Object.fromEntries(organizations.map((org) => [org.id, true])),
|
||||
};
|
||||
};
|
||||
|
||||
export const WithOrganizations: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: organizationsKey,
|
||||
data: [MockOrganization, MockOrganization2],
|
||||
},
|
||||
permissionCheckQuery([MockOrganization, MockOrganization2]),
|
||||
],
|
||||
},
|
||||
args: {
|
||||
showOrganizations: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByLabelText("Organization *"));
|
||||
},
|
||||
};
|
||||
|
||||
export const FormError: Story = {
|
||||
args: {
|
||||
error: mockApiError({
|
||||
|
@ -7,10 +7,11 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FormFooter } from "components/Form/Form";
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { PasswordField } from "components/PasswordField/PasswordField";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { type FormikContextType, useFormik } from "formik";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
displayNameValidator,
|
||||
@ -52,14 +53,6 @@ export const authMethodLanguage = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface CreateUserFormProps {
|
||||
onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void;
|
||||
onCancel: () => void;
|
||||
error?: unknown;
|
||||
isLoading: boolean;
|
||||
authMethods?: TypesGen.AuthMethods;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string()
|
||||
.trim()
|
||||
@ -75,27 +68,51 @@ const validationSchema = Yup.object({
|
||||
login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)),
|
||||
});
|
||||
|
||||
type CreateUserFormData = {
|
||||
readonly username: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly organization: string;
|
||||
readonly login_type: TypesGen.LoginType;
|
||||
readonly password: string;
|
||||
};
|
||||
|
||||
export interface CreateUserFormProps {
|
||||
error?: unknown;
|
||||
isLoading: boolean;
|
||||
onSubmit: (user: CreateUserFormData) => void;
|
||||
onCancel: () => void;
|
||||
authMethods?: TypesGen.AuthMethods;
|
||||
showOrganizations: boolean;
|
||||
}
|
||||
|
||||
export const CreateUserForm: FC<
|
||||
React.PropsWithChildren<CreateUserFormProps>
|
||||
> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => {
|
||||
const form: FormikContextType<TypesGen.CreateUserRequestWithOrgs> =
|
||||
useFormik<TypesGen.CreateUserRequestWithOrgs>({
|
||||
> = ({
|
||||
error,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
showOrganizations,
|
||||
authMethods,
|
||||
}) => {
|
||||
const form = useFormik<CreateUserFormData>({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
username: "",
|
||||
name: "",
|
||||
organization_ids: ["00000000-0000-0000-0000-000000000000"],
|
||||
// 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: "",
|
||||
user_status: null,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequestWithOrgs>(
|
||||
form,
|
||||
error,
|
||||
);
|
||||
const getFieldHelpers = getFormHelpers(form, error);
|
||||
|
||||
const methods = [
|
||||
authMethods?.password.enabled && "password",
|
||||
@ -132,6 +149,20 @@ export const CreateUserForm: FC<
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
{...getFieldHelpers("login_type", {
|
||||
helperText: "Authentication method for this user",
|
||||
|
@ -9,7 +9,9 @@ import { Language as FormLanguage } from "./Language";
|
||||
|
||||
const renderCreateUserPage = async () => {
|
||||
renderWithAuth(<CreateUserPage />, {
|
||||
extraRoutes: [{ path: "/users", element: <div>Users Page</div> }],
|
||||
extraRoutes: [
|
||||
{ path: "/deployment/users", element: <div>Users Page</div> },
|
||||
],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { authMethods, createUser } from "api/queries/users";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@ -17,6 +18,7 @@ export const CreateUserPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const createUserMutation = useMutation(createUser(queryClient));
|
||||
const authMethodsQuery = useQuery(authMethods());
|
||||
const { showOrganizations } = useDashboard();
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
@ -26,16 +28,25 @@ export const CreateUserPage: FC = () => {
|
||||
|
||||
<CreateUserForm
|
||||
error={createUserMutation.error}
|
||||
authMethods={authMethodsQuery.data}
|
||||
isLoading={createUserMutation.isLoading}
|
||||
onSubmit={async (user) => {
|
||||
await createUserMutation.mutateAsync(user);
|
||||
await createUserMutation.mutateAsync({
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
organization_ids: [user.organization],
|
||||
login_type: user.login_type,
|
||||
password: user.password,
|
||||
user_status: null,
|
||||
});
|
||||
displaySuccess("Successfully created user.");
|
||||
navigate("..", { relative: "path" });
|
||||
}}
|
||||
onCancel={() => {
|
||||
navigate("..", { relative: "path" });
|
||||
}}
|
||||
isLoading={createUserMutation.isLoading}
|
||||
authMethods={authMethodsQuery.data}
|
||||
showOrganizations={showOrganizations}
|
||||
/>
|
||||
</Margins>
|
||||
);
|
||||
|
Reference in New Issue
Block a user