Skip to content

Commit 4e6c94e

Browse files
authored
feat(clerk-js): Support transferable prop on SignIn (#3845)
1 parent 346e787 commit 4e6c94e

File tree

9 files changed

+111
-18
lines changed

9 files changed

+111
-18
lines changed

.changeset/gold-emus-talk.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/types": minor
4+
"@clerk/clerk-react": minor
5+
---
6+
7+
Introduce `transferable` prop for `<SignIn />` to disable the automatic transfer of a sign in attempt to a sign up attempt when attempting to sign in with a social provider when the account does not exist. Also adds a `transferable` option to `Clerk.handleRedirectCallback()` with the same functionality.

packages/clerk-js/src/core/__tests__/clerk.test.ts

+73-13
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe('Clerk singleton', () => {
183183
await sut.setActive({ session: null });
184184
await waitFor(() => {
185185
expect(mockSession.touch).not.toHaveBeenCalled();
186-
expect(evenBusSpy).toBeCalledWith('token:update', { token: null });
186+
expect(evenBusSpy).toHaveBeenCalledWith('token:update', { token: null });
187187
});
188188
});
189189

@@ -207,7 +207,7 @@ describe('Clerk singleton', () => {
207207
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
208208
await waitFor(() => {
209209
expect(mockSession.touch).not.toHaveBeenCalled();
210-
expect(mockSession.getToken).toBeCalled();
210+
expect(mockSession.getToken).toHaveBeenCalled();
211211
});
212212
});
213213

@@ -308,7 +308,7 @@ describe('Clerk singleton', () => {
308308
expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']);
309309
expect(mockSession2.touch).toHaveBeenCalled();
310310
expect(mockSession2.getToken).toHaveBeenCalled();
311-
expect(beforeEmitMock).toBeCalledWith(mockSession2);
311+
expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2);
312312
expect(sut.session).toMatchObject(mockSession2);
313313
});
314314
});
@@ -342,7 +342,7 @@ describe('Clerk singleton', () => {
342342
expect(mockSession.touch).toHaveBeenCalled();
343343
expect(mockSession.getToken).toHaveBeenCalled();
344344
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id');
345-
expect(beforeEmitMock).toBeCalledWith(mockSession);
345+
expect(beforeEmitMock).toHaveBeenCalledWith(mockSession);
346346
expect(sut.session).toMatchObject(mockSession);
347347
});
348348
});
@@ -408,8 +408,8 @@ describe('Clerk singleton', () => {
408408
expect(executionOrder).toEqual(['session.touch', 'before emit']);
409409
expect(mockSession.touch).toHaveBeenCalled();
410410
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id');
411-
expect(mockSession.getToken).toBeCalled();
412-
expect(beforeEmitMock).toBeCalledWith(mockSession);
411+
expect(mockSession.getToken).toHaveBeenCalled();
412+
expect(beforeEmitMock).toHaveBeenCalledWith(mockSession);
413413
expect(sut.session).toMatchObject(mockSession);
414414
});
415415
});
@@ -619,15 +619,15 @@ describe('Clerk singleton', () => {
619619
const toUrl = 'http://test.host/';
620620
await sut.navigate(toUrl);
621621
expect(mockHref).toHaveBeenCalledWith(toUrl);
622-
expect(logSpy).not.toBeCalled();
622+
expect(logSpy).not.toHaveBeenCalled();
623623
});
624624

625625
it('uses window location if a custom navigate is defined but destination has different origin', async () => {
626626
await sut.load(mockedLoadOptions);
627627
const toUrl = 'https://www.origindifferent.com/';
628628
await sut.navigate(toUrl);
629629
expect(mockHref).toHaveBeenCalledWith(toUrl);
630-
expect(logSpy).not.toBeCalled();
630+
expect(logSpy).not.toHaveBeenCalled();
631631
});
632632

