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..460b63751ed --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts @@ -0,0 +1,69 @@ +import type { + OrganizationCreationAdvisorySeverity, + OrganizationCreationAdvisoryType, + OrganizationCreationDefaultsJSON, + OrganizationCreationDefaultsJSONSnapshot, + OrganizationCreationDefaultsResource, +} from '@clerk/shared/types'; + +import { BaseResource } from './internal'; + +export class OrganizationCreationDefaults extends BaseResource implements OrganizationCreationDefaultsResource { + advisory: { + type: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + } | 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 + ? { + type: this.advisory.type, + 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/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 3ad74f109f2..211d46a6d5e 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -865,6 +865,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 domain. 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 8208b1e185d..6f7c3dd23de 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1290,6 +1290,9 @@ export type __internal_LocalizationResource = { action__createOrganization: LocalizationValue; action__invitationAccept: LocalizationValue; }; + alerts: { + existingOrgWithDomain: LocalizationValue; + }; }; 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..4b3cd280f19 --- /dev/null +++ b/packages/shared/src/types/organizationCreationDefaults.ts @@ -0,0 +1,30 @@ +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: { + type: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + } | null; + form: { + name: string; + slug: string; + logo: string | null; + }; +} + +export interface OrganizationCreationDefaultsResource extends ClerkResource { + advisory: { + type: OrganizationCreationAdvisoryType; + severity: OrganizationCreationAdvisorySeverity; + } | 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/ui/src/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/ui/src/components/CreateOrganization/CreateOrganizationForm.tsx index 0bc7f29a13d..f206a91f31d 100644 --- a/packages/ui/src/components/CreateOrganization/CreateOrganizationForm.tsx +++ b/packages/ui/src/components/CreateOrganization/CreateOrganizationForm.tsx @@ -36,6 +36,7 @@ type CreateOrganizationFormProps = { }; }; +// TODO -> Prefill form with organization creation defaults export const CreateOrganizationForm = withCardStateProvider((props: CreateOrganizationFormProps) => { const card = useCardState(); const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index 2cf177d11a4..3566f6a6e20 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -3,17 +3,37 @@ import type { CreateOrganizationParams } from '@clerk/shared/types'; 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'; + +// TODO: Replace with actual API call to OrganizationCreationDefaults.retrieve() +// TODO - Only replace if .organization_settings.organization_creation_defaults.enabled +const organizationCreationDefaults = { + advisory: { + type: 'existing_org_with_domain' as const, + severity: 'warning' as const, + }, + form: { + name: '', + slug: '', + logo: null, + }, + pathRoot: '', + reload: () => Promise.resolve({} as any), +}; type CreateOrganizationScreenProps = { onCancel?: () => void; @@ -27,6 +47,7 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = userMemberships: organizationListParams.userMemberships, }); const { organizationSettings } = useEnvironment(); + const [file, setFile] = React.useState(); const nameField = useFormControl('name', '', { type: 'text', @@ -57,6 +78,10 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = const organization = await createOrganization(createOrgParams); + if (file) { + await organization.setLogo({ file }); + } + await setActive({ organization, navigate: async ({ session }) => { @@ -77,6 +102,11 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = slugField.setValue(val); }; + const onAvatarRemove = () => { + card.setIdle(); + return setFile(null); + }; + const isSubmitButtonDisabled = !nameField.value || !isLoaded; return ( @@ -88,8 +118,46 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = + ({ padding: `${t.space.$none} ${t.space.$10} ${t.space.$8}` })}> + + await setFile(file)} + onAvatarRemove={file ? 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 advisoryTypeToLocalizationKey: Record = { + existing_org_with_domain: localizationKeys('taskChooseOrganization.alerts.existingOrgWithDomain'), +}; 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 f26e4fae9d2..4d0b34fc850 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 @@ -278,4 +278,10 @@ describe('TaskChooseOrganization', () => { expect(queryByLabelText(/Slug/i)).toBeInTheDocument(); }); }); + + describe('with organization creation defaults', () => { + it.todo('displays warning when organization already exists for user email domain'); + + it.todo('prefills create organization form with defaults'); + }); });