diff --git a/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts b/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts new file mode 100644 index 00000000000..865d9085f3a --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts @@ -0,0 +1,71 @@ +import type { + OrganizationCreationAdvisorySeverity, + OrganizationCreationAdvisoryType, + OrganizationCreationDefaultsJSON, + OrganizationCreationDefaultsJSONSnapshot, + OrganizationCreationDefaultsResource, +} from '@clerk/shared/types'; + +import { BaseResource } from './internal'; + +export class OrganizationCreationDefaults extends BaseResource implements OrganizationCreationDefaultsResource { + advisory: { + code: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + meta: Record; + } | null = null; + form: { + name: string; + slug: string; + logo: string | null; + } = { + name: '', + slug: '', + logo: null, + }; + + public constructor(data: OrganizationCreationDefaultsJSON | OrganizationCreationDefaultsJSONSnapshot | null = null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: OrganizationCreationDefaultsJSON | OrganizationCreationDefaultsJSONSnapshot | null): this { + if (!data) { + return this; + } + + if (data.advisory) { + this.advisory = this.withDefault(data.advisory, this.advisory ?? null); + } + + if (data.form) { + this.form.name = this.withDefault(data.form.name, this.form.name); + this.form.slug = this.withDefault(data.form.slug, this.form.slug); + this.form.logo = this.withDefault(data.form.logo, this.form.logo); + } + + return this; + } + + static async retrieve(): Promise { + return await BaseResource._fetch({ + path: '/me/organization_creation_defaults', + method: 'GET', + }).then(res => { + const data = res?.response as unknown as OrganizationCreationDefaultsJSON; + return new OrganizationCreationDefaults(data); + }); + } + + public __internal_toSnapshot(): OrganizationCreationDefaultsJSONSnapshot { + return { + advisory: this.advisory + ? { + code: this.advisory.code, + meta: this.advisory.meta, + severity: this.advisory.severity, + } + : null, + } as unknown as OrganizationCreationDefaultsJSONSnapshot; + } +} diff --git a/packages/clerk-js/src/core/resources/OrganizationSettings.ts b/packages/clerk-js/src/core/resources/OrganizationSettings.ts index 8960b347d62..a9fa5873d49 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSettings.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSettings.ts @@ -23,6 +23,11 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe } = { disabled: false, }; + organizationCreationDefaults: { + enabled: boolean; + } = { + enabled: false, + }; enabled: boolean = false; maxAllowedMemberships: number = 1; forceOrganizationSelection!: boolean; @@ -51,6 +56,13 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe this.slug.disabled = this.withDefault(data.slug.disabled, this.slug.disabled); } + if (data.organization_creation_defaults) { + this.organizationCreationDefaults.enabled = this.withDefault( + data.organization_creation_defaults.enabled, + this.organizationCreationDefaults.enabled, + ); + } + 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/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index ae7ef203c63..079b7ae2f75 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -53,6 +53,7 @@ import { UserOrganizationInvitation, Web3Wallet, } from './internal'; +import { OrganizationCreationDefaults } from './OrganizationCreationDefaults'; export class User extends BaseResource implements UserResource { pathRoot = '/me'; @@ -275,6 +276,8 @@ export class User extends BaseResource implements UserResource { getOrganizationMemberships: GetOrganizationMemberships = retrieveMembership => OrganizationMembership.retrieve(retrieveMembership); + getOrganizationCreationDefaults = () => OrganizationCreationDefaults.retrieve(); + leaveOrganization = async (organizationId: string): Promise => { const json = ( await BaseResource._fetch({ diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index 86547dae2c0..b1564bcd841 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -344,6 +344,9 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) const withOrganizationSlug = (enabled = false) => { os.slug.disabled = !enabled; }; + const withOrganizationCreationDefaults = (enabled = false) => { + os.organization_creation_defaults.enabled = enabled; + }; const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[], defaultRole?: string) => { os.domains.enabled = true; @@ -356,6 +359,7 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) withOrganizationDomains, withForceOrganizationSelection, withOrganizationSlug, + withOrganizationCreationDefaults, }; }; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 5edd6618118..cca24bd22ef 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -877,6 +877,10 @@ export const enUS: LocalizationResource = { actionLink: 'Sign out', actionText: 'Signed in as {{identifier}}', }, + alerts: { + existingOrgWithDomain: + 'An organization already exists for the detected company name and {{email}}. Join by invitation.', + }, }, taskResetPassword: { formButtonPrimary: 'Reset Password', diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 85cecc41a27..5ef821571b1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -13,6 +13,7 @@ export type * from './customPages'; export type * from './deletedObject'; export type * from './devtools'; export type * from './displayConfig'; +export type * from './elementIds'; export type * from './emailAddress'; export type * from './enterpriseAccount'; export type * from './environment'; @@ -32,6 +33,7 @@ export type * from './localization'; export type * from './multiDomain'; export type * from './oauth'; export type * from './organization'; +export type * from './organizationCreationDefaults'; export type * from './organizationDomain'; export type * from './organizationInvitation'; export type * from './organizationMembership'; @@ -49,7 +51,6 @@ export type * from './protectConfig'; export type * from './redirects'; export type * from './resource'; export type * from './role'; -export type * from './elementIds'; export type * from './router'; /** * TODO @revamp-hooks: Drop this in the next major release. diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index a8f3f980653..e3322a40d4a 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1307,6 +1307,9 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + alerts: { + existingOrgWithDomain: LocalizationValue<'email'>; + }; }; taskResetPassword: { title: LocalizationValue; diff --git a/packages/shared/src/types/organizationCreationDefaults.ts b/packages/shared/src/types/organizationCreationDefaults.ts new file mode 100644 index 00000000000..5c3fdb2a24d --- /dev/null +++ b/packages/shared/src/types/organizationCreationDefaults.ts @@ -0,0 +1,32 @@ +import type { ClerkResourceJSON } from './json'; +import type { ClerkResource } from './resource'; + +export type OrganizationCreationAdvisoryType = 'existing_org_with_domain'; + +export type OrganizationCreationAdvisorySeverity = 'warning'; + +export interface OrganizationCreationDefaultsJSON extends ClerkResourceJSON { + advisory: { + code: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + meta: Record; + } | null; + form: { + name: string; + slug: string; + logo: string | null; + }; +} + +export interface OrganizationCreationDefaultsResource extends ClerkResource { + advisory: { + code: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + meta: Record; + } | null; + form: { + name: string; + slug: string; + logo: string | null; + }; +} diff --git a/packages/shared/src/types/organizationSettings.ts b/packages/shared/src/types/organizationSettings.ts index ab9e0704e1e..e9a24b8e0f0 100644 --- a/packages/shared/src/types/organizationSettings.ts +++ b/packages/shared/src/types/organizationSettings.ts @@ -20,6 +20,9 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON { slug: { disabled: boolean; }; + organization_creation_defaults: { + enabled: boolean; + }; } export interface OrganizationSettingsResource extends ClerkResource { @@ -37,5 +40,8 @@ export interface OrganizationSettingsResource extends ClerkResource { slug: { disabled: boolean; }; + organizationCreationDefaults: { + enabled: boolean; + }; __internal_toSnapshot: () => OrganizationSettingsJSONSnapshot; } diff --git a/packages/shared/src/types/snapshots.ts b/packages/shared/src/types/snapshots.ts index 6db74374c44..a1d239c329f 100644 --- a/packages/shared/src/types/snapshots.ts +++ b/packages/shared/src/types/snapshots.ts @@ -28,6 +28,7 @@ import type { VerificationJSON, Web3WalletJSON, } from './json'; +import type { OrganizationCreationDefaultsJSON } from './organizationCreationDefaults'; import type { OrganizationSettingsJSON } from './organizationSettings'; import type { ProtectConfigJSON } from './protectConfig'; import type { SignInJSON } from './signIn'; @@ -143,6 +144,8 @@ export type OrganizationMembershipJSONSnapshot = OrganizationMembershipJSON; export type OrganizationSettingsJSONSnapshot = OrganizationSettingsJSON; +export type OrganizationCreationDefaultsJSONSnapshot = OrganizationCreationDefaultsJSON; + export type PasskeyJSONSnapshot = Override; export type PhoneNumberJSONSnapshot = Override< diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 93d7a9ef4a4..ac1c40b2fbd 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -7,6 +7,7 @@ import type { ExternalAccountResource } from './externalAccount'; import type { ImageResource } from './image'; import type { UserJSON } from './json'; import type { OAuthScope } from './oauth'; +import type { OrganizationCreationDefaultsResource } from './organizationCreationDefaults'; import type { OrganizationInvitationStatus } from './organizationInvitation'; import type { OrganizationMembershipResource } from './organizationMembership'; import type { OrganizationSuggestionResource, OrganizationSuggestionStatus } from './organizationSuggestion'; @@ -115,6 +116,7 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { getOrganizationSuggestions: ( params?: GetUserOrganizationSuggestionsParams, ) => Promise>; + getOrganizationCreationDefaults: () => Promise; leaveOrganization: (organizationId: string) => Promise; createTOTP: () => Promise; verifyTOTP: (params: VerifyTOTPParams) => Promise; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index 2cf177d11a4..e0f3ee05eed 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -1,22 +1,28 @@ import { useOrganizationList } from '@clerk/shared/react'; -import type { CreateOrganizationParams } from '@clerk/shared/types'; +import type { CreateOrganizationParams, OrganizationCreationDefaultsResource } from '@clerk/shared/types'; +import { useState } from 'react'; import { useEnvironment } from '@/ui/contexts'; import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; -import { localizationKeys } from '@/ui/customizables'; +import { Icon, localizationKeys } from '@/ui/customizables'; import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; import { Header } from '@/ui/elements/Header'; +import { IconButton } from '@/ui/elements/IconButton'; +import { Upload } from '@/ui/icons'; import { createSlug } from '@/ui/utils/createSlug'; import { handleError } from '@/ui/utils/errorHandler'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { OrganizationProfileAvatarUploader } from '../../../OrganizationProfile/OrganizationProfileAvatarUploader'; import { organizationListParams } from '../../../OrganizationSwitcher/utils'; +import { OrganizationCreationDefaultsAlert } from './OrganizationCreationDefaultsAlert'; type CreateOrganizationScreenProps = { onCancel?: () => void; + organizationCreationDefaults?: OrganizationCreationDefaultsResource; }; export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) => { @@ -27,13 +33,14 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = userMemberships: organizationListParams.userMemberships, }); const { organizationSettings } = useEnvironment(); + const [file, setFile] = useState(); - const nameField = useFormControl('name', '', { + const nameField = useFormControl('name', props.organizationCreationDefaults?.form.name ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__name'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__name'), }); - const slugField = useFormControl('slug', '', { + const slugField = useFormControl('slug', props.organizationCreationDefaults?.form.slug ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__slug'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__slug'), @@ -57,6 +64,15 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = const organization = await createOrganization(createOrgParams); + if (file) { + await organization.setLogo({ file }); + } else if (defaultLogoUrl) { + const response = await fetch(defaultLogoUrl); + const blob = await response.blob(); + const logoFile = new File([blob], 'logo', { type: blob.type }); + await organization.setLogo({ file: logoFile }); + } + await setActive({ organization, navigate: async ({ session }) => { @@ -77,7 +93,13 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = slugField.setValue(val); }; + const onAvatarRemove = () => { + card.setIdle(); + return setFile(null); + }; + const isSubmitButtonDisabled = !nameField.value || !isLoaded; + const defaultLogoUrl = file === undefined ? props.organizationCreationDefaults?.form.logo : undefined; return ( <> @@ -88,8 +110,46 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = + ({ padding: `${t.space.$none} ${t.space.$10} ${t.space.$8}` })}> + + await setFile(file)} + onAvatarRemove={file || defaultLogoUrl ? onAvatarRemove : null} + avatarPreviewPlaceholder={ + ({ + color: t.colors.$colorMutedForeground, + transitionDuration: t.transitionDuration.$controls, + })} + /> + } + sx={t => ({ + width: t.sizes.$16, + height: t.sizes.$16, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$dashed, + borderColor: t.colors.$borderAlpha200, + backgroundColor: t.colors.$neutralAlpha50, + ':hover': { + backgroundColor: t.colors.$neutralAlpha50, + svg: { + transform: 'scale(1.2)', + }, + }, + })} + /> + } + /> + + + ); +} + +const advisoryToLocalizationKey = (advisory?: OrganizationCreationDefaultsResource['advisory']) => { + if (!advisory) { + return null; + } + + switch (advisory.code) { + case 'existing_org_with_domain': + return localizationKeys('taskChooseOrganization.alerts.existingOrgWithDomain', { + email: advisory.meta.email, + }); + default: + return null; + } +}; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index 28952bd96c9..93f5d7a30dc 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -297,4 +297,84 @@ describe('TaskChooseOrganization', () => { expect(queryByText(/contact your organization admin for an invitation/i)).toBeInTheDocument(); }); }); + + describe('with organization creation defaults', () => { + describe('when enabled on environment', () => { + it('displays warning when organization already exists for user email domain', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + fixtures.clerk.user?.getOrganizationCreationDefaults.mockReturnValueOnce( + Promise.resolve({ + advisory: { + code: 'existing_org_with_domain', + severity: 'warning', + meta: { email: 'test@clerk.com' }, + }, + }), + ); + + const { findByText } = render(, { wrapper }); + + expect( + await findByText(/an organization already exists for the detected company name and test@clerk\.com/i), + ).toBeInTheDocument(); + }); + + it('prefills create organization form with defaults', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + fixtures.clerk.user?.getOrganizationCreationDefaults.mockReturnValueOnce( + Promise.resolve({ + form: { + name: 'Test Org', + slug: 'test-org', + logo: null, + }, + }), + ); + + const { findByText } = render(, { wrapper }); + + expect(await findByText('Test Org')).toBeInTheDocument(); + expect(await findByText('test-org')).toBeInTheDocument(); + }); + }); + + describe('when disabled on environment', () => { + it('does not fetch for creation defaults', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(false); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + render(, { wrapper }); + + expect(fixtures.clerk.user?.getOrganizationCreationDefaults).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index 3275a184707..4ce89f856f2 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -1,7 +1,9 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; -import { useState, type ComponentType } from 'react'; +import type { OrganizationCreationDefaultsResource } from '@clerk/shared/types'; +import { type ComponentType, useState } from 'react'; -import { useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; +import { useFetch } from '@/hooks'; +import { useEnvironment, useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; import { descriptors, Flex, Flow, localizationKeys, Spinner } from '@/ui/customizables'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -14,24 +16,20 @@ import { ChooseOrganizationScreen } from './ChooseOrganizationScreen'; import { CreateOrganizationScreen } from './CreateOrganizationScreen'; const TaskChooseOrganizationInternal = () => { - const { signOut } = useClerk(); const { user } = useUser(); - const { session } = useSession(); + const { organizationSettings } = useEnvironment(); const { userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); - const { otherSessions } = useMultipleSessions({ user }); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); - - const handleSignOut = () => { - if (otherSessions.length === 0) { - return signOut(navigateAfterSignOut); - } - - return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); - }; + const organizationCreationDefaults = useFetch( + organizationSettings.organizationCreationDefaults?.enabled ? user?.getOrganizationCreationDefaults : undefined, + 'organization-creation-defaults', + ); - const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; + const isLoading = + userMemberships?.isLoading || + userInvitations?.isLoading || + userSuggestions?.isLoading || + organizationCreationDefaults.isLoading; const hasExistingResources = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count); - const identifier = user?.primaryEmailAddress?.emailAddress ?? user?.username; return ( @@ -55,7 +53,10 @@ const TaskChooseOrganizationInternal = () => { /> ) : ( - + )} @@ -111,6 +112,7 @@ const TaskChooseOrganizationCardFooter = () => { type TaskChooseOrganizationFlowsProps = { initialFlow: 'create' | 'choose'; + organizationCreationDefaults?: OrganizationCreationDefaultsResource | null; }; const TaskChooseOrganizationFlows = withCardStateProvider((props: TaskChooseOrganizationFlowsProps) => { @@ -120,6 +122,7 @@ const TaskChooseOrganizationFlows = withCardStateProvider((props: TaskChooseOrga return ( setCurrentFlow('choose') : undefined} + organizationCreationDefaults={props.organizationCreationDefaults} /> ); } diff --git a/packages/ui/src/elements/AvatarUploader.tsx b/packages/ui/src/elements/AvatarUploader.tsx index f1ae367f2c2..8a7f99b7a0d 100644 --- a/packages/ui/src/elements/AvatarUploader.tsx +++ b/packages/ui/src/elements/AvatarUploader.tsx @@ -90,9 +90,10 @@ export const AvatarUploader = (props: AvatarUploaderProps) => { await handleFileDrop(f); }; + const hasExistingImage = !!(avatarPreview.props as { imageUrl?: string })?.imageUrl; const previewElement = objectUrl ? React.cloneElement(avatarPreview, { imageUrl: objectUrl }) - : avatarPreviewPlaceholder + : avatarPreviewPlaceholder && !hasExistingImage ? React.cloneElement(avatarPreviewPlaceholder, { onClick: openDialog }) : avatarPreview;