diff --git a/.changeset/bright-cats-lay.md b/.changeset/bright-cats-lay.md new file mode 100644 index 00000000000..a3e152d99a6 --- /dev/null +++ b/.changeset/bright-cats-lay.md @@ -0,0 +1,20 @@ +--- +'@clerk/clerk-js': minor +--- + +Introduce `` component. + +It allows you to eject the organization selection task flow from the default `SignIn` and `SignUp` components and render it on custom URL paths using `taskUrls`. + +Usage example: +```tsx + + + +``` + +```tsx +function OnboardingSelectOrganization() { + return +} +``` diff --git a/.changeset/vast-places-tap.md b/.changeset/vast-places-tap.md new file mode 100644 index 00000000000..86b378d964c --- /dev/null +++ b/.changeset/vast-places-tap.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': patch +--- + +Add TypeScript types for `` component. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5d1de0e53ee..d4f48ae67b1 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -76,6 +76,7 @@ import type { SignUpProps, SignUpRedirectOptions, SignUpResource, + TaskSelectOrganizationProps, UnsubscribeCallback, UserButtonProps, UserProfileProps, @@ -1164,6 +1165,35 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); }; + public mountTaskSelectOrganization = (node: HTMLDivElement, props?: TaskSelectOrganizationProps) => { + this.assertComponentsReady(this.#componentControls); + + if (disabledOrganizationsFeature(this, this.environment)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskSelectOrganization'), { + code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, + }); + } + return; + } + + void this.#componentControls.ensureMounted({ preloadHint: 'TaskSelectOrganization' }).then(controls => + controls.mountComponent({ + name: 'TaskSelectOrganization', + appearanceKey: 'taskSelectOrganization', + node, + props, + }), + ); + + this.telemetry?.record(eventPrebuiltComponentMounted('TaskSelectOrganization', props)); + }; + + public unmountTaskSelectOrganization = (node: HTMLDivElement) => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); + }; + /** * `setActive` can be used to set the active session and/or organization. */ diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 134e4b44f6c..7dd3c1e31ed 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -27,23 +27,25 @@ export function navigateToTask( routeKey: keyof typeof INTERNAL_SESSION_TASK_ROUTE_BY_KEY, { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, ) { - const taskRoute = `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[routeKey]}`; + const customTaskUrl = options?.taskUrls?.[routeKey]; + const internalTaskRoute = `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[routeKey]}`; - if (componentNavigationContext) { - return componentNavigationContext.navigate(componentNavigationContext.indexPath + taskRoute); + if (componentNavigationContext && !customTaskUrl) { + return componentNavigationContext.navigate(componentNavigationContext.indexPath + internalTaskRoute); } const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); - const sessionTaskUrl = buildURL( - { - base: isReferrerSignUpUrl ? signUpUrl : signInUrl, - hashPath: taskRoute, - }, - { stringify: true }, + return globalNavigate( + customTaskUrl ?? + buildURL( + { + base: isReferrerSignUpUrl ? signUpUrl : signInUrl, + hashPath: internalTaskRoute, + }, + { stringify: true }, + ), ); - - return globalNavigate(options.taskUrls?.[routeKey] ?? sessionTaskUrl); } diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 7afd2e4c493..e9e774d042c 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -5,7 +5,12 @@ const formatWarning = (msg: string) => { }; const createMessageForDisabledOrganizations = ( - componentName: 'OrganizationProfile' | 'OrganizationSwitcher' | 'OrganizationList' | 'CreateOrganization', + componentName: + | 'OrganizationProfile' + | 'OrganizationSwitcher' + | 'OrganizationList' + | 'CreateOrganization' + | 'TaskSelectOrganization', ) => { return formatWarning( `The <${componentName}/> cannot be rendered when the feature is turned off. Visit 'dashboard.clerk.com' to enable the feature. Since the feature is turned off, this is no-op.`, @@ -23,6 +28,8 @@ const warnings = { 'The component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the value set in `afterSignUp` URL instead.', cannotRenderSignUpComponentWhenTaskExists: 'The component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.', + cannotRenderComponentWhenTaskDoesNotExist: + ' cannot render unless a session task is pending. Clerk is redirecting to the value set in `redirectUrlComplete` instead.', cannotRenderSignInComponentWhenSessionExists: 'The component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.', cannotRenderSignInComponentWhenTaskExists: diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx index b7d653ce884..9a88ef12eda 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx @@ -1,4 +1,4 @@ -import { useOrganization, useOrganizationList } from '@clerk/shared/react'; +import { useClerk, useOrganization, useOrganizationList } from '@clerk/shared/react'; import type { CreateOrganizationParams, OrganizationResource } from '@clerk/types'; import React, { useContext } from 'react'; @@ -41,6 +41,7 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani const card = useCardState(); const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); const sessionTasksContext = useContext(SessionTasksContext); + const clerk = useClerk(); const lastCreatedOrganizationRef = React.useRef(null); const { createOrganization, isLoaded, setActive, userMemberships } = useOrganizationList({ @@ -89,13 +90,15 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani lastCreatedOrganizationRef.current = organization; await setActive({ organization }); - void userMemberships.revalidate?.(); - if (sessionTasksContext) { - await sessionTasksContext.nextTask(); + await clerk.__internal_navigateToTaskIfAvailable({ + redirectUrlComplete: sessionTasksContext.redirectUrlComplete, + }); return; } + void userMemberships.revalidate?.(); + if (props.skipInvitationScreen ?? organization.maxAllowedMemberships === 1) { return completeFlow(); } diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx index bd01736c5cd..893f7f140ed 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -1,4 +1,4 @@ -import { useOrganizationList, useUser } from '@clerk/shared/react'; +import { useClerk, useOrganizationList, useUser } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/types'; import { useContext } from 'react'; @@ -15,6 +15,7 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O const card = useCardState(); const { navigateAfterSelectOrganization } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); + const clerk = useClerk(); const sessionTasksContext = useContext(SessionTasksContext); if (!isLoaded) { @@ -26,8 +27,10 @@ export const MembershipPreview = withCardStateProvider((props: { organization: O organization, }); - if (sessionTasksContext?.nextTask) { - return sessionTasksContext?.nextTask(); + if (sessionTasksContext) { + return clerk.__internal_navigateToTaskIfAvailable({ + redirectUrlComplete: sessionTasksContext.redirectUrlComplete, + }); } await navigateAfterSelectOrganization(organization); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index eae394b0c0c..49a6f2865e2 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -1,6 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; -import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -8,9 +8,13 @@ import { LoadingCardContainer } from '@/ui/elements/LoadingCard'; import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { SignInContext, SignUpContext } from '../../../ui/contexts'; -import { SessionTasksContext, useSessionTasksContext } from '../../contexts/components/SessionTasks'; +import { + SessionTasksContext, + TaskSelectOrganizationContext, + useSessionTasksContext, +} from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; -import { ForceOrganizationSelectionTask } from './tasks/ForceOrganizationSelection'; +import { TaskSelectOrganization } from './tasks/TaskSelectOrganization'; const SessionTasksStart = () => { const clerk = useClerk(); @@ -36,10 +40,16 @@ const SessionTasksStart = () => { }; function SessionTaskRoutes(): JSX.Element { + const ctx = useSessionTasksContext(); + return ( - + + + @@ -56,7 +66,6 @@ export const SessionTask = withCardStateProvider(() => { const { navigate } = useRouter(); const signInContext = useContext(SignInContext); const signUpContext = useContext(SignUpContext); - const [isNavigatingToTask, setIsNavigatingToTask] = useState(false); const currentTaskContainer = useRef(null); const redirectUrlComplete = @@ -67,10 +76,6 @@ export const SessionTask = withCardStateProvider(() => { // for example by using browser back navigation. Since there are no pending tasks, // we redirect them to their intended destination. useEffect(() => { - if (isNavigatingToTask) { - return; - } - // Tasks can only exist on pending sessions, but we check both conditions // here to be defensive and ensure proper redirection const task = clerk.session?.currentTask; @@ -80,14 +85,7 @@ export const SessionTask = withCardStateProvider(() => { } clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); - }, [clerk, navigate, isNavigatingToTask, redirectUrlComplete]); - - const nextTask = useCallback(() => { - setIsNavigatingToTask(true); - return clerk - .__internal_navigateToTaskIfAvailable({ redirectUrlComplete }) - .finally(() => setIsNavigatingToTask(false)); - }, [clerk, redirectUrlComplete]); + }, [clerk, navigate, redirectUrlComplete]); if (!clerk.session?.currentTask) { return ( @@ -105,7 +103,7 @@ export const SessionTask = withCardStateProvider(() => { } return ( - + ); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx deleted file mode 100644 index 5f98dba91eb..00000000000 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useOrganizationList } from '@clerk/shared/react/index'; -import type { PropsWithChildren } from 'react'; -import { useEffect, useRef, useState } from 'react'; - -import { OrganizationListContext } from '@/ui/contexts'; -import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks'; -import { Card } from '@/ui/elements/Card'; -import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; - -import { Box, descriptors, Flex, localizationKeys, Spinner } from '../../../customizables'; -import { CreateOrganizationForm } from '../../CreateOrganization/CreateOrganizationForm'; -import { OrganizationListPageList } from '../../OrganizationList/OrganizationListPage'; -import { organizationListParams } from '../../OrganizationSwitcher/utils'; - -/** - * @internal - */ -export const ForceOrganizationSelectionTask = withCardStateProvider(() => { - const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams); - const currentFlow = useRef<'create-organization' | 'organization-selection'>(); - - const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - const hasData = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count); - - if (isLoading) { - return ( - - - - ); - } - - // Only show the organization selection page if organizations exist when the component first mounts. - // This prevents unwanted screen transitions that could occur from data revalidation, - // such as when a user accepts an organization invitation and the membership list updates. - if (hasData || currentFlow.current === 'organization-selection') { - return ; - } - - return ; -}); - -type CommonPageProps = { - currentFlow: React.MutableRefObject<'create-organization' | 'organization-selection' | undefined>; -}; - -const OrganizationSelectionPage = ({ currentFlow }: CommonPageProps) => { - const [showCreateOrganizationForm, setShowCreateOrganizationForm] = useState(false); - - useEffect(() => { - currentFlow.current = 'organization-selection'; - }, [currentFlow]); - - return ( - - - {showCreateOrganizationForm ? ( - ({ - padding: `${t.space.$none} ${t.space.$5} ${t.space.$5}`, - })} - > - setShowCreateOrganizationForm(false)} - /> - - ) : ( - setShowCreateOrganizationForm(true)} /> - )} - - - ); -}; - -const CreateOrganizationPage = ({ currentFlow }: CommonPageProps) => { - useEffect(() => { - currentFlow.current = 'create-organization'; - }, [currentFlow]); - - return ( - - ({ - padding: `${t.space.$none} ${t.space.$5} ${t.space.$5}`, - })} - > - - - - ); -}; - -const FlowCard = ({ children }: PropsWithChildren) => { - const card = useCardState(); - const { currentTaskContainer } = useSessionTasksContext(); - - return ( - - ({ padding: `${t.space.$8} ${t.space.$none} ${t.space.$none}` })}> - ({ margin: `${t.space.$none} ${t.space.$5}` })}>{card.error} - {children} - - - - ); -}; - -const FlowLoadingState = () => ( - ({ - height: '100%', - minHeight: t.sizes.$60, - })} - > - - -); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSelectOrganization.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSelectOrganization.tsx new file mode 100644 index 00000000000..cf2671d434f --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSelectOrganization.tsx @@ -0,0 +1,26 @@ +import { OrganizationListContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; +import { useTaskSelectOrganizationContext } from '@/ui/contexts/components/SessionTasks'; +import { withCardStateProvider } from '@/ui/elements/contexts'; + +import { OrganizationList } from '../../OrganizationList'; +import { withTaskGuard } from './withTaskGuard'; + +const TaskSelectOrganizationInternal = () => { + const ctx = useTaskSelectOrganizationContext(); + + return ( + + + + ); +}; + +export const TaskSelectOrganization = withCoreSessionSwitchGuard( + withTaskGuard(withCardStateProvider(TaskSelectOrganizationInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts b/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts new file mode 100644 index 00000000000..36d07e86fb2 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from 'react'; + +import { warnings } from '@/core/warnings'; +import { withRedirect } from '@/ui/common'; +import { useTaskSelectOrganizationContext } from '@/ui/contexts/components/SessionTasks'; +import type { AvailableComponentProps } from '@/ui/types'; + +export const withTaskGuard =

