diff --git a/.changeset/mosaic-sign-in-identifier-fields.md b/.changeset/mosaic-sign-in-identifier-fields.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-sign-in-identifier-fields.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx index 9e298b89703..52ef0ae0038 100644 --- a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx @@ -1,6 +1,6 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Captcha } from '../captcha'; @@ -31,4 +31,16 @@ describe('Captcha', () => { expect(element).toHaveAttribute('data-cl-interactive', 'true'); expect(element).toHaveAttribute('data-cl-theme', 'dark'); }); + + it('reports when clerk-js starts and stops showing an interactive challenge', async () => { + const onInteractiveChange = vi.fn(); + const { container } = render(); + const element = container.querySelector('#clerk-captcha'); + + element?.setAttribute('data-cl-interactive', 'true'); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + element?.removeAttribute('data-cl-interactive'); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + expect(onInteractiveChange).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx index 36c389fcd44..2d3cf668798 100644 --- a/packages/mosaic/src/components/captcha/captcha.tsx +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -1,5 +1,6 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { useSafeLayoutEffect } from '@clerk/shared/react'; +import { useLatestRef } from '@floating-ui/react/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -17,6 +18,8 @@ export interface CaptchaProps extends Omit, 'id' | ' size?: 'normal' | 'flexible' | 'compact'; /** The Turnstile widget's language, an ISO 639-1 code such as `en` or `en-us`. Turnstile follows the visitor's language when it is unset. */ language?: string; + /** Called when Turnstile starts or stops asking the visitor to solve an interactive challenge, so the caller can make room for it. */ + onInteractiveChange?: (interactive: boolean) => void; } /** @@ -30,7 +33,7 @@ export interface CaptchaProps extends Omit, 'id' | ' * */ export const Captcha = React.forwardRef(function MosaicCaptcha( - { render, xstyle, theme, size, language, ...rest }, + { render, xstyle, theme, size, language, onInteractiveChange, ...rest }, ref, ) { const [element, setElement] = React.useState(null); @@ -43,6 +46,24 @@ export const Captcha = React.forwardRef(function M setSchemeTheme(themeFromColorScheme(getComputedStyle(element).colorScheme)); }); + const onInteractiveChangeRef = useLatestRef(onInteractiveChange); + + React.useEffect(() => { + if (!element) { + return undefined; + } + let interactive = element.hasAttribute('data-cl-interactive'); + const observer = new MutationObserver(() => { + const next = element.hasAttribute('data-cl-interactive'); + if (next !== interactive) { + interactive = next; + onInteractiveChangeRef.current?.(next); + } + }); + observer.observe(element, { attributes: true, attributeFilter: ['data-cl-interactive'] }); + return () => observer.disconnect(); + }, [element, onInteractiveChangeRef]); + return useRender({ defaultTagName: 'div', render, diff --git a/packages/mosaic/src/components/field/__tests__/field.test.tsx b/packages/mosaic/src/components/field/__tests__/field.test.tsx index 23660d012c5..26d35fa6887 100644 --- a/packages/mosaic/src/components/field/__tests__/field.test.tsx +++ b/packages/mosaic/src/components/field/__tests__/field.test.tsx @@ -239,6 +239,31 @@ describe('Mosaic Field', () => { expect(checkbox).toBeChecked(); }); + it('keeps the label associated when it shares a header row with an action', async () => { + const onAction = vi.fn(); + render( + + + Password + + + + , + ); + + const input = screen.getByRole('textbox', { name: 'Password' }); + await userEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + expect(onAction).toHaveBeenCalledOnce(); + expect(input).not.toHaveFocus(); + await userEvent.click(screen.getByText('Password')); + expect(input).toHaveFocus(); + }); + it('toggles a checkbox wrapped in its label', async () => { render( @@ -380,6 +405,7 @@ describe('Mosaic Field', () => { const labelRef = React.createRef(); const descriptionRef = React.createRef(); const contentRef = React.createRef(); + const headerRef = React.createRef(); const errorRef = React.createRef(); render( @@ -403,6 +429,10 @@ describe('Mosaic Field', () => { ref={contentRef} data-content='field' /> + { expect(labelRef.current).toHaveAttribute('for', 'name'); expect(descriptionRef.current).toHaveAttribute('title', 'Help'); expect(contentRef.current).toHaveAttribute('data-content', 'field'); + expect(headerRef.current).toHaveAttribute('data-header', 'field'); expect(errorRef.current).toHaveAttribute('role', 'status'); }); @@ -471,6 +502,7 @@ describe('Mosaic Field', () => {
}>Description
}>Content +
}>Header
}>Error , ); @@ -478,6 +510,7 @@ describe('Mosaic Field', () => { expect(screen.getByText('Biography').closest('section')).not.toBeNull(); expect(screen.getByText('Description').tagName).toBe('DIV'); expect(screen.getByText('Content').tagName).toBe('SECTION'); + expect(screen.getByText('Header').tagName).toBe('HEADER'); expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error'); }); diff --git a/packages/mosaic/src/components/field/field.styles.ts b/packages/mosaic/src/components/field/field.styles.ts index 088766a8c43..b897a8c651d 100644 --- a/packages/mosaic/src/components/field/field.styles.ts +++ b/packages/mosaic/src/components/field/field.styles.ts @@ -13,6 +13,13 @@ export const styles = stylex.create({ alignItems: 'flex-start', flexDirection: 'row', }, + header: { + alignItems: 'baseline', + columnGap: space['2'], + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, content: { '--_cl-feedback-gap': space['1'], gap: space['1'], diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 0703224c391..5b8a301591f 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -142,6 +142,21 @@ const Label = React.forwardRef(function MosaicFiel }); }); +/** Props for the row that puts a field's label at the start and an action, such as a link, at the end. */ +export type FieldHeaderProps = MosaicComponentProps<'div'>; + +const Header = React.forwardRef(function MosaicFieldHeader( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('field-header'), stylex.props(reset.base, styles.header, xstyle), rest), + }); +}); + /** Props for the container that stacks a horizontal field's label and supporting text. */ export type FieldContentProps = MosaicComponentProps<'div'>; @@ -326,6 +341,7 @@ const Feedback = React.forwardRef(function M /** Styled parts for composing an automatically associated single-control field. */ export const Field = { Root, + Header, Label, Content, Description, diff --git a/packages/mosaic/src/components/field/index.ts b/packages/mosaic/src/components/field/index.ts index 9daf83fd93f..4fa95d14164 100644 --- a/packages/mosaic/src/components/field/index.ts +++ b/packages/mosaic/src/components/field/index.ts @@ -3,6 +3,7 @@ export type { FieldDescriptionProps, FieldErrorProps, FieldFeedbackProps, + FieldHeaderProps, FieldHintProps, FieldLabelProps, FieldMessageProps, diff --git a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts index d9cacdc2b38..e4dd95cb815 100644 --- a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts +++ b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts @@ -2,12 +2,30 @@ import { describe, expect, it } from 'vitest'; import { signInMessages } from '../sign-in.messages'; import type { SignInStep } from '../sign-in.types'; -import { codeCopy, codeIdentity, codeNotice, codeSecondaryAction, httpsUrl, stepKey } from '../sign-in.utils'; +import { + codeCopy, + codeIdentity, + codeNotice, + codeSecondaryAction, + httpsUrl, + identifierFieldCopy, + splitLastUsedProvider, + startPrompt, + stepKey, +} from '../sign-in.utils'; describe('stepKey', () => { it('keys each step by its kind and the variant that changes its copy', () => { const steps: [SignInStep, string][] = [ - [{ kind: 'identifier' }, 'identifier'], + [{ kind: 'identifier', field: 'email' }, 'identifier'], + [ + { + kind: 'identifier', + field: 'phone', + channel: { id: 'whatsapp', name: 'WhatsApp', iconUrl: 'https://img.clerk.com/static/whatsapp.svg' }, + }, + 'identifier:channel', + ], [{ kind: 'credential', credential: 'password', identifier: 'jane@acme.com' }, 'credential:password'], [{ kind: 'credential', credential: 'backup-code', identifier: 'jane@acme.com' }, 'credential:backup-code'], [{ kind: 'credential', credential: 'passkey', identifier: 'jane@acme.com' }, 'credential:passkey'], @@ -104,3 +122,71 @@ describe('code step copy', () => { }); }); }); + +describe('identifierFieldCopy', () => { + it('labels each identifier field and the link that switches to it', () => { + const m = signInMessages; + expect(identifierFieldCopy('email', m)).toEqual({ + label: 'Email address', + placeholder: 'Enter your email address', + switchLabel: 'Use email', + }); + expect(identifierFieldCopy('email-or-username', m)).toEqual({ + label: 'Email address or username', + placeholder: 'Enter email or username', + switchLabel: 'Use email or username', + }); + expect(identifierFieldCopy('username', m)).toEqual({ + label: 'Username', + placeholder: 'Enter your username', + switchLabel: 'Use username', + }); + expect(identifierFieldCopy('phone', m)).toEqual({ + label: 'Phone number', + placeholder: 'Enter your phone number', + switchLabel: 'Use phone', + }); + }); +}); + +describe('startPrompt', () => { + it('offers the waitlist over sign-up, and nothing without either', () => { + const onSignUp = () => undefined; + const onJoinWaitlist = () => undefined; + expect(startPrompt(signInMessages, { onSignUp })).toEqual({ + text: 'Don’t have an account?', + link: 'Sign up', + onClick: onSignUp, + }); + expect(startPrompt(signInMessages, { onSignUp, onJoinWaitlist })).toEqual({ + text: 'Want early access?', + link: 'Join waitlist', + onClick: onJoinWaitlist, + }); + expect(startPrompt(signInMessages, {})).toBeUndefined(); + }); +}); + +describe('splitLastUsedProvider', () => { + const google = { id: 'oauth_google', name: 'Google', iconUrl: 'google.svg' }; + const github = { id: 'oauth_github', name: 'GitHub', iconUrl: 'github.svg' }; + const x = { id: 'oauth_x', name: 'X', iconUrl: 'x.svg' }; + + it('takes the last used provider out of the others, keeping their order', () => { + expect(splitLastUsedProvider([google, github, x], 'oauth_github')).toEqual({ + lastUsed: github, + others: [google, x], + }); + }); + + it('leaves every provider in the others when none was used last or it is not offered', () => { + expect(splitLastUsedProvider([google, github], undefined)).toEqual({ + lastUsed: undefined, + others: [google, github], + }); + expect(splitLastUsedProvider([google, github], 'oauth_apple')).toEqual({ + lastUsed: undefined, + others: [google, github], + }); + }); +}); diff --git a/packages/mosaic/src/features/sign-in/index.ts b/packages/mosaic/src/features/sign-in/index.ts index 12162ad0bad..a28cda2df2d 100644 --- a/packages/mosaic/src/features/sign-in/index.ts +++ b/packages/mosaic/src/features/sign-in/index.ts @@ -2,6 +2,7 @@ export { SignInView } from './sign-in.view'; export type { SignInActionBlockedDetails, SignInFactor, + SignInIdentifierField, SignInProvider, SignInStep, SignInSubmitEvent, diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts index e591e0edd64..197abc20d67 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -6,10 +6,32 @@ export const signInMessages = { descriptionCombined: 'Welcome! Sign in or create an account to continue', socialButton: 'Continue with {provider}', divider: 'or', + lastUsed: 'Last used', emailLabel: 'Email address', emailPlaceholder: 'Enter your email address', + phoneLabel: 'Phone number', + phonePlaceholder: 'Enter your phone number', + usernameLabel: 'Username', + usernamePlaceholder: 'Enter your username', + emailOrUsernameLabel: 'Email address or username', + emailOrUsernamePlaceholder: 'Enter email or username', + useEmail: 'Use email', + usePhone: 'Use phone', + useUsername: 'Use username', + useEmailOrUsername: 'Use email or username', + selectedCountry: 'Country, {country}', + chooseCountry: 'Choose a country', + searchCountries: 'Search countries', + usePasskey: 'Use passkey instead', signUpPrompt: 'Don’t have an account?', signUpLink: 'Sign up', + waitlistPrompt: 'Want early access?', + waitlistLink: 'Join waitlist', + }, + alternativeChannel: { + title: 'Sign in to {applicationName} with {provider}', + description: 'Enter your phone number to get a verification code on {provider}.', + label: '{provider} phone number', }, password: { title: 'Enter your password', diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts index f1c6baf3267..8b01564f1ef 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -44,9 +44,17 @@ export const styles = stylex.create({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', }, - forgotPassword: { - alignSelf: 'start', - justifySelf: 'start', + captcha: { + justifySelf: 'center', + }, + spotlightGroup: { + display: 'contents', + }, + spotlightHidden: { + display: 'none', + }, + passkeyAction: { + alignSelf: 'center', }, footerText: { textAlign: 'center', diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index a1635b51a87..a86e640c2f0 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -16,8 +16,11 @@ export type SignInFactor = type SignInCodeChannel = 'email' | 'phone' | 'totp'; +export type SignInIdentifierField = 'email' | 'email-or-username' | 'username' | 'phone'; + export type SignInStep = - | { kind: 'identifier' } + | { kind: 'identifier'; field: SignInIdentifierField; nextField?: SignInIdentifierField; lastUsed?: boolean } + | { kind: 'identifier'; field: 'phone'; channel: SignInProvider } | { kind: 'credential'; credential: 'password' | 'backup-code' | 'passkey'; identifier: string } | { kind: 'code'; @@ -59,6 +62,7 @@ export interface SignInProvider { export interface SignInValues { identifier: string; + phoneNumber: string; password: string; code: string; backupCode: string; @@ -81,7 +85,9 @@ export interface SignInViewProps { combined: boolean; socialProviders: readonly SignInProvider[]; pendingProviderId?: string; + lastUsedProviderId?: string; onSelectProvider: (id: string) => void; + onPasskey?: () => void; form: UseFormResult; onSubmit: (event?: SignInSubmitEvent) => void; onCodeComplete: (code: string) => void; @@ -91,7 +97,9 @@ export interface SignInViewProps { globalErrorMessage?: string; newPasswordFeedback?: FieldFeedback; onEditIdentifier: () => void; + onSwitchField?: () => void; onSignUp?: () => void; + onJoinWaitlist?: () => void; onForgotPassword?: () => void; onCancel?: () => void; onShowHelp: () => void; diff --git a/packages/mosaic/src/features/sign-in/sign-in.utils.ts b/packages/mosaic/src/features/sign-in/sign-in.utils.ts index 546122b086c..9c2bb5e7c6f 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.utils.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.utils.ts @@ -1,10 +1,11 @@ import type { MosaicMessages } from '../../localization'; import { fill } from '../../localization'; -import type { SignInStep } from './sign-in.types'; +import type { SignInIdentifierField, SignInProvider, SignInStep } from './sign-in.types'; export function stepKey(step: SignInStep): string { switch (step.kind) { case 'identifier': + return 'channel' in step ? 'identifier:channel' : 'identifier'; case 'sign-up': return step.kind; case 'credential': @@ -151,3 +152,51 @@ export function codeSecondaryAction( } return { label: m.useAnotherMethod, onClick: onCancel }; } + +interface IdentifierFieldCopy { + label: string; + placeholder: string; + switchLabel: string; +} + +export function identifierFieldCopy(field: SignInIdentifierField, m: Messages): IdentifierFieldCopy { + switch (field) { + case 'email': + return { label: m.start.emailLabel, placeholder: m.start.emailPlaceholder, switchLabel: m.start.useEmail }; + case 'email-or-username': + return { + label: m.start.emailOrUsernameLabel, + placeholder: m.start.emailOrUsernamePlaceholder, + switchLabel: m.start.useEmailOrUsername, + }; + case 'username': + return { + label: m.start.usernameLabel, + placeholder: m.start.usernamePlaceholder, + switchLabel: m.start.useUsername, + }; + case 'phone': + return { label: m.start.phoneLabel, placeholder: m.start.phonePlaceholder, switchLabel: m.start.usePhone }; + } +} + +export function startPrompt( + m: Messages, + { onSignUp, onJoinWaitlist }: { onSignUp?: () => void; onJoinWaitlist?: () => void }, +): { text: string; link: string; onClick: () => void } | undefined { + if (onJoinWaitlist) { + return { text: m.start.waitlistPrompt, link: m.start.waitlistLink, onClick: onJoinWaitlist }; + } + if (onSignUp) { + return { text: m.start.signUpPrompt, link: m.start.signUpLink, onClick: onSignUp }; + } + return undefined; +} + +export function splitLastUsedProvider( + providers: readonly SignInProvider[], + lastUsedProviderId: string | undefined, +): { lastUsed?: SignInProvider; others: readonly SignInProvider[] } { + const lastUsed = providers.find(provider => provider.id === lastUsedProviderId); + return { lastUsed, others: lastUsed ? providers.filter(provider => provider !== lastUsed) : providers }; +} diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index b98989a3f64..1b25f76cbd9 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -58,7 +58,9 @@ export function SignInView({ combined, socialProviders, pendingProviderId, + lastUsedProviderId, onSelectProvider, + onPasskey, form, onSubmit, onCodeComplete, @@ -68,7 +70,9 @@ export function SignInView({ globalErrorMessage, newPasswordFeedback, onEditIdentifier, + onSwitchField, onSignUp, + onJoinWaitlist, onForgotPassword, onCancel, onShowHelp, @@ -106,6 +110,8 @@ export function SignInView({ onClick: onResend, }; + const logo = applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined; + return ( {current => { const secondary = current.kind === 'code' ? codeSecondaryAction(current, m, { onCancel, onBack }) : undefined; + const identifierStep = + current.kind === 'identifier' ? ( + + ) : null; return ( <> - - + {stepKey(current) === 'identifier' ? identifierStep : null} + + + {stepKey(current) === 'identifier:channel' ? identifierStep : null} @@ -213,7 +231,7 @@ export function SignInView({ onSelectFactor={onSelectFactor} onShowHelp={onShowHelp} onBack={onBack} - logo={applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined} + logo={logo} globalErrorMessage={globalErrorMessage} pendingProviderId={pendingProviderId} onSelectProvider={onSelectProvider} @@ -238,7 +256,7 @@ export function SignInView({ focusRef={focusRef} step={current} applicationName={applicationName} - logo={applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined} + logo={logo} globalErrorMessage={globalErrorMessage} isPending={isPending} resend={current.purpose === 'email-link' ? resendFor(m.emailLink) : undefined} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index 7397d687529..2b117cfc6d6 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -1,27 +1,80 @@ +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { Ref } from 'react'; +import React, { type Ref } from 'react'; +import { Badge } from '../../../components/badge'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Field } from '../../../components/field'; import { useFlowAutoFocus } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; +import { PhoneInput } from '../../../components/phone-input'; import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; import { Text } from '../../../components/text'; import { fill, useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; import { styles } from '../sign-in.styles'; -import type { SignInProvider, SignInSubmitEvent, SignInValues } from '../sign-in.types'; +import type { + SignInIdentifierField, + SignInProvider, + SignInStep, + SignInSubmitEvent, + SignInValues, +} from '../sign-in.types'; +import { identifierFieldCopy, splitLastUsedProvider, startPrompt } from '../sign-in.utils'; +import { SignInAlternativeChannel } from './sign-in-alternative-channel'; +import { SignInCaptcha } from './sign-in-captcha'; -interface IdentifierStepProps { +type IdentifierStepData = Extract; + +interface IdentifierStepProps extends Omit { + step: IdentifierStepData; + onCancel?: () => void; +} + +export function IdentifierStep({ step, onCancel, ...props }: IdentifierStepProps) { + if ('channel' in step) { + return ( + + ); + } + return ( + + ); +} + +interface IdentifierStartProps { + field: SignInIdentifierField; + nextField?: SignInIdentifierField; + lastUsed?: boolean; + lastUsedProviderId?: string; + onSwitchField?: () => void; applicationName: string; combined: boolean; logo?: { src: string; alt: string }; socialProviders: readonly SignInProvider[]; pendingProviderId?: string; onSelectProvider: (id: string) => void; + onPasskey?: () => void; form: UseFormResult; errorMessage?: string; globalErrorMessage?: string; @@ -29,16 +82,23 @@ interface IdentifierStepProps { isDisabled: boolean; onSubmit: (event: SignInSubmitEvent) => void; onSignUp?: () => void; + onJoinWaitlist?: () => void; focusRef?: Ref; } -export function IdentifierStep({ +function IdentifierStart({ + field, + nextField, + lastUsed = false, + lastUsedProviderId, + onSwitchField, applicationName, combined, logo, socialProviders, pendingProviderId, onSelectProvider, + onPasskey, form, errorMessage, globalErrorMessage, @@ -46,11 +106,28 @@ export function IdentifierStep({ isDisabled, onSubmit, onSignUp, + onJoinWaitlist, focusRef, -}: IdentifierStepProps) { +}: IdentifierStartProps) { const m = useMessages('signIn'); const { ref: identifierFieldRef, ...identifier } = form.register('identifier'); - const identifierRef = useMergeRefs([identifierFieldRef, focusRef, useFlowAutoFocus()]); + const { ref: phoneFieldRef, ...phone } = form.registerValue('phoneNumber'); + const autoFocusRef = useFlowAutoFocus(); + const switchedRef = React.useRef(false); + const focusAfterSwitch = React.useCallback((element: HTMLElement | null) => { + if (element && switchedRef.current) { + switchedRef.current = false; + element.focus(); + } + }, []); + const switchField = () => { + switchedRef.current = true; + onSwitchField?.(); + }; + const identifierRef = useMergeRefs([identifierFieldRef, focusRef, autoFocusRef, focusAfterSwitch]); + const phoneRef = useMergeRefs([phoneFieldRef, focusRef, autoFocusRef, focusAfterSwitch]); + const copy = identifierFieldCopy(field, m); + const [isChallengeShown, setIsChallengeShown] = React.useState(false); const feedback: FieldFeedback | undefined = errorMessage ? { type: 'error', message: errorMessage } : undefined; return ( @@ -75,83 +152,89 @@ export function IdentifierStep({ {globalErrorMessage} - {socialProviders.length > 0 ? ( - <> - - {socialProviders.map(provider => ( - - } - longLabel={fill(m.start.socialButton, { provider: provider.name })} - shortLabel={provider.name} - isPending={pendingProviderId === provider.id} - pendingLabel={m.verifying} - onClick={() => onSelectProvider(provider.id)} - /> - ))} - -
- - - {m.start.divider} - - -
- - ) : null} -
- - {m.start.emailLabel} - - - -
- {onSignUp ? ( - +
- {m.start.signUpPrompt}{' '} - - + + {copy.label} + {nextField && onSwitchField ? ( + + ) : lastUsed ? ( + {m.start.lastUsed} + ) : null} + + {field === 'phone' ? ( + + ) : ( + + )} + + + +
+ + {onPasskey ? ( + ) : null} + - + ); } + +interface SignInProvidersProps { + providers: readonly SignInProvider[]; + pendingProviderId?: string; + lastUsedProviderId?: string; + isDisabled: boolean; + onSelectProvider: (id: string) => void; +} + +function SignInProviders({ + providers, + pendingProviderId, + lastUsedProviderId, + isDisabled, + onSelectProvider, +}: SignInProvidersProps) { + const m = useMessages('signIn'); + if (providers.length === 0) { + return null; + } + const { lastUsed: lastUsedProvider, others: otherProviders } = splitLastUsedProvider(providers, lastUsedProviderId); + const providerButton = (provider: SignInProvider, badge?: string) => ( + + } + longLabel={fill(m.start.socialButton, { provider: provider.name })} + shortLabel={provider.name} + badge={badge} + isPending={pendingProviderId === provider.id} + pendingLabel={m.verifying} + onClick={() => onSelectProvider(provider.id)} + /> + ); + + return ( + <> + + {otherProviders.map(provider => providerButton(provider))} + +
+ + + {m.start.divider} + + +
+ + ); +} + +interface StartPromptProps { + onSignUp?: () => void; + onJoinWaitlist?: () => void; + isDisabled: boolean; +} + +function StartPrompt({ onSignUp, onJoinWaitlist, isDisabled }: StartPromptProps) { + const m = useMessages('signIn'); + const prompt = startPrompt(m, { onSignUp, onJoinWaitlist }); + if (!prompt) { + return null; + } + + return ( + + {prompt.text}{' '} + + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx new file mode 100644 index 00000000000..fd2f18edbd8 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx @@ -0,0 +1,121 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; + +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; +import { PhoneInput } from '../../../components/phone-input'; +import { fill, useMessages } from '../../../localization'; +import { Form } from '../../../primitives/form'; +import { styles } from '../sign-in.styles'; +import type { SignInProvider, SignInSubmitEvent, SignInValues } from '../sign-in.types'; +import { SignInCaptcha } from './sign-in-captcha'; + +interface SignInAlternativeChannelProps { + channel: SignInProvider; + applicationName: string; + logo?: { src: string; alt: string }; + form: UseFormResult; + errorMessage?: string; + globalErrorMessage?: string; + isPending: boolean; + isDisabled: boolean; + onSubmit: (event: SignInSubmitEvent) => void; + onCancel?: () => void; + focusRef?: Ref; +} + +export function SignInAlternativeChannel({ + channel, + applicationName, + logo, + form, + errorMessage, + globalErrorMessage, + isPending, + isDisabled, + onSubmit, + onCancel, + focusRef, +}: SignInAlternativeChannelProps) { + const m = useMessages('signIn'); + const { ref: phoneFieldRef, ...phone } = form.registerValue('phoneNumber'); + const phoneRef = useMergeRefs([phoneFieldRef, focusRef, useFlowAutoFocus()]); + const feedback: FieldFeedback | undefined = errorMessage ? { type: 'error', message: errorMessage } : undefined; + const provider = channel.name; + + return ( + <> + + {logo ? ( + + {logo.alt} + + ) : null} + {fill(m.alternativeChannel.title, { applicationName, provider })} + {fill(m.alternativeChannel.description, { provider })} + + + {globalErrorMessage} + + +
+ + {fill(m.alternativeChannel.label, { provider })} + + + +
+ +
+ + {onCancel ? ( + + ) : null} + + {m.continue} + + + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-captcha.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-captcha.tsx new file mode 100644 index 00000000000..b73914732ca --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-captcha.tsx @@ -0,0 +1,20 @@ +import { Captcha } from '../../../components/captcha'; +import { useFlowStepActive } from '../../../primitives/flow'; +import { styles } from '../sign-in.styles'; + +interface SignInCaptchaProps { + onInteractiveChange?: (interactive: boolean) => void; +} + +export function SignInCaptcha({ onInteractiveChange }: SignInCaptchaProps) { + const isActive = useFlowStepActive(); + if (!isActive) { + return null; + } + return ( + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx index e06f67caa85..994ac301d22 100644 --- a/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx @@ -9,7 +9,6 @@ import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { PasswordInput } from '../../../components/password-input'; import { useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; -import { styles } from '../sign-in.styles'; import type { SignInSubmitEvent, SignInValues } from '../sign-in.types'; import { SignInIdentityPreview } from './sign-in-identity-preview'; @@ -72,7 +71,20 @@ export function SignInPassword({ disabled={isPending} focusableWhenDisabled > - {m.password.label} + + {m.password.label} + {onForgotPassword ? ( + + ) : null} + - {onForgotPassword ? ( - - ) : null} diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx index 0c6db42e283..46819c06398 100644 --- a/packages/swingset/src/stories/field.component.mdx +++ b/packages/swingset/src/stories/field.component.mdx @@ -65,6 +65,15 @@ Wrap `Field.Error` in `Field.Message` and pass the message as its children. `Fie `Field.Success` is the positive counterpart. Render both inside one `Field.Message` and pass each its own message; when one replaces the other, the outgoing message fades out over the incoming one while the container height moves once from the old height to the new. +### Header action + + + +Wrap `Field.Label` in `Field.Header` to put an action, such as a link-style `Button`, at the end of the label's row. The action sits outside the `label`, so pressing it doesn't focus the control. + ### Controls without a native label + + Password + + + + + ); +} + export function VisuallyHiddenLabel() { return ( diff --git a/packages/swingset/src/stories/sign-in.mdx b/packages/swingset/src/stories/sign-in.mdx index e7607ce2914..106f4e5fe52 100644 --- a/packages/swingset/src/stories/sign-in.mdx +++ b/packages/swingset/src/stories/sign-in.mdx @@ -50,7 +50,7 @@ Each section is one component in `features/sign-in/steps/`, and each example und ### Identifier step -`identifier-step.tsx` draws `{ kind: 'identifier' }`: social providers, the email field and, outside the combined flow, the sign-up link. +`identifier-step.tsx` draws `{ kind: 'identifier' }`: social providers, the identifier field and, outside the combined flow, the sign-up link. `field` picks the email, username, email-or-username or phone input, and `nextField` adds the link that switches to it through `onSwitchField`. #### Default @@ -88,6 +88,10 @@ Each section is one component in `features/sign-in/steps/`, and each example und +#### Email or username + + + #### Passkey @@ -104,6 +108,10 @@ Each section is one component in `features/sign-in/steps/`, and each example und +#### Last used identifier + + + #### Captcha diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index b88b20bacf7..35b754ef4a6 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -3,6 +3,7 @@ import type { FieldsConfig } from '@clerk/mosaic/components/form'; import { useForm } from '@clerk/mosaic/components/form'; import type { SignInFactor, + SignInIdentifierField, SignInProvider, SignInStep, SignInSubmitEvent, @@ -11,6 +12,7 @@ import type { import { SignInView } from '@clerk/mosaic/features/sign-in'; import { useRef, useState } from 'react'; +import { useTestTurnstile } from '@/lib/test-turnstile'; import type { StoryMeta } from '@/lib/types'; export { default as __source } from './sign-in.stories?raw'; @@ -53,6 +55,7 @@ const resetFactors: SignInFactor[] = [ const emptyValues: SignInValues = { identifier: '', + phoneNumber: '', password: '', code: '', backupCode: '', @@ -109,7 +112,7 @@ function offersResend(step: SignInStep): boolean { type Outcome = { kind: 'signed-in' } | { kind: 'signed-up' } | { kind: 'redirect'; provider: string }; function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void }) { - const [step, setStep] = useState({ kind: 'identifier' }); + const [step, setStep] = useState({ kind: 'identifier', field: 'email' }); const [direction, setDirection] = useState<-1 | 1>(1); const [errorMessage, setErrorMessage] = useState(); const [globalErrorMessage, setGlobalErrorMessage] = useState(); @@ -185,7 +188,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void return; } if (values.password.toLowerCase() === 'locked') { - navigate({ kind: 'identifier' }, -1); + navigate({ kind: 'identifier', field: 'email' }, -1); setGlobalErrorMessage('Your account is locked. You will be able to try again in 30 minutes.'); return; } @@ -395,7 +398,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void } onEditIdentifier={() => { setStage('first'); - navigate({ kind: 'identifier' }, -1); + navigate({ kind: 'identifier', field: 'email' }, -1); }} onForgotPassword={() => navigate({ kind: 'notice', purpose: 'forgot-password', identifier }, 1)} onCancel={cancelFor(step)} @@ -462,6 +465,12 @@ function StepPanel({ providers = socialProviders, identifier, otherMethods = true, + onSwitchField, + onPasskey, + onSelectProvider, + onCancel, + lastUsedProviderId, + onJoinWaitlist, }: { step: SignInStep; combined?: boolean; @@ -472,6 +481,12 @@ function StepPanel({ providers?: SignInProvider[]; identifier?: string; otherMethods?: boolean; + onSwitchField?: () => void; + onPasskey?: () => void; + onSelectProvider?: (id: string) => void; + onCancel?: () => void; + lastUsedProviderId?: string; + onJoinWaitlist?: () => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -493,7 +508,10 @@ function StepPanel({ combined={combined} socialProviders={providers} pendingProviderId={pendingProviderId} - onSelectProvider={() => undefined} + lastUsedProviderId={lastUsedProviderId} + onSelectProvider={onSelectProvider ?? (() => undefined)} + onSwitchField={onSwitchField} + onPasskey={onPasskey} form={form} onSubmit={event => event?.preventDefault()} onCodeComplete={code => form.setValue('code', code)} @@ -503,9 +521,11 @@ function StepPanel({ globalErrorMessage={globalErrorMessage} onEditIdentifier={() => undefined} onSignUp={combined ? undefined : () => undefined} + onJoinWaitlist={onJoinWaitlist} onForgotPassword={() => undefined} onCancel={ - !otherMethods || (step.kind === 'code' && step.purpose === 'verify-email') ? undefined : () => undefined + onCancel ?? + (!otherMethods || (step.kind === 'code' && step.purpose === 'verify-email') ? undefined : () => undefined) } onShowHelp={() => undefined} onBack={() => undefined} @@ -520,13 +540,13 @@ function StepPanel({ const passwordStep: SignInStep = { kind: 'credential', credential: 'password', identifier: IDENTIFIER }; export function IdentifierDefault() { - return ; + return ; } export function IdentifierSignInOnly() { return ( ); @@ -535,7 +555,7 @@ export function IdentifierSignInOnly() { export function IdentifierManyProviders() { return ( ); @@ -557,7 +577,7 @@ export function IdentifierProviderPending() { export function IdentifierError() { return ( ); @@ -566,7 +586,7 @@ export function IdentifierError() { export function IdentifierLocked() { return ( 1 ? fields[(index + 1) % fields.length] : undefined; + return ( + setIndex(current => (current + 1) % fields.length)} + /> + ); +} + +export function IdentifierPhone() { + return ; +} + +export function IdentifierUsername() { + return ; +} + +export function IdentifierPasskey() { + return ( + undefined} + /> + ); +} + +const whatsApp: SignInProvider = { + id: 'whatsapp', + name: 'WhatsApp', + iconUrl: 'https://img.clerk.com/static/whatsapp.svg', +}; + +export function IdentifierAlternativePhoneChannel() { + const [step, setStep] = useState({ kind: 'identifier', field: 'phone', channel: whatsApp }); + return ( + { + if (id === whatsApp.id) { + setStep({ kind: 'identifier', field: 'phone', channel: whatsApp }); + } + }} + onCancel={() => setStep({ kind: 'identifier', field: 'email' })} + /> + ); +} + +export function IdentifierLastUsed() { + return ( + + ); +} + +export function IdentifierLastUsedField() { + return ( + + ); +} + +export function IdentifierCaptcha() { + const ref = useRef(null); + useTestTurnstile(() => ref.current?.querySelector('.cl-captcha')); + return ( +
+ +
+ ); +} + +export function IdentifierWaitlist() { + return ( + undefined} + /> + ); +} + +export function IdentifierEmailOrUsername() { + return ; +} + export function IdentifierBannerError() { return ( );