Skip to content

Commit 550c7e9

Browse files
alexcarpenterdstaleybrkalow
authored
feat(clerk-js): Add experimental combined flow (#4607)
Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com> Co-authored-by: Bryce Kalow <bryce@clerk.dev>
1 parent 6fdffaf commit 550c7e9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+703
-29
lines changed

.changeset/loud-balloons-grow.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/clerk-js': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Introduce experimental sign-in combined flow.

integration/presets/envs.ts

+10
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ const withWaitlistdMode = withEmailCodes
113113
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk)
114114
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk);
115115

116+
const withCombinedFlow = withEmailCodes
117+
.clone()
118+
.setId('withCombinedFlow')
119+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk)
120+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
121+
.setEnvVariable('public', 'EXPERIMENTAL_COMBINED_FLOW', 'true')
122+
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
123+
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-in');
124+
116125
export const envs = {
117126
base,
118127
withEmailCodes,
@@ -129,4 +138,5 @@ export const envs = {
129138
withRestrictedMode,
130139
withLegalConsent,
131140
withWaitlistdMode,
141+
withCombinedFlow,
132142
} as const;

integration/presets/longRunningApps.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
3232
},
3333
{ id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles },
3434
{ id: 'next.appRouter.withReverification', config: next.appRouter, env: envs.withReverification },
35+
{ id: 'next.appRouter.withCombinedFlow', config: next.appRouter, env: envs.withCombinedFlow },
3536
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
3637
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
3738
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },

