diff --git a/.changeset/dull-paws-march.md b/.changeset/dull-paws-march.md new file mode 100644 index 00000000000..f6d68bea87a --- /dev/null +++ b/.changeset/dull-paws-march.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Display organization slug based on environment settings diff --git a/packages/clerk-js/src/core/resources/OrganizationSettings.ts b/packages/clerk-js/src/core/resources/OrganizationSettings.ts index 09edfd6b58b..6c8dfa4d814 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSettings.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSettings.ts @@ -18,6 +18,11 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe enrollmentModes: [], defaultRole: null, }; + slug: { + disabled: boolean; + } = { + disabled: false, + }; enabled: boolean = false; maxAllowedMemberships: number = 1; forceOrganizationSelection!: boolean; @@ -42,6 +47,10 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe this.domains.defaultRole = this.withDefault(data.domains.default_role, this.domains.defaultRole); } + if (data.slug) { + this.slug.disabled = this.withDefault(data.slug.disabled, this.slug.disabled); + } + this.enabled = this.withDefault(data.enabled, this.enabled); this.maxAllowedMemberships = this.withDefault(data.max_allowed_memberships, this.maxAllowedMemberships); this.forceOrganizationSelection = this.withDefault( diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index 847e3dedab2..143e7e6a7c3 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -342,13 +342,22 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) const withForceOrganizationSelection = () => { os.force_organization_selection = true; }; + const withOrganizationSlug = (enabled = false) => { + os.slug.disabled = !enabled; + }; const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[], defaultRole?: string) => { os.domains.enabled = true; os.domains.enrollment_modes = modes || ['automatic_invitation', 'manual_invitation']; os.domains.default_role = defaultRole ?? null; }; - return { withOrganizations, withMaxAllowedMemberships, withOrganizationDomains, withForceOrganizationSelection }; + return { + withOrganizations, + withMaxAllowedMemberships, + withOrganizationDomains, + withForceOrganizationSelection, + withOrganizationSlug, + }; }; const createBillingSettingsFixtureHelpers = (environment: EnvironmentJSON) => { diff --git a/packages/clerk-js/src/test/fixtures.ts b/packages/clerk-js/src/test/fixtures.ts index 1edd74561f7..8fd8c659558 100644 --- a/packages/clerk-js/src/test/fixtures.ts +++ b/packages/clerk-js/src/test/fixtures.ts @@ -89,6 +89,9 @@ const createBaseOrganizationSettings = (): OrganizationSettingsJSON => { enabled: false, enrollment_modes: [], }, + slug: { + disabled: true, + }, } as unknown as OrganizationSettingsJSON; }; diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx index bb909e3aa7e..bed14d233f8 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx @@ -2,6 +2,7 @@ import { useOrganization, useOrganizationList } from '@clerk/shared/react'; import type { CreateOrganizationParams, OrganizationResource } from '@clerk/types'; import React from 'react'; +import { useEnvironment } from '@/ui/contexts'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; @@ -33,6 +34,11 @@ type CreateOrganizationFormProps = { headerTitle?: LocalizationKey; headerSubtitle?: LocalizationKey; }; + /** + * @deprecated + * This prop will be removed in a future version. + * Configure whether organization slug is enabled via the Clerk Dashboard under Organization Settings. + */ hideSlug?: boolean; }; @@ -45,6 +51,7 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani userMemberships: organizationListParams.userMemberships, }); const { organization } = useOrganization(); + const { organizationSettings } = useEnvironment(); const [file, setFile] = React.useState(); const nameField = useFormControl('name', '', { @@ -62,6 +69,9 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani const dataChanged = !!nameField.value; const canSubmit = dataChanged; + // Environment setting takes precedence over prop + const organizationSlugEnabled = !organizationSettings.slug.disabled && !props.hideSlug; + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) { @@ -75,7 +85,7 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani try { const createOrgParams: CreateOrganizationParams = { name: nameField.value }; - if (!props.hideSlug) { + if (organizationSlugEnabled) { createOrgParams.slug = slugField.value; } @@ -188,7 +198,7 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani ignorePasswordManager /> - {!props.hideSlug && ( + {organizationSlugEnabled && ( { expect(getByRole('heading', { name: 'Create organization', level: 1 })).toBeInTheDocument(); }); - it('renders component without slug field', async () => { - const { wrapper, fixtures, props } = await createFixtures(f => { - f.withOrganizations(); - f.withUser({ - email_addresses: ['test@clerk.com'], + describe('with `hideSlug` prop', () => { + it('renders component without slug field', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + slug: 'new-org-1722578361', + }), + ), + ); + + props.setProps({ hideSlug: true }); + const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { + wrapper, + }); + + expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + + await userEvent.type(getByLabelText(/Name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite new members/i)).toBeInTheDocument(); }); }); + }); - fixtures.clerk.createOrganization.mockReturnValue( - Promise.resolve( - getCreatedOrg({ - maxAllowedMemberships: 1, - slug: 'new-org-1722578361', - }), - ), - ); + describe('with organization slug configured on environment', () => { + it('when disabled, renders component without slug field', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(false); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); - props.setProps({ hideSlug: true }); - const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { - wrapper, + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + slug: 'new-org-1722578361', + }), + ), + ); + + const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { + wrapper, + }); + + expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + + await userEvent.type(getByLabelText(/Name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite new members/i)).toBeInTheDocument(); + }); + }); + + it('when enabled, renders component slug field', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + slug: 'new-org-1722578361', + }), + ), + ); + + const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { + wrapper, + }); + + expect(queryByLabelText(/Slug/i)).toBeInTheDocument(); + + await userEvent.type(getByLabelText(/Name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite new members/i)).toBeInTheDocument(); + }); }); - expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + it('when enabled and `hideSlug` prop is passed, renders component without slug field', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); - await userEvent.type(getByLabelText(/Name/i), 'new org'); - await userEvent.click(getByRole('button', { name: /create organization/i })); + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + slug: 'new-org-1722578361', + }), + ), + ); + + props.setProps({ hideSlug: true }); + const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { + wrapper, + }); - await waitFor(() => { - expect(queryByText(/Invite new members/i)).toBeInTheDocument(); + expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + + await userEvent.type(getByLabelText(/Name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite new members/i)).toBeInTheDocument(); + }); + }); + + it('when disabled and `hideSlug` prop is passed, renders component without slug field', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(false); // Environment disables slug + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + slug: 'new-org-1722578361', + }), + ), + ); + + props.setProps({ hideSlug: true }); + const { userEvent, getByRole, queryByText, queryByLabelText, getByLabelText } = render(, { + wrapper, + }); + + expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + + await userEvent.type(getByLabelText(/Name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite new members/i)).toBeInTheDocument(); + }); }); }); diff --git a/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx b/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx index 63cfcc228ce..09ad363316b 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/__tests__/OrganizationList.test.tsx @@ -85,6 +85,7 @@ describe('OrganizationList', () => { it('hides the personal account with no data to list', async () => { const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); + f.withOrganizationSlug(true); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', id: '1', role: 'admin' }], @@ -210,6 +211,7 @@ describe('OrganizationList', () => { it('display CreateOrganization within OrganizationList', async () => { const { wrapper } = await createFixtures(f => { f.withOrganizations(); + f.withOrganizationSlug(true); f.withUser({ email_addresses: ['test@clerk.com'], create_organization_enabled: true, diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index 14afd0c8913..55019e6a966 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -1,5 +1,7 @@ import { useOrganizationList } from '@clerk/shared/react'; +import type { CreateOrganizationParams } from '@clerk/types'; +import { useEnvironment } from '@/ui/contexts'; import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; import { localizationKeys } from '@/ui/customizables'; import { useCardState } from '@/ui/elements/contexts'; @@ -25,6 +27,7 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = const { createOrganization, isLoaded, setActive } = useOrganizationList({ userMemberships: organizationListParams.userMemberships, }); + const { organizationSettings } = useEnvironment(); const nameField = useFormControl('name', '', { type: 'text', @@ -37,6 +40,8 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__slug'), }); + const organizationSlugEnabled = !organizationSettings.slug.disabled; + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -45,7 +50,13 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = } try { - const organization = await createOrganization({ name: nameField.value, slug: slugField.value }); + const createOrgParams: CreateOrganizationParams = { name: nameField.value }; + + if (organizationSlugEnabled) { + createOrgParams.slug = slugField.value; + } + + const organization = await createOrganization(createOrgParams); await setActive({ organization, @@ -90,15 +101,17 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = ignorePasswordManager /> - - updateSlugField(event.target.value)} - isRequired - pattern='^(?=.*[a-z0-9])[a-z0-9\-]+$' - ignorePasswordManager - /> - + {organizationSlugEnabled && ( + + updateSlugField(event.target.value)} + isRequired + pattern='^(?=.*[a-z0-9])[a-z0-9\-]+$' + ignorePasswordManager + /> + + )} ({ flexDirection: 'column' })}> { expect(await findByText(/testuser/)).toBeInTheDocument(); }); + + describe('on create organization form', () => { + it("does not display slug field if it's disabled on environment", async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(false); + f.withForceOrganizationSelection(); + f.withUser({ + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + const { findByRole, queryByLabelText } = render(, { wrapper }); + + expect(await findByRole('textbox', { name: /name/i })).toBeInTheDocument(); + expect(queryByLabelText(/Slug/i)).not.toBeInTheDocument(); + }); + + it("display slug field if it's enabled on environment", async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationSlug(true); + f.withForceOrganizationSelection(); + f.withUser({ + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + const { findByRole, queryByLabelText } = render(, { wrapper }); + + expect(await findByRole('textbox', { name: /name/i })).toBeInTheDocument(); + expect(queryByLabelText(/Slug/i)).toBeInTheDocument(); + }); + }); }); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index fcf84df542f..5140c03f27a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1561,8 +1561,9 @@ export type CreateOrganizationProps = RoutingOptions & { */ appearance?: CreateOrganizationTheme; /** - * Hides the optional "slug" field in the organization creation screen. - * @default false + * @deprecated + * This prop will be removed in a future version. + * Configure whether organization slug is enabled via the Clerk Dashboard under Organization Settings. */ hideSlug?: boolean; }; @@ -1721,8 +1722,9 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & */ skipInvitationScreen?: boolean; /** - * Hides the optional "slug" field in the organization creation screen. - * @default false + * @deprecated + * This prop will be removed in a future version. + * Configure whether organization slug is enabled via the Clerk Dashboard under Organization Settings. */ hideSlug?: boolean; /** @@ -1781,8 +1783,9 @@ export type OrganizationListProps = { */ afterSelectPersonalUrl?: ((user: UserResource) => string) | LooseExtractedParams>; /** - * Hides the optional "slug" field in the organization creation screen. - * @default false + * @deprecated + * This prop will be removed in a future version. + * Configure whether organization slug is enabled via the Clerk Dashboard under Organization Settings. */ hideSlug?: boolean; }; diff --git a/packages/types/src/organizationSettings.ts b/packages/types/src/organizationSettings.ts index afbe09c5c97..ab9e0704e1e 100644 --- a/packages/types/src/organizationSettings.ts +++ b/packages/types/src/organizationSettings.ts @@ -17,6 +17,9 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON { enrollment_modes: OrganizationEnrollmentMode[]; default_role: string | null; }; + slug: { + disabled: boolean; + }; } export interface OrganizationSettingsResource extends ClerkResource { @@ -31,5 +34,8 @@ export interface OrganizationSettingsResource extends ClerkResource { enrollmentModes: OrganizationEnrollmentMode[]; defaultRole: string | null; }; + slug: { + disabled: boolean; + }; __internal_toSnapshot: () => OrganizationSettingsJSONSnapshot; }