diff --git a/.changeset/quiet-tickets-continue.md b/.changeset/quiet-tickets-continue.md new file mode 100644 index 00000000000..5e8cd27ec63 --- /dev/null +++ b/.changeset/quiet-tickets-continue.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix an issue where Clerk's ticket query parameters were not removed from the URL when completing a sign-up that was missing requirements. diff --git a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts index d13513a7c04..df15c09b583 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts @@ -31,6 +31,52 @@ describe('completeSignUpFlow', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + it('removes ticket query parameters before calling handleComplete', async () => { + const mockSignUp = { + status: 'complete', + missingFields: [] as SignUpField[], + } as SignUpResource; + let currentUrl = new URL( + 'http://localhost/sign-up/continue?__clerk_ticket=test_ticket&__clerk_invitation_token=test_invitation', + ); + let ticketAtHandleComplete: string | null | undefined; + let invitationAtHandleComplete: string | null | undefined; + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { + return currentUrl.href; + }, + get search() { + return currentUrl.search; + }, + }, + }); + Object.defineProperty(window, 'history', { + configurable: true, + value: { + state: undefined, + replaceState: vi.fn((_state, _title, url) => { + currentUrl = new URL(url, currentUrl); + }), + }, + }); + mockHandleComplete.mockImplementationOnce(() => { + ticketAtHandleComplete = currentUrl.searchParams.get('__clerk_ticket'); + invitationAtHandleComplete = currentUrl.searchParams.get('__clerk_invitation_token'); + }); + + await completeSignUpFlow({ + signUp: mockSignUp, + handleComplete: mockHandleComplete, + navigate: mockNavigate, + }); + + expect(ticketAtHandleComplete).toBeNull(); + expect(invitationAtHandleComplete).toBeNull(); + }); + it('navigates to verify email page if email still unverified', async () => { const mockSignUp = { status: 'missing_requirements', diff --git a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts index bc019ed150a..d2bae881512 100644 --- a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts +++ b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts @@ -1,5 +1,5 @@ import type { SignUpResource } from '../../types'; -import { forwardClerkQueryParams } from './queryParams'; +import { forwardClerkQueryParams, removeClerkQueryParam } from './queryParams'; type CompleteSignUpFlowProps = { signUp: SignUpResource; @@ -27,6 +27,8 @@ export const completeSignUpFlow = ({ oidcPrompt, }: CompleteSignUpFlowProps): Promise | undefined => { if (signUp.status === 'complete') { + removeClerkQueryParam('__clerk_ticket'); + removeClerkQueryParam('__clerk_invitation_token'); return handleComplete && handleComplete(); } else if (signUp.status === 'missing_requirements') { if (signUp.missingFields.some(mf => mf === 'enterprise_sso')) { diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx index 2777d63324b..93cfb3f63ef 100644 --- a/packages/ui/src/components/SignUp/SignUpContinue.tsx +++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx @@ -1,3 +1,4 @@ +import { removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import React, { useEffect, useMemo } from 'react'; @@ -180,13 +181,16 @@ function SignUpContinueInternal() { verifyEmailPath: './verify-email-address', verifyPhonePath: './verify-phone-number', protectCheckPath: '../protect-check', - handleComplete: () => - clerk.setActive({ + handleComplete: () => { + removeClerkQueryParam('__clerk_ticket'); + removeClerkQueryParam('__clerk_invitation_token'); + return clerk.setActive({ session: res.createdSessionId, navigate: async ({ session, decorateUrl }) => { await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, - }), + }); + }, navigate, oidcPrompt: ctx.oidcPrompt, }), diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 8eba3452453..3b2e4a125c6 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -1,7 +1,7 @@ import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode'; import { isClerkAPIResponseError } from '@clerk/shared/error'; import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; -import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; +import { getClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types'; import React from 'react'; @@ -170,8 +170,6 @@ function SignUpStartInternal(): JSX.Element { protectCheckPath: 'protect-check', continuePath: 'continue', handleComplete: () => { - removeClerkQueryParam('__clerk_ticket'); - removeClerkQueryParam('__clerk_invitation_token'); return setActive({ session: signUp.createdSessionId, navigate: async ({ session, decorateUrl }) => { diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index 73807b7968d..eb5fb3f4739 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -10,6 +10,7 @@ import { CardStateProvider } from '@/ui/elements/contexts'; import { OptionsProvider } from '../../../contexts'; import { AppearanceProvider } from '../../../customizables'; +import { SignUpContinue } from '../SignUpContinue'; import { SignUpStart } from '../SignUpStart'; const { createFixtures } = bindCreateFixtures('SignUp'); @@ -497,6 +498,77 @@ describe('SignUpStart', () => { ); }); + it('removes the ticket before setting the session active after continuing the sign up', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withPassword({ required: true }); + f.startSignUpWithEmailAddress({ emailVerificationStatus: 'verified' }); + }); + let currentUrl = new URL('http://localhost/sign-up?__clerk_ticket=test_ticket'); + let ticketAtSetActive: string | null | undefined; + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { + return currentUrl.href; + }, + get search() { + return currentUrl.search; + }, + }, + }); + Object.defineProperty(window, 'history', { + configurable: true, + value: { + state: undefined, + replaceState: vi.fn((_state, _title, url) => { + currentUrl = new URL(url, currentUrl); + }), + }, + }); + + fixtures.signUp.create.mockResolvedValueOnce(fixtures.signUp as SignUpResource); + fixtures.router.navigate.mockImplementation((to, options) => { + currentUrl = new URL(`/sign-up/${to}`, currentUrl); + currentUrl.search = options?.searchParams?.toString() || ''; + return Promise.resolve(true); + }); + fixtures.signUp.update.mockImplementationOnce(() => { + fixtures.signUp.status = 'complete'; + fixtures.signUp.createdSessionId = 'sess_ticket'; + return Promise.resolve(fixtures.signUp); + }); + fixtures.clerk.setActive.mockImplementationOnce(() => { + ticketAtSetActive = new URL(window.location.href).searchParams.get('__clerk_ticket'); + return Promise.resolve(); + }); + + const { rerender, userEvent } = render(, { wrapper }); + + await waitFor(() => + expect(fixtures.signUp.create).toHaveBeenCalledWith({ + strategy: 'ticket', + ticket: 'test_ticket', + unsafeMetadata: undefined, + }), + ); + await waitFor(() => + expect(fixtures.router.navigate).toHaveBeenCalledWith('continue', { + searchParams: new URLSearchParams('__clerk_ticket=test_ticket'), + }), + ); + expect(currentUrl.href).toBe('http://localhost/sign-up/continue?__clerk_ticket=test_ticket'); + + rerender(); + await userEvent.type(screen.getByLabelText('Password'), 'a-secure-password'); + await userEvent.click(screen.getByText('Continue')); + + await waitFor(() => expect(fixtures.signUp.update).toHaveBeenCalled()); + await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled()); + expect(ticketAtSetActive).toBeNull(); + }); + it('should show the sign up form when ticket detected and mode is restricted', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress();