diff --git a/.changeset/gold-emus-talk.md b/.changeset/gold-emus-talk.md
new file mode 100644
index 00000000000..8bc4292897b
--- /dev/null
+++ b/.changeset/gold-emus-talk.md
@@ -0,0 +1,7 @@
+---
+"@clerk/clerk-js": minor
+"@clerk/types": minor
+"@clerk/clerk-react": minor
+---
+
+Introduce `transferable` prop for `` 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.
diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts
index 3178012b225..442f8d73bc5 100644
--- a/packages/clerk-js/src/core/__tests__/clerk.test.ts
+++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts
@@ -183,7 +183,7 @@ describe('Clerk singleton', () => {
await sut.setActive({ session: null });
await waitFor(() => {
expect(mockSession.touch).not.toHaveBeenCalled();
- expect(evenBusSpy).toBeCalledWith('token:update', { token: null });
+ expect(evenBusSpy).toHaveBeenCalledWith('token:update', { token: null });
});
});
@@ -207,7 +207,7 @@ describe('Clerk singleton', () => {
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
await waitFor(() => {
expect(mockSession.touch).not.toHaveBeenCalled();
- expect(mockSession.getToken).toBeCalled();
+ expect(mockSession.getToken).toHaveBeenCalled();
});
});
@@ -308,7 +308,7 @@ describe('Clerk singleton', () => {
expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']);
expect(mockSession2.touch).toHaveBeenCalled();
expect(mockSession2.getToken).toHaveBeenCalled();
- expect(beforeEmitMock).toBeCalledWith(mockSession2);
+ expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2);
expect(sut.session).toMatchObject(mockSession2);
});
});
@@ -342,7 +342,7 @@ describe('Clerk singleton', () => {
expect(mockSession.touch).toHaveBeenCalled();
expect(mockSession.getToken).toHaveBeenCalled();
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id');
- expect(beforeEmitMock).toBeCalledWith(mockSession);
+ expect(beforeEmitMock).toHaveBeenCalledWith(mockSession);
expect(sut.session).toMatchObject(mockSession);
});
});
@@ -408,8 +408,8 @@ describe('Clerk singleton', () => {
expect(executionOrder).toEqual(['session.touch', 'before emit']);
expect(mockSession.touch).toHaveBeenCalled();
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id');
- expect(mockSession.getToken).toBeCalled();
- expect(beforeEmitMock).toBeCalledWith(mockSession);
+ expect(mockSession.getToken).toHaveBeenCalled();
+ expect(beforeEmitMock).toHaveBeenCalledWith(mockSession);
expect(sut.session).toMatchObject(mockSession);
});
});
@@ -619,7 +619,7 @@ describe('Clerk singleton', () => {
const toUrl = 'http://test.host/';
await sut.navigate(toUrl);
expect(mockHref).toHaveBeenCalledWith(toUrl);
- expect(logSpy).not.toBeCalled();
+ expect(logSpy).not.toHaveBeenCalled();
});
it('uses window location if a custom navigate is defined but destination has different origin', async () => {
@@ -627,7 +627,7 @@ describe('Clerk singleton', () => {
const toUrl = 'https://www.origindifferent.com/';
await sut.navigate(toUrl);
expect(mockHref).toHaveBeenCalledWith(toUrl);
- expect(logSpy).not.toBeCalled();
+ expect(logSpy).not.toHaveBeenCalled();
});
it('wraps custom navigate method in a promise if provided and it sync', async () => {
@@ -637,7 +637,7 @@ describe('Clerk singleton', () => {
expect(res.then).toBeDefined();
expect(mockHref).not.toHaveBeenCalled();
expect(mockNavigate.mock.calls[0][0]).toBe('/path#hash');
- expect(logSpy).not.toBeCalled();
+ expect(logSpy).not.toHaveBeenCalled();
});
it('logs navigation external navigation when routerDebug is enabled', async () => {
@@ -646,8 +646,8 @@ describe('Clerk singleton', () => {
await sut.navigate(toUrl);
expect(mockHref).toHaveBeenCalledWith(toUrl);
- expect(logSpy).toBeCalledTimes(1);
- expect(logSpy).toBeCalledWith(`Clerk is navigating to: ${toUrl}`);
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ expect(logSpy).toHaveBeenCalledWith(`Clerk is navigating to: ${toUrl}`);
});
it('logs navigation custom navigation when routerDebug is enabled', async () => {
@@ -658,8 +658,8 @@ describe('Clerk singleton', () => {
expect(mockHref).not.toHaveBeenCalled();
expect(mockNavigate.mock.calls[0][0]).toBe('/path#hash');
- expect(logSpy).toBeCalledTimes(1);
- expect(logSpy).toBeCalledWith(`Clerk is navigating to: ${toUrl}`);
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ expect(logSpy).toHaveBeenCalledWith(`Clerk is navigating to: ${toUrl}`);
});
});
@@ -728,6 +728,66 @@ describe('Clerk singleton', () => {
});
});
+ it('does not initiate the transfer flow when transferable: false is passed', async () => {
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ authConfig: {},
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => false,
+ isDevelopmentOrStaging: () => true,
+ onWindowLocationHost: () => false,
+ }),
+ );
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ activeSessions: [],
+ signIn: new SignIn({
+ status: 'needs_identifier',
+ first_factor_verification: {
+ status: 'transferable',
+ strategy: 'oauth_google',
+ external_verification_redirect_url: '',
+ error: {
+ code: 'external_account_not_found',
+ long_message: 'The External Account was not found.',
+ message: 'Invalid external account',
+ },
+ },
+ second_factor_verification: null,
+ identifier: '',
+ user_data: null,
+ created_session_id: null,
+ created_user_id: null,
+ } as any as SignInJSON),
+ signUp: new SignUp(null),
+ }),
+ );
+
+ const mockSetActive = jest.fn();
+ const mockSignUpCreate = jest
+ .fn()
+ .mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+ if (!sut.client) {
+ fail('we should always have a client');
+ }
+ sut.client.signUp.create = mockSignUpCreate;
+ sut.setActive = mockSetActive;
+
+ await sut.handleRedirectCallback({ transferable: false });
+
+ await waitFor(() => {
+ expect(mockSignUpCreate).not.toHaveBeenCalledWith({ transfer: true });
+ expect(mockSetActive).not.toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith('/sign-in', undefined);
+ });
+ });
+
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 () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 3668f245ffd..4b0ca388918 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -1207,6 +1207,10 @@ export class Clerk implements ClerkInterface {
const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
if (userNeedsToBeCreated) {
+ if (params.transferable === false) {
+ return navigateToSignIn();
+ }
+
const res = await signUp.create({ transfer: true });
switch (res.status) {
case 'complete':
diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts
index d87856fa4f9..82a8c3f3207 100644
--- a/packages/clerk-js/src/core/constants.ts
+++ b/packages/clerk-js/src/core/constants.ts
@@ -22,7 +22,8 @@ export const ERROR_CODES = {
NOT_ALLOWED_ACCESS: 'not_allowed_access',
SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing',
USER_LOCKED: 'user_locked',
-};
+ EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found',
+} as const;
export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username'];
export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name'];
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
index be5c48950f3..0f4287db471 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
@@ -47,6 +47,7 @@ function SignInRoutes(): JSX.Element {
signInForceRedirectUrl={signInContext.afterSignInUrl}
signUpForceRedirectUrl={signInContext.afterSignUpUrl}
continueSignUpUrl={signInContext.signUpContinueUrl}
+ transferable={signInContext.transferable}
firstFactorUrl={'../factor-one'}
secondFactorUrl={'../factor-two'}
resetPasswordUrl={'../reset-password'}
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
index cdc4a70e962..754432c67bd 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
@@ -205,6 +205,11 @@ export function _SignInStart(): JSX.Element {
useEffect(() => {
async function handleOauthError() {
+ const defaultErrorHandler = () => {
+ // Error from server may be too much information for the end user, so set a generic error
+ card.setError('Unable to complete action at this time. If the problem persists please contact support.');
+ };
+
const error = signIn?.firstFactorVerification?.error;
if (error) {
switch (error.code) {
@@ -214,12 +219,13 @@ export function _SignInStart(): JSX.Element {
case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING:
case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML:
case ERROR_CODES.USER_LOCKED:
+ case ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND:
card.setError(error);
break;
default:
- // Error from server may be too much information for the end user, so set a generic error
- card.setError('Unable to complete action at this time. If the problem persists please contact support.');
+ defaultErrorHandler();
}
+
// TODO: This is a workaround in order to reset the sign in attempt
// so that the oauth error does not persist on full page reloads.
void (await signIn.create({}));
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index 118ecb224db..d155efc32d4 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -127,6 +127,7 @@ export type SignInContextType = SignInCtx & {
authQueryString: string | null;
afterSignUpUrl: string;
afterSignInUrl: string;
+ transferable: boolean;
};
export const useSignInContext = (): SignInContextType => {
@@ -175,6 +176,7 @@ export const useSignInContext = (): SignInContextType => {
return {
...ctx,
+ transferable: ctx.transferable ?? true,
componentName,
signUpUrl,
signInUrl,
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index c17dafa45f9..eaf5e89f8db 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -501,7 +501,8 @@ export interface Clerk {
handleUnauthenticated: () => Promise;
}
-export type HandleOAuthCallbackParams = SignInForceRedirectUrl &
+export type HandleOAuthCallbackParams = TransferableOption &
+ SignInForceRedirectUrl &
SignInFallbackRedirectUrl &
SignUpForceRedirectUrl &
SignUpFallbackRedirectUrl &
@@ -729,11 +730,21 @@ export type SignInProps = RoutingOptions & {
* Initial values that are used to prefill the sign in form.
*/
initialValues?: SignInInitialValues;
-} & SignUpForceRedirectUrl &
+} & TransferableOption &
+ SignUpForceRedirectUrl &
SignUpFallbackRedirectUrl &
LegacyRedirectProps &
AfterSignOutUrl;
+interface TransferableOption {
+ /**
+ * Indicates whether or not sign in attempts are transferable to the sign up flow.
+ * Prevents opaque sign ups when a user attempts to sign in via OAuth with an email that doesn't exist.
+ * @default true
+ */
+ transferable?: boolean;
+}
+
export type SignInModalProps = WithoutRouting;
type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl;
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index a1b2ae02666..712a68f7615 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -731,6 +731,7 @@ type _LocalizationResource = {
type WithParamName = T &
Partial>}`, LocalizationValue>>;
type UnstableErrors = WithParamName<{
+ external_account_not_found: LocalizationValue;
identification_deletion_failed: LocalizationValue;
phone_number_exists: LocalizationValue;
form_identifier_not_found: LocalizationValue;