diff --git a/.changeset/brown-garlics-boil.md b/.changeset/brown-garlics-boil.md new file mode 100644 index 00000000000..1eab1f4b3c0 --- /dev/null +++ b/.changeset/brown-garlics-boil.md @@ -0,0 +1,13 @@ +--- +'@clerk/tanstack-react-start': patch +'@clerk/localizations': patch +'@clerk/react-router': patch +'@clerk/clerk-js': patch +'@clerk/testing': patch +'@clerk/nextjs': patch +'@clerk/clerk-react': patch +'@clerk/remix': patch +'@clerk/types': patch +--- + +Introduce `TaskChooseOrganization` component which replaces `TaskSelectOrganization` with a new UI that make the experience similar to the previous `SignIn` and `SignUp` steps diff --git a/integration/tests/session-tasks-eject-flow.test.ts b/integration/tests/session-tasks-eject-flow.test.ts index 964a75b0eb2..d8a44744dcf 100644 --- a/integration/tests/session-tasks-eject-flow.test.ts +++ b/integration/tests/session-tasks-eject-flow.test.ts @@ -52,11 +52,11 @@ return ( .addFile( 'src/app/onboarding/select-organization/page.tsx', () => ` -import { TaskSelectOrganization } from '@clerk/nextjs'; +import { TaskChooseOrganization } from '@clerk/nextjs'; export default function Page() { return ( - + ); }`, ) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index dc2341dcfb5..df3e12a2a25 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "618KB" }, + { "path": "./dist/clerk.js", "maxSize": "620KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "74KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115.08KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" }, diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index cafc549ea1f..db52e1ac505 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,5 @@ -import * as l from '../../localizations'; import type { Clerk as ClerkType } from '../'; +import * as l from '../../localizations'; const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[]; @@ -35,6 +35,7 @@ const AVAILABLE_COMPONENTS = [ 'pricingTable', 'apiKeys', 'oauthConsent', + 'taskChooseOrganization', ] as const; const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox'; @@ -95,6 +96,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component pricingTable: buildComponentControls('pricingTable'), apiKeys: buildComponentControls('apiKeys'), oauthConsent: buildComponentControls('oauthConsent'), + taskChooseOrganization: buildComponentControls('taskChooseOrganization'), }; declare global { @@ -335,6 +337,14 @@ void (async () => { }, ); }, + '/task-choose-organization': () => { + Clerk.mountTaskChooseOrganization( + app, + componentControls.taskChooseOrganization.getProps() ?? { + redirectUrlComplete: '/user-profile', + }, + ); + }, '/open-sign-in': () => { mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {}); }, diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 315482ffc8d..d1cc06fadf6 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -154,6 +154,14 @@ OAuthConsent +
  • + + TaskChooseOrganization + +
  • { signUp: new SignUp({ status: 'complete', } as any as SignUpJSON), + isEligibleForTouch: () => false, }), ); @@ -995,6 +996,7 @@ describe('Clerk singleton', () => { signedInSessions: [mockResource], signIn: new SignIn(null), signUp: new SignUp(null), + isEligibleForTouch: () => false, }), ); @@ -2451,7 +2453,12 @@ describe('Clerk singleton', () => { beforeEach(() => { mockResource.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] })); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockResource], + isEligibleForTouch: () => false, + }), + ); }); afterEach(() => { @@ -2516,7 +2523,12 @@ describe('Clerk singleton', () => { it('navigates to redirect url on completion', async () => { mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + isEligibleForTouch: () => false, + }), + ); const sut = new Clerk(productionPublishableKey); await sut.load(mockedLoadOptions); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 261031cb60e..c402d6f1aa3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -76,7 +76,7 @@ import type { SignUpProps, SignUpRedirectOptions, SignUpResource, - TaskSelectOrganizationProps, + TaskChooseOrganizationProps, UnsubscribeCallback, UserButtonProps, UserProfileProps, @@ -1163,31 +1163,31 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); }; - public mountTaskSelectOrganization = (node: HTMLDivElement, props?: TaskSelectOrganizationProps) => { + public mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps) => { this.assertComponentsReady(this.#componentControls); if (disabledOrganizationsFeature(this, this.environment)) { if (this.#instanceType === 'development') { - throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskSelectOrganization'), { + throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskChooseOrganization'), { code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE, }); } return; } - void this.#componentControls.ensureMounted({ preloadHint: 'TaskSelectOrganization' }).then(controls => + void this.#componentControls.ensureMounted({ preloadHint: 'TaskChooseOrganization' }).then(controls => controls.mountComponent({ - name: 'TaskSelectOrganization', - appearanceKey: 'taskSelectOrganization', + name: 'TaskChooseOrganization', + appearanceKey: 'taskChooseOrganization', node, props, }), ); - this.telemetry?.record(eventPrebuiltComponentMounted('TaskSelectOrganization', props)); + this.telemetry?.record(eventPrebuiltComponentMounted('TaskChooseOrganization', props)); }; - public unmountTaskSelectOrganization = (node: HTMLDivElement) => { + public unmountTaskChooseOrganization = (node: HTMLDivElement) => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); }; @@ -1388,6 +1388,16 @@ export class Clerk implements ClerkInterface { public __internal_navigateToTaskIfAvailable = async ({ redirectUrlComplete, }: __internal_NavigateToTaskIfAvailableParams = {}): Promise => { + const onBeforeSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' + ? window.__unstable__onBeforeSetActive + : noop; + + const onAfterSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' + ? window.__unstable__onAfterSetActive + : noop; + const session = this.session; if (!session || !this.environment) { return; @@ -1403,17 +1413,30 @@ export class Clerk implements ClerkInterface { return; } + await onBeforeSetActive(); + if (redirectUrlComplete) { const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); await tracker.track(async () => { - await this.navigate(redirectUrlComplete); + if (!this.client) { + return; + } + + if (this.client.isEligibleForTouch()) { + const absoluteRedirectUrl = new URL(redirectUrlComplete, window.location.href); + await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); + } else { + await this.navigate(redirectUrlComplete); + } }); if (tracker.isUnloading()) { return; } } + + await onAfterSetActive(); }; public addListener = (listener: ListenerCallback): UnsubscribeCallback => { diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index e9e774d042c..09f86d7a925 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -10,7 +10,7 @@ const createMessageForDisabledOrganizations = ( | 'OrganizationSwitcher' | 'OrganizationList' | 'CreateOrganization' - | 'TaskSelectOrganization', + | 'TaskChooseOrganization', ) => { 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.`, @@ -29,7 +29,7 @@ const warnings = { 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.', + ' 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/common/organizations/OrganizationPreview.tsx b/packages/clerk-js/src/ui/common/organizations/OrganizationPreview.tsx new file mode 100644 index 00000000000..ef949abd917 --- /dev/null +++ b/packages/clerk-js/src/ui/common/organizations/OrganizationPreview.tsx @@ -0,0 +1,138 @@ +import type { UserOrganizationInvitationResource } from '@clerk/types'; +import type { PropsWithChildren } from 'react'; +import { forwardRef } from 'react'; + +import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors'; +import { OrganizationPreview } from '@/ui/elements/OrganizationPreview'; +import { PreviewButton } from '@/ui/elements/PreviewButton'; + +import { Box, Button, Col, descriptors, Flex, Spinner } from '../../customizables'; +import { SwitchArrowRight } from '../../icons'; +import type { ThemableCssProp } from '../../styledSystem'; +import { common } from '../../styledSystem'; + +type OrganizationPreviewListItemsProps = PropsWithChildren<{ + elementDescriptor: ElementDescriptor; +}>; + +export const OrganizationPreviewListItems = ({ elementDescriptor, children }: OrganizationPreviewListItemsProps) => { + return ( + ({ + maxHeight: `calc(8 * ${t.sizes.$12})`, + overflowY: 'auto', + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + ...common.unstyledScrollbar(t), + })} + > + {children} + + ); +}; + +const sharedStyles: ThemableCssProp = t => ({ + padding: `${t.space.$4} ${t.space.$5}`, +}); + +export const sharedMainIdentifierSx: ThemableCssProp = t => ({ + color: t.colors.$colorForeground, + ':hover': { + color: t.colors.$colorForeground, + }, +}); + +type OrganizationPreviewListItemProps = PropsWithChildren<{ + elementId: React.ComponentProps['elementId']; + elementDescriptor: React.ComponentProps['elementDescriptor']; + organizationData: UserOrganizationInvitationResource['publicOrganizationData']; +}>; + +export const OrganizationPreviewListItem = ({ + children, + elementId, + elementDescriptor, + organizationData, +}: OrganizationPreviewListItemProps) => { + return ( + ({ + minHeight: 'unset', + justifyContent: 'space-between', + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + }), + sharedStyles, + ]} + elementDescriptor={elementDescriptor} + > + + {children} + + ); +}; + +export const OrganizationPreviewSpinner = forwardRef((_, ref) => { + return ( + ({ + width: '100%', + height: t.space.$12, + position: 'relative', + })} + > + + + + + ); +}); + +export const OrganizationPreviewListItemButton = (props: Parameters[0]) => { + return ( +