Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e53e4cf
wip
dstaley Nov 14, 2024
1196121
Merge branch 'main' into ds.feat/clerk-js-kitchen-sink
dstaley Nov 15, 2024
a5d7e4c
feat(clerk-js): Add JSDoc
dstaley Nov 15, 2024
b583b36
chore(repo): Add empty changeset
dstaley Nov 15, 2024
3dedf15
fix(clerk-js): Use shared team app
dstaley Nov 15, 2024
5f26fbd
feat(clerk-js): Use docs dark tailwind config
dstaley Nov 18, 2024
132233d
init
alexcarpenter Nov 18, 2024
ec88896
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 19, 2024
aec24d4
wip
alexcarpenter Nov 19, 2024
4916296
wip
alexcarpenter Nov 19, 2024
75663b5
wip
alexcarpenter Nov 19, 2024
022c6ae
wip
alexcarpenter Nov 19, 2024
a78605a
wip
alexcarpenter Nov 19, 2024
1508e49
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 19, 2024
9b58e3b
wip
alexcarpenter Nov 19, 2024
d82bb14
experimental prefix
alexcarpenter Nov 20, 2024
da880cc
move signUpProps to experimental
alexcarpenter Nov 20, 2024
555fd91
add changeset
alexcarpenter Nov 20, 2024
3f1e79f
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 20, 2024
5e0c32a
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 20, 2024
78c045c
feat(clerk-js,types): Move to waitlist with email address in combined…
alexcarpenter Nov 20, 2024
5e5f3f7
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 20, 2024
11e7827
padding
alexcarpenter Nov 21, 2024
f06ae91
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 21, 2024
274816b
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 22, 2024
719dd8e
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 22, 2024
0bf9516
feat(clerk-js): Add support for combined flow in `buildUrl` (#4626)
alexcarpenter Nov 25, 2024
f9db2df
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 25, 2024
acee56f
feat(clerk-js): Combined flow transfer (#4637)
alexcarpenter Nov 27, 2024
ab07986
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Nov 27, 2024
0595202
remove signUpContext usage
alexcarpenter Nov 27, 2024
a20a08c
chore(clerk-js,types): Add experimental `SignInCombinedProps` option …
alexcarpenter Dec 3, 2024
a2dadee
test(e2e): Add tests for combined sign in/sign up flow (#4707)
dstaley Dec 3, 2024
fe02091
signup continue navigation
alexcarpenter Dec 4, 2024
09c408b
fix(clerk-js): Detect if SignUp is rendered within SignIn
dstaley Dec 4, 2024
b472f45
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
dstaley Dec 4, 2024
c6cdeab
fix(elements): Restore type cast removed by eslint
dstaley Dec 4, 2024
faea92e
Merge branch 'main' into alexcarpenter/sign-in-up-flow-init
alexcarpenter Dec 5, 2024
256e597
remove sign-in action from continue when in combinedFlow
alexcarpenter Dec 5, 2024
d53bc91
Update packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
alexcarpenter Dec 5, 2024
697bc38
Update packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
alexcarpenter Dec 5, 2024
742be9e
Update packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx
alexcarpenter Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading