Skip to content

Commit

Permalink
feat(clerk-js): Add experimental combined flow (#4607)
Browse files Browse the repository at this point in the history
Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com>
Co-authored-by: Bryce Kalow <bryce@clerk.dev>
  • Loading branch information
3 people authored Dec 5, 2024
1 parent 6fdffaf commit 550c7e9
Show file tree
Hide file tree
Showing 60 changed files with 703 additions and 29 deletions.
7 changes: 7 additions & 0 deletions .changeset/loud-balloons-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Introduce experimental sign-in combined flow.
10 changes: 10 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -129,4 +138,5 @@ export const envs = {
withRestrictedMode,
withLegalConsent,
withWaitlistdMode,
withCombinedFlow,
} as const;
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions integration/templates/next-app-router/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<ClerkProvider
experimental={{
combinedFlow: process.env.NEXT_PUBLIC_EXPERIMENTAL_COMBINED_FLOW
? process.env.NEXT_PUBLIC_EXPERIMENTAL_COMBINED_FLOW === 'true'
: undefined,
persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT
? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true'
: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export default function Page() {
routing={'path'}
path={'/sign-in'}
signUpUrl={'/sign-up'}
__experimental={{
combinedProps: {},
}}
/>
</div>
);
Expand Down
160 changes: 160 additions & 0 deletions integration/tests/combined-sign-in-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
118 changes: 118 additions & 0 deletions integration/tests/combined-sign-up-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 4 additions & 1 deletion packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@
</div>

<main class="bg-gray-25 flex h-full flex-1 items-center justify-center overflow-y-auto overflow-x-hidden pl-72">
<div id="app"></div>
<div
id="app"
class="max-w-full px-8 py-12"
></div>
</main>

<!-- This app is in the Team SDK organization. -->
Expand Down
21 changes: 16 additions & 5 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1052,14 +1056,13 @@ export class Clerk implements ClerkInterface {
return this.buildUrlWithAuth(this.#options.afterSignOutUrl);
}

public buildWaitlistUrl(): string {
public buildWaitlistUrl(options?: { initialValues?: Record<string, string> }): 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 {
Expand Down Expand Up @@ -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);
};

Expand Down
Loading

0 comments on commit 550c7e9

Please sign in to comment.