(Component: ComponentType

) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const ctx = useTaskSelectOrganizationContext(); + return withRedirect( + Component, + clerk => !clerk.session?.currentTask, + ({ clerk }) => ctx.redirectUrlComplete || clerk.buildAfterSignInUrl(), + warnings.cannotRenderComponentWhenTaskDoesNotExist, + )(props); + }; + + HOC.displayName = `withTaskGuard(${displayName})`; + + return HOC; +}; diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 2cada4a7da1..b3651a5c44d 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -2,6 +2,7 @@ import type { __internal_OAuthConsentProps, APIKeysProps, PricingTableProps, + TaskSelectOrganizationProps, UserButtonProps, WaitlistProps, } from '@clerk/types'; @@ -25,6 +26,7 @@ import { UserVerificationContext, WaitlistContext, } from './components'; +import { SessionTasksContext, TaskSelectOrganizationContext } from './components/SessionTasks'; export function ComponentContextProvider({ componentName, @@ -108,6 +110,16 @@ export function ComponentContextProvider({ {children} ); + case 'TaskSelectOrganization': + return ( + + + {children} + + + ); default: throw new Error(`Unknown component context: ${componentName}`); } diff --git a/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts b/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts index 1c9e0be6c5c..73008bab797 100644 --- a/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts +++ b/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts @@ -1,10 +1,10 @@ import { createContext, useContext } from 'react'; -import type { SessionTasksCtx } from '../../types'; +import type { SessionTasksCtx, TaskSelectOrganizationCtx } from '../../types'; export const SessionTasksContext = createContext(null); -export const useSessionTasksContext = () => { +export const useSessionTasksContext = (): SessionTasksCtx => { const context = useContext(SessionTasksContext); if (context === null) { @@ -13,3 +13,17 @@ export const useSessionTasksContext = () => { return context; }; + +export const TaskSelectOrganizationContext = createContext(null); + +export const useTaskSelectOrganizationContext = (): TaskSelectOrganizationCtx => { + const context = useContext(TaskSelectOrganizationContext); + + if (context === null) { + throw new Error( + 'Clerk: useTaskSelectOrganizationContext called outside of the mounted TaskSelectOrganization component.', + ); + } + + return context; +}; diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index 7204a7a14d2..ea1ed35f8e2 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -99,7 +99,8 @@ export type FlowMetadata = { | 'pricingTable' | 'apiKeys' | 'oauthConsent' - | 'subscriptionDetails'; + | 'subscriptionDetails' + | 'taskSelectOrganization'; part?: | 'start' | 'emailCode' diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index daa94dc0368..a4072adf412 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -20,6 +20,8 @@ const componentImportPaths = { PricingTable: () => import(/* webpackChunkName: "pricingTable" */ '../components/PricingTable'), Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), SessionTasks: () => import(/* webpackChunkName: "sessionTasks" */ '../components/SessionTasks'), + TaskSelectOrganization: () => + import(/* webpackChunkName: "taskSelectOrganization" */ '../components/SessionTasks/tasks/TaskSelectOrganization'), PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans/PlanDetails'), SubscriptionDetails: () => import(/* webpackChunkName: "subscriptionDetails" */ '../components/SubscriptionDetails'), APIKeys: () => import(/* webpackChunkName: "apiKeys" */ '../components/ApiKeys/ApiKeys'), @@ -103,6 +105,10 @@ export const APIKeys = lazy(() => componentImportPaths.APIKeys().then(module => export const Checkout = lazy(() => componentImportPaths.Checkout().then(module => ({ default: module.Checkout }))); +export const TaskSelectOrganization = lazy(() => + componentImportPaths.TaskSelectOrganization().then(module => ({ default: module.TaskSelectOrganization })), +); + export const PlanDetails = lazy(() => componentImportPaths.PlanDetails().then(module => ({ default: module.PlanDetails })), ); @@ -149,6 +155,7 @@ export const ClerkComponents = { APIKeys, OAuthConsent, SubscriptionDetails, + TaskSelectOrganization, }; export type ClerkComponentName = keyof typeof ClerkComponents; diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 9673d239350..c3f4454ffd7 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -18,24 +18,25 @@ import type { SignUpFallbackRedirectUrl, SignUpForceRedirectUrl, SignUpProps, + TaskSelectOrganizationProps, UserButtonProps, UserProfileProps, WaitlistProps, } from '@clerk/types'; export type { + __internal_OAuthConsentProps, + __internal_UserVerificationProps, + CreateOrganizationProps, GoogleOneTapProps, + OrganizationListProps, + OrganizationProfileProps, + OrganizationSwitcherProps, SignInProps, SignUpProps, UserButtonProps, UserProfileProps, - OrganizationSwitcherProps, - OrganizationProfileProps, - CreateOrganizationProps, - OrganizationListProps, WaitlistProps, - __internal_UserVerificationProps, - __internal_OAuthConsentProps, }; export type AvailableComponentProps = @@ -53,7 +54,8 @@ export type AvailableComponentProps = | __internal_UserVerificationProps | __internal_SubscriptionDetailsProps | __internal_PlanDetailsProps - | APIKeysProps; + | APIKeysProps + | TaskSelectOrganizationProps; type ComponentMode = 'modal' | 'mounted'; type SignInMode = 'modal' | 'redirect'; @@ -132,9 +134,12 @@ export type CheckoutCtx = __internal_CheckoutProps & { } & NewSubscriptionRedirectUrl; export type SessionTasksCtx = { - nextTask: () => Promise; - redirectUrlComplete?: string; - currentTaskContainer: React.RefObject | null; + redirectUrlComplete: string; + currentTaskContainer?: React.RefObject | null; +}; + +export type TaskSelectOrganizationCtx = TaskSelectOrganizationProps & { + componentName: 'TaskSelectOrganization'; }; export type OAuthConsentCtx = __internal_OAuthConsentProps & { @@ -166,5 +171,6 @@ export type AvailableComponentCtx = | APIKeysCtx | OAuthConsentCtx | SubscriptionDetailsCtx - | PlanDetailsCtx; + | PlanDetailsCtx + | TaskSelectOrganizationCtx; export type AvailableComponentName = AvailableComponentCtx['componentName']; diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 38f0a9be4f9..50e0365c316 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -12,18 +12,19 @@ import React from 'react'; import { useEnforceCorrectRoutingProps } from './hooks/useEnforceRoutingProps'; export { + APIKeys, CreateOrganization, + GoogleOneTap, OrganizationList, OrganizationSwitcher, + PricingTable, SignInButton, SignInWithMetamaskButton, SignOutButton, SignUpButton, + TaskSelectOrganization, UserButton, - GoogleOneTap, Waitlist, - PricingTable, - APIKeys, } from '@clerk/clerk-react'; // The assignment of UserProfile with BaseUserProfile props is used diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..b3f31c65cb5 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -4,10 +4,10 @@ */ export { AuthenticateWithRedirectCallback, - ClerkLoaded, - ClerkLoading, ClerkDegraded, ClerkFailed, + ClerkLoaded, + ClerkLoading, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, @@ -20,22 +20,23 @@ export { * If you do, app router will break. */ export { + APIKeys, CreateOrganization, + GoogleOneTap, OrganizationList, OrganizationProfile, OrganizationSwitcher, + PricingTable, SignIn, SignInButton, SignInWithMetamaskButton, SignOutButton, SignUp, SignUpButton, + TaskSelectOrganization, UserButton, UserProfile, - GoogleOneTap, Waitlist, - PricingTable, - APIKeys, } from './client-boundary/uiComponents'; /** @@ -48,12 +49,12 @@ export { useEmailLink, useOrganization, useOrganizationList, + useReverification, useSession, useSessionList, useSignIn, useSignUp, useUser, - useReverification, } from './client-boundary/hooks'; /** diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 8ad881f8b90..cf341e7e63b 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -29,6 +29,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "SignedIn", "SignedOut", + "TaskSelectOrganization", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index a905a3aea0b..1bf25df2516 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,37 +1,38 @@ export { - SignUp, - SignIn, - UserProfile, - UserButton, - OrganizationSwitcher, - OrganizationProfile, + APIKeys, CreateOrganization, - OrganizationList, GoogleOneTap, - Waitlist, + OrganizationList, + OrganizationProfile, + OrganizationSwitcher, PricingTable, - APIKeys, + SignIn, + SignUp, + TaskSelectOrganization, + UserButton, + UserProfile, + Waitlist, } from './uiComponents'; export { - ClerkLoaded, - ClerkLoading, + AuthenticateWithRedirectCallback, ClerkDegraded, ClerkFailed, - SignedOut, - SignedIn, + ClerkLoaded, + ClerkLoading, Protect, + RedirectToCreateOrganization, + RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, - AuthenticateWithRedirectCallback, - RedirectToCreateOrganization, - RedirectToOrganizationProfile, + SignedIn, + SignedOut, } from './controlComponents'; export type { ProtectProps } from './controlComponents'; export { SignInButton } from './SignInButton'; -export { SignUpButton } from './SignUpButton'; -export { SignOutButton } from './SignOutButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; +export { SignOutButton } from './SignOutButton'; +export { SignUpButton } from './SignUpButton'; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 5e628d65789..d3b6242b7de 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -9,6 +9,7 @@ import type { PricingTableProps, SignInProps, SignUpProps, + TaskSelectOrganizationProps, UserButtonProps, UserProfileProps, WaitlistProps, @@ -633,3 +634,31 @@ export const APIKeys = withClerk( }, { component: 'ApiKeys', renderWhileLoading: true }, ); + +export const TaskSelectOrganization = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'TaskSelectOrganization', renderWhileLoading: true }, +); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 1250c68c739..8e7deb0ce02 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -44,6 +44,7 @@ import type { SignUpProps, SignUpRedirectOptions, SignUpResource, + TaskSelectOrganizationProps, UnsubscribeCallback, UserButtonProps, UserProfileProps, @@ -141,6 +142,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountPricingTableNodes = new Map(); private premountApiKeysNodes = new Map(); private premountOAuthConsentNodes = new Map(); + private premountTaskSelectOrganizationNodes = new Map(); // A separate Map of `addListener` method calls to handle multiple listeners. private premountAddListenerCalls = new Map< ListenerCallback, @@ -631,6 +633,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.__internal_mountOAuthConsent(node, props); }); + this.premountTaskSelectOrganizationNodes.forEach((props, node) => { + clerkjs.mountTaskSelectOrganization(node, props); + }); + /** * Only update status in case `clerk.status` is missing. In any other case, `clerk-js` should be the orchestrator. */ @@ -1128,6 +1134,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + mountTaskSelectOrganization = (node: HTMLDivElement, props?: TaskSelectOrganizationProps): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.mountTaskSelectOrganization(node, props); + } else { + this.premountTaskSelectOrganizationNodes.set(node, props); + } + }; + + unmountTaskSelectOrganization = (node: HTMLDivElement): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.unmountTaskSelectOrganization(node); + } else { + this.premountTaskSelectOrganizationNodes.delete(node); + } + }; + addListener = (listener: ListenerCallback): UnsubscribeCallback => { if (this.clerkjs) { return this.clerkjs.addListener(listener); diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index c642b325a6e..ec0a68f23ca 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -30,6 +30,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "SignedIn", "SignedOut", + "TaskSelectOrganization", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index be0a59fc5f9..2bf005468b4 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -41,6 +41,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "SignedIn", "SignedOut", + "TaskSelectOrganization", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 4e39e02ebe8..e5333b5c7c5 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -956,6 +956,7 @@ export type PlanDetailTheme = Theme; export type SubscriptionDetailsTheme = Theme; export type APIKeysTheme = Theme; export type OAuthConsentTheme = Theme; +export type TaskSelectOrganizationTheme = Theme; type GlobalAppearanceOptions = { /** @@ -1028,4 +1029,8 @@ export type Appearance = T & * Theme overrides that only apply to the `` component */ __internal_oauthConsent?: T; + /** + * Theme overrides that only apply to the `` component + */ + taskSelectOrganization?: T; }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 652e06174de..af12ec8df19 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -14,6 +14,7 @@ import type { SignInTheme, SignUpTheme, SubscriptionDetailsTheme, + TaskSelectOrganizationTheme, UserButtonTheme, UserProfileTheme, UserVerificationTheme, @@ -557,6 +558,21 @@ export interface Clerk { */ __internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void; + /** + * Mounts a TaskSelectOrganization component at the target element. + * @param targetNode Target node to mount the TaskSelectOrganization component. + * @param props configuration parameters. + */ + mountTaskSelectOrganization: (targetNode: HTMLDivElement, props?: TaskSelectOrganizationProps) => void; + + /** + * Unmount a TaskSelectOrganization component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode Target node to unmount the TaskSelectOrganization component from. + */ + unmountTaskSelectOrganization: (targetNode: HTMLDivElement) => void; + /** * @internal * Loads Stripe libraries for commerce functionality @@ -2036,6 +2052,14 @@ export type SignUpButtonProps = (SignUpButtonPropsModal | ButtonPropsRedirect) & | 'oauthFlow' >; +export type TaskSelectOrganizationProps = { + /** + * Full URL or path to navigate to after successfully resolving all tasks + */ + redirectUrlComplete: string; + appearance?: TaskSelectOrganizationTheme; +}; + export type CreateOrganizationInvitationParams = { emailAddress: string; role: OrganizationCustomRoleKey;