diff --git a/.changeset/chubby-memes-kiss.md b/.changeset/chubby-memes-kiss.md new file mode 100644 index 00000000000..93807c565aa --- /dev/null +++ b/.changeset/chubby-memes-kiss.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +When a session already exists on sign in, complete the sign in and redirect instead of only rendering an error. diff --git a/integration/tests/oauth-flows.test.ts b/integration/tests/oauth-flows.test.ts index b880121b01f..6ff96a3cea2 100644 --- a/integration/tests/oauth-flows.test.ts +++ b/integration/tests/oauth-flows.test.ts @@ -256,5 +256,37 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withLegalConsent] })( await u.page.waitForAppUrl('/protected'); }); + + test('redirects when attempting OAuth sign in with existing session in another tab', async ({ + page, + context, + browser, + }) => { + const u = createTestUtils({ app, page, context, browser }); + + // Open sign-in page in both tabs before signing in + await u.po.signIn.goTo(); + + let secondTabUtils: any; + await u.tabs.runInNewTab(async u2 => { + secondTabUtils = u2; + await u2.po.signIn.goTo(); + }); + + // Sign in via OAuth on the first tab + await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); + await u.page.getByText('Sign in to oauth-provider').waitFor(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.enterTestOtpCode(); + await u.page.getByText('SignedIn').waitFor(); + await u.po.expect.toBeSignedIn(); + + // Attempt to sign in via OAuth on the second tab (which already has sign-in mounted) + await secondTabUtils.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); + + // Should redirect and be signed in without error + await secondTabUtils.po.expect.toBeSignedIn(); + }); }, ); diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index 0d16c45e327..42d84bab929 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -152,4 +152,35 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f await u.po.expect.toBeSignedIn(); }); + + test('redirects when attempting to sign in with existing session in another tab', async ({ + page, + context, + browser, + }) => { + const u = createTestUtils({ app, page, context, browser }); + + // Open sign-in page in both tabs before signing in + await u.po.signIn.goTo(); + + let secondTabUtils: any; + await u.tabs.runInNewTab(async u2 => { + secondTabUtils = u2; + await u2.po.signIn.goTo(); + }); + + // Sign in on the first tab + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + // Attempt to sign in on the second tab (which already has sign-in mounted) + await secondTabUtils.po.signIn.setIdentifier(fakeUser.email); + await secondTabUtils.po.signIn.continue(); + + // Should redirect and be signed in without error + await secondTabUtils.po.expect.toBeSignedIn(); + }); }); diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index 98cd6bdcce9..a20f7d2f460 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -27,6 +27,7 @@ export const ERROR_CODES = { SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing', USER_LOCKED: 'user_locked', EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found', + SESSION_EXISTS: 'session_exists', SIGN_UP_MODE_RESTRICTED: 'sign_up_mode_restricted', SIGN_UP_MODE_RESTRICTED_WAITLIST: 'sign_up_restricted_waitlist', ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: 'enterprise_sso_user_attribute_missing', diff --git a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx index 58d204c43fe..4adeea4c1ff 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx @@ -1,7 +1,7 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useOrganizationList, useUser } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/shared/types'; -import { isClerkAPIResponseError } from '@/index.headless'; import { sharedMainIdentifierSx } from '@/ui/common/organizations/OrganizationPreview'; import { localizationKeys, useLocalizations } from '@/ui/customizables'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx index 52e7c5a39bf..5a03b610c4e 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx @@ -1,8 +1,11 @@ +import type { ClerkAPIError } from '@clerk/shared/error'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk } from '@clerk/shared/react'; import type { PhoneCodeChannel } from '@clerk/shared/types'; import React from 'react'; -import { handleError } from '@/ui/utils/errorHandler'; +import { ERROR_CODES } from '@/core/constants'; +import { handleError as _handleError } from '@/ui/utils/errorHandler'; import { originPrefersPopup } from '@/ui/utils/originPrefersPopup'; import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler'; @@ -30,6 +33,25 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup()); const { onAlternativePhoneCodeProviderClick, ...rest } = props; + const handleError = (err: any) => { + if (isClerkAPIResponseError(err)) { + const sessionAlreadyExistsError: ClerkAPIError | undefined = err.errors.find( + (e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS, + ); + + if (sessionAlreadyExistsError) { + return clerk.setActive({ + session: clerk.client.lastActiveSessionId, + navigate: async ({ session }) => { + await ctx.navigateOnSetActive({ session, redirectUrl: ctx.afterSignInUrl }); + }, + }); + } + } + + return _handleError(err, [], card.setError); + }; + return ( handleError(err, [], card.setError)); + .catch(err => handleError(err)); } return signIn .authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt }) - .catch(err => handleError(err, [], card.setError)); + .catch(err => handleError(err)); }} web3Callback={strategy => { return clerk diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index c12bd76fce9..32c60f39097 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -432,6 +432,9 @@ function SignInStartInternal(): JSX.Element { e.code === ERROR_CODES.FORM_PASSWORD_PWNED, ); + const sessionAlreadyExistsError: ClerkAPIError = e.errors.find( + (e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS, + ); const alreadySignedInError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === 'identifier_already_signed_in', ); @@ -442,6 +445,13 @@ function SignInStartInternal(): JSX.Element { if (instantPasswordError) { await signInWithFields(identifierField); + } else if (sessionAlreadyExistsError) { + await clerk.setActive({ + session: clerk.client.lastActiveSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); } else if (alreadySignedInError) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const sid = alreadySignedInError.meta!.sessionId!; diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index e5f4bf4e64f..fd647caf3cf 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -252,6 +252,38 @@ describe('SignInStart', () => { expect(icon.length).toEqual(1); }); }); + + it('redirects user when session_exists error is returned during OAuth sign-in', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + const sessionExistsError = new ClerkAPIResponseError('Error', { + data: [ + { + code: 'session_exists', + long_message: 'A session already exists', + message: 'Session exists', + }, + ], + status: 422, + }); + + fixtures.clerk.client.lastActiveSessionId = 'sess_123'; + fixtures.signIn.authenticateWithRedirect.mockRejectedValueOnce(sessionExistsError); + + const { userEvent } = render(, { wrapper }); + + const googleButton = screen.getByText('Continue with Google'); + await userEvent.click(googleButton); + + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalledWith({ + session: 'sess_123', + navigate: expect.any(Function), + }); + }); + }); }); describe('navigation', () => { @@ -523,6 +555,76 @@ describe('SignInStart', () => { }); }); + describe('Session already exists error handling', () => { + it('redirects user when session_exists error is returned during sign-in', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + + const sessionExistsError = new ClerkAPIResponseError('Error', { + data: [ + { + code: 'session_exists', + long_message: 'A session already exists', + message: 'Session exists', + }, + ], + status: 422, + }); + + fixtures.clerk.client.lastActiveSessionId = 'sess_123'; + fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalledWith({ + session: 'sess_123', + navigate: expect.any(Function), + }); + }); + }); + + it('calls navigate after setting session active on session_exists error', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + + const sessionExistsError = new ClerkAPIResponseError('Error', { + data: [ + { + code: 'session_exists', + long_message: 'A session already exists', + message: 'Session exists', + }, + ], + status: 422, + }); + + fixtures.clerk.client.lastActiveSessionId = 'sess_123'; + fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError); + + const mockSession = { id: 'sess_123' } as any; + (fixtures.clerk.setActive as any).mockImplementation( + async ({ navigate }: { navigate: ({ session }: { session: any }) => Promise }) => { + await navigate({ session: mockSession }); + }, + ); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + }); + }); + }); + describe('ticket flow', () => { it('calls the appropriate resource function upon detecting the ticket', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 54a82cc5b6e..c03baabd7d0 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -1,9 +1,9 @@ import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk } from '@clerk/shared/react'; import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types'; import React from 'react'; -import { isClerkAPIResponseError } from '@/index.headless'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header';