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(
|
export async function createUser(
|
||||||
page: Page,
|
page: Page,
|
||||||
userValues: Partial<UserValues> = {},
|
userValues: Partial<UserValues> = {},
|
||||||
|
orgName = defaultOrganizationName,
|
||||||
): Promise<UserValues> {
|
): Promise<UserValues> {
|
||||||
const returnTo = page.url();
|
const returnTo = page.url();
|
||||||
|
|
||||||
@ -1082,6 +1083,16 @@ export async function createUser(
|
|||||||
await page.getByLabel("Full name").fill(name);
|
await page.getByLabel("Full name").fill(name);
|
||||||
}
|
}
|
||||||
await page.getByLabel("Email").fill(email);
|
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.getByLabel("Login Type").click();
|
||||||
await page.getByRole("option", { name: "Password", exact: false }).click();
|
await page.getByRole("option", { name: "Password", exact: false }).click();
|
||||||
// Using input[name=password] due to the select element utilizing 'password'
|
// 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 type { AuthorizationCheck, Organization } from "api/typesGenerated";
|
||||||
import { Avatar } from "components/Avatar/Avatar";
|
import { Avatar } from "components/Avatar/Avatar";
|
||||||
import { AvatarData } from "components/Avatar/AvatarData";
|
import { AvatarData } from "components/Avatar/AvatarData";
|
||||||
import { useDebouncedFunction } from "hooks/debounce";
|
import { type ComponentProps, type FC, useState } from "react";
|
||||||
import {
|
|
||||||
type ChangeEvent,
|
|
||||||
type ComponentProps,
|
|
||||||
type FC,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
|
|
||||||
export type OrganizationAutocompleteProps = {
|
export type OrganizationAutocompleteProps = {
|
||||||
value: Organization | null;
|
|
||||||
onChange: (organization: Organization | null) => void;
|
onChange: (organization: Organization | null) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -27,7 +20,6 @@ export type OrganizationAutocompleteProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||||
value,
|
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
@ -35,13 +27,9 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
|||||||
required,
|
required,
|
||||||
check,
|
check,
|
||||||
}) => {
|
}) => {
|
||||||
const [autoComplete, setAutoComplete] = useState<{
|
const [open, setOpen] = useState(false);
|
||||||
value: string;
|
const [selected, setSelected] = useState<Organization | null>(null);
|
||||||
open: boolean;
|
|
||||||
}>({
|
|
||||||
value: value?.name ?? "",
|
|
||||||
open: false,
|
|
||||||
});
|
|
||||||
const organizationsQuery = useQuery(organizations());
|
const organizationsQuery = useQuery(organizations());
|
||||||
|
|
||||||
const permissionsQuery = useQuery(
|
const permissionsQuery = useQuery(
|
||||||
@ -60,16 +48,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
|||||||
: { enabled: false },
|
: { 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
|
// If an authorization check was provided, filter the organizations based on
|
||||||
// the results of that check.
|
// the results of that check.
|
||||||
let options = organizationsQuery.data ?? [];
|
let options = organizationsQuery.data ?? [];
|
||||||
@ -85,24 +63,18 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
|||||||
className={className}
|
className={className}
|
||||||
options={options}
|
options={options}
|
||||||
loading={organizationsQuery.isLoading}
|
loading={organizationsQuery.isLoading}
|
||||||
value={value}
|
|
||||||
data-testid="organization-autocomplete"
|
data-testid="organization-autocomplete"
|
||||||
open={autoComplete.open}
|
open={open}
|
||||||
isOptionEqualToValue={(a, b) => a.name === b.name}
|
isOptionEqualToValue={(a, b) => a.id === b.id}
|
||||||
getOptionLabel={(option) => option.display_name}
|
getOptionLabel={(option) => option.display_name}
|
||||||
onOpen={() => {
|
onOpen={() => {
|
||||||
setAutoComplete((state) => ({
|
setOpen(true);
|
||||||
...state,
|
|
||||||
open: true,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAutoComplete({
|
setOpen(false);
|
||||||
value: value?.name ?? "",
|
|
||||||
open: false,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onChange={(_, newValue) => {
|
onChange={(_, newValue) => {
|
||||||
|
setSelected(newValue);
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
}}
|
}}
|
||||||
renderOption={({ key, ...props }, option) => (
|
renderOption={({ key, ...props }, option) => (
|
||||||
@ -130,13 +102,12 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
|||||||
}}
|
}}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
...params.InputProps,
|
...params.InputProps,
|
||||||
onChange: debouncedInputOnChange,
|
startAdornment: selected && (
|
||||||
startAdornment: value && (
|
<Avatar size="sm" src={selected.icon} fallback={selected.name} />
|
||||||
<Avatar size="sm" src={value.icon} fallback={value.name} />
|
|
||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<>
|
<>
|
||||||
{organizationsQuery.isFetching && autoComplete.open && (
|
{organizationsQuery.isFetching && open && (
|
||||||
<CircularProgress size={16} />
|
<CircularProgress size={16} />
|
||||||
)}
|
)}
|
||||||
{params.InputProps.endAdornment}
|
{params.InputProps.endAdornment}
|
||||||
@ -154,6 +125,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const root = css`
|
const root = css`
|
||||||
padding-left: 14px !important; // Same padding left as input
|
padding-left: 14px !important; // Same padding left as input
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
`;
|
`;
|
||||||
|
@ -266,7 +266,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
|||||||
{...getFieldHelpers("organization")}
|
{...getFieldHelpers("organization")}
|
||||||
required
|
required
|
||||||
label="Belongs to"
|
label="Belongs to"
|
||||||
value={selectedOrg}
|
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
setSelectedOrg(newValue);
|
setSelectedOrg(newValue);
|
||||||
void form.setFieldValue("organization", newValue?.name || "");
|
void form.setFieldValue("organization", newValue?.name || "");
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
import { action } from "@storybook/addon-actions";
|
import { action } from "@storybook/addon-actions";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
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";
|
import { CreateUserForm } from "./CreateUserForm";
|
||||||
|
|
||||||
const meta: Meta<typeof CreateUserForm> = {
|
const meta: Meta<typeof CreateUserForm> = {
|
||||||
@ -18,6 +25,48 @@ type Story = StoryObj<typeof CreateUserForm>;
|
|||||||
|
|
||||||
export const Ready: Story = {};
|
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 = {
|
export const FormError: Story = {
|
||||||
args: {
|
args: {
|
||||||
error: mockApiError({
|
error: mockApiError({
|
||||||
|
@ -7,10 +7,11 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
|
|||||||
import { Button } from "components/Button/Button";
|
import { Button } from "components/Button/Button";
|
||||||
import { FormFooter } from "components/Form/Form";
|
import { FormFooter } from "components/Form/Form";
|
||||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||||
|
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||||
import { PasswordField } from "components/PasswordField/PasswordField";
|
import { PasswordField } from "components/PasswordField/PasswordField";
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
import { type FormikContextType, useFormik } from "formik";
|
import { useFormik } from "formik";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import {
|
import {
|
||||||
displayNameValidator,
|
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({
|
const validationSchema = Yup.object({
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.trim()
|
.trim()
|
||||||
@ -75,27 +68,51 @@ const validationSchema = Yup.object({
|
|||||||
login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)),
|
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<
|
export const CreateUserForm: FC<
|
||||||
React.PropsWithChildren<CreateUserFormProps>
|
React.PropsWithChildren<CreateUserFormProps>
|
||||||
> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => {
|
> = ({
|
||||||
const form: FormikContextType<TypesGen.CreateUserRequestWithOrgs> =
|
error,
|
||||||
useFormik<TypesGen.CreateUserRequestWithOrgs>({
|
isLoading,
|
||||||
initialValues: {
|
onSubmit,
|
||||||
email: "",
|
onCancel,
|
||||||
password: "",
|
showOrganizations,
|
||||||
username: "",
|
authMethods,
|
||||||
name: "",
|
}) => {
|
||||||
organization_ids: ["00000000-0000-0000-0000-000000000000"],
|
const form = useFormik<CreateUserFormData>({
|
||||||
login_type: "",
|
initialValues: {
|
||||||
user_status: null,
|
email: "",
|
||||||
},
|
password: "",
|
||||||
validationSchema,
|
username: "",
|
||||||
onSubmit,
|
name: "",
|
||||||
});
|
// If organizations aren't enabled, use the fallback ID to add the user to
|
||||||
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequestWithOrgs>(
|
// the default organization.
|
||||||
form,
|
organization: showOrganizations
|
||||||
error,
|
? ""
|
||||||
);
|
: "00000000-0000-0000-0000-000000000000",
|
||||||
|
login_type: "",
|
||||||
|
},
|
||||||
|
validationSchema,
|
||||||
|
onSubmit,
|
||||||
|
});
|
||||||
|
const getFieldHelpers = getFormHelpers(form, error);
|
||||||
|
|
||||||
const methods = [
|
const methods = [
|
||||||
authMethods?.password.enabled && "password",
|
authMethods?.password.enabled && "password",
|
||||||
@ -132,6 +149,20 @@ export const CreateUserForm: FC<
|
|||||||
fullWidth
|
fullWidth
|
||||||
label={Language.emailLabel}
|
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
|
<TextField
|
||||||
{...getFieldHelpers("login_type", {
|
{...getFieldHelpers("login_type", {
|
||||||
helperText: "Authentication method for this user",
|
helperText: "Authentication method for this user",
|
||||||
|
@ -9,7 +9,9 @@ import { Language as FormLanguage } from "./Language";
|
|||||||
|
|
||||||
const renderCreateUserPage = async () => {
|
const renderCreateUserPage = async () => {
|
||||||
renderWithAuth(<CreateUserPage />, {
|
renderWithAuth(<CreateUserPage />, {
|
||||||
extraRoutes: [{ path: "/users", element: <div>Users Page</div> }],
|
extraRoutes: [
|
||||||
|
{ path: "/deployment/users", element: <div>Users Page</div> },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await waitForLoaderToBeRemoved();
|
await waitForLoaderToBeRemoved();
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { authMethods, createUser } from "api/queries/users";
|
import { authMethods, createUser } from "api/queries/users";
|
||||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||||
import { Margins } from "components/Margins/Margins";
|
import { Margins } from "components/Margins/Margins";
|
||||||
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
@ -17,6 +18,7 @@ export const CreateUserPage: FC = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const createUserMutation = useMutation(createUser(queryClient));
|
const createUserMutation = useMutation(createUser(queryClient));
|
||||||
const authMethodsQuery = useQuery(authMethods());
|
const authMethodsQuery = useQuery(authMethods());
|
||||||
|
const { showOrganizations } = useDashboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Margins>
|
<Margins>
|
||||||
@ -26,16 +28,25 @@ export const CreateUserPage: FC = () => {
|
|||||||
|
|
||||||
<CreateUserForm
|
<CreateUserForm
|
||||||
error={createUserMutation.error}
|
error={createUserMutation.error}
|
||||||
authMethods={authMethodsQuery.data}
|
isLoading={createUserMutation.isLoading}
|
||||||
onSubmit={async (user) => {
|
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.");
|
displaySuccess("Successfully created user.");
|
||||||
navigate("..", { relative: "path" });
|
navigate("..", { relative: "path" });
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
navigate("..", { relative: "path" });
|
navigate("..", { relative: "path" });
|
||||||
}}
|
}}
|
||||||
isLoading={createUserMutation.isLoading}
|
authMethods={authMethodsQuery.data}
|
||||||
|
showOrganizations={showOrganizations}
|
||||||
/>
|
/>
|
||||||
</Margins>
|
</Margins>
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user