diff --git a/.changeset/loud-balloons-grow.md b/.changeset/loud-balloons-grow.md new file mode 100644 index 00000000000..abd8cb131b9 --- /dev/null +++ b/.changeset/loud-balloons-grow.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduce experimental sign-in combined flow. diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index ef44f865f3a..068a9845b5b 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -113,6 +113,15 @@ const withWaitlistdMode = withEmailCodes .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk); +const withCombinedFlow = withEmailCodes + .clone() + .setId('withCombinedFlow') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) + .setEnvVariable('public', 'EXPERIMENTAL_COMBINED_FLOW', 'true') + .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-in'); + export const envs = { base, withEmailCodes, @@ -129,4 +138,5 @@ export const envs = { withRestrictedMode, withLegalConsent, withWaitlistdMode, + withCombinedFlow, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 3c310184f4e..f6de9ce0992 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -32,6 +32,7 @@ export const createLongRunningApps = () => { }, { id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles }, { id: 'next.appRouter.withReverification', config: next.appRouter, env: envs.withReverification }, + { id: 'next.appRouter.withCombinedFlow', config: next.appRouter, env: envs.withCombinedFlow }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index b8b377146ce..0c43679815c 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -13,6 +13,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( ); diff --git a/integration/tests/combined-sign-in-flow.test.ts b/integration/tests/combined-sign-in-flow.test.ts new file mode 100644 index 00000000000..548ea47ef63 --- /dev/null +++ b/integration/tests/combined-sign-in-flow.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, type FakeUser, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combined sign in flow @nextjs', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('flows are combined', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + + await expect(u.page.getByText(`Don’t have an account?`)).toBeHidden(); + }); + + test('sign in with email and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + 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(); + }); + + test('sign in with email and instant password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + }); + + test('sign in with email code', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.getUseAnotherMethodLink().click(); + await u.po.signIn.getAltMethodsEmailCodeButton().click(); + await u.po.signIn.enterTestOtpCode(); + await u.po.expect.toBeSignedIn(); + }); + + test('sign in with phone number and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.usePhoneNumberIdentifier().click(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.phoneNumber); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + }); + + test('sign in only with phone number', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUserWithoutPassword = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: false, + withPhoneNumber: true, + }); + await u.services.users.createBapiUser(fakeUserWithoutPassword); + await u.po.signIn.goTo(); + await u.po.signIn.usePhoneNumberIdentifier().click(); + await u.po.signIn.getIdentifierInput().fill(fakeUserWithoutPassword.phoneNumber); + await u.po.signIn.continue(); + await u.po.signIn.enterTestOtpCode(); + await u.po.expect.toBeSignedIn(); + + await fakeUserWithoutPassword.deleteIfExists(); + }); + + test('sign in with username and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.username); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + }); + + test('can reset password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUserWithPasword = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + }); + await u.services.users.createBapiUser(fakeUserWithPasword); + + await u.po.signIn.goTo(); + await u.po.signIn.getIdentifierInput().fill(fakeUserWithPasword.email); + await u.po.signIn.continue(); + await u.po.signIn.getForgotPassword().click(); + await u.po.signIn.getResetPassword().click(); + await u.po.signIn.enterTestOtpCode(); + await u.po.signIn.setPassword(`${fakeUserWithPasword.password}_reset`); + await u.po.signIn.setPasswordConfirmation(`${fakeUserWithPasword.password}_reset`); + await u.po.signIn.getResetPassword().click(); + await u.po.expect.toBeSignedIn(); + + await fakeUserWithPasword.deleteIfExists(); + }); + + test('cannot sign in with wrong password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword('wrong-password'); + await u.po.signIn.continue(); + await expect(u.page.getByText(/^password is incorrect/i)).toBeVisible(); + + await u.po.expect.toBeSignedOut(); + }); + + test('cannot sign in with wrong password but can sign in with email', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.getIdentifierInput().fill(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword('wrong-password'); + await u.po.signIn.continue(); + + await expect(u.page.getByText(/^password is incorrect/i)).toBeVisible(); + + await u.po.signIn.getUseAnotherMethodLink().click(); + await u.po.signIn.getAltMethodsEmailCodeButton().click(); + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + }); + + test('access protected page @express', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + expect(await u.page.locator("data-test-id='protected-api-response'").count()).toEqual(0); + await u.page.goToRelative('/protected'); + await u.page.isVisible("data-test-id='protected-api-response'"); + }); +}); diff --git a/integration/tests/combined-sign-up-flow.test.ts b/integration/tests/combined-sign-up-flow.test.ts new file mode 100644 index 00000000000..e2c3d8adc29 --- /dev/null +++ b/integration/tests/combined-sign-up-flow.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combined sign up flow @nextjs', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('sign up with email and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + }); + + // Go to sign in page + await u.po.signIn.goTo(); + + // Fill in sign in form + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + + // Verify email + await u.po.signUp.enterTestOtpCode(); + + await u.page.waitForAppUrl('/sign-in/create/continue'); + + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + // Check if user is signed in + await u.po.expect.toBeSignedIn(); + + await fakeUser.deleteIfExists(); + }); + + test('sign up with username, email, and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + withUsername: true, + }); + + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.username); + await u.po.signIn.continue(); + await u.page.waitForAppUrl('/sign-in/create'); + + const prefilledUsername = await u.po.signUp.getUsernameInput().inputValue(); + expect(prefilledUsername).toBe(fakeUser.username); + + await u.po.signUp.setEmailAddress(fakeUser.email); + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + await u.po.signUp.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + + await fakeUser.deleteIfExists(); + }); + + test('sign up, sign out and sign in again', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + + // Go to sign in page + await u.po.signIn.goTo(); + + // Fill in sign in form + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + + // Verify email + await u.po.signUp.enterTestOtpCode(); + + await u.page.waitForAppUrl('/sign-in/create/continue'); + + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + // Check if user is signed in + await u.po.expect.toBeSignedIn(); + + // Toggle user button + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + + // Click sign out + await u.po.userButton.triggerSignOut(); + + // Check if user is signed out + await u.po.expect.toBeSignedOut(); + + // Go to sign in page + await u.po.signIn.goTo(); + + // Fill in sign in form + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + // Check if user is signed in + await u.po.expect.toBeSignedIn(); + + await fakeUser.deleteIfExists(); + }); +}); diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 356b25b08b8..cc57518fc10 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -265,7 +265,10 @@
-
+
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5e0b5b826a..46a1f967f8b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -358,6 +358,10 @@ export class Clerk implements ClerkInterface { } }; + #isCombinedFlow(): boolean { + return this.#options.experimental?.combinedFlow && this.#options.signInUrl === this.#options.signUpUrl; + } + public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => { if (!this.client || this.client.sessions.length === 0) { return; @@ -1052,14 +1056,13 @@ export class Clerk implements ClerkInterface { return this.buildUrlWithAuth(this.#options.afterSignOutUrl); } - public buildWaitlistUrl(): string { + public buildWaitlistUrl(options?: { initialValues?: Record }): string { if (!this.environment || !this.environment.displayConfig) { return ''; } - const waitlistUrl = this.#options['waitlistUrl'] || this.environment.displayConfig.waitlistUrl; - - return buildURL({ base: waitlistUrl }, { stringify: true }); + const initValues = new URLSearchParams(options?.initialValues || {}); + return buildURL({ base: waitlistUrl, hashSearchParams: [initValues] }, { stringify: true }); } public buildAfterMultiSessionSingleSignOutUrl(): string { @@ -2051,10 +2054,18 @@ export class Clerk implements ClerkInterface { if (!key || !this.loaded || !this.environment || !this.environment.displayConfig) { return ''; } + const signInOrUpUrl = this.#options[key] || this.environment.displayConfig[key]; const redirectUrls = new RedirectUrls(this.#options, options).toSearchParams(); const initValues = new URLSearchParams(_initValues || {}); - const url = buildURL({ base: signInOrUpUrl, hashSearchParams: [initValues, redirectUrls] }, { stringify: true }); + const url = buildURL( + { + base: signInOrUpUrl, + hashPath: this.#isCombinedFlow() && key === 'signUpUrl' ? '/create' : '', + hashSearchParams: [initValues, redirectUrls], + }, + { stringify: true }, + ); return this.buildUrlWithAuth(url); }; diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index 4048385e631..bcc5c01bd13 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -32,6 +32,7 @@ export const ERROR_CODES = { ENTERPRISE_SSO_EMAIL_ADDRESS_DOMAIN_MISMATCH: 'enterprise_sso_email_address_domain_mismatch', ENTERPRISE_SSO_HOSTED_DOMAIN_MISMATCH: 'enterprise_sso_hosted_domain_mismatch', SAML_EMAIL_ADDRESS_DOMAIN_MISMATCH: 'saml_email_address_domain_mismatch', + INVITATION_ACCOUNT_NOT_EXISTS: 'invitation_account_not_exists', } as const; export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username']; diff --git a/packages/clerk-js/src/ui/common/EmailLinkVerify.tsx b/packages/clerk-js/src/ui/common/EmailLinkVerify.tsx index 9f0c08110c9..e67d25be897 100644 --- a/packages/clerk-js/src/ui/common/EmailLinkVerify.tsx +++ b/packages/clerk-js/src/ui/common/EmailLinkVerify.tsx @@ -15,11 +15,12 @@ export type EmailLinkVerifyProps = { redirectUrl?: string; verifyEmailPath?: string; verifyPhonePath?: string; + continuePath?: string; texts: Record; }; export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { - const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath } = props; + const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath, continuePath } = props; const { handleEmailLinkVerification } = useClerk(); const { navigate } = useRouter(); const signUp = useCoreSignUp(); @@ -36,6 +37,7 @@ export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { signUp, verifyEmailPath, verifyPhonePath, + continuePath, navigate, }); } catch (err) { diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 212dfeca9dc..347b45bf9a2 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -9,12 +9,13 @@ export function buildEmailLinkRedirectUrl( baseUrl: string | undefined = '', ): string { const { routing, authQueryString, path } = ctx; + const isCombinedFlow = '__experimental' in ctx && ctx.__experimental?.combinedProps; return buildRedirectUrl({ routing, baseUrl, authQueryString, path, - endpoint: MAGIC_LINK_VERIFY_PATH_ROUTE, + endpoint: isCombinedFlow ? `/create${MAGIC_LINK_VERIFY_PATH_ROUTE}` : MAGIC_LINK_VERIFY_PATH_ROUTE, }); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index bef7b6e6eb6..fbce6789212 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,10 +2,22 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; -import { SignInEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; -import { SignInContext, useSignInContext, withCoreSessionSwitchGuard } from '../../contexts'; +import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; +import { + SignInContext, + SignUpContext, + useOptions, + useSignInContext, + useSignUpContext, + withCoreSessionSwitchGuard, +} from '../../contexts'; import { Flow } from '../../customizables'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { SignUpContinue } from '../SignUp/SignUpContinue'; +import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback'; +import { SignUpStart } from '../SignUp/SignUpStart'; +import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail'; +import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone'; import { ResetPassword } from './ResetPassword'; import { ResetPasswordSuccess } from './ResetPasswordSuccess'; import { SignInAccountSwitcher } from './SignInAccountSwitcher'; @@ -24,6 +36,8 @@ function RedirectToSignIn() { function SignInRoutes(): JSX.Element { const signInContext = useSignInContext(); + const signUpContext = useSignUpContext(); + const options = useOptions(); return ( @@ -62,6 +76,62 @@ function SignInRoutes(): JSX.Element { redirectUrl='../factor-two' /> + {options.experimental?.combinedFlow && ( + + !!clerk.client.signUp.emailAddress} + > + + + !!clerk.client.signUp.phoneNumber} + > + + + + + + + + + + !!clerk.client.signUp.emailAddress} + > + + + !!clerk.client.signUp.phoneNumber} + > + + + + + + + + + + + )} @@ -73,9 +143,24 @@ function SignInRoutes(): JSX.Element { ); } +function SignInRoot() { + const signInContext = useSignInContext(); + + return ( + + + + ); +} + SignInRoutes.displayName = 'SignIn'; -export const SignIn: React.ComponentType = withCoreSessionSwitchGuard(SignInRoutes); +export const SignIn: React.ComponentType = withCoreSessionSwitchGuard(SignInRoot); export const SignInModal = (props: SignInModalProps): JSX.Element => { const signInProps = { diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 52052503f9a..483cfd5eb23 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -9,7 +9,7 @@ import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils'; import type { SignInStartIdentifier } from '../../common'; import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn } from '../../common'; import { buildSSOCallbackURL } from '../../common/redirects'; -import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useOptions, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { Card, @@ -25,8 +25,10 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router'; import type { FormControlState } from '../../utils'; import { buildRequest, handleError, isMobileDevice, useFormControl } from '../../utils'; +import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; import { useHandleAuthenticateWithPasskey } from './shared'; import { SignInSocialButtons } from './SignInSocialButtons'; +import { getSignUpAttributeFromIdentifier } from './utils'; const useAutoFillPasskey = () => { const [isSupported, setIsSupported] = useState(false); @@ -64,8 +66,10 @@ export function _SignInStart(): JSX.Element { const { displayConfig, userSettings } = useEnvironment(); const signIn = useCoreSignIn(); const { navigate } = useRouter(); + const options = useOptions(); const ctx = useSignInContext(); const { afterSignInUrl, signUpUrl, waitlistUrl } = ctx; + const isCombinedFlow = !!options?.experimental?.combinedFlow; const supportEmail = useSupportEmail(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -332,15 +336,57 @@ export function _SignInStart(): JSX.Element { (e: ClerkAPIError) => e.code === ERROR_CODES.INVALID_STRATEGY_FOR_USER || e.code === ERROR_CODES.FORM_PASSWORD_INCORRECT, ); + const alreadySignedInError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === 'identifier_already_signed_in', ); + const accountDoesNotExistError: ClerkAPIError = e.errors.find( + (e: ClerkAPIError) => + e.code === ERROR_CODES.INVITATION_ACCOUNT_NOT_EXISTS || e.code === ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND, + ); if (instantPasswordError) { await signInWithFields(identifierField); } else if (alreadySignedInError) { const sid = alreadySignedInError.meta!.sessionId!; await clerk.setActive({ session: sid, redirectUrl: afterSignInUrl }); + } else if (isCombinedFlow && accountDoesNotExistError) { + const attribute = getSignUpAttributeFromIdentifier(identifierField); + + if (userSettings.signUp.mode === SIGN_UP_MODES.WAITLIST) { + const waitlistUrl = clerk.buildWaitlistUrl( + attribute === 'emailAddress' + ? { + initialValues: { + [attribute]: identifierField.value, + }, + } + : {}, + ); + return navigate(waitlistUrl); + } + + clerk.client.signUp[attribute] = identifierField.value; + const paramsToForward = new URLSearchParams(); + if (organizationTicket) { + paramsToForward.set('__clerk_ticket', organizationTicket); + } + + const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signUpUrl); + const redirectUrlComplete = ctx.afterSignUpUrl || '/'; + + return handleCombinedFlowTransfer({ + afterSignUpUrl: ctx.afterSignUpUrl || '/', + clerk, + handleError: e => handleError(e, [identifierField, instantPasswordField], card.setError), + identifierAttribute: attribute, + identifierValue: identifierField.value, + navigate, + organizationTicket, + signUpMode: userSettings.signUp.mode, + redirectUrl, + redirectUrlComplete, + }); } else { handleError(e, [identifierField, instantPasswordField], card.setError); } @@ -373,8 +419,14 @@ export function _SignInStart(): JSX.Element { - - + {isCombinedFlow ? ( + + ) : ( + <> + + + + )} {card.error} {/*TODO: extract main in its own component */} @@ -423,7 +475,7 @@ export function _SignInStart(): JSX.Element { - {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && ( + {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && ( Promise; + organizationTicket?: string; + afterSignUpUrl: string; + clerk: LoadedClerk; + handleError: (err: any) => void; + redirectUrl?: string; + redirectUrlComplete?: string; +}; + +/** + * This function is used to handle transfering from a sign in to a sign up when SignIn is rendered as the combined flow. + * There is special logic to handle transfer email-based sign ups directly to verification, bypassing the initial sign up form. + */ +export function handleCombinedFlowTransfer({ + identifierAttribute, + identifierValue, + signUpMode, + navigate, + organizationTicket, + afterSignUpUrl, + clerk, + handleError, + redirectUrl, + redirectUrlComplete, +}: HandleCombinedFlowTransferProps): Promise | void { + if (signUpMode === SIGN_UP_MODES.WAITLIST) { + const waitlistUrl = clerk.buildWaitlistUrl( + identifierAttribute === 'emailAddress' + ? { + initialValues: { + [identifierAttribute]: identifierValue, + }, + } + : {}, + ); + return navigate(waitlistUrl); + } + + clerk.client.signUp[identifierAttribute] = identifierValue; + const paramsToForward = new URLSearchParams(); + if (organizationTicket) { + paramsToForward.set('__clerk_ticket', organizationTicket); + } + + // Attempt to transfer directly to sign up verification if email or phone was used. The signUp.create() call will + // inform us if the instance is eligible for moving directly to verification. + if (identifierAttribute === 'emailAddress' || identifierAttribute === 'phoneNumber') { + return clerk.client.signUp + .create({ + [identifierAttribute]: identifierValue, + }) + .then(res => { + return completeSignUpFlow({ + signUp: res, + verifyEmailPath: 'create/verify-email-address', + verifyPhonePath: 'create/verify-phone-number', + handleComplete: () => clerk.setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), + navigate, + redirectUrl, + redirectUrlComplete, + }); + }) + .catch(err => handleError(err)); + } + + return navigate(`create?${paramsToForward.toString()}`); +} diff --git a/packages/clerk-js/src/ui/components/SignIn/utils.ts b/packages/clerk-js/src/ui/components/SignIn/utils.ts index ced616f462e..b76aceb2942 100644 --- a/packages/clerk-js/src/ui/components/SignIn/utils.ts +++ b/packages/clerk-js/src/ui/components/SignIn/utils.ts @@ -1,6 +1,7 @@ import { titleize } from '@clerk/shared/underscore'; import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import type { PreferredSignInStrategy, SignInFactor, SignInResource, SignInStrategy } from '@clerk/types'; +import type { FormControlState } from 'ui/utils'; import { PREFERRED_SIGN_IN_STRATEGIES } from '../../common/constants'; import { otpPrefFactorComparator, passwordPrefFactorComparator } from '../../utils/factorSorting'; @@ -108,3 +109,16 @@ export function determineStartingSignInSecondFactor(secondFactors: SignInFactor[ const resetPasswordStrategies: SignInStrategy[] = ['reset_password_phone_code', 'reset_password_email_code']; export const isResetPasswordStrategy = (strategy: SignInStrategy | string | null | undefined) => !!strategy && resetPasswordStrategies.includes(strategy as SignInStrategy); + +const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); +export function getSignUpAttributeFromIdentifier(identifier: FormControlState<'identifier'>) { + if (identifier.type === 'tel') { + return 'phoneNumber'; + } + + if (isEmail(identifier.value)) { + return 'emailAddress'; + } + + return 'username'; +} diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 37444a1ed8a..63b3748a2a3 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx @@ -1,7 +1,7 @@ import { useClerk } from '@clerk/shared/react'; import React, { useEffect, useMemo } from 'react'; -import { useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; +import { SignInContext, useCoreSignUp, useEnvironment, useOptions, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys } from '../../customizables'; import { Card, @@ -33,6 +33,9 @@ function _SignUpContinue() { const { attributes } = userSettings; const { afterSignUpUrl, signInUrl, unsafeMetadata, initialValues = {} } = useSignUpContext(); const signUp = useCoreSignUp(); + const options = useOptions(); + const isWithinSignInContext = !!React.useContext(SignInContext); + const isCombinedFlow = !!(options.experimental?.combinedFlow && !!isWithinSignInContext); const isProgressiveSignUp = userSettings.signUp.progressive; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), @@ -218,13 +221,15 @@ function _SignUpContinue() { - - - - + {!isCombinedFlow ? ( + + + + + ) : null} diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 9a425e3ea70..2fe85d65aff 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ERROR_CODES, SIGN_UP_MODES } from '../../../core/constants'; import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils/getClerkQueryParam'; import { buildSSOCallbackURL, withRedirectToAfterSignUp } from '../../common'; -import { useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; +import { SignInContext, useCoreSignUp, useEnvironment, useOptions, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { Card, @@ -39,7 +39,10 @@ function _SignUpStart(): JSX.Element { const { attributes } = userSettings; const { setActive } = useClerk(); const ctx = useSignUpContext(); + const options = useOptions(); + const isWithinSignInContext = !!React.useContext(SignInContext); const { afterSignUpUrl, signInUrl, unsafeMetadata } = ctx; + const isCombinedFlow = !!(options.experimental?.combinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), ); @@ -315,7 +318,7 @@ function _SignUpStart(): JSX.Element { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx index 358ff0fae1f..3f0ae424894 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx @@ -36,6 +36,7 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) signUp: res, verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', + continuePath: '../continue', handleComplete: () => setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), navigate, }); diff --git a/packages/clerk-js/src/ui/components/Waitlist/Waitlist.tsx b/packages/clerk-js/src/ui/components/Waitlist/Waitlist.tsx index 398b4707e02..ded89c5caf2 100644 --- a/packages/clerk-js/src/ui/components/Waitlist/Waitlist.tsx +++ b/packages/clerk-js/src/ui/components/Waitlist/Waitlist.tsx @@ -13,8 +13,10 @@ const _Waitlist = () => { const ctx = useWaitlistContext(); const { signInUrl } = ctx; + const initialValues = ctx.initialValues || {}; + const formState = { - emailAddress: useFormControl('emailAddress', '', { + emailAddress: useFormControl('emailAddress', initialValues.emailAddress || '', { type: 'email', label: localizationKeys('formFieldLabel__emailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 1d45b1edb02..c717bec4dab 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -37,7 +37,8 @@ export const useSignInContext = (): SignInContextType => { throw new Error(`Clerk: useSignInContext called outside of the mounted SignIn component.`); } - const { componentName, ...ctx } = context; + const { componentName, ..._ctx } = context; + const ctx = _ctx.__experimental?.combinedProps || _ctx; const initialValuesFromQueryParams = useMemo( () => getInitialValuesFromQueryParams(queryString, SIGN_IN_INITIAL_VALUE_KEYS), @@ -71,6 +72,13 @@ export const useSignInContext = (): SignInContextType => { signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); waitlistUrl = buildURL({ base: waitlistUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + if (options.experimental?.combinedFlow) { + signUpUrl = buildURL( + { base: signInUrl, hashPath: '/create', hashSearchParams: [queryParams, preservedParams] }, + { stringify: true }, + ); + } + const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); return { diff --git a/packages/clerk-js/src/ui/contexts/components/Waitlist.ts b/packages/clerk-js/src/ui/contexts/components/Waitlist.ts index 6a00f335b7a..767a37ee12d 100644 --- a/packages/clerk-js/src/ui/contexts/components/Waitlist.ts +++ b/packages/clerk-js/src/ui/contexts/components/Waitlist.ts @@ -1,12 +1,17 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import { buildURL } from '../../../utils'; import { useEnvironment, useOptions } from '../../contexts'; +import { useRouter } from '../../router'; import type { WaitlistCtx } from '../../types'; +import { getInitialValuesFromQueryParams } from '../utils'; + +const WAITLIST_INITIAL_VALUE_KEYS = ['email_address']; export type WaitlistContextType = WaitlistCtx & { signInUrl: string; afterJoinWaitlistUrl?: string; + initialValues: any; }; export const WaitlistContext = createContext(null); @@ -15,6 +20,12 @@ export const useWaitlistContext = (): WaitlistContextType => { const context = useContext(WaitlistContext); const { displayConfig } = useEnvironment(); const options = useOptions(); + const { queryString } = useRouter(); + + const initialValuesFromQueryParams = useMemo( + () => getInitialValuesFromQueryParams(queryString, WAITLIST_INITIAL_VALUE_KEYS), + [], + ); if (!context || context.componentName !== 'Waitlist') { throw new Error('Clerk: useWaitlistContext called outside Waitlist.'); @@ -29,5 +40,6 @@ export const useWaitlistContext = (): WaitlistContextType => { ...ctx, componentName, signInUrl, + initialValues: { ...initialValuesFromQueryParams }, }; }; diff --git a/packages/clerk-js/src/utils/completeSignUpFlow.ts b/packages/clerk-js/src/utils/completeSignUpFlow.ts index baf951eba21..b18f34940d1 100644 --- a/packages/clerk-js/src/utils/completeSignUpFlow.ts +++ b/packages/clerk-js/src/utils/completeSignUpFlow.ts @@ -4,6 +4,7 @@ type CompleteSignUpFlowProps = { signUp: SignUpResource; verifyEmailPath?: string; verifyPhonePath?: string; + continuePath?: string; navigate: (to: string) => Promise; handleComplete?: () => Promise; redirectUrl?: string; @@ -14,6 +15,7 @@ export const completeSignUpFlow = ({ signUp, verifyEmailPath, verifyPhonePath, + continuePath, navigate, handleComplete, redirectUrl = '', @@ -37,6 +39,10 @@ export const completeSignUpFlow = ({ if (signUp.unverifiedFields?.includes('phone_number') && verifyPhonePath) { return navigate(verifyPhonePath); } + + if (continuePath) { + return navigate(continuePath); + } } return; }; diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index ef5f77f3b1c..7163b8247dd 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -460,6 +460,7 @@ export const arSA: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'للمتابعة إلى {{applicationName}}', title: 'تسجيل الدخول', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'رمز التحقق', diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 0a03674b0f4..12ac2267e0f 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -464,6 +464,7 @@ export const beBY: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'каб працягнуць працу ў "{{applicationName}}"', title: 'Увайсці', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Код верыфікацыі', diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 1627394c2d4..f29526fe37d 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -460,6 +460,7 @@ export const bgBG: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Добре дошли обратно! Моля, влезте, за да продължите', title: 'Влезте в {{applicationName}}', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Код за потвърждение', diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 12ec9f02448..a53a5fcb4fd 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -459,6 +459,7 @@ export const csCZ: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'pro pokračování do {{applicationName}}', title: 'Přihlásit se', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Ověřovací kód', diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 0c65ca601a8..c569e202532 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -460,6 +460,7 @@ export const daDK: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Forsæt til {{applicationName}}', title: 'Log ind', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Bekræftelseskode', diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 6555b908882..e786ca62ca2 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -466,6 +466,7 @@ export const deDE: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'weiter zu {{applicationName}}', title: 'Einloggen', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Bestätigungscode', diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 93c4613a7f7..0b69da0e26a 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -462,6 +462,7 @@ export const elGR: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'για να συνεχίσετε στο {{applicationName}}', title: 'Σύνδεση', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Κωδικός επαλήθευσης', diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e53c4ac6e11..928c6045eae 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -450,6 +450,7 @@ export const enUS: LocalizationResource = { actionText__join_waitlist: 'Want early access?', subtitle: 'Welcome back! Please sign in to continue', title: 'Sign in to {{applicationName}}', + __experimental_titleCombined: 'Continue to {{applicationName}}', }, totpMfa: { formTitle: 'Verification code', diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 1fbdef0393f..1a154a9b76d 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -461,6 +461,7 @@ export const esES: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'para continuar a {{applicationName}}', title: 'Entrar', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Código de verificación', diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 36e167f6fff..2f69c0711c6 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -465,6 +465,7 @@ export const esMX: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'para continuar con {{applicationName}}', title: 'Iniciar sesión', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Código de verificación', diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index f3615c6bacf..6c972fc358f 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -462,6 +462,7 @@ export const fiFI: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'jatkaaksesi kohteeseen {{applicationName}}', title: 'Kirjaudu sisään', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Todennuskoodi', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 22f6861bfa6..34d4248c69a 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -464,6 +464,7 @@ export const frFR: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'pour continuer vers {{applicationName}}', title: "S'identifier", + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Le code de vérification', diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index b193a5e41a4..5480320c19c 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -454,6 +454,7 @@ export const heIL: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'להמשיך אל {{applicationName}}', title: 'התחבר', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'קוד אימות', diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 24628d7518f..3effa0b4aa9 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -461,6 +461,7 @@ export const huHU: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Üdv újra! A folytatáshoz kérlek jelentkezz be.', title: 'Bejelentkezés a(z) {{applicationName}} fiókba', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Visszaigazoló kód', diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 3e5fe66d596..9629fc1164a 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -463,6 +463,7 @@ export const isIS: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Velkomin aftur! Vinsamlegast skráðu þig inn til að halda áfram', title: 'Skrá inn í {{applicationName}}', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Staðfestingarkóði', diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index b7a977abf2c..9c4aabdb4a2 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -461,6 +461,7 @@ export const itIT: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'per continuare su {{applicationName}}', title: 'Accedi', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Codice di verifica', diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 78e0faf5e54..dbdcdd34e24 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -461,6 +461,7 @@ export const jaJP: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: '{{applicationName}}へのアクセスを続ける', title: 'サインイン', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: '検証コード', diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index f247fd4b836..4dc49672099 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -456,6 +456,7 @@ export const koKR: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: '환영합니다! 계속하려면 로그인해 주세요', title: '{{applicationName}}에 로그인', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: '인증 코드', diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 816baadd1db..885d199a807 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -462,6 +462,7 @@ export const mnMN: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Тавтай морил! Үргэлжлүүлэхийн тулд нэвтэрнэ үү', title: '{{applicationName}} руу нэвтрэх', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Баталгаажуулах код', diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 07b4b869c38..4956900b257 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -461,6 +461,7 @@ export const nbNO: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'for å fortsette til {{applicationName}}', title: 'Logg inn', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Verifiseringskode', diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index b7e02eed51f..27693e3846c 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -461,6 +461,7 @@ export const nlNL: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'om door te gaan naar {{applicationName}}', title: 'Inloggen', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Verificatiecode', diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 2a4a9ffd533..d6bdb6b7db8 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -460,6 +460,7 @@ export const plPL: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'aby przejść do {{applicationName}}', title: 'Zaloguj się', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Kod weryfikacyjny', diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 431b00cf945..5d9b56e8db5 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -461,6 +461,7 @@ export const ptBR: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'para continuar em {{applicationName}}', title: 'Entrar', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Código de verificação', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index 12f672df683..8f50b8fb2ea 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -459,6 +459,7 @@ export const ptPT: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'para continuar em {{applicationName}}', title: 'Entrar', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Código de verificação', diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index 49b78a51fdf..456dbb18424 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -463,6 +463,7 @@ export const roRO: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'pentru a continua la {{applicationName}}', title: 'Conectați-vă', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Cod de verificare', diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index c4bdb233903..e302f1075a5 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -470,6 +470,7 @@ export const ruRU: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'чтобы продолжить работу в "{{applicationName}}"', title: 'Войти', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Верификационный код', diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 033ec3d6578..1354051d097 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -459,6 +459,7 @@ export const skSK: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'pre pokračovanie do {{applicationName}}', title: 'Prihlásiť sa', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Overovací kód', diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 1d949a48111..2b9ee9a2802 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -460,6 +460,7 @@ export const srRS: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'Dobro došao nazad! Molimo prijavi se da nastaviš', title: 'Prijavi se na {{applicationName}}', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Verifikacioni kod', diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index f9f033e38b3..0d39cdbd5f8 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -463,6 +463,7 @@ export const svSE: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'för att fortsätta till {{applicationName}}', title: 'Logga in', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Verifieringskod', diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 5d0bc6ffeb9..8cd4b69adfc 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -457,6 +457,7 @@ export const thTH: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'ยินดีต้อนรับกลับ! กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ', title: 'เข้าสู่ระบบ {{applicationName}}', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'รหัสการตรวจสอบ', diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index 3bea5c5a03a..9a9aeeee5f6 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -462,6 +462,7 @@ export const trTR: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: '{{applicationName}} ile devam etmek için', title: 'Giriş yap', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Doğrulama kodu', diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 26d9eae3815..7e8408d0491 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -459,6 +459,7 @@ export const ukUA: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'щоб продовжити роботу в "{{applicationName}}"', title: 'Увійти', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Верифікаційний код', diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index b246c585b88..b81c4b8c490 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -459,6 +459,7 @@ export const viVN: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: 'để tiếp tục với {{applicationName}}', title: 'Đăng nhập', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: 'Mã xác minh', diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 530649b8716..2f472355eb0 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -450,6 +450,7 @@ export const zhCN: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: '继续使用 {{applicationName}}', title: '登录', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: '验证码', diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 3ded2234266..c727b29bccc 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -456,6 +456,7 @@ export const zhTW: LocalizationResource = { actionText__join_waitlist: undefined, subtitle: '繼續使用 {{applicationName}}', title: '登錄', + __experimental_titleCombined: undefined, }, totpMfa: { formTitle: '驗證碼', diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 9265e8f63b9..6cb2a0438a1 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -463,7 +463,7 @@ export interface Clerk { /** * Returns the configured url where is mounted or a custom waitlist page is rendered. */ - buildWaitlistUrl(): string; + buildWaitlistUrl(opts?: { initialValues?: Record }): string; /** * @@ -748,6 +748,7 @@ export type ClerkOptions = ClerkOptionsNavigation & { persistClient: boolean; rethrowOfflineNetworkErrors: boolean; + combinedFlow: boolean; }, Record >; @@ -900,6 +901,55 @@ export type SignInProps = RoutingOptions & { * Initial values that are used to prefill the sign in form. */ initialValues?: SignInInitialValues; + /** + * Enable experimental flags to gain access to new features. These flags are not guaranteed to be stable and may change drastically in between patch or minor versions. + */ + __experimental?: Record & { newComponents?: boolean; combinedProps?: SignInCombinedProps }; + /** + * Full URL or path to for the waitlist process. + * Used to fill the "Join waitlist" link in the SignUp component. + */ + waitlistUrl?: string; +} & TransferableOption & + SignUpForceRedirectUrl & + SignUpFallbackRedirectUrl & + LegacyRedirectProps & + AfterSignOutUrl; + +export type SignInCombinedProps = RoutingOptions & { + /** + * Full URL or path to navigate after successful sign in. + * This value has precedence over other redirect props, environment variables or search params. + * Use this prop to override the redirect URL when needed. + * @default undefined + */ + forceRedirectUrl?: string | null; + /** + * Full URL or path to navigate after successful sign in. + * This value is used when no other redirect props, environment variables or search params are present. + * @default undefined + */ + fallbackRedirectUrl?: string | null; + /** + * Full URL or path to for the sign in process. + * Used to fill the "Sign in" link in the SignUp component. + */ + signInUrl?: string; + /** + * Full URL or path to for the sign up process. + * Used to fill the "Sign up" link in the SignUp component. + */ + signUpUrl?: string; + /** + * Customisation options to fully match the Clerk components to your own brand. + * These options serve as overrides and will be merged with the global `appearance` + * prop of ClerkProvider (if one is provided) + */ + appearance?: SignInTheme; + /** + * Initial values that are used to prefill the sign in or up forms. + */ + initialValues?: SignInInitialValues & SignUpInitialValues; /** * Enable experimental flags to gain access to new features. These flags are not guaranteed to be stable and may change drastically in between patch or minor versions. */ @@ -909,6 +959,10 @@ export type SignInProps = RoutingOptions & { * Used to fill the "Join waitlist" link in the SignUp component. */ waitlistUrl?: string; + /** + * Additional arbitrary metadata to be stored alongside the User object + */ + unsafeMetadata?: SignUpUnsafeMetadata; } & TransferableOption & SignUpForceRedirectUrl & SignUpFallbackRedirectUrl & diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 076782e9432..302e6115d28 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -168,6 +168,7 @@ type _LocalizationResource = { signIn: { start: { title: LocalizationValue; + __experimental_titleCombined: LocalizationValue; subtitle: LocalizationValue; actionText: LocalizationValue; actionLink: LocalizationValue;