Skip to content

Commit b352cae

Browse files
authored
Merge branch 'main' into feat/enhanced-poller-browser-build
2 parents c720c5c + 1f9bf68 commit b352cae

File tree

9 files changed

+208
-5
lines changed

9 files changed

+208
-5
lines changed

.changeset/chubby-memes-kiss.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
When a session already exists on sign in, complete the sign in and redirect instead of only rendering an error.

integration/tests/oauth-flows.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,5 +256,37 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withLegalConsent] })(
256256

257257
await u.page.waitForAppUrl('/protected');
258258
});
259+
260+
test('redirects when attempting OAuth sign in with existing session in another tab', async ({
261+
page,
262+
context,
263+
browser,
264+
}) => {
265+
const u = createTestUtils({ app, page, context, browser });
266+
267+
// Open sign-in page in both tabs before signing in
268+
await u.po.signIn.goTo();
269+
270+
let secondTabUtils: any;
271+
await u.tabs.runInNewTab(async u2 => {
272+
secondTabUtils = u2;
273+
await u2.po.signIn.goTo();
274+
});
275+
276+
// Sign in via OAuth on the first tab
277+
await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();
278+
await u.page.getByText('Sign in to oauth-provider').waitFor();
279+
await u.po.signIn.setIdentifier(fakeUser.email);
280+
await u.po.signIn.continue();
281+
await u.po.signIn.enterTestOtpCode();
282+
await u.page.getByText('SignedIn').waitFor();
283+
await u.po.expect.toBeSignedIn();
284+
285+
// Attempt to sign in via OAuth on the second tab (which already has sign-in mounted)
286+
await secondTabUtils.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();
287+
288+
// Should redirect and be signed in without error
289+
await secondTabUtils.po.expect.toBeSignedIn();
290+
});
259291
},
260292
);

integration/tests/sign-in-flow.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,35 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f
152152

153153
await u.po.expect.toBeSignedIn();
154154
});
155+
156+
test('redirects when attempting to sign in with existing session in another tab', async ({
157+
page,
158+
context,
159+
browser,
160+
}) => {
161+
const u = createTestUtils({ app, page, context, browser });
162+
163+
// Open sign-in page in both tabs before signing in
164+
await u.po.signIn.goTo();
165+
166+
let secondTabUtils: any;
167+
await u.tabs.runInNewTab(async u2 => {
168+
secondTabUtils = u2;
169+
await u2.po.signIn.goTo();
170+
});
171+
172+
// Sign in on the first tab
173+
await u.po.signIn.setIdentifier(fakeUser.email);
174+
await u.po.signIn.continue();
175+
await u.po.signIn.setPassword(fakeUser.password);
176+
await u.po.signIn.continue();
177+
await u.po.expect.toBeSignedIn();
178+
179+
// Attempt to sign in on the second tab (which already has sign-in mounted)
180+
await secondTabUtils.po.signIn.setIdentifier(fakeUser.email);
181+
await secondTabUtils.po.signIn.continue();
182+
183+
// Should redirect and be signed in without error
184+
await secondTabUtils.po.expect.toBeSignedIn();
185+
});
155186
});

packages/clerk-js/src/core/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const ERROR_CODES = {
2727
SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing',
2828
USER_LOCKED: 'user_locked',
2929
EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found',
30+
SESSION_EXISTS: 'session_exists',
3031
SIGN_UP_MODE_RESTRICTED: 'sign_up_mode_restricted',
3132
SIGN_UP_MODE_RESTRICTED_WAITLIST: 'sign_up_restricted_waitlist',
3233
ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: 'enterprise_sso_user_attribute_missing',

packages/clerk-js/src/ui/components/OrganizationList/UserMembershipList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { isClerkAPIResponseError } from '@clerk/shared/error';
12
import { useOrganizationList, useUser } from '@clerk/shared/react';
23
import type { OrganizationResource } from '@clerk/shared/types';
34

4-
import { isClerkAPIResponseError } from '@/index.headless';
55
import { sharedMainIdentifierSx } from '@/ui/common/organizations/OrganizationPreview';
66
import { localizationKeys, useLocalizations } from '@/ui/customizables';
77
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';

packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import type { ClerkAPIError } from '@clerk/shared/error';
2+
import { isClerkAPIResponseError } from '@clerk/shared/error';
13
import { useClerk } from '@clerk/shared/react';
24
import type { PhoneCodeChannel } from '@clerk/shared/types';
35
import React from 'react';
46

5-
import { handleError } from '@/ui/utils/errorHandler';
7+
import { ERROR_CODES } from '@/core/constants';
8+
import { handleError as _handleError } from '@/ui/utils/errorHandler';
69
import { originPrefersPopup } from '@/ui/utils/originPrefersPopup';
710
import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler';
811

@@ -30,6 +33,25 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps)
3033
const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup());
3134
const { onAlternativePhoneCodeProviderClick, ...rest } = props;
3235

36+
const handleError = (err: any) => {
37+
if (isClerkAPIResponseError(err)) {
38+
const sessionAlreadyExistsError: ClerkAPIError | undefined = err.errors.find(
39+
(e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS,
40+
);
41+
42+
if (sessionAlreadyExistsError) {
43+
return clerk.setActive({
44+
session: clerk.client.lastActiveSessionId,
45+
navigate: async ({ session }) => {
46+
await ctx.navigateOnSetActive({ session, redirectUrl: ctx.afterSignInUrl });
47+
},
48+
});
49+
}
50+
}
51+
52+
return _handleError(err, [], card.setError);
53+
};
54+
3355
return (
3456
<SocialButtons
3557
{...rest}
@@ -50,12 +72,12 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps)
5072

5173
return signIn
5274
.authenticateWithPopup({ strategy, redirectUrl, redirectUrlComplete, popup, oidcPrompt: ctx.oidcPrompt })
53-
.catch(err => handleError(err, [], card.setError));
75+
.catch(err => handleError(err));
5476
}
5577

5678
return signIn
5779
.authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt })
58-
.catch(err => handleError(err, [], card.setError));
80+
.catch(err => handleError(err));
5981
}}
6082
web3Callback={strategy => {
6183
return clerk

packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,9 @@ function SignInStartInternal(): JSX.Element {
434434
e.code === ERROR_CODES.FORM_PASSWORD_PWNED,
435435
);
436436

437+
const sessionAlreadyExistsError: ClerkAPIError = e.errors.find(
438+
(e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS,
439+
);
437440
const alreadySignedInError: ClerkAPIError = e.errors.find(
438441
(e: ClerkAPIError) => e.code === 'identifier_already_signed_in',
439442
);
@@ -444,6 +447,13 @@ function SignInStartInternal(): JSX.Element {
444447

445448
if (instantPasswordError) {
446449
await signInWithFields(identifierField);
450+
} else if (sessionAlreadyExistsError) {
451+
await clerk.setActive({
452+
session: clerk.client.lastActiveSessionId,
453+
navigate: async ({ session }) => {
454+
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
455+
},
456+
});
447457
} else if (alreadySignedInError) {
448458
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
449459
const sid = alreadySignedInError.meta!.sessionId!;

packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,38 @@ describe('SignInStart', () => {
252252
expect(icon.length).toEqual(1);
253253
});
254254
});
255+
256+
it('redirects user when session_exists error is returned during OAuth sign-in', async () => {
257+
const { wrapper, fixtures } = await createFixtures(f => {
258+
f.withSocialProvider({ provider: 'google' });
259+
});
260+
261+
const sessionExistsError = new ClerkAPIResponseError('Error', {
262+
data: [
263+
{
264+
code: 'session_exists',
265+
long_message: 'A session already exists',
266+
message: 'Session exists',
267+
},
268+
],
269+
status: 422,
270+
});
271+
272+
fixtures.clerk.client.lastActiveSessionId = 'sess_123';
273+
fixtures.signIn.authenticateWithRedirect.mockRejectedValueOnce(sessionExistsError);
274+
275+
const { userEvent } = render(<SignInStart />, { wrapper });
276+
277+
const googleButton = screen.getByText('Continue with Google');
278+
await userEvent.click(googleButton);
279+
280+
await waitFor(() => {
281+
expect(fixtures.clerk.setActive).toHaveBeenCalledWith({
282+
session: 'sess_123',
283+
navigate: expect.any(Function),
284+
});
285+
});
286+
});
255287
});
256288

257289
describe('navigation', () => {
@@ -523,6 +555,76 @@ describe('SignInStart', () => {
523555
});
524556
});
525557

558+
describe('Session already exists error handling', () => {
559+
it('redirects user when session_exists error is returned during sign-in', async () => {
560+
const { wrapper, fixtures } = await createFixtures(f => {
561+
f.withEmailAddress();
562+
});
563+
564+
const sessionExistsError = new ClerkAPIResponseError('Error', {
565+
data: [
566+
{
567+
code: 'session_exists',
568+
long_message: 'A session already exists',
569+
message: 'Session exists',
570+
},
571+
],
572+
status: 422,
573+
});
574+
575+
fixtures.clerk.client.lastActiveSessionId = 'sess_123';
576+
fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError);
577+
578+
const { userEvent } = render(<SignInStart />, { wrapper });
579+
580+
await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
581+
await userEvent.click(screen.getByText('Continue'));
582+
583+
await waitFor(() => {
584+
expect(fixtures.clerk.setActive).toHaveBeenCalledWith({
585+
session: 'sess_123',
586+
navigate: expect.any(Function),
587+
});
588+
});
589+
});
590+
591+
it('calls navigate after setting session active on session_exists error', async () => {
592+
const { wrapper, fixtures } = await createFixtures(f => {
593+
f.withEmailAddress();
594+
});
595+
596+
const sessionExistsError = new ClerkAPIResponseError('Error', {
597+
data: [
598+
{
599+
code: 'session_exists',
600+
long_message: 'A session already exists',
601+
message: 'Session exists',
602+
},
603+
],
604+
status: 422,
605+
});
606+
607+
fixtures.clerk.client.lastActiveSessionId = 'sess_123';
608+
fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError);
609+
610+
const mockSession = { id: 'sess_123' } as any;
611+
(fixtures.clerk.setActive as any).mockImplementation(
612+
async ({ navigate }: { navigate: ({ session }: { session: any }) => Promise<void> }) => {
613+
await navigate({ session: mockSession });
614+
},
615+
);
616+
617+
const { userEvent } = render(<SignInStart />, { wrapper });
618+
619+
await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
620+
await userEvent.click(screen.getByText('Continue'));
621+
622+
await waitFor(() => {
623+
expect(fixtures.clerk.setActive).toHaveBeenCalled();
624+
});
625+
});
626+
});
627+
526628
describe('ticket flow', () => {
527629
it('calls the appropriate resource function upon detecting the ticket', async () => {
528630
const { wrapper, fixtures } = await createFixtures(f => {

packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
2+
import { isClerkAPIResponseError } from '@clerk/shared/error';
23
import { useClerk } from '@clerk/shared/react';
34
import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types';
45
import React from 'react';
56

6-
import { isClerkAPIResponseError } from '@/index.headless';
77
import { Card } from '@/ui/elements/Card';
88
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
99
import { Header } from '@/ui/elements/Header';

0 commit comments

Comments
 (0)