From 6ac694b18ab0ef039a13d8cd97e509c72d41151c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Wed, 2 Sep 2026 09:36:13 -0300 Subject: [PATCH] fix(ui): Consume sign-up tickets in place when sign-up has nowhere to go A ticket for a user who doesn't exist yet is redirected to the instance's sign-up URL with __clerk_status=sign_up. Invitations and IdP-initiated enterprise SSO both route this way. When that URL is the sign-in page itself, SignInStart navigated to it, which is a same-page no-op: the ticket was never submitted and the user sat on the loading card indefinitely. The combined flow would have handled it, but restricted sign-up mode disables it. Create the sign-up here instead when the sign-up URL resolves to the sign-in page. A ticket sign-up takes no user input, so it needs no sign-up component. If it returns missing_requirements the instance wants fields the ticket can't supply, so release the loading card and warn the developer. --- .../idp-initiated-ticket-in-place-sign-up.md | 5 + .../ui/src/components/SignIn/SignInStart.tsx | 57 ++++++++++- .../SignIn/__tests__/SignInStart.test.tsx | 96 ++++++++++++++++++- packages/ui/src/contexts/components/SignIn.ts | 20 +++- packages/ui/src/test/fixture-helpers.ts | 13 ++- 5 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 .changeset/idp-initiated-ticket-in-place-sign-up.md diff --git a/.changeset/idp-initiated-ticket-in-place-sign-up.md b/.changeset/idp-initiated-ticket-in-place-sign-up.md new file mode 100644 index 00000000000..5726dfe9045 --- /dev/null +++ b/.changeset/idp-initiated-ticket-in-place-sign-up.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix an issue where `` stalled on a loading card when handling a ticket for a user who does not exist yet. Invitations and IdP-initiated enterprise SSO redirect these tickets to the instance's sign-up URL, and when that URL is the sign-in page itself there was no sign-up flow to hand them off to. `` now consumes the ticket in place, creating the user and signing them in without a separate sign-up page. diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 658643d83bb..71c1d8ba061 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -2,6 +2,7 @@ import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePh import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; +import { logger } from '@clerk/shared/logger'; import { useClerk } from '@clerk/shared/react'; import type { ClerkAPIError, @@ -31,7 +32,7 @@ import { withRedirectToAfterSignIn, withRedirectToSignInTask, } from '../../common'; -import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useCoreSignUp, useEnvironment, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useLoadingStatus } from '../../hooks'; @@ -90,9 +91,19 @@ function SignInStartInternal(): JSX.Element { const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); const signIn = useCoreSignIn(); + const signUp = useCoreSignUp(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, signUpIfMissingEnabled, navigateOnSetActive } = ctx; + const { + afterSignInUrl, + afterSignUpUrl, + signUpUrl, + waitlistUrl, + isCombinedFlow, + signUpUrlIsSignInPage, + signUpIfMissingEnabled, + navigateOnSetActive, + } = ctx; const supportEmail = useSupportEmail(); const totalEnabledAuthMethods = useTotalEnabledAuthMethods(); const identifierAttributes = useMemo( @@ -122,6 +133,8 @@ function SignInStartInternal(): JSX.Element { const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; const clerkStatus = getClerkQueryParam('__clerk_status') || ''; + // Releases the loading card below, which would otherwise wait on a hand-off that never happens. + const [ticketSignUpUnavailable, setTicketSignUpUnavailable] = useState(false); const standardFormAttributes = userSettings.enabledFirstFactorIdentifiers; const web3FirstFactors = userSettings.web3FirstFactors; @@ -222,9 +235,45 @@ function SignInStartInternal(): JSX.Element { if (organizationTicket) { paramsToForward.set('__clerk_ticket', organizationTicket); } + // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to // signUpUrl triggers a full page reload when used with the hash router. - void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); + if (!signUpUrlIsSignInPage) { + void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); + return; + } + + // The hand-off would land back here, so there is no sign-up flow to reach. A ticket sign-up + // takes no user input, so consume it in place instead of leaving the ticket unspent. + status.setLoading(); + card.setLoading(); + signUp + .create({ strategy: 'ticket', ticket: organizationTicket }) + .then(res => { + if (res.status !== 'complete') { + logger.warnOnce( + `Clerk: this sign-up needs more information than the ticket carries, but the instance's sign-up URL points at this sign-in page. Set the sign-up URL to a page rendering , or render here.`, + ); + setTicketSignUpUnavailable(true); + return; + } + + removeClerkQueryParam('__clerk_ticket'); + return clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); + }, + }); + }) + .catch(err => { + setTicketSignUpUnavailable(true); + return handleError(err, [], card.setError); + }) + .finally(() => { + status.setIdle(); + card.setIdle(); + }); return; } @@ -578,7 +627,7 @@ function SignInStartInternal(): JSX.Element { return components[identifierField.type as keyof typeof components]; }, [identifierField.type]); - if (status.isLoading || clerkStatus === 'sign_up') { + if (!ticketSignUpUnavailable && (status.isLoading || clerkStatus === 'sign_up')) { // clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of // rendering the sign in flow. return ; diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 36a0b24858b..f70ae599c0d 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,7 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; -import type { SignInResource } from '@clerk/shared/types'; +import type { SignInResource, SignUpResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -983,6 +983,100 @@ describe('SignInStart', () => { expect.not.stringContaining('__clerk_ticket'), ); }); + + // An IdP-initiated ticket for an unknown user is redirected to the instance's sign-up URL with + // `__clerk_status=sign_up`. When that URL is the sign-in page itself, the hand-off below has + // nowhere to go, so the ticket is consumed here instead of hanging on the loading card. + describe('sign_up status', () => { + // The fixture's sign-in URL. Pointing the sign-up URL at it reproduces the instance config + // that leaves the hand-off with nowhere to go. + const SIGN_IN_URL = 'https://dashboard.clerk.com/sign-in'; + + const landOnTicket = () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: `${SIGN_IN_URL}?__clerk_ticket=test_ticket&__clerk_status=sign_up` }, + }); + Object.defineProperty(window, 'history', { writable: true, value: { replaceState: vi.fn() } }); + }; + + it('consumes the ticket in place when the sign-up URL is the sign-in page', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withRestrictedMode(); + f.withSignUpUrl(SIGN_IN_URL); + }); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'complete', + createdSessionId: 'sess_123', + } as SignUpResource); + + landOnTicket(); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => + expect(fixtures.signUp.create).toHaveBeenCalledWith({ strategy: 'ticket', ticket: 'test_ticket' }), + ); + await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled()); + expect(fixtures.router.navigate).not.toHaveBeenCalled(); + }); + + it('hands the ticket off when the sign-up URL is a separate page', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withRestrictedMode(); + }); + + landOnTicket(); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => + expect(fixtures.router.navigate).toHaveBeenCalledWith( + 'https://dashboard.clerk.com/sign-up', + expect.anything(), + ), + ); + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + + // The ticket cannot supply every required field, so only a sign-up form could finish — and + // there is none to reach. Release the loading card rather than spinning forever. + it('falls back to the sign-in form when the ticket sign-up needs more fields', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withRestrictedMode(); + f.withSignUpUrl(SIGN_IN_URL); + }); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['phone_number'], + } as unknown as SignUpResource); + + landOnTicket(); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + expect(fixtures.clerk.setActive).not.toHaveBeenCalled(); + }); + }); }); describe('Captcha', () => { diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 2d44b0bb838..d12e94c867c 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -1,7 +1,7 @@ import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import { buildURL } from '@clerk/shared/internal/clerk-js/url'; +import { buildURL, trimTrailingSlash } from '@clerk/shared/internal/clerk-js/url'; import { useClerk } from '@clerk/shared/react'; import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { isAbsoluteUrl } from '@clerk/shared/url'; @@ -31,6 +31,7 @@ export type SignInContextType = Omit { + const absolute = (url: string) => { + try { + return trimTrailingSlash(new URL(url, window.location.href).href); + } catch { + return url; + } + }; + return absolute(a) === absolute(b); +}; + export const SignInContext = createContext(null); export const useSignInContext = (): SignInContextType => { @@ -96,6 +108,11 @@ export const useSignInContext = (): SignInContextType => { : ctx.signUpUrl || options.signUpUrl || displayConfig.signUpUrl; let waitlistUrl = ctx.waitlistUrl || options.waitlistUrl || displayConfig.waitlistUrl; + // An instance whose sign-up URL is its sign-in page gives ticket hand-offs nowhere to go — see + // SignInStart. Compared here, before `buildURL` decorates both with the same params, and resolved + // because `signInUrl` is relative under path routing while `signUpUrl` usually is not. + const signUpUrlIsSignInPage = !isCombinedFlow && sameDestination(signInUrl, signUpUrl); + const preservedParams = redirectUrls.getPreservedSearchParams(); signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); @@ -217,6 +234,7 @@ export const useSignInContext = (): SignInContextType => { initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + signUpUrlIsSignInPage, signUpIfMissingEnabled, navigateOnSetActive, taskUrl, diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index a35c7f8adb6..933431c348b 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -420,7 +420,18 @@ const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => { dc.terms_url = opts.termsOfService || ''; dc.privacy_policy_url = opts.privacyPolicy || ''; }; - return { withSupportEmail, withoutClerkBranding, withPreferredSignInStrategy, withTermsPrivacyPolicyUrls }; + + const withSignUpUrl = (url: string) => { + dc.sign_up_url = url; + }; + + return { + withSupportEmail, + withoutClerkBranding, + withPreferredSignInStrategy, + withTermsPrivacyPolicyUrls, + withSignUpUrl, + }; }; const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) => {