integration/templates/next-app-router/src/app/layout.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
1313
return (
1414
<ClerkProvider
1515
experimental={{
16+
combinedFlow: process.env.NEXT_PUBLIC_EXPERIMENTAL_COMBINED_FLOW
17+
? process.env.NEXT_PUBLIC_EXPERIMENTAL_COMBINED_FLOW === 'true'
18+
: undefined,
1619
persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT
1720
? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true'
1821
: undefined,

integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default function Page() {
77
routing={'path'}
88
path={'/sign-in'}
99
signUpUrl={'/sign-up'}
10+
__experimental={{
11+
combinedProps: {},
12+
}}
1013
/>
1114
</div>
1215
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import { createTestUtils, type FakeUser, testAgainstRunningApps } from '../testUtils';
5+
6+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combined sign in flow @nextjs', ({ app }) => {
7+
test.describe.configure({ mode: 'serial' });
8+
9+
let fakeUser: FakeUser;
10+
11+
test.beforeAll(async () => {
12+
const u = createTestUtils({ app });
13+
fakeUser = u.services.users.createFakeUser({
14+
withPhoneNumber: true,
15+
withUsername: true,
16+
});
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
test('flows are combined', async ({ page, context }) => {
26+
const u = createTestUtils({ app, page, context });
27+
await u.po.signIn.goTo();
28+
29+
await expect(u.page.getByText(`Don’t have an account?`)).toBeHidden();
30+
});
31+
32+
test('sign in with email and password', async ({ page, context }) => {
33+
const u = createTestUtils({ app, page, context });
34+
await u.po.signIn.goTo();
35+
await u.po.signIn.setIdentifier(fakeUser.email);
36+
await u.po.signIn.continue();
37+
await u.po.signIn.setPassword(fakeUser.password);
38+
await u.po.signIn.continue();
39+
await u.po.expect.toBeSignedIn();
40+
});
41+
42+
test('sign in with email and instant password', async ({ page, context }) => {
43+
const u = createTestUtils({ app, page, context });
44+
await u.po.signIn.goTo();
45+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
46+
await u.po.expect.toBeSignedIn();
47+
});
48+
49+
test('sign in with email code', async ({ page, context }) => {
50+
const u = createTestUtils({ app, page, context });
51+
await u.po.signIn.goTo();
52+
await u.po.signIn.getIdentifierInput().fill(fakeUser.email);
53+
await u.po.signIn.continue();
54+
await u.po.signIn.getUseAnotherMethodLink().click();
55+
await u.po.signIn.getAltMethodsEmailCodeButton().click();
56+
await u.po.signIn.enterTestOtpCode();
57+
await u.po.expect.toBeSignedIn();
58+
});
59+
60+
test('sign in with phone number and password', async ({ page, context }) => {
61+
const u = createTestUtils({ app, page, context });
62+
await u.po.signIn.goTo();
63+
await u.po.signIn.usePhoneNumberIdentifier().click();
64+
await u.po.signIn.getIdentifierInput().fill(fakeUser.phoneNumber);
65+
await u.po.signIn.setPassword(fakeUser.password);
66+
await u.po.signIn.continue();
67+
await u.po.expect.toBeSignedIn();
68+
});
69+
70+
test('sign in only with phone number', async ({ page, context }) => {
71+
const u = createTestUtils({ app, page, context });
72+
const fakeUserWithoutPassword = u.services.users.createFakeUser({
73+
fictionalEmail: true,
74+
withPassword: false,
75+
withPhoneNumber: true,
76+
});
77+
await u.services.users.createBapiUser(fakeUserWithoutPassword);
78+
await u.po.signIn.goTo();
79+
await u.po.signIn.usePhoneNumberIdentifier().click();
80+
await u.po.signIn.getIdentifierInput().fill(fakeUserWithoutPassword.phoneNumber);
81+
await u.po.signIn.continue();
82+
await u.po.signIn.enterTestOtpCode();
83+
await u.po.expect.toBeSignedIn();
84+
85+
await fakeUserWithoutPassword.deleteIfExists();
86+
});
87+
88+
test('sign in with username and password', async ({ page, context }) => {
89+
const u = createTestUtils({ app, page, context });
90+
await u.po.signIn.goTo();
91+
await u.po.signIn.getIdentifierInput().fill(fakeUser.username);
92+
await u.po.signIn.setPassword(fakeUser.password);
93+
await u.po.signIn.continue();
94+
await u.po.expect.toBeSignedIn();
95+
});
96+
97+
test('can reset password', async ({ page, context }) => {
98+
const u = createTestUtils({ app, page, context });
99+
const fakeUserWithPasword = u.services.users.createFakeUser({
100+
fictionalEmail: true,
101+
withPassword: true,
102+
});
103+
await u.services.users.createBapiUser(fakeUserWithPasword);
104+
105+
await u.po.signIn.goTo();
106+
await u.po.signIn.getIdentifierInput().fill(fakeUserWithPasword.email);
107+
await u.po.signIn.continue();
108+
await u.po.signIn.getForgotPassword().click();
109+
await u.po.signIn.getResetPassword().click();
110+
await u.po.signIn.enterTestOtpCode();
111+
await u.po.signIn.setPassword(`${fakeUserWithPasword.password}_reset`);
112+
await u.po.signIn.setPasswordConfirmation(`${fakeUserWithPasword.password}_reset`);
113+
await u.po.signIn.getResetPassword().click();
114+
await u.po.expect.toBeSignedIn();
115+
116+
await fakeUserWithPasword.deleteIfExists();
117+
});
118+
119+
test('cannot sign in with wrong password', async ({ page, context }) => {
120+
const u = createTestUtils({ app, page, context });
121+
122+
await u.po.signIn.goTo();
123+
await u.po.signIn.getIdentifierInput().fill(fakeUser.email);
124+
await u.po.signIn.continue();
125+
await u.po.signIn.setPassword('wrong-password');
126+
await u.po.signIn.continue();
127+
await expect(u.page.getByText(/^password is incorrect/i)).toBeVisible();
128+
129+
await u.po.expect.toBeSignedOut();
130+
});
131+
132+
test('cannot sign in with wrong password but can sign in with email', async ({ page, context }) => {
133+
const u = createTestUtils({ app, page, context });
134+
135+
await u.po.signIn.goTo();
136+
await u.po.signIn.getIdentifierInput().fill(fakeUser.email);
137+
await u.po.signIn.continue();
138+
await u.po.signIn.setPassword('wrong-password');
139+
await u.po.signIn.continue();
140+
141+
await expect(u.page.getByText(/^password is incorrect/i)).toBeVisible();
142+
143+
await u.po.signIn.getUseAnotherMethodLink().click();
144+
await u.po.signIn.getAltMethodsEmailCodeButton().click();
145+
await u.po.signIn.enterTestOtpCode();
146+
147+
await u.po.expect.toBeSignedIn();
148+
});
149+
150+
test('access protected page @express', async ({ page, context }) => {
151+
const u = createTestUtils({ app, page, context });
152+
await u.po.signIn.goTo();
153+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
154+
await u.po.expect.toBeSignedIn();
155+
156+
expect(await u.page.locator("data-test-id='protected-api-response'").count()).toEqual(0);
157+
await u.page.goToRelative('/protected');
158+
await u.page.isVisible("data-test-id='protected-api-response'");
159+
});
160+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
5+
6+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combined sign up flow @nextjs', ({ app }) => {
7+
test.describe.configure({ mode: 'serial' });
8+
9+
test.afterAll(async () => {
10+
await app.teardown();
11+
});
12+
13+
test('sign up with email and password', async ({ page, context }) => {
14+
const u = createTestUtils({ app, page, context });
15+
const fakeUser = u.services.users.createFakeUser({
16+
fictionalEmail: true,
17+
withPassword: true,
18+
});
19+
20+
// Go to sign in page
21+
await u.po.signIn.goTo();
22+
23+
// Fill in sign in form
24+
await u.po.signIn.setIdentifier(fakeUser.email);
25+
await u.po.signIn.continue();
26+
27+
// Verify email
28+
await u.po.signUp.enterTestOtpCode();
29+
30+
await u.page.waitForAppUrl('/sign-in/create/continue');
31+
32+
await u.po.signUp.setPassword(fakeUser.password);
33+
await u.po.signUp.continue();
34+
35+
// Check if user is signed in
36+
await u.po.expect.toBeSignedIn();
37+
38+
await fakeUser.deleteIfExists();
39+
});
40+
41+
test('sign up with username, email, and password', async ({ page, context }) => {
42+
const u = createTestUtils({ app, page, context });
43+
const fakeUser = u.services.users.createFakeUser({
44+
fictionalEmail: true,
45+
withPassword: true,
46+
withUsername: true,
47+
});
48+
49+
await u.po.signIn.goTo();
50+
await u.po.signIn.setIdentifier(fakeUser.username);
51+
await u.po.signIn.continue();
52+
await u.page.waitForAppUrl('/sign-in/create');
53+
54+
const prefilledUsername = await u.po.signUp.getUsernameInput().inputValue();
55+
expect(prefilledUsername).toBe(fakeUser.username);
56+
57+
await u.po.signUp.setEmailAddress(fakeUser.email);
58+
await u.po.signUp.setPassword(fakeUser.password);
59+
await u.po.signUp.continue();
60+
61+
await u.po.signUp.enterTestOtpCode();
62+
63+
await u.po.expect.toBeSignedIn();
64+
65+
await fakeUser.deleteIfExists();
66+
});
67+
68+
test('sign up, sign out and sign in again', async ({ page, context }) => {
69+
const u = createTestUtils({ app, page, context });
70+
const fakeUser = u.services.users.createFakeUser({
71+
fictionalEmail: true,
72+
withPhoneNumber: true,
73+
withUsername: true,
74+
});
75+
76+
// Go to sign in page
77+
await u.po.signIn.goTo();
78+
79+
// Fill in sign in form
80+
await u.po.signIn.setIdentifier(fakeUser.email);
81+
await u.po.signIn.continue();
82+
83+
// Verify email
84+
await u.po.signUp.enterTestOtpCode();
85+
86+
await u.page.waitForAppUrl('/sign-in/create/continue');
87+
88+
await u.po.signUp.setPassword(fakeUser.password);
89+
await u.po.signUp.continue();
90+
91+
// Check if user is signed in
92+
await u.po.expect.toBeSignedIn();
93+
94+
// Toggle user button
95+
await u.po.userButton.toggleTrigger();
96+
await u.po.userButton.waitForPopover();
97+
98+
// Click sign out
99+
await u.po.userButton.triggerSignOut();
100+
101+
// Check if user is signed out
102+
await u.po.expect.toBeSignedOut();
103+
104+
// Go to sign in page
105+
await u.po.signIn.goTo();
106+
107+
// Fill in sign in form
108+
await u.po.signIn.signInWithEmailAndInstantPassword({
109+
email: fakeUser.email,
110+
password: fakeUser.password,
111+
});
112+
113+
// Check if user is signed in
114+
await u.po.expect.toBeSignedIn();
115+
116+
await fakeUser.deleteIfExists();
117+
});
118+
});

packages/clerk-js/sandbox/template.html

+4-1
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@
265265
</div>
266266

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

271274
<!-- This app is in the Team SDK organization. -->

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

+16-5
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ export class Clerk implements ClerkInterface {
358358
}
359359
};
360360

361+
#isCombinedFlow(): boolean {
362+
return this.#options.experimental?.combinedFlow && this.#options.signInUrl === this.#options.signUpUrl;
363+
}
364+
361365
public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => {
362366
if (!this.client || this.client.sessions.length === 0) {
363367
return;
@@ -1052,14 +1056,13 @@ export class Clerk implements ClerkInterface {
10521056
return this.buildUrlWithAuth(this.#options.afterSignOutUrl);
10531057
}
10541058

1055-
public buildWaitlistUrl(): string {
1059+
public buildWaitlistUrl(options?: { initialValues?: Record<string, string> }): string {
10561060
if (!this.environment || !this.environment.displayConfig) {
10571061
return '';
10581062
}
1059-
10601063
const waitlistUrl = this.#options['waitlistUrl'] || this.environment.displayConfig.waitlistUrl;
1061-
1062-
return buildURL({ base: waitlistUrl }, { stringify: true });
1064+
const initValues = new URLSearchParams(options?.initialValues || {});
1065+
return buildURL({ base: waitlistUrl, hashSearchParams: [initValues] }, { stringify: true });
10631066
}
10641067

10651068
public buildAfterMultiSessionSingleSignOutUrl(): string {
@@ -2051,10 +2054,18 @@ export class Clerk implements ClerkInterface {
20512054
if (!key || !this.loaded || !this.environment || !this.environment.displayConfig) {
20522055
return '';
20532056
}
2057+
20542058
const signInOrUpUrl = this.#options[key] || this.environment.displayConfig[key];
20552059
const redirectUrls = new RedirectUrls(this.#options, options).toSearchParams();
20562060
const initValues = new URLSearchParams(_initValues || {});
2057-
const url = buildURL({ base: signInOrUpUrl, hashSearchParams: [initValues, redirectUrls] }, { stringify: true });
2061+
const url = buildURL(
2062+
{
2063+
base: signInOrUpUrl,
2064+
hashPath: this.#isCombinedFlow() && key === 'signUpUrl' ? '/create' : '',
2065+
hashSearchParams: [initValues, redirectUrls],
2066+
},
2067+
{ stringify: true },
2068+
);
20582069
return this.buildUrlWithAuth(url);
20592070
};
20602071

0 commit comments

Comments
 (0)