From 0f25ab125301de8af6da0aeac52a4d512732aefd Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 6 Mar 2025 17:55:48 +0000 Subject: [PATCH 1/5] feat: allow selecting the initial organization for new users --- site/e2e/helpers.ts | 11 +++ .../pages/CreateUserPage/CreateUserForm.tsx | 81 ++++++++++++------- .../CreateUserPage/CreateUserPage.test.tsx | 4 +- .../pages/CreateUserPage/CreateUserPage.tsx | 17 +++- 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 18e3a04ad5428..0dc2642ab4634 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1062,6 +1062,7 @@ type UserValues = { export async function createUser( page: Page, userValues: Partial = {}, + orgName = defaultOrganizationName, ): Promise { 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' diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index be8b4a15797b5..17f7427871aac 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -7,6 +7,7 @@ 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"; @@ -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,42 @@ 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; + organizations?: readonly TypesGen.Organization[]; +} + export const CreateUserForm: FC< React.PropsWithChildren -> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => { - const form: FormikContextType = - useFormik({ - initialValues: { - email: "", - password: "", - username: "", - name: "", - organization_ids: ["00000000-0000-0000-0000-000000000000"], - login_type: "", - user_status: null, - }, - validationSchema, - onSubmit, - }); - const getFieldHelpers = getFormHelpers( - form, - error, - ); +> = ({ error, isLoading, onSubmit, onCancel, organizations, authMethods }) => { + const form = useFormik({ + initialValues: { + email: "", + password: "", + username: "", + name: "", + // If we aren't given a list of organizations to choose from, use the + // fallback ID to add the user to the default organization. + organization: organizations ? "" : "00000000-0000-0000-0000-000000000000", + login_type: "", + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, error); const methods = [ authMethods?.password.enabled && "password", @@ -132,6 +140,25 @@ export const CreateUserForm: FC< fullWidth label={Language.emailLabel} /> + {organizations && ( + it.id === form.values.organization, + ) ?? null + } + onChange={(newValue) => { + void form.setFieldValue("organization", newValue?.id ?? ""); + }} + check={{ + object: { resource_type: "organization_member" }, + action: "create", + }} + /> + )} { renderWithAuth(, { - extraRoutes: [{ path: "/users", element:
Users Page
}], + extraRoutes: [ + { path: "/deployment/users", element:
Users Page
}, + ], }); await waitForLoaderToBeRemoved(); }; diff --git a/site/src/pages/CreateUserPage/CreateUserPage.tsx b/site/src/pages/CreateUserPage/CreateUserPage.tsx index 5ebbdccf76581..4758f2bdb0773 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.tsx @@ -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 { organizations, showOrganizations } = useDashboard(); return ( @@ -26,16 +28,25 @@ export const CreateUserPage: FC = () => { { - 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} + organizations={showOrganizations ? organizations : undefined} /> ); From ce20040e7323c7e48593b36f6f00b27cb4ed2fba Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 6 Mar 2025 19:47:47 +0000 Subject: [PATCH 2/5] gut it --- .../OrganizationAutocomplete.tsx | 58 ++++++------------- .../CreateTemplatePage/CreateTemplateForm.tsx | 1 - .../pages/CreateUserPage/CreateUserForm.tsx | 24 ++++---- .../pages/CreateUserPage/CreateUserPage.tsx | 4 +- 4 files changed, 34 insertions(+), 53 deletions(-) diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 348c312ec9fe7..9d89b6efe4568 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -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 = ({ - value, onChange, label, className, @@ -35,13 +27,9 @@ export const OrganizationAutocomplete: FC = ({ required, check, }) => { - const [autoComplete, setAutoComplete] = useState<{ - value: string; - open: boolean; - }>({ - value: value?.name ?? "", - open: false, - }); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + const organizationsQuery = useQuery(organizations()); const permissionsQuery = useQuery( @@ -60,15 +48,12 @@ export const OrganizationAutocomplete: FC = ({ : { enabled: false }, ); - const { debounced: debouncedInputOnChange } = useDebouncedFunction( - (event: ChangeEvent) => { - setAutoComplete((state) => ({ - ...state, - value: event.target.value, - })); - }, - 750, - ); + // const { debounced: debouncedInputOnChange } = useDebouncedFunction( + // (event: ChangeEvent) => { + // setInputValue(event.target.value); + // }, + // 750, + // ); // If an authorization check was provided, filter the organizations based on // the results of that check. @@ -85,24 +70,18 @@ export const OrganizationAutocomplete: FC = ({ 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 +109,12 @@ export const OrganizationAutocomplete: FC = ({ }} InputProps={{ ...params.InputProps, - onChange: debouncedInputOnChange, - startAdornment: value && ( - + startAdornment: selected && ( + ), endAdornment: ( <> - {organizationsQuery.isFetching && autoComplete.open && ( + {organizationsQuery.isFetching && open && ( )} {params.InputProps.endAdornment} diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index f5417872b27cd..3a05bf6f7c494 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -266,7 +266,6 @@ export const CreateTemplateForm: FC = (props) => { {...getFieldHelpers("organization")} required label="Belongs to" - value={selectedOrg} onChange={(newValue) => { setSelectedOrg(newValue); void form.setFieldValue("organization", newValue?.name || ""); diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 17f7427871aac..8ce7487f67c4d 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -11,7 +11,7 @@ import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/Or 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, @@ -83,12 +83,19 @@ export interface CreateUserFormProps { onSubmit: (user: CreateUserFormData) => void; onCancel: () => void; authMethods?: TypesGen.AuthMethods; - organizations?: readonly TypesGen.Organization[]; + showOrganizations: boolean; } export const CreateUserForm: FC< React.PropsWithChildren -> = ({ error, isLoading, onSubmit, onCancel, organizations, authMethods }) => { +> = ({ + error, + isLoading, + onSubmit, + onCancel, + showOrganizations, + authMethods, +}) => { const form = useFormik({ initialValues: { email: "", @@ -97,7 +104,9 @@ export const CreateUserForm: FC< name: "", // If we aren't given a list of organizations to choose from, use the // fallback ID to add the user to the default organization. - organization: organizations ? "" : "00000000-0000-0000-0000-000000000000", + organization: showOrganizations + ? "" + : "00000000-0000-0000-0000-000000000000", login_type: "", }, validationSchema, @@ -140,16 +149,11 @@ export const CreateUserForm: FC< fullWidth label={Language.emailLabel} /> - {organizations && ( + {showOrganizations && ( it.id === form.values.organization, - ) ?? null - } onChange={(newValue) => { void form.setFieldValue("organization", newValue?.id ?? ""); }} diff --git a/site/src/pages/CreateUserPage/CreateUserPage.tsx b/site/src/pages/CreateUserPage/CreateUserPage.tsx index 4758f2bdb0773..ecc755026ed2c 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.tsx @@ -18,7 +18,7 @@ export const CreateUserPage: FC = () => { const queryClient = useQueryClient(); const createUserMutation = useMutation(createUser(queryClient)); const authMethodsQuery = useQuery(authMethods()); - const { organizations, showOrganizations } = useDashboard(); + const { showOrganizations } = useDashboard(); return ( @@ -46,7 +46,7 @@ export const CreateUserPage: FC = () => { navigate("..", { relative: "path" }); }} authMethods={authMethodsQuery.data} - organizations={showOrganizations ? organizations : undefined} + showOrganizations={showOrganizations} /> ); From f9f57c2788ca850262c3be9c264a6927b454a30f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 6 Mar 2025 22:24:37 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OrganizationAutocomplete.tsx | 11 ++--------- site/src/pages/CreateUserPage/CreateUserForm.tsx | 4 ++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 9d89b6efe4568..9449252bda3f2 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -48,13 +48,6 @@ export const OrganizationAutocomplete: FC = ({ : { enabled: false }, ); - // const { debounced: debouncedInputOnChange } = useDebouncedFunction( - // (event: ChangeEvent) => { - // setInputValue(event.target.value); - // }, - // 750, - // ); - // If an authorization check was provided, filter the organizations based on // the results of that check. let options = organizationsQuery.data ?? []; @@ -132,6 +125,6 @@ export const OrganizationAutocomplete: FC = ({ }; const root = css` - padding-left: 14px !important; // Same padding left as input - gap: 4px; + padding-left: 14px !important; // Same padding left as input + gap: 4px; `; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 8ce7487f67c4d..ef3a490a59a68 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -102,8 +102,8 @@ export const CreateUserForm: FC< password: "", username: "", name: "", - // If we aren't given a list of organizations to choose from, use the - // fallback ID to add the user to the default organization. + // If organizations aren't enabled, use the fallback ID to add the user to + // the default organization. organization: showOrganizations ? "" : "00000000-0000-0000-0000-000000000000", From e4eb1888c108eaf66803c4c0b2ddfceb0f7498db Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 6 Mar 2025 22:44:12 +0000 Subject: [PATCH 4/5] add a story --- .../CreateUserPage/CreateUserForm.stories.tsx | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx index e96dad4316023..266e26c2b0ae1 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx @@ -1,7 +1,14 @@ 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 { + mockApiError, + MockOrganization, + MockOrganization2, +} from "testHelpers/entities"; import { CreateUserForm } from "./CreateUserForm"; +import { organizationsKey } from "api/queries/organizations"; +import type { Organization } from "api/typesGenerated"; const meta: Meta = { title: "pages/CreateUserPage", @@ -18,6 +25,48 @@ type Story = StoryObj; 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({ From 6ee8dcfc0cfbfc2faf3767bb42f916ac7bbb7562 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 6 Mar 2025 22:46:57 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/CreateUserPage/CreateUserForm.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx index 266e26c2b0ae1..f836a7bde8fc7 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx @@ -1,14 +1,14 @@ import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/test"; +import { organizationsKey } from "api/queries/organizations"; +import type { Organization } from "api/typesGenerated"; import { - mockApiError, MockOrganization, MockOrganization2, + mockApiError, } from "testHelpers/entities"; import { CreateUserForm } from "./CreateUserForm"; -import { organizationsKey } from "api/queries/organizations"; -import type { Organization } from "api/typesGenerated"; const meta: Meta = { title: "pages/CreateUserPage",