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", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy