Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js): Add experimental combined flow #4607

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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.
1 change: 1 addition & 0 deletions packages/clerk-js/sandbox/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ function addCurrentRouteIndicator(currentRoute) {
...(componentControls.clerk.getProps() ?? {}),
signInUrl: '/sign-in',
signUpUrl: '/sign-up',
waitlistUrl: '/waitlist',
});
renderCurrentRoute();
} else {
Expand Down
9 changes: 6 additions & 3 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
viewBox="0 0 62 18"
fill="none"
aria-hidden="true"
class="h-[1.125rem] text-gray-950 dark:text-white"
class="h-[1.125rem] text-gray-950"
>
<ellipse
cx="8.99999"
Expand All @@ -159,7 +159,7 @@
></path>
</svg>
<div
class="bg-gray-25 mt-0.5 rounded-full px-2 py-1 text-[0.625rem]/3 font-medium text-gray-600 ring-1 ring-inset ring-gray-100 dark:bg-gray-900 dark:text-gray-400 dark:ring-gray-700"
class="bg-gray-25 mt-0.5 rounded-full px-2 py-1 text-[0.625rem]/3 font-medium text-gray-600 ring-1 ring-inset ring-gray-100"
>
Sandbox
</div>
Expand Down Expand Up @@ -249,7 +249,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 @@ -356,6 +356,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 @@ -1049,14 +1053,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 @@ -2028,10 +2031,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
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ERROR_CODES = {
ENTERPRISE_SSO_EMAIL_ADDRESS_DOMAIN_MISMATCH: 'enterprise_sso_email_address_domain_mismatch',
ENTERPRISE_SSO_HOSTED_DOMAIN_MISMATCH: 'enterprise_sso_hosted_domain_mismatch',
SAML_EMAIL_ADDRESS_DOMAIN_MISMATCH: 'saml_email_address_domain_mismatch',
INVITATION_ACCOUNT_NOT_EXISTS: 'invitation_account_not_exists',
} as const;

export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username'];
Expand Down
4 changes: 3 additions & 1 deletion packages/clerk-js/src/ui/common/EmailLinkVerify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export type EmailLinkVerifyProps = {
redirectUrl?: string;
verifyEmailPath?: string;
verifyPhonePath?: string;
continuePath?: string;
texts: Record<VerificationStatus, { title: LocalizationKey; subtitle: LocalizationKey }>;
};

export const EmailLinkVerify = (props: EmailLinkVerifyProps) => {
const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath } = props;
const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath, continuePath } = props;
const { handleEmailLinkVerification } = useClerk();
const { navigate } = useRouter();
const signUp = useCoreSignUp();
Expand All @@ -36,6 +37,7 @@ export const EmailLinkVerify = (props: EmailLinkVerifyProps) => {
signUp,
verifyEmailPath,
verifyPhonePath,
continuePath,
navigate,
});
} catch (err) {
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/common/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ export function buildEmailLinkRedirectUrl(
baseUrl: string | undefined = '',
): string {
const { routing, authQueryString, path } = ctx;
const isCombinedFlow = 'signInUrl' in ctx && 'signUpUrl' in ctx && ctx.signInUrl === ctx.signUpUrl;
return buildRedirectUrl({
routing,
baseUrl,
authQueryString,
path,
endpoint: MAGIC_LINK_VERIFY_PATH_ROUTE,
endpoint: isCombinedFlow ? `/create${MAGIC_LINK_VERIFY_PATH_ROUTE}` : MAGIC_LINK_VERIFY_PATH_ROUTE,
});
}

Expand Down
91 changes: 88 additions & 3 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { SignInEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import { SignInContext, useSignInContext, withCoreSessionSwitchGuard } from '../../contexts';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import {
SignInContext,
SignUpContext,
useOptions,
useSignInContext,
useSignUpContext,
withCoreSessionSwitchGuard,
} from '../../contexts';
import { Flow } from '../../customizables';
import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router';
import { SignUpContinue } from '../SignUp/SignUpContinue';
import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
Expand All @@ -24,6 +36,8 @@ function RedirectToSignIn() {

function SignInRoutes(): JSX.Element {
const signInContext = useSignInContext();
const signUpContext = useSignUpContext();
const options = useOptions();

return (
<Flow.Root flow='signIn'>
Expand Down Expand Up @@ -62,6 +76,62 @@ function SignInRoutes(): JSX.Element {
redirectUrl='../factor-two'
/>
</Route>
{options.experimental?.combinedFlow && (
<Route path='create'>
<Route
path='verify-email-address'
canActivate={clerk => !!clerk.client.signUp.emailAddress}
>
<SignUpVerifyEmail />
</Route>
<Route
path='verify-phone-number'
canActivate={clerk => !!clerk.client.signUp.phoneNumber}
>
<SignUpVerifyPhone />
</Route>
<Route path='sso-callback'>
<SignUpSSOCallback
signUpUrl={signUpContext.signUpUrl}
signInUrl={signUpContext.signInUrl}
signUpForceRedirectUrl={signUpContext.afterSignUpUrl}
signInForceRedirectUrl={signUpContext.afterSignInUrl}
secondFactorUrl={signUpContext.secondFactorUrl}
continueSignUpUrl='../continue'
verifyEmailAddressUrl='../verify-email-address'
verifyPhoneNumberUrl='../verify-phone-number'
/>
</Route>
<Route path='verify'>
<SignUpEmailLinkFlowComplete
redirectUrlComplete={signUpContext.afterSignUpUrl}
verifyEmailPath='../verify-email-address'
verifyPhonePath='../verify-phone-number'
continuePath='../continue'
/>
</Route>
<Route path='continue'>
<Route
path='verify-email-address'
canActivate={clerk => !!clerk.client.signUp.emailAddress}
>
<SignUpVerifyEmail />
</Route>
<Route
path='verify-phone-number'
canActivate={clerk => !!clerk.client.signUp.phoneNumber}
>
<SignUpVerifyPhone />
</Route>
<Route index>
<SignUpContinue />
</Route>
</Route>
<Route index>
<SignUpStart />
</Route>
</Route>
)}
<Route index>
<SignInStart />
</Route>
Expand All @@ -73,9 +143,24 @@ function SignInRoutes(): JSX.Element {
);
}

function SignInRoot() {
const signInContext = useSignInContext();

return (
<SignUpContext.Provider
value={{
componentName: 'SignUp',
...signInContext.__experimental?.signUpProps,
}}
>
<SignInRoutes />
</SignUpContext.Provider>
);
}

SignInRoutes.displayName = 'SignIn';

export const SignIn: React.ComponentType<SignInProps> = withCoreSessionSwitchGuard(SignInRoutes);
export const SignIn: React.ComponentType<SignInProps> = withCoreSessionSwitchGuard(SignInRoot);

export const SignInModal = (props: SignInModalProps): JSX.Element => {
const signInProps = {
Expand Down
60 changes: 56 additions & 4 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils';
import type { SignInStartIdentifier } from '../../common';
import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn } from '../../common';
import { buildSSOCallbackURL } from '../../common/redirects';
import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts';
import { useCoreSignIn, useEnvironment, useOptions, useSignInContext } from '../../contexts';
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
import {
Card,
Expand All @@ -25,8 +25,10 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
import type { FormControlState } from '../../utils';
import { buildRequest, handleError, isMobileDevice, useFormControl } from '../../utils';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
import { useHandleAuthenticateWithPasskey } from './shared';
import { SignInSocialButtons } from './SignInSocialButtons';
import { getSignUpAttributeFromIdentifier } from './utils';

const useAutoFillPasskey = () => {
const [isSupported, setIsSupported] = useState(false);
Expand Down Expand Up @@ -64,8 +66,10 @@ export function _SignInStart(): JSX.Element {
const { displayConfig, userSettings } = useEnvironment();
const signIn = useCoreSignIn();
const { navigate } = useRouter();
const options = useOptions();
const ctx = useSignInContext();
const { afterSignInUrl, signUpUrl, waitlistUrl } = ctx;
const isCombinedFlow = (options?.experimental?.combinedFlow && options.signInUrl === options.signUpUrl) || false;
const supportEmail = useSupportEmail();
const identifierAttributes = useMemo<SignInStartIdentifier[]>(
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
Expand Down Expand Up @@ -325,15 +329,57 @@ export function _SignInStart(): JSX.Element {
(e: ClerkAPIError) =>
e.code === ERROR_CODES.INVALID_STRATEGY_FOR_USER || e.code === ERROR_CODES.FORM_PASSWORD_INCORRECT,
);

const alreadySignedInError: ClerkAPIError = e.errors.find(
(e: ClerkAPIError) => e.code === 'identifier_already_signed_in',
);
const accountDoesNotExistError: ClerkAPIError = e.errors.find(
(e: ClerkAPIError) =>
e.code === ERROR_CODES.INVITATION_ACCOUNT_NOT_EXISTS || e.code === ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND,
);

if (instantPasswordError) {
await signInWithFields(identifierField);
} else if (alreadySignedInError) {
const sid = alreadySignedInError.meta!.sessionId!;
await clerk.setActive({ session: sid, redirectUrl: afterSignInUrl });
} else if (isCombinedFlow && accountDoesNotExistError) {
const attribute = getSignUpAttributeFromIdentifier(identifierField);

if (userSettings.signUp.mode === SIGN_UP_MODES.WAITLIST) {
const waitlistUrl = clerk.buildWaitlistUrl(
attribute === 'emailAddress'
? {
initialValues: {
[attribute]: identifierField.value,
},
}
: {},
);
return navigate(waitlistUrl);
}

clerk.client.signUp[attribute] = identifierField.value;
const paramsToForward = new URLSearchParams();
if (organizationTicket) {
paramsToForward.set('__clerk_ticket', organizationTicket);
}

const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signUpUrl);
const redirectUrlComplete = ctx.afterSignUpUrl || '/';

return handleCombinedFlowTransfer({
afterSignUpUrl: ctx.afterSignUpUrl || '/',
clerk,
handleError: e => handleError(e, [identifierField, instantPasswordField], card.setError),
identifierAttribute: attribute,
identifierValue: identifierField.value,
navigate,
organizationTicket,
signUpMode: userSettings.signUp.mode,
redirectUrl,
redirectUrlComplete,
});
} else {
handleError(e, [identifierField, instantPasswordField], card.setError);
}
Expand Down Expand Up @@ -366,8 +412,14 @@ export function _SignInStart(): JSX.Element {
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={localizationKeys('signIn.start.title')} />
<Header.Subtitle localizationKey={localizationKeys('signIn.start.subtitle')} />
{isCombinedFlow ? (
<Header.Title localizationKey={localizationKeys('signIn.start.__experimental_titleCombined')} />
) : (
<>
<Header.Title localizationKey={localizationKeys('signIn.start.title')} />
<Header.Subtitle localizationKey={localizationKeys('signIn.start.subtitle')} />
</>
)}
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
{/*TODO: extract main in its own component */}
Expand Down Expand Up @@ -416,7 +468,7 @@ export function _SignInStart(): JSX.Element {
</Col>
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && (
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
<Card.Action elementId='signIn'>
<Card.ActionText localizationKey={localizationKeys('signIn.start.actionText')} />
<Card.ActionLink
Expand Down
Loading