diff --git a/.gitignore b/.gitignore index d6d4562bddf..7b44af4edf8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ out **/dist/* **/build/* packages/*/dist/** +**/.pnpm-store/** # dependencies node_modules diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 63405363870..20e6d83c83e 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2406,4 +2406,195 @@ describe('Clerk singleton', () => { }); }); }); + + describe('__internal_attemptToEnableEnvironmentSetting', () => { + afterEach(() => { + mockEnvironmentFetch.mockReset(); + mockClientFetch.mockReset(); + }); + + describe('for organizations', () => { + it('does not open prompt if organizations is enabled in development', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + organizationSettings: { + enabled: true, + }, + }), + ); + + const sut = new Clerk(productionPublishableKey); + + const __internal_openEnableOrganizationsPromptSpy = vi.fn(); + sut.__internal_openEnableOrganizationsPrompt = __internal_openEnableOrganizationsPromptSpy; + + await sut.load(); + + const result = await sut.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + }); + + expect(result?.isEnabled).toBe(true); + expect(__internal_openEnableOrganizationsPromptSpy).not.toHaveBeenCalled(); + }); + + it('does not open prompt if organizations is enabled in production', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => true, + isDevelopmentOrStaging: () => true, + organizationSettings: { + enabled: true, + }, + }), + ); + + const sut = new Clerk(productionPublishableKey); + + const __internal_openEnableOrganizationsPromptSpy = vi.fn(); + sut.__internal_openEnableOrganizationsPrompt = __internal_openEnableOrganizationsPromptSpy; + + await sut.load(); + + const result = await sut.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + }); + + expect(result?.isEnabled).toBe(true); + expect(__internal_openEnableOrganizationsPromptSpy).not.toHaveBeenCalled(); + }); + + it('opens prompt if organizations is disabled in development', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + organizationSettings: { + enabled: false, + }, + }), + ); + + const sut = new Clerk(developmentPublishableKey); + + const __internal_openEnableOrganizationsPromptSpy = vi.fn(); + sut.__internal_openEnableOrganizationsPrompt = __internal_openEnableOrganizationsPromptSpy; + + await sut.load(); + + const result = await sut.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + }); + + expect(result?.isEnabled).toBe(false); + expect(__internal_openEnableOrganizationsPromptSpy).toHaveBeenCalled(); + }); + + it('does not open prompt if organizations is disabled in production', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + organizationSettings: { + enabled: false, + }, + }), + ); + + const sut = new Clerk(productionPublishableKey); + + const __internal_openEnableOrganizationsPromptSpy = vi.fn(); + sut.__internal_openEnableOrganizationsPrompt = __internal_openEnableOrganizationsPromptSpy; + + await sut.load(); + + const result = await sut.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + }); + + expect(result?.isEnabled).toBe(false); + expect(__internal_openEnableOrganizationsPromptSpy).not.toHaveBeenCalled(); + }); + + // Handles case where environment gets enabled via BAPI, but it gets cached and the user is redirected to the choose-organization task + // The enable org prompt should not appear in the task screen since orgs have already been enabled + it('does not open prompt if organizations is disabled in development and session has choose-organization task', async () => { + const mockSession = { + id: '1', + remove: vi.fn(), + status: 'pending', + user: {}, + touch: vi.fn(() => Promise.resolve()), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + tasks: [{ key: 'choose-organization' }], + currentTask: { key: 'choose-organization' }, + reload: vi.fn(() => + Promise.resolve({ + id: '1', + status: 'pending', + user: {}, + tasks: [{ key: 'choose-organization' }], + currentTask: { + key: 'choose-organization', + }, + }), + ), + }; + + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + organizationSettings: { + enabled: false, + }, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + }), + ); + + const sut = new Clerk(developmentPublishableKey); + + const __internal_openEnableOrganizationsPromptSpy = vi.fn(); + sut.__internal_openEnableOrganizationsPrompt = __internal_openEnableOrganizationsPromptSpy; + + await sut.load(); + + const result = await sut.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + }); + + // Contains the organization task, so the prompt should not be opened + expect(result?.isEnabled).toBe(true); + expect(__internal_openEnableOrganizationsPromptSpy).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 8501c15b759..8d1334cd9e5 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -44,7 +44,10 @@ import { } from '@clerk/shared/telemetry'; import type { __experimental_CheckoutOptions, + __internal_AttemptToEnableEnvironmentSettingParams, + __internal_AttemptToEnableEnvironmentSettingResult, __internal_CheckoutProps, + __internal_EnableOrganizationsPromptProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, __internal_SubscriptionDetailsProps, @@ -745,6 +748,62 @@ export class Clerk implements ClerkInterface { void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('userVerification')); }; + public __internal_attemptToEnableEnvironmentSetting = ( + params: __internal_AttemptToEnableEnvironmentSettingParams, + ): __internal_AttemptToEnableEnvironmentSettingResult => { + const { for: setting, caller } = params; + + if (!this.user && this.#instanceType === 'development') { + logger.warnOnce( + `Clerk: "${caller}" requires an active user session. Ensure a user is signed in before executing ${caller}.`, + ); + } + + switch (setting) { + case 'organizations': { + const isSettingDisabled = + disabledOrganizationsFeature(this, this.environment) && + // Handles case where environment gets enabled via BAPI, but it gets cached and the user is redirected to the choose-organization task + // The enable org prompt should not appear in the task screen since orgs have already been enabled + this.session?.currentTask?.key !== 'choose-organization'; + + if (!isSettingDisabled) { + return { isEnabled: true }; + } + + if (this.#instanceType === 'development') { + this.__internal_openEnableOrganizationsPrompt({ + caller, + // Reload current window to all invalidate all resources + // related to organizations, eg: roles + onSuccess: () => window.location.reload(), + onClose: params.onClose, + } as __internal_EnableOrganizationsPromptProps); + } + + return { isEnabled: false }; + } + default: + throw new Error(`Attempted to enable an unknown or unsupported setting "${setting}".`); + } + }; + + public __internal_openEnableOrganizationsPrompt = ( + props: __internal_EnableOrganizationsPromptProps, + ): Promise => { + this.assertComponentsReady(this.#clerkUi); + return this.#clerkUi + .then(ui => ui.ensureMounted()) + .then(controls => controls.openModal('enableOrganizationsPrompt', props || {})); + }; + + public __internal_closeEnableOrganizationsPrompt = (): Promise => { + this.assertComponentsReady(this.#clerkUi); + return this.#clerkUi + .then(ui => ui.ensureMounted()) + .then(controls => controls.closeModal('enableOrganizationsPrompt')); + }; + public __internal_openBlankCaptchaModal = (): Promise => { this.assertComponentsReady(this.#clerkUi); return this.#clerkUi.then(ui => ui.ensureMounted()).then(controls => controls.openModal('blankCaptcha', {})); @@ -805,14 +864,20 @@ export class Clerk implements ClerkInterface { }; public openOrganizationProfile = (props?: OrganizationProfileProps): void => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationProfile', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('OrganizationProfile'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + if (noOrganizationExists(this)) { if (this.#instanceType === 'development') { throw new ClerkRuntimeError(warnings.cannotRenderComponentWhenOrgDoesNotExist, { @@ -821,6 +886,7 @@ export class Clerk implements ClerkInterface { } return; } + this.assertComponentsReady(this.#clerkUi); void this.#clerkUi .then(ui => ui.ensureMounted()) @@ -834,14 +900,20 @@ export class Clerk implements ClerkInterface { }; public openCreateOrganization = (props?: CreateOrganizationProps): void => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'CreateOrganization', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('CreateOrganization'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + this.assertComponentsReady(this.#clerkUi); void this.#clerkUi .then(ui => ui.ensureMounted()) @@ -960,14 +1032,20 @@ export class Clerk implements ClerkInterface { }; public mountOrganizationProfile = (node: HTMLDivElement, props?: OrganizationProfileProps) => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationProfile', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('OrganizationProfile'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + const userExists = !noUserExists(this); if (noOrganizationExists(this) && userExists) { if (this.#instanceType === 'development') { @@ -977,6 +1055,7 @@ export class Clerk implements ClerkInterface { } return; } + this.assertComponentsReady(this.#clerkUi); const component = 'OrganizationProfile'; void this.#clerkUi @@ -998,14 +1077,20 @@ export class Clerk implements ClerkInterface { }; public mountCreateOrganization = (node: HTMLDivElement, props?: CreateOrganizationProps) => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'CreateOrganization', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('CreateOrganization'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + this.assertComponentsReady(this.#clerkUi); const component = 'CreateOrganization'; void this.#clerkUi @@ -1027,14 +1112,20 @@ export class Clerk implements ClerkInterface { }; public mountOrganizationSwitcher = (node: HTMLDivElement, props?: OrganizationSwitcherProps) => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationSwitcher', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('OrganizationSwitcher'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + this.assertComponentsReady(this.#clerkUi); const component = 'OrganizationSwitcher'; void this.#clerkUi @@ -1066,14 +1157,20 @@ export class Clerk implements ClerkInterface { }; public mountOrganizationList = (node: HTMLDivElement, props?: OrganizationListProps) => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'OrganizationList', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('OrganizationList'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } + this.assertComponentsReady(this.#clerkUi); const component = 'OrganizationList'; void this.#clerkUi @@ -1260,12 +1357,17 @@ export class Clerk implements ClerkInterface { }; public mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps) => { - if (disabledOrganizationsFeature(this, this.environment)) { - if (this.#instanceType === 'development') { + const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({ + for: 'organizations', + caller: 'TaskChooseOrganization', + onClose: () => { throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskChooseOrganization'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); - } + }, + }); + + if (!isOrganizationsEnabled) { return; } diff --git a/packages/clerk-js/src/core/resources/DevTools.ts b/packages/clerk-js/src/core/resources/DevTools.ts new file mode 100644 index 00000000000..9a517858604 --- /dev/null +++ b/packages/clerk-js/src/core/resources/DevTools.ts @@ -0,0 +1,21 @@ +import type { ClerkResourceJSON, DevToolsResource, EnableEnvironmentSettingParams } from '@clerk/shared/types'; + +import { BaseResource } from './Base'; + +/** + * @internal + */ +export class DevTools extends BaseResource implements DevToolsResource { + pathRoot = '/dev_tools'; + + protected fromJSON(_data: ClerkResourceJSON | null): this { + return this; + } + + async __internal_enableEnvironmentSetting(params: EnableEnvironmentSettingParams) { + await this._basePatch({ + path: `${this.pathRoot}/enable_environment_setting`, + body: params, + }); + } +} diff --git a/packages/clerk-js/src/core/resources/index.ts b/packages/clerk-js/src/core/resources/index.ts index b07196edac8..d137d5d588b 100644 --- a/packages/clerk-js/src/core/resources/index.ts +++ b/packages/clerk-js/src/core/resources/index.ts @@ -1,6 +1,7 @@ export * from './AuthConfig'; export * from './Client'; export * from './DeletedObject'; +export * from './DevTools'; export * from './DisplayConfig'; export * from './EmailAddress'; export * from './Environment'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 85a7d0e1399..f620246994b 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -2,7 +2,10 @@ import { inBrowser } from '@clerk/shared/browser'; import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { loadClerkJsScript, loadClerkUiScript } from '@clerk/shared/loadClerkJsScript'; import type { + __internal_AttemptToEnableEnvironmentSettingParams, + __internal_AttemptToEnableEnvironmentSettingResult, __internal_CheckoutProps, + __internal_EnableOrganizationsPromptProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, __internal_SubscriptionDetailsProps, @@ -124,6 +127,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; private preopenOneTap?: null | GoogleOneTapProps = null; private preopenUserVerification?: null | __internal_UserVerificationProps = null; + private preopenEnableOrganizationsPrompt?: null | __internal_EnableOrganizationsPromptProps = null; private preopenSignIn?: null | SignInProps = null; private preopenCheckout?: null | __internal_CheckoutProps = null; private preopenPlanDetails: null | __internal_PlanDetailsProps = null; @@ -637,6 +641,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openWaitlist(this.preOpenWaitlist); } + if (this.preopenEnableOrganizationsPrompt) { + clerkjs.__internal_openEnableOrganizationsPrompt(this.preopenEnableOrganizationsPrompt); + } + this.premountSignInNodes.forEach((props, node) => { clerkjs.mountSignIn(node, props); }); @@ -880,6 +888,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_openEnableOrganizationsPrompt = (props: __internal_EnableOrganizationsPromptProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_openEnableOrganizationsPrompt(props); + } else { + this.preopenEnableOrganizationsPrompt = props; + } + }; + + __internal_closeEnableOrganizationsPrompt = () => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_closeEnableOrganizationsPrompt(); + } else { + this.preopenEnableOrganizationsPrompt = null; + } + }; + openGoogleOneTap = (props?: GoogleOneTapProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.openGoogleOneTap(props); @@ -1477,4 +1501,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.premountMethodCalls.set('signOut', callback); } }; + + __internal_attemptToEnableEnvironmentSetting = ( + options: __internal_AttemptToEnableEnvironmentSettingParams, + ): __internal_AttemptToEnableEnvironmentSettingResult | void => { + const callback = () => this.clerkjs?.__internal_attemptToEnableEnvironmentSetting(options); + if (this.clerkjs && this.loaded) { + return callback() as __internal_AttemptToEnableEnvironmentSettingResult; + } else { + this.premountMethodCalls.set('__internal_attemptToEnableEnvironmentSetting', callback); + } + }; } diff --git a/packages/shared/src/react/__tests__/payment-element.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx index e256836e4ae..b5ebf5ce578 100644 --- a/packages/shared/src/react/__tests__/payment-element.test.tsx +++ b/packages/shared/src/react/__tests__/payment-element.test.tsx @@ -53,12 +53,6 @@ vi.mock('../hooks/useUser', () => ({ }), })); -vi.mock('../hooks/useOrganization', () => ({ - useOrganization: () => ({ - organization: null, - }), -})); - const mockInitializePaymentMethod = vi.fn().mockResolvedValue({ externalGatewayId: 'acct_123', externalClientSecret: 'seti_123', diff --git a/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts new file mode 100644 index 00000000000..68a178b90dd --- /dev/null +++ b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react'; + +import { useClerk } from './useClerk'; + +/** + * Attempts to enable the organizations environment setting for a given caller + * + * @internal + */ +export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') { + const clerk = useClerk(); + const hasAttempted = useRef(false); + + useEffect(() => { + // Guard to not run this effect twice on Clerk resource update + if (hasAttempted.current) { + return; + } + + hasAttempted.current = true; + // Optional chaining is important for `@clerk/clerk-react` usage with older clerk-js versions that don't have the method + clerk.__internal_attemptToEnableEnvironmentSetting?.({ + for: 'organizations', + caller, + }); + }, [clerk, caller]); +} diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx index 6b735d6125e..0a15152e90e 100644 --- a/packages/shared/src/react/hooks/useOrganization.tsx +++ b/packages/shared/src/react/hooks/useOrganization.tsx @@ -20,6 +20,7 @@ import { import { STABLE_KEYS } from '../stable-keys'; import type { PaginatedHookConfig, PaginatedResources, PaginatedResourcesWithDefault } from '../types'; import { createCacheKeys } from './createCacheKeys'; +import { useAttemptToEnableOrganizations } from './useAttemptToEnableOrganizations'; import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; /** @@ -280,6 +281,7 @@ export function useOrganization(params?: T): Us } = params || {}; useAssertWrappedByClerkProvider('useOrganization'); + useAttemptToEnableOrganizations('useOrganization'); const { organization } = useOrganizationContext(); const session = useSessionContext(); diff --git a/packages/shared/src/react/hooks/useOrganizationList.tsx b/packages/shared/src/react/hooks/useOrganizationList.tsx index 555dde39f0b..7d5eaf821d7 100644 --- a/packages/shared/src/react/hooks/useOrganizationList.tsx +++ b/packages/shared/src/react/hooks/useOrganizationList.tsx @@ -14,6 +14,7 @@ import { useAssertWrappedByClerkProvider, useClerkInstanceContext, useUserContex import { STABLE_KEYS } from '../stable-keys'; import type { PaginatedHookConfig, PaginatedResources, PaginatedResourcesWithDefault } from '../types'; import { createCacheKeys } from './createCacheKeys'; +import { useAttemptToEnableOrganizations } from './useAttemptToEnableOrganizations'; import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; /** @@ -251,6 +252,7 @@ export function useOrganizationList(params? const { userMemberships, userInvitations, userSuggestions } = params || {}; useAssertWrappedByClerkProvider('useOrganizationList'); + useAttemptToEnableOrganizations('useOrganizationList'); const userMembershipsSafeValues = useWithSafeValues(userMemberships, { initialPage: 1, diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 57a7a153c01..419a6015bf3 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -331,6 +331,23 @@ export interface Clerk { */ __internal_closeReverification: () => void; + /** + * Attempts to enable a environment setting from a development instance, prompting if disabled. + */ + __internal_attemptToEnableEnvironmentSetting: ( + options: __internal_AttemptToEnableEnvironmentSettingParams, + ) => __internal_AttemptToEnableEnvironmentSettingResult; + + /** + * Opens the Clerk Enable Organizations prompt for development instance + */ + __internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void; + + /** + * Closes the Clerk Enable Organizations modal. + */ + __internal_closeEnableOrganizationsPrompt: () => void; + /** * Opens the Google One Tap component. * @@ -1420,25 +1437,33 @@ export type __internal_UserVerificationProps = RoutingOptions & { export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>; -export type __internal_ComponentNavigationContext = { - /** - * The `navigate` reference within the component router context - */ - navigate: ( - to: string, - options?: { - searchParams?: URLSearchParams; - }, - ) => Promise; - /** - * This path represents the root route for a specific component type and is used - * for internal routing and navigation. - * - * @example - * indexPath: '/sign-in' // When - * indexPath: '/sign-up' // When - */ - indexPath: string; +export type __internal_EnableOrganizationsPromptProps = { + onSuccess?: () => void; + onClose?: () => void; +} & { + caller: + | 'OrganizationSwitcher' + | 'OrganizationProfile' + | 'OrganizationList' + | 'useOrganizationList' + | 'useOrganization'; +}; + +export type __internal_AttemptToEnableEnvironmentSettingParams = { + for: 'organizations'; + caller: + | 'OrganizationSwitcher' + | 'OrganizationProfile' + | 'OrganizationList' + | 'CreateOrganization' + | 'TaskChooseOrganization' + | 'useOrganizationList' + | 'useOrganization'; + onClose?: () => void; +}; + +export type __internal_AttemptToEnableEnvironmentSettingResult = { + isEnabled: boolean; }; type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; diff --git a/packages/shared/src/types/devtools.ts b/packages/shared/src/types/devtools.ts new file mode 100644 index 00000000000..93a46416245 --- /dev/null +++ b/packages/shared/src/types/devtools.ts @@ -0,0 +1,13 @@ +import type { ClerkResource } from './resource'; + +export type EnableEnvironmentSettingParams = { + enable_organizations: boolean; + organization_allow_personal_accounts?: boolean; +}; + +/** + * @internal + */ +export interface DevToolsResource extends ClerkResource { + __internal_enableEnvironmentSetting: (params: EnableEnvironmentSettingParams) => Promise; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b2cc2a5a314..85cecc41a27 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,6 +11,7 @@ export type * from './commerceSettings'; export type * from './customMenuItems'; export type * from './customPages'; export type * from './deletedObject'; +export type * from './devtools'; export type * from './displayConfig'; export type * from './emailAddress'; export type * from './enterpriseAccount'; diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 8c2ac885f8e..23102740559 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -2,6 +2,7 @@ import { clerkUIErrorDOMElementNotFound } from '@clerk/shared/internal/clerk-js/ import type { ModuleManager } from '@clerk/shared/moduleManager'; import type { __internal_CheckoutProps, + __internal_EnableOrganizationsPromptProps, __internal_PlanDetailsProps, __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, @@ -28,6 +29,7 @@ import type { ClerkComponentName } from './lazyModules/components'; import { BlankCaptchaModal, CreateOrganizationModal, + EnableOrganizationsPrompt, ImpersonationFab, KeylessPrompt, OrganizationProfileModal, @@ -41,6 +43,7 @@ import { import { MountedCheckoutDrawer, MountedPlanDetailDrawer, MountedSubscriptionDetailDrawer } from './lazyModules/drawers'; import { LazyComponentRenderer, + LazyEnableOrganizationsPromptProvider, LazyImpersonationFabProvider, LazyModalRenderer, LazyOneTapRenderer, @@ -86,7 +89,8 @@ export type ComponentControls = { | 'createOrganization' | 'userVerification' | 'waitlist' - | 'blankCaptcha', + | 'blankCaptcha' + | 'enableOrganizationsPrompt', >( modal: T, props: T extends 'signIn' @@ -97,7 +101,9 @@ export type ComponentControls = { ? __internal_UserVerificationProps : T extends 'waitlist' ? WaitlistProps - : UserProfileProps, + : T extends 'enableOrganizationsPrompt' + ? __internal_EnableOrganizationsPromptProps + : UserProfileProps, ) => void; closeModal: ( modal: @@ -109,7 +115,8 @@ export type ComponentControls = { | 'createOrganization' | 'userVerification' | 'waitlist' - | 'blankCaptcha', + | 'blankCaptcha' + | 'enableOrganizationsPrompt', options?: { notify?: boolean; }, @@ -160,6 +167,7 @@ interface ComponentsState { userVerificationModal: null | __internal_UserVerificationProps; organizationProfileModal: null | OrganizationProfileProps; createOrganizationModal: null | CreateOrganizationProps; + enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps; blankCaptchaModal: null; organizationSwitcherPrefetch: boolean; waitlistModal: null | WaitlistProps; @@ -265,6 +273,7 @@ const Components = (props: ComponentsProps) => { userVerificationModal: null, organizationProfileModal: null, createOrganizationModal: null, + enableOrganizationsPromptModal: null, organizationSwitcherPrefetch: false, waitlistModal: null, blankCaptchaModal: null, @@ -345,9 +354,10 @@ const Components = (props: ComponentsProps) => { clearUrlStateParam(); setState(s => { function handleCloseModalForExperimentalUserVerification() { - const modal = s[`${name}Modal`] || {}; + const modal = s[`${name}Modal`]; if (modal && typeof modal === 'object' && 'afterVerificationCancelled' in modal && notify) { - modal.afterVerificationCancelled?.(); + // TypeScript doesn't narrow properly with template literal access and 'in' operator + (modal as { afterVerificationCancelled?: () => void }).afterVerificationCancelled?.(); } } @@ -362,6 +372,20 @@ const Components = (props: ComponentsProps) => { }; componentsControls.openModal = (name, props) => { + // Prevent opening enableOrganizations prompt if it's already open + // It should open the first call and ignore the subsequent calls + if (name === 'enableOrganizationsPrompt') { + setState(prev => { + // Modal is already open, don't update state + if (prev.enableOrganizationsPromptModal) { + return prev; + } + + return { ...prev, [`${name}Modal`]: props }; + }); + return; + } + function handleCloseModalForExperimentalUserVerification() { if (!('afterVerificationCancelled' in props)) { return; @@ -633,6 +657,12 @@ const Components = (props: ComponentsProps) => { )} + {state.enableOrganizationsPromptModal && ( + + + + )} + {state.options?.__internal_keyless_claimKeylessApplicationUrl && state.options?.__internal_keyless_copyInstanceKeysUrl && ( diff --git a/packages/ui/src/components/APIKeys/APIKeys.tsx b/packages/ui/src/components/APIKeys/APIKeys.tsx index f6f83d406a0..0c12dd31bc2 100644 --- a/packages/ui/src/components/APIKeys/APIKeys.tsx +++ b/packages/ui/src/components/APIKeys/APIKeys.tsx @@ -1,6 +1,11 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { isOrganizationId } from '@clerk/shared/internal/clerk-js/organization'; -import { __experimental_useAPIKeys as useAPIKeys, useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import { + __experimental_useAPIKeys as useAPIKeys, + useClerk, + useOrganizationContext, + useUser, +} from '@clerk/shared/react'; import type { APIKeyResource } from '@clerk/shared/types'; import { lazy, useState } from 'react'; @@ -234,9 +239,10 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr const _APIKeys = () => { const ctx = useAPIKeysContext(); const { user } = useUser(); - const { organization } = useOrganization(); + // Do not use `useOrganization` to avoid triggering the in-app enable organizations prompt in development instance + const organizationCtx = useOrganizationContext(); - const subject = organization?.id ?? user?.id ?? ''; + const subject = organizationCtx?.organization?.id ?? user?.id ?? ''; return ( - - - - - - - - - - - - - - - - - - - ); -} diff --git a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx new file mode 100644 index 00000000000..aab44a915e0 --- /dev/null +++ b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx @@ -0,0 +1,666 @@ +import { useClerk } from '@clerk/shared/react'; +import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettingParams } from '@clerk/shared/types'; +// eslint-disable-next-line no-restricted-imports +import type { SerializedStyles } from '@emotion/react'; +// eslint-disable-next-line no-restricted-imports +import { css, type Theme } from '@emotion/react'; +import { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react'; + +import { useEnvironment } from '@/ui/contexts'; +import { Modal } from '@/ui/elements/Modal'; +import { common, InternalThemeProvider } from '@/ui/styledSystem'; + +import { Box, Flex, Span } from '../../../customizables'; +import { Portal } from '../../../elements/Portal'; +import { basePromptElementStyles, ClerkLogoIcon, PromptContainer, PromptSuccessIcon } from '../shared'; + +const organizationsDashboardUrl = 'https://dashboard.clerk.com/~/organizations-settings'; + +const EnableOrganizationsPromptInternal = ({ + caller, + onSuccess, + onClose, +}: __internal_EnableOrganizationsPromptProps) => { + const clerk = useClerk(); + const [isLoading, setIsLoading] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + const [allowPersonalAccount, setAllowPersonalAccount] = useState(false); + + const initialFocusRef = useRef(null); + const environment = useEnvironment(); + + const isComponent = !caller.startsWith('use'); + + // 'forceOrganizationSelection' is omitted from the environment settings object if the instance does not have it available as a feature + const hasPersonalAccountsEnabled = + typeof environment?.organizationSettings.forceOrganizationSelection !== 'undefined'; + + const handleEnableOrganizations = () => { + setIsLoading(true); + + const params: EnableEnvironmentSettingParams = { + enable_organizations: true, + }; + + if (hasPersonalAccountsEnabled) { + params.organization_allow_personal_accounts = allowPersonalAccount; + } + + void new DevTools() + .__internal_enableEnvironmentSetting(params) + .then(() => { + setIsEnabled(true); + setIsLoading(false); + }) + .catch(() => { + setIsLoading(false); + }); + }; + + return ( + + ({ alignItems: 'center' })} + initialFocusRef={initialFocusRef} + > + ({ + display: 'flex', + flexDirection: 'column', + width: '30rem', + maxWidth: 'calc(100vw - 2rem)', + })} + > + ({ + padding: `${t.sizes.$4} ${t.sizes.$6}`, + paddingBottom: t.sizes.$4, + gap: t.sizes.$2, + })} + > + ({ + gap: t.sizes.$2, + })} + > + + +

+ {isEnabled ? 'Organizations feature enabled' : 'Organizations feature required'} +

+
+ + ({ + gap: t.sizes.$0x5, + })} + > + {isEnabled ? ( +

+ {clerk.user + ? `The Organizations feature has been enabled for your application. A default organization named "My Organization" was created automatically. You can manage or rename it in your` + : `The Organizations feature has been enabled for your application. You can manage it in your`}{' '} + + dashboard + + . +

+ ) : ( + <> +

+ Enable Organizations to use{' '} + + {isComponent ? `<${caller} />` : caller} + {' '} +

+ + + Learn more + + + )} +
+ + {hasPersonalAccountsEnabled && ( + ({ + display: 'grid', + gridTemplateRows: isEnabled ? '0fr' : '1fr', + transition: `grid-template-rows ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`, + marginInline: '-0.5rem', + overflow: 'hidden', + })} + {...(isEnabled && { inert: '' })} + > + ({ + minHeight: 0, + paddingInline: '0.5rem', + opacity: isEnabled ? 0 : 1, + transition: `opacity ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`, + })} + > + ({ marginTop: t.sizes.$2 })}> + setAllowPersonalAccount(prev => !prev)} + /> + + + + )} +
+ + + + ({ + padding: `${t.sizes.$4} ${t.sizes.$6}`, + gap: t.sizes.$3, + justifyContent: 'flex-end', + })} + > + {isEnabled ? ( + { + if (!clerk.user) { + void clerk.redirectToSignIn(); + clerk.__internal_closeEnableOrganizationsPrompt?.(); + } else { + onSuccess?.(); + } + }} + > + {clerk.user ? 'Continue' : 'Sign in to continue'} + + ) : ( + <> + { + clerk?.__internal_closeEnableOrganizationsPrompt?.(); + onClose?.(); + }} + > + I'll remove it myself + + + + Enable Organizations + + + )} + +
+
+
+ ); +}; + +/** + * A prompt that allows the user to enable the Organizations feature for their development instance + * @internal + */ +export const EnableOrganizationsPrompt = (props: __internal_EnableOrganizationsPromptProps) => { + return ( + + + + ); +}; + +const baseButtonStyles = css` + ${basePromptElementStyles}; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 1.75rem; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.12px; + color: white; + text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32); + white-space: nowrap; + user-select: none; + color: white; + outline: none; + + &:not(:disabled) { + transition: 120ms ease-in-out; + transition-property: background-color, border-color, box-shadow, color; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:focus-visible:not(:disabled) { + outline: 2px solid white; + outline-offset: 2px; + } +`; + +const buttonSolidStyles = css` + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545; + box-shadow: + 0 0 3px 0 rgba(253, 224, 71, 0) inset, + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, + 0 0 0 1px rgba(0, 0, 0, 0.12), + 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); + + &:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.15) 100%), #5f5f5f; + box-shadow: + 0 0 3px 0 rgba(253, 224, 71, 0) inset, + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, + 0 0 0 1px rgba(0, 0, 0, 0.12), + 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); + } +`; + +const buttonOutlineStyles = css` + border: 1px solid rgba(118, 118, 132, 0.25); + background: rgba(69, 69, 69, 0.1); + + &:hover:not(:disabled) { + border-color: rgba(118, 118, 132, 0.5); + } +`; + +const buttonVariantStyles = { + solid: buttonSolidStyles, + outline: buttonOutlineStyles, +} as const; + +type PromptButtonVariant = keyof typeof buttonVariantStyles; + +type PromptButtonProps = Pick, 'onClick' | 'children' | 'disabled'> & { + variant?: PromptButtonVariant; +}; + +const PromptButton = forwardRef(({ variant = 'solid', ...props }, ref) => { + return ( +