diff --git a/.changeset/tough-bugs-vanish.md b/.changeset/tough-bugs-vanish.md new file mode 100644 index 00000000000..92bcc78c96d --- /dev/null +++ b/.changeset/tough-bugs-vanish.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +'@clerk/vue': minor +--- + +Introduce sign-in-or-up flow. diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 068a9845b5b..5d60c29d114 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -113,14 +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 +const withSignInOrUpFlow = 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'); + .setId('withSignInOrUpFlow') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined); + +const withSignInOrUpEmailLinksFlow = withEmailLinks + .clone() + .setId('withSignInOrUpEmailLinksFlow') + .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined); export const envs = { base, @@ -138,5 +139,6 @@ export const envs = { withRestrictedMode, withLegalConsent, withWaitlistdMode, - withCombinedFlow, + withSignInOrUpFlow, + withSignInOrUpEmailLinksFlow, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index cd1f27cc19e..d5573f015e0 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -31,7 +31,12 @@ 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: 'next.appRouter.withSignInOrUpFlow', config: next.appRouter, env: envs.withSignInOrUpFlow }, + { + id: 'next.appRouter.withSignInOrUpEmailLinksFlow', + config: next.appRouter, + env: envs.withSignInOrUpEmailLinksFlow, + }, { 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 0c43679815c..b8b377146ce 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -13,9 +13,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + Loading sign in} + withSignUp + /> + + ); +} diff --git a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx index d574c6244f1..d193e28a464 100644 --- a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx +++ b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx @@ -8,9 +8,6 @@ export default function Page() { path={'/sign-in'} signUpUrl={'/sign-up'} fallback={<>Loading sign in} - __experimental={{ - combinedProps: {}, - }} /> ); diff --git a/integration/tests/combined-sign-in-flow.test.ts b/integration/tests/combined-sign-in-flow.test.ts deleted file mode 100644 index f0afacb4c4e..00000000000 --- a/integration/tests/combined-sign-in-flow.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -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 you entered 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 you entered 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 deleted file mode 100644 index 93818d0b95b..00000000000 --- a/integration/tests/combined-sign-up-flow.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -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(); - await u.page.waitForAppUrl('/sign-in/create'); - - const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); - expect(prefilledEmail).toBe(fakeUser.email); - - await u.po.signUp.setPassword(fakeUser.password); - await u.po.signUp.continue(); - - // Verify email - await u.po.signUp.enterTestOtpCode(); - - // 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, - withPassword: 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(); - await u.page.waitForAppUrl('/sign-in/create'); - - const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); - expect(prefilledEmail).toBe(fakeUser.email); - - await u.po.signUp.setPassword(fakeUser.password); - await u.po.signUp.continue(); - - // Verify email - await u.po.signUp.enterTestOtpCode(); - - // 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/integration/tests/sign-in-or-up-component.test.ts b/integration/tests/sign-in-or-up-component.test.ts new file mode 100644 index 00000000000..94dd73df059 --- /dev/null +++ b/integration/tests/sign-in-or-up-component.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { createTestUtils } from '../testUtils'; + +test.describe('sign-in-or-up component initialization flow @nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('flows are combined', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in-or-up'); + await expect(u.page.getByText(`Don’t have an account?`)).toBeHidden(); + }); +}); diff --git a/integration/tests/sign-in-or-up-email-links-flow.test.ts b/integration/tests/sign-in-or-up-email-links-flow.test.ts new file mode 100644 index 00000000000..575b0e5da94 --- /dev/null +++ b/integration/tests/sign-in-or-up-email-links-flow.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [] })('sign-in-or-up email links flow', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(() => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('sign up with email link', 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.page.waitForAppUrl('/sign-in/create'); + + const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); + expect(prefilledEmail).toBe(fakeUser.email); + + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + await u.po.signUp.waitForEmailVerificationScreen(); + await u.tabs.runInNewTab(async u => { + const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email); + await u.page.goto(verificationLink); + await u.po.expect.toBeSignedIn(); + await u.page.close(); + }); + await u.po.expect.toBeSignedIn(); + }); + + test('sign in with email link', 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.page.waitForAppUrl('/sign-in/factor-one'); + // Defaults to password, so we need to switch to email link + await u.page.getByRole('link', { name: /Use another method/i }).click(); + await u.page.getByRole('button', { name: /Email link to/i }).click(); + await page.getByRole('heading', { name: /Check your email/i }).waitFor(); + await u.tabs.runInNewTab(async u => { + const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email); + await u.page.goto(verificationLink); + await u.po.expect.toBeSignedIn(); + await u.page.close(); + }); + await u.po.expect.toBeSignedIn(); + await fakeUser.deleteIfExists(); + }); +}); diff --git a/integration/tests/sign-in-or-up-flow.test.ts b/integration/tests/sign-in-or-up-flow.test.ts new file mode 100644 index 00000000000..952c240a362 --- /dev/null +++ b/integration/tests/sign-in-or-up-flow.test.ts @@ -0,0 +1,286 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, type FakeUser, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('sign-in-or-up flow @nextjs', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test.describe('sign-in', () => { + 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(); + }); + + 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 you entered 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 you entered 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', 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'"); + }); + + 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(); + await u.page.waitForAppUrl('/sign-in/create'); + + const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); + expect(prefilledEmail).toBe(fakeUser.email); + + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + // Verify email + await u.po.signUp.enterTestOtpCode(); + + // Check if user is signed in + await u.po.expect.toBeSignedIn(); + + await fakeUser.deleteIfExists(); + }); + }); + + test.describe('sign-up', () => { + 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, + withPassword: 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(); + await u.page.waitForAppUrl('/sign-in/create'); + + const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); + expect(prefilledEmail).toBe(fakeUser.email); + + await u.po.signUp.setPassword(fakeUser.password); + await u.po.signUp.continue(); + + // Verify email + await u.po.signUp.enterTestOtpCode(); + + // 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(); + }); + + test('sign in with ticket renders sign up', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo({ + searchParams: new URLSearchParams({ __clerk_ticket: '123', __clerk_status: 'sign_up' }), + }); + await u.page.waitForAppUrl('/sign-in/create?__clerk_ticket=123'); + await expect(u.page.getByText(/Create your account/i)).toBeVisible(); + }); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index de52b1e20a3..bf0e074ef14 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -6,7 +6,7 @@ import { LocalStorageBroadcastChannel } from '@clerk/shared/localStorageBroadcas import { logger } from '@clerk/shared/logger'; import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, TelemetryCollector } from '@clerk/shared/telemetry'; -import { addClerkPrefix, stripScheme } from '@clerk/shared/url'; +import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { handleValueOrFn, noop } from '@clerk/shared/utils'; import type { __internal_UserVerificationModalProps, @@ -365,8 +365,8 @@ export class Clerk implements ClerkInterface { } }; - #isCombinedFlow(): boolean { - return this.#options.experimental?.combinedFlow && this.#options.signInUrl === this.#options.signUpUrl; + #isCombinedSignInOrUpFlow(): boolean { + return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); } public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => { @@ -2114,13 +2114,17 @@ export class Clerk implements ClerkInterface { return ''; } - const signInOrUpUrl = this.#options[key] || this.environment.displayConfig[key]; + let signInOrUpUrl = this.#options[key] || this.environment.displayConfig[key]; + if (this.#isCombinedSignInOrUpFlow()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The isCombinedSignInOrUpFlow() function checks for the existence of signInUrl + signInOrUpUrl = this.#options.signInUrl!; + } const redirectUrls = new RedirectUrls(this.#options, options).toSearchParams(); const initValues = new URLSearchParams(_initValues || {}); const url = buildURL( { base: signInOrUpUrl, - hashPath: this.#isCombinedFlow() && key === 'signUpUrl' ? '/create' : '', + hashPath: this.#isCombinedSignInOrUpFlow() && key === 'signUpUrl' ? '/create' : '', hashSearchParams: [initValues, redirectUrls], }, { stringify: true }, diff --git a/packages/clerk-js/src/ui/common/__tests__/redirects.test.ts b/packages/clerk-js/src/ui/common/__tests__/redirects.test.ts index 20b32825fd4..a706b340de6 100644 --- a/packages/clerk-js/src/ui/common/__tests__/redirects.test.ts +++ b/packages/clerk-js/src/ui/common/__tests__/redirects.test.ts @@ -2,128 +2,174 @@ import { buildEmailLinkRedirectUrl, buildSSOCallbackURL } from '../redirects'; describe('buildEmailLinkRedirectUrl(routing, baseUrl)', () => { it('defaults to hash based routing strategy on empty routing', function () { - expect(buildEmailLinkRedirectUrl({ path: '', authQueryString: '' } as any, '')).toBe('http://localhost/#/verify'); + expect( + buildEmailLinkRedirectUrl({ ctx: { path: '', authQueryString: '' } as any, baseUrl: '', intent: 'sign-in' }), + ).toBe('http://localhost/#/verify'); }); it('returns the magic link redirect url for components using path based routing ', function () { - expect(buildEmailLinkRedirectUrl({ routing: 'path', authQueryString: '' } as any, '')).toBe( - 'http://localhost/verify', - ); + expect( + buildEmailLinkRedirectUrl({ + ctx: { routing: 'path', authQueryString: '' } as any, + baseUrl: '', + intent: 'sign-in', + }), + ).toBe('http://localhost/verify'); - expect(buildEmailLinkRedirectUrl({ routing: 'path', path: '/sign-in', authQueryString: '' } as any, '')).toBe( - 'http://localhost/sign-in/verify', - ); + expect( + buildEmailLinkRedirectUrl({ + ctx: { routing: 'path', path: '/sign-in', authQueryString: '' } as any, + baseUrl: '', + intent: 'sign-in', + }), + ).toBe('http://localhost/sign-in/verify'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'path', path: '', authQueryString: 'redirectUrl=https://clerk.com', } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/verify?redirectUrl=https://clerk.com'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'path', path: '/sign-in', authQueryString: 'redirectUrl=https://clerk.com', } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/sign-in/verify?redirectUrl=https://clerk.com'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'path', path: '/sign-in', authQueryString: 'redirectUrl=https://clerk.com', } as any, - 'https://accounts.clerk.com/sign-in', - ), + baseUrl: 'https://accounts.clerk.com/sign-in', + intent: 'sign-in', + }), ).toBe('http://localhost/sign-in/verify?redirectUrl=https://clerk.com'); }); it('returns the magic link redirect url for components using hash based routing ', function () { expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'hash', authQueryString: '', } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/#/verify'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'hash', path: '/sign-in', authQueryString: null, } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/#/verify'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'hash', path: '', authQueryString: 'redirectUrl=https://clerk.com', } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'hash', path: '/sign-in', authQueryString: 'redirectUrl=https://clerk.com', } as any, - '', - ), + baseUrl: '', + intent: 'sign-in', + }), ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'hash', path: '/sign-in', authQueryString: 'redirectUrl=https://clerk.com', } as any, - 'https://accounts.clerk.com/sign-in', - ), + baseUrl: 'https://accounts.clerk.com/sign-in', + intent: 'sign-in', + }), ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); }); it('returns the magic link redirect url for components using virtual routing ', function () { expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'virtual', authQueryString: 'redirectUrl=https://clerk.com', } as any, - 'https://accounts.clerk.com/sign-in', - ), + baseUrl: 'https://accounts.clerk.com/sign-in', + intent: 'sign-in', + }), ).toBe('https://accounts.clerk.com/sign-in#/verify?redirectUrl=https://clerk.com'); expect( - buildEmailLinkRedirectUrl( - { + buildEmailLinkRedirectUrl({ + ctx: { routing: 'virtual', } as any, - 'https://accounts.clerk.com/sign-in', - ), + baseUrl: 'https://accounts.clerk.com/sign-in', + intent: 'sign-in', + }), ).toBe('https://accounts.clerk.com/sign-in#/verify'); }); + + it('returns the magic link redirect url for components using the combined flow based on intent', function () { + expect( + buildEmailLinkRedirectUrl({ + ctx: { + routing: 'path', + path: '/sign-up', + isCombinedFlow: true, + } as any, + baseUrl: '', + intent: 'sign-up', + }), + ).toBe('http://localhost/sign-up/create/verify'); + + expect( + buildEmailLinkRedirectUrl({ + ctx: { + routing: 'path', + path: '/sign-in', + isCombinedFlow: true, + } as any, + baseUrl: '', + intent: 'sign-in', + }), + ).toBe('http://localhost/sign-in/verify'); + }); }); describe('buildSSOCallbackURL(ctx, baseUrl)', () => { diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index f8bfaf6a365..7f4058d84ed 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -4,18 +4,24 @@ import type { SignInContextType, SignUpContextType, UserProfileContextType } fro export const SSO_CALLBACK_PATH_ROUTE = '/sso-callback'; export const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify'; -export function buildEmailLinkRedirectUrl( - ctx: SignInContextType | SignUpContextType | UserProfileContextType, - baseUrl: string | undefined = '', -): string { +export function buildEmailLinkRedirectUrl({ + ctx, + baseUrl = '', + intent = 'sign-in', +}: { + ctx: SignInContextType | SignUpContextType | UserProfileContextType; + baseUrl?: string; + intent?: 'sign-in' | 'sign-up' | 'profile'; +}): string { const { routing, authQueryString, path } = ctx; - const isCombinedFlow = '__experimental' in ctx && ctx.__experimental?.combinedProps; + const isCombinedFlow = 'isCombinedFlow' in ctx && ctx.isCombinedFlow; return buildRedirectUrl({ routing, baseUrl, authQueryString, path, - endpoint: isCombinedFlow ? `/create${MAGIC_LINK_VERIFY_PATH_ROUTE}` : MAGIC_LINK_VERIFY_PATH_ROUTE, + endpoint: + isCombinedFlow && intent === 'sign-up' ? `/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 dde7ba7278f..cd26298bd7f 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -8,7 +8,6 @@ import type { SignUpContextType } from '../../contexts'; import { SignInContext, SignUpContext, - useOptions, useSignInContext, useSignUpContext, withCoreSessionSwitchGuard, @@ -39,7 +38,6 @@ function RedirectToSignIn() { function SignInRoutes(): JSX.Element { const signInContext = useSignInContext(); const signUpContext = useSignUpContext(); - const options = useOptions(); return ( @@ -78,7 +76,7 @@ function SignInRoutes(): JSX.Element { redirectUrl='../factor-two' /> - {options.experimental?.combinedFlow && ( + {signInContext.isCombinedFlow && ( { startEmailLinkFlow({ emailAddressId: props.factor.emailAddressId, - redirectUrl: buildEmailLinkRedirectUrl(signInContext, signInUrl), + redirectUrl: buildEmailLinkRedirectUrl({ ctx: signInContext, baseUrl: signInUrl, intent: 'sign-in' }), }) .then(res => handleVerificationResult(res)) .catch(err => { diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 7526f413218..d24ae2addd7 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, useOptions, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { Card, @@ -66,10 +66,8 @@ 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 { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow } = ctx; const supportEmail = useSupportEmail(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -175,9 +173,13 @@ export function _SignInStart(): JSX.Element { } if (clerkStatus === 'sign_up') { + const paramsToForward = new URLSearchParams(); + if (organizationTicket) { + paramsToForward.set('__clerk_ticket', organizationTicket); + } // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to // signUpUrl triggers a full page reload when used with the hash router. - navigate(isCombinedFlow ? 'create' : signUpUrl); + void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); return; } @@ -376,10 +378,6 @@ export function _SignInStart(): JSX.Element { } 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 || '/'; diff --git a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts index 60ff47cb996..1337057372b 100644 --- a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts +++ b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts @@ -1,13 +1,14 @@ import type { LoadedClerk, SignUpModes, SignUpResource } from '@clerk/types'; import { SIGN_UP_MODES } from '../../../core/constants'; +import type { RouteContextValue } from '../../router/RouteContext'; import { completeSignUpFlow } from '../SignUp/util'; type HandleCombinedFlowTransferProps = { identifierAttribute: 'emailAddress' | 'phoneNumber' | 'username'; identifierValue: string; signUpMode: SignUpModes; - navigate: (to: string) => Promise; + navigate: RouteContextValue['navigate']; organizationTicket?: string; afterSignUpUrl: string; clerk: LoadedClerk; @@ -78,7 +79,7 @@ export function handleCombinedFlowTransfer({ .catch(err => handleError(err)); } - return navigate(`create?${paramsToForward.toString()}`); + return navigate(`create`, { searchParams: paramsToForward }); } function hasOptionalFields(signUp: SignUpResource) { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 02a2cbfc051..a80b32a90e4 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 { SignInContext, useCoreSignUp, useEnvironment, useOptions, useSignUpContext } from '../../contexts'; +import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useLocalizations } from '../../customizables'; import { Card, @@ -32,11 +32,16 @@ function _SignUpContinue() { const { displayConfig, userSettings } = useEnvironment(); const { attributes, usernameSettings } = userSettings; const { t, locale } = useLocalizations(); - const { afterSignUpUrl, signInUrl, unsafeMetadata, initialValues = {} } = useSignUpContext(); + const { + afterSignUpUrl, + signInUrl, + unsafeMetadata, + initialValues = {}, + isCombinedFlow: _isCombinedFlow, + } = useSignUpContext(); const signUp = useCoreSignUp(); - const options = useOptions(); const isWithinSignInContext = !!React.useContext(SignInContext); - const isCombinedFlow = !!(options.experimental?.combinedFlow && !!isWithinSignInContext); + const isCombinedFlow = !!(_isCombinedFlow && !!isWithinSignInContext); const isProgressiveSignUp = userSettings.signUp.progressive; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 879c48034e0..7279d2c074e 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 { withRedirectToAfterSignUp } from '../../common'; -import { SignInContext, useCoreSignUp, useEnvironment, useOptions, useSignUpContext } from '../../contexts'; +import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { Card, @@ -39,10 +39,9 @@ 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 isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), ); diff --git a/packages/clerk-js/src/ui/components/UserProfile/VerifyWithLink.tsx b/packages/clerk-js/src/ui/components/UserProfile/VerifyWithLink.tsx index 4c693b71936..be2213d71da 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/VerifyWithLink.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/VerifyWithLink.tsx @@ -37,7 +37,7 @@ export const VerifyWithLink = (props: VerifyWithLinkProps) => { const { routing } = profileContext; const baseUrl = routing === 'virtual' ? displayConfig.userProfileUrl : ''; - const redirectUrl = buildEmailLinkRedirectUrl(profileContext, baseUrl); + const redirectUrl = buildEmailLinkRedirectUrl({ ctx: profileContext, baseUrl, intent: 'profile' }); startEmailLinkFlow({ redirectUrl }) .then(() => nextStep()) .catch(err => handleError(err, [], card.setError)); diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 772345947cf..fed4ad6e959 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -1,4 +1,5 @@ import { useClerk } from '@clerk/shared/react'; +import { isAbsoluteUrl } from '@clerk/shared/url'; import { createContext, useContext, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; @@ -24,6 +25,7 @@ export type SignInContextType = SignInCtx & { waitlistUrl: string; emailLinkRedirectUrl: string; ssoCallbackUrl: string; + isCombinedFlow: boolean; }; export const SignInContext = createContext(null); @@ -35,14 +37,17 @@ export const useSignInContext = (): SignInContextType => { const { queryParams, queryString } = useRouter(); const options = useOptions(); const clerk = useClerk(); - const isCombinedFlow = options.experimental?.combinedFlow; if (context === null || context.componentName !== 'SignIn') { throw new Error(`Clerk: useSignInContext called outside of the mounted SignIn component.`); } - const { componentName, mode, ..._ctx } = context; - const ctx = _ctx.__experimental?.combinedProps ? { ..._ctx, ..._ctx.__experimental?.combinedProps } : _ctx; + const isCombinedFlow = + Boolean(!options.signUpUrl && options.signInUrl && !isAbsoluteUrl(options.signInUrl)) || + context.withSignUp || + false; + + const { componentName, mode, ...ctx } = context; const initialValuesFromQueryParams = useMemo( () => getInitialValuesFromQueryParams(queryString, SIGN_IN_INITIAL_VALUE_KEYS), [], @@ -82,16 +87,14 @@ export const useSignInContext = (): SignInContextType => { baseUrl: signUpUrl, authQueryString: '', path: ctx.path, - endpoint: options.experimental?.combinedFlow - ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE - : MAGIC_LINK_VERIFY_PATH_ROUTE, + endpoint: isCombinedFlow ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE : MAGIC_LINK_VERIFY_PATH_ROUTE, }); const ssoCallbackUrl = buildRedirectUrl({ routing: ctx.routing, baseUrl: signUpUrl, authQueryString: '', path: ctx.path, - endpoint: options.experimental?.combinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, + endpoint: isCombinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, }); if (isCombinedFlow) { @@ -119,5 +122,6 @@ export const useSignInContext = (): SignInContextType => { queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString: redirectUrls.toSearchParams().toString(), - } as SignInContextType; + isCombinedFlow, + }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index e142ac4e5a9..b34ce7a8171 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -1,4 +1,5 @@ import { useClerk } from '@clerk/shared/react'; +import { isAbsoluteUrl } from '@clerk/shared/url'; import { createContext, useContext, useMemo } from 'react'; import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; @@ -21,6 +22,7 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; + isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; }; @@ -34,6 +36,7 @@ export const useSignUpContext = (): SignUpContextType => { const { queryParams, queryString } = useRouter(); const options = useOptions(); const clerk = useClerk(); + const isCombinedFlow = Boolean(!options.signUpUrl && options.signInUrl && !isAbsoluteUrl(options.signInUrl)); const initialValuesFromQueryParams = useMemo( () => getInitialValuesFromQueryParams(queryString, SIGN_UP_INITIAL_VALUE_KEYS), @@ -81,9 +84,7 @@ export const useSignUpContext = (): SignUpContextType => { baseUrl: signUpUrl, authQueryString: '', path: ctx.path, - endpoint: options.experimental?.combinedFlow - ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE - : MAGIC_LINK_VERIFY_PATH_ROUTE, + endpoint: isCombinedFlow ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE : MAGIC_LINK_VERIFY_PATH_ROUTE, }); const ssoCallbackUrl = ctx.ssoCallbackUrl ?? @@ -92,7 +93,7 @@ export const useSignUpContext = (): SignUpContextType => { baseUrl: signUpUrl, authQueryString: '', path: ctx.path, - endpoint: options.experimental?.combinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, + endpoint: isCombinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, }); // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. @@ -113,5 +114,6 @@ export const useSignUpContext = (): SignUpContextType => { queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString: redirectUrls.toSearchParams().toString(), + isCombinedFlow, }; }; diff --git a/packages/clerk-js/src/ui/router/Route.tsx b/packages/clerk-js/src/ui/router/Route.tsx index c68ab28c4e4..1e0e046f75c 100644 --- a/packages/clerk-js/src/ui/router/Route.tsx +++ b/packages/clerk-js/src/ui/router/Route.tsx @@ -58,8 +58,11 @@ export function Route(props: RouteProps): JSX.Element | null { const [indexPath, fullPath] = newPaths(router.indexPath, router.fullPath, props.path, props.index); - const resolve = (to: string) => { + const resolve = (to: string, { searchParams }: { searchParams?: URLSearchParams } = {}) => { const url = new URL(to, window.location.origin + fullPath + '/'); + if (searchParams) { + url.search = searchParams.toString(); + } url.pathname = trimTrailingSlash(url.pathname); return url; }; @@ -109,8 +112,8 @@ export function Route(props: RouteProps): JSX.Element | null { return newGetMatchData(path, index) ? true : false; }, resolve: resolve, - navigate: (to: string) => { - const toURL = resolve(to); + navigate: (to: string, { searchParams } = {}) => { + const toURL = resolve(to, { searchParams }); return router.baseNavigate(toURL); }, refresh: router.refresh, diff --git a/packages/clerk-js/src/ui/router/RouteContext.tsx b/packages/clerk-js/src/ui/router/RouteContext.tsx index 8496c211856..08da052e9c0 100644 --- a/packages/clerk-js/src/ui/router/RouteContext.tsx +++ b/packages/clerk-js/src/ui/router/RouteContext.tsx @@ -9,7 +9,7 @@ export interface RouteContextValue { currentPath: string; matches: (path?: string, index?: boolean) => boolean; baseNavigate: (toURL: URL) => Promise; - navigate: (to: string) => Promise; + navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; resolve: (to: string) => URL; refresh: () => void; params: { [key: string]: string }; diff --git a/packages/react/src/components/SignInButton.tsx b/packages/react/src/components/SignInButton.tsx index 1036cbef43a..a18ba92e57c 100644 --- a/packages/react/src/components/SignInButton.tsx +++ b/packages/react/src/components/SignInButton.tsx @@ -14,6 +14,7 @@ export const SignInButton = withClerk( signUpForceRedirectUrl, mode, initialValues, + withSignUp, ...rest } = props; children = normalizeWithDefaultValue(children, 'Sign in'); @@ -26,6 +27,7 @@ export const SignInButton = withClerk( signUpFallbackRedirectUrl, signUpForceRedirectUrl, initialValues, + withSignUp, }; if (mode === 'modal') { diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 0c732dbc1c7..cab42fd9950 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -122,6 +122,7 @@ export type SignInButtonProps = ButtonProps & | 'signUpForceRedirectUrl' | 'signUpFallbackRedirectUrl' | 'initialValues' + | 'withSignUp' >; export type SignUpButtonProps = { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 00cf8a46cb1..3a98c31b73b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -894,50 +894,6 @@ export type RoutingOptions = | { path?: never; routing?: Extract }; export type SignInProps = 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 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 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. @@ -984,6 +940,10 @@ export type SignInCombinedProps = RoutingOptions & { * Additional arbitrary metadata to be stored alongside the User object */ unsafeMetadata?: SignUpUnsafeMetadata; + /** + * Enable sign-in-or-up flow for `` component instance. + */ + withSignUp?: boolean; } & TransferableOption & SignUpForceRedirectUrl & SignUpFallbackRedirectUrl & diff --git a/packages/vue/src/components/SignInButton.ts b/packages/vue/src/components/SignInButton.ts new file mode 100644 index 00000000000..d90bfc40f20 --- /dev/null +++ b/packages/vue/src/components/SignInButton.ts @@ -0,0 +1,62 @@ +import type { SignInProps } from '@clerk/types'; +import { defineComponent, h } from 'vue'; + +import { useClerk } from '../composables/useClerk'; +import { assertSingleChild, normalizeWithDefaultValue } from '../utils'; + +type SignInButtonProps = Pick< + SignInProps, + | 'fallbackRedirectUrl' + | 'forceRedirectUrl' + | 'signUpForceRedirectUrl' + | 'signUpFallbackRedirectUrl' + | 'initialValues' + | 'withSignUp' +>; + +export const SignInButton = defineComponent( + ( + props: SignInButtonProps & { + mode?: 'modal' | 'redirect'; + }, + { slots, attrs }, + ) => { + const clerk = useClerk(); + + function clickHandler() { + const { mode, ...opts } = props; + + if (mode === 'modal') { + return clerk.value?.openSignIn(opts); + } + + const { withSignUp, ...redirectOpts } = opts; + + void clerk.value?.redirectToSignIn({ + ...redirectOpts, + signInFallbackRedirectUrl: props.fallbackRedirectUrl, + signInForceRedirectUrl: props.forceRedirectUrl, + }); + } + + return () => { + const children = normalizeWithDefaultValue(slots.default?.(), 'Sign in'); + const child = assertSingleChild(children, 'SignInButton'); + return h(child, { + ...attrs, + onClick: clickHandler, + }); + }; + }, + { + props: [ + 'signUpForceRedirectUrl', + 'signUpFallbackRedirectUrl', + 'fallbackRedirectUrl', + 'forceRedirectUrl', + 'mode', + 'initialValues', + 'withSignUp', + ], + }, +); diff --git a/packages/vue/src/components/SignInButton.vue b/packages/vue/src/components/SignInButton.vue index fbcae81a3fc..42aed6a6e4e 100644 --- a/packages/vue/src/components/SignInButton.vue +++ b/packages/vue/src/components/SignInButton.vue @@ -6,7 +6,12 @@ import { assertSingleChild, normalizeWithDefaultValue } from '../utils'; type SignInButtonProps = Pick< SignInProps, - 'fallbackRedirectUrl' | 'forceRedirectUrl' | 'signUpForceRedirectUrl' | 'signUpFallbackRedirectUrl' | 'initialValues' + | 'fallbackRedirectUrl' + | 'forceRedirectUrl' + | 'signUpForceRedirectUrl' + | 'signUpFallbackRedirectUrl' + | 'initialValues' + | 'withSignUp' > & { mode?: 'modal' | 'redirect'; }; @@ -29,8 +34,10 @@ function clickHandler() { return clerk.value?.openSignIn(opts); } + const { withSignUp, ...redirectOpts } = opts; + void clerk.value?.redirectToSignIn({ - ...opts, + ...redirectOpts, signInFallbackRedirectUrl: props.fallbackRedirectUrl, signInForceRedirectUrl: props.forceRedirectUrl, });