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/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 348c312ec9fe7..9449252bda3f2 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,16 +48,6 @@ export const OrganizationAutocomplete: FC = ({ : { enabled: false }, ); - const { debounced: debouncedInputOnChange } = useDebouncedFunction( - (event: ChangeEvent) => { - 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 = ({ 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 = ({ }} InputProps={{ ...params.InputProps, - onChange: debouncedInputOnChange, - startAdornment: value && ( - + startAdornment: selected && ( + ), endAdornment: ( <> - {organizationsQuery.isFetching && autoComplete.open && ( + {organizationsQuery.isFetching && open && ( )} {params.InputProps.endAdornment} @@ -154,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/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.stories.tsx b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx index e96dad4316023..f836a7bde8fc7 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.stories.tsx @@ -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 = { @@ -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({ diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index be8b4a15797b5..ef3a490a59a68 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -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 -> = ({ 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, + showOrganizations, + authMethods, +}) => { + const form = useFormik({ + initialValues: { + email: "", + password: "", + username: "", + name: "", + // 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: "", + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, error); const methods = [ authMethods?.password.enabled && "password", @@ -132,6 +149,20 @@ export const CreateUserForm: FC< fullWidth label={Language.emailLabel} /> + {showOrganizations && ( + { + 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..ecc755026ed2c 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 { 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} + showOrganizations={showOrganizations} /> ); 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