633633
it('wraps custom navigate method in a promise if provided and it sync', async () => {
@@ -637,7 +637,7 @@ describe('Clerk singleton', () => {
637637
expect(res.then).toBeDefined();
638638
expect(mockHref).not.toHaveBeenCalled();
639639
expect(mockNavigate.mock.calls[0][0]).toBe('/path#hash');
640-
expect(logSpy).not.toBeCalled();
640+
expect(logSpy).not.toHaveBeenCalled();
641641
});
642642

643643
it('logs navigation external navigation when routerDebug is enabled', async () => {
@@ -646,8 +646,8 @@ describe('Clerk singleton', () => {
646646
await sut.navigate(toUrl);
647647
expect(mockHref).toHaveBeenCalledWith(toUrl);
648648

649-
expect(logSpy).toBeCalledTimes(1);
650-
expect(logSpy).toBeCalledWith(`Clerk is navigating to: ${toUrl}`);
649+
expect(logSpy).toHaveBeenCalledTimes(1);
650+
expect(logSpy).toHaveBeenCalledWith(`Clerk is navigating to: ${toUrl}`);
651651
});
652652

653653
it('logs navigation custom navigation when routerDebug is enabled', async () => {
@@ -658,8 +658,8 @@ describe('Clerk singleton', () => {
658658
expect(mockHref).not.toHaveBeenCalled();
659659
expect(mockNavigate.mock.calls[0][0]).toBe('/path#hash');
660660

661-
expect(logSpy).toBeCalledTimes(1);
662-
expect(logSpy).toBeCalledWith(`Clerk is navigating to: ${toUrl}`);
661+
expect(logSpy).toHaveBeenCalledTimes(1);
662+
expect(logSpy).toHaveBeenCalledWith(`Clerk is navigating to: ${toUrl}`);
663663
});
664664
});
665665

@@ -728,6 +728,66 @@ describe('Clerk singleton', () => {
728728
});
729729
});
730730

731+
it('does not initiate the transfer flow when transferable: false is passed', async () => {
732+
mockEnvironmentFetch.mockReturnValue(
733+
Promise.resolve({
734+
authConfig: {},
735+
userSettings: mockUserSettings,
736+
displayConfig: mockDisplayConfig,
737+
isSingleSession: () => false,
738+
isProduction: () => false,
739+
isDevelopmentOrStaging: () => true,
740+
onWindowLocationHost: () => false,
741+
}),
742+
);
743+
744+
mockClientFetch.mockReturnValue(
745+
Promise.resolve({
746+
activeSessions: [],
747+
signIn: new SignIn({
748+
status: 'needs_identifier',
749+
first_factor_verification: {
750+
status: 'transferable',
751+
strategy: 'oauth_google',
752+
external_verification_redirect_url: '',
753+
error: {
754+
code: 'external_account_not_found',
755+
long_message: 'The External Account was not found.',
756+
message: 'Invalid external account',
757+
},
758+
},
759+
second_factor_verification: null,
760+
identifier: '',
761+
user_data: null,
762+
created_session_id: null,
763+
created_user_id: null,
764+
} as any as SignInJSON),
765+
signUp: new SignUp(null),
766+
}),
767+
);
768+
769+
const mockSetActive = jest.fn();
770+
const mockSignUpCreate = jest
771+
.fn()
772+
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));
773+
774+
const sut = new Clerk(productionPublishableKey);
775+
await sut.load(mockedLoadOptions);
776+
if (!sut.client) {
777+
fail('we should always have a client');
778+
}
779+
sut.client.signUp.create = mockSignUpCreate;
780+
sut.setActive = mockSetActive;
781+
782+
await sut.handleRedirectCallback({ transferable: false });
783+
784+
await waitFor(() => {
785+
expect(mockSignUpCreate).not.toHaveBeenCalledWith({ transfer: true });
786+
expect(mockSetActive).not.toHaveBeenCalled();
787+
expect(mockNavigate).toHaveBeenCalledWith('/sign-in', undefined);
788+
});
789+
});
790+
731791
it('creates a new sign up and navigates to the continue sign-up path if the user was not found during sso signup and there are missing requirements', async () => {
732792
mockEnvironmentFetch.mockReturnValue(
733793
Promise.resolve({

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

+4
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,10 @@ export class Clerk implements ClerkInterface {
12071207
const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
12081208

12091209
if (userNeedsToBeCreated) {
1210+
if (params.transferable === false) {
1211+
return navigateToSignIn();
1212+
}
1213+
12101214
const res = await signUp.create({ transfer: true });
12111215
switch (res.status) {
12121216
case 'complete':

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export const ERROR_CODES = {
2222
NOT_ALLOWED_ACCESS: 'not_allowed_access',
2323
SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing',
2424
USER_LOCKED: 'user_locked',
25-
};
25+
EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found',
26+
} as const;
2627

2728
export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username'];
2829
export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name'];

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

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function SignInRoutes(): JSX.Element {
4747
signInForceRedirectUrl={signInContext.afterSignInUrl}
4848
signUpForceRedirectUrl={signInContext.afterSignUpUrl}
4949
continueSignUpUrl={signInContext.signUpContinueUrl}
50+
transferable={signInContext.transferable}
5051
firstFactorUrl={'../factor-one'}
5152
secondFactorUrl={'../factor-two'}
5253
resetPasswordUrl={'../reset-password'}

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

+8-2
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ export function _SignInStart(): JSX.Element {
205205

206206
useEffect(() => {
207207
async function handleOauthError() {
208+
const defaultErrorHandler = () => {
209+
// Error from server may be too much information for the end user, so set a generic error
210+
card.setError('Unable to complete action at this time. If the problem persists please contact support.');
211+
};
212+
208213
const error = signIn?.firstFactorVerification?.error;
209214
if (error) {
210215
switch (error.code) {
@@ -214,12 +219,13 @@ export function _SignInStart(): JSX.Element {
214219
case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING:
215220
case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML:
216221
case ERROR_CODES.USER_LOCKED:
222+
case ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND:
217223
card.setError(error);
218224
break;
219225
default:
220-
// Error from server may be too much information for the end user, so set a generic error
221-
card.setError('Unable to complete action at this time. If the problem persists please contact support.');
226+
defaultErrorHandler();
222227
}
228+
223229
// TODO: This is a workaround in order to reset the sign in attempt
224230
// so that the oauth error does not persist on full page reloads.
225231
void (await signIn.create({}));

packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export type SignInContextType = SignInCtx & {
127127
authQueryString: string | null;
128128
afterSignUpUrl: string;
129129
afterSignInUrl: string;
130+
transferable: boolean;
130131
};
131132

132133
export const useSignInContext = (): SignInContextType => {
@@ -175,6 +176,7 @@ export const useSignInContext = (): SignInContextType => {
175176

176177
return {
177178
...ctx,
179+
transferable: ctx.transferable ?? true,
178180
componentName,
179181
signUpUrl,
180182
signInUrl,

packages/types/src/clerk.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,8 @@ export interface Clerk {
501501
handleUnauthenticated: () => Promise<unknown>;
502502
}
503503

504-
export type HandleOAuthCallbackParams = SignInForceRedirectUrl &
504+
export type HandleOAuthCallbackParams = TransferableOption &
505+
SignInForceRedirectUrl &
505506
SignInFallbackRedirectUrl &
506507
SignUpForceRedirectUrl &
507508
SignUpFallbackRedirectUrl &
@@ -729,11 +730,21 @@ export type SignInProps = RoutingOptions & {
729730
* Initial values that are used to prefill the sign in form.
730731
*/
731732
initialValues?: SignInInitialValues;
732-
} & SignUpForceRedirectUrl &
733+
} & TransferableOption &
734+
SignUpForceRedirectUrl &
733735
SignUpFallbackRedirectUrl &
734736
LegacyRedirectProps &
735737
AfterSignOutUrl;
736738

739+
interface TransferableOption {
740+
/**
741+
* Indicates whether or not sign in attempts are transferable to the sign up flow.
742+
* Prevents opaque sign ups when a user attempts to sign in via OAuth with an email that doesn't exist.
743+
* @default true
744+
*/
745+
transferable?: boolean;
746+
}
747+
737748
export type SignInModalProps = WithoutRouting<SignInProps>;
738749

739750
type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl;

packages/types/src/localization.ts

+1
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,7 @@ type _LocalizationResource = {
731731
type WithParamName<T> = T &
732732
Partial<Record<`${keyof T & string}__${CamelToSnake<Exclude<FieldId, 'role'>>}`, LocalizationValue>>;
733733
type UnstableErrors = WithParamName<{
734+
external_account_not_found: LocalizationValue;
734735
identification_deletion_failed: LocalizationValue;
735736
phone_number_exists: LocalizationValue;
736737
form_identifier_not_found: LocalizationValue;

0 commit comments

Comments
 (0)