diff --git a/.changeset/mosaic-card-root-align.md b/.changeset/mosaic-card-root-align.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-card-root-align.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-sign-in-mock.md b/.changeset/mosaic-sign-in-mock.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-sign-in-mock.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/card/card.browser.test.tsx b/packages/mosaic/src/components/card/card.browser.test.tsx index bf420f9bff9..2f5d56d6c6b 100644 --- a/packages/mosaic/src/components/card/card.browser.test.tsx +++ b/packages/mosaic/src/components/card/card.browser.test.tsx @@ -30,14 +30,15 @@ describe('Card.Media', () => { }); }); -describe('Card.Header align', () => { +describe('Card.Root align', () => { function CenteredCard() { return ( - + { expectCentered('title'); }); + it('keeps a description that does not wrap inside the card', () => { + render( + + + + Title + + + {'jane@acme.com'.repeat(12)} + + + , + ); + + const root = screen.getByTestId('root').getBoundingClientRect(); + const description = screen.getByTestId('description').getBoundingClientRect(); + expect(description.left).toBeGreaterThanOrEqual(root.left); + expect(description.right).toBeLessThanOrEqual(root.right); + expectCentered('title'); + }); + it('keeps them centered in the card beside the dialog close button', () => { render( diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts index 2f0235f36ac..e1ca53ef3b6 100644 --- a/packages/mosaic/src/components/card/card.styles.ts +++ b/packages/mosaic/src/components/card/card.styles.ts @@ -78,6 +78,7 @@ export const header = stylex.create({ flexDirection: 'column', flexGrow: '1', rowGap: HEADER_ROW_GAP, + minWidth: 0, }, contentBesideCloseButton: { paddingInlineStart: `calc(${CLOSE_BUTTON_WIDTH} + ${HEADER_COLUMN_GAP})`, @@ -104,11 +105,17 @@ export const header = stylex.create({ export const headerAlign = stylex.create({ start: {}, center: { - alignItems: 'center', textAlign: 'center', }, }); +export const mediaAlign = stylex.create({ + start: {}, + center: { + justifyContent: 'center', + }, +}); + const INSET = space['4']; export const banner = stylex.create({ diff --git a/packages/mosaic/src/components/card/card.test.tsx b/packages/mosaic/src/components/card/card.test.tsx index 113285e953d..7d9bc9953de 100644 --- a/packages/mosaic/src/components/card/card.test.tsx +++ b/packages/mosaic/src/components/card/card.test.tsx @@ -454,15 +454,15 @@ describe('Mosaic Card', () => { expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull(); }); - it('reflects the header alignment, starting by default', () => { + it('reflects the alignment on the root, starting by default', () => { render( - - - + + - , + , ); expect(screen.getByTestId('default')).toHaveAttribute('data-align', 'start'); diff --git a/packages/mosaic/src/components/card/card.tsx b/packages/mosaic/src/components/card/card.tsx index 74318bd4ce7..2114de982d3 100644 --- a/packages/mosaic/src/components/card/card.tsx +++ b/packages/mosaic/src/components/card/card.tsx @@ -24,13 +24,17 @@ type CardElevation = 'card' | 'flush' | 'overlay'; export type CardSize = keyof typeof slots.sizes; -export type CardHeaderAlign = keyof typeof slots.headerAlign; +export type CardAlign = keyof typeof slots.headerAlign; const DEFAULT_ELEVATION: CardElevation = 'card'; // The enclosing card and the dialog it was rendered in. Context crosses portals, so a card in a // dialog opened from inside another card is still the dialog's outermost one. -const CardContext = React.createContext<{ elevation: CardElevation; dialog: DialogContextValue | null } | null>(null); +const CardContext = React.createContext<{ + elevation: CardElevation; + align: CardAlign; + dialog: DialogContextValue | null; +} | null>(null); function CardBranding() { return ( @@ -45,6 +49,8 @@ export interface CardProps extends MosaicComponentProps<'div'> { elevation?: CardElevation; /** The card's width. `lg` is for forms that need the room, like a tag input. @default 'md' */ size?: CardSize; + /** Aligns the content of every `Card.Header` in the card. @default 'start' */ + align?: CardAlign; /** * Signs the foot of the card with "Secured by Clerk". An instance that has paid the branding off * carries none of it, so a connected surface passes `displayConfig.branded` here. @@ -55,20 +61,29 @@ export interface CardProps extends MosaicComponentProps<'div'> { } const Root = React.forwardRef(function CardRoot( - { elevation = DEFAULT_ELEVATION, size = 'md', renderBranding = true, render, xstyle, children, ...rest }, + { + elevation = DEFAULT_ELEVATION, + size = 'md', + align = 'start', + renderBranding = true, + render, + xstyle, + children, + ...rest + }, ref, ) { const dialog = React.useContext(DialogContext); const enclosing = React.useContext(CardContext); const isSurface = (enclosing === null || enclosing.dialog !== dialog) && elevation !== 'flush'; - const context = React.useMemo(() => ({ elevation, dialog }), [elevation, dialog]); + const context = React.useMemo(() => ({ elevation, align, dialog }), [elevation, align, dialog]); const element = useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('card-root', { elevation, size }), + themeProps('card-root', { elevation, size, align }), stylex.props( reset.base, slots.root.base, @@ -115,15 +130,11 @@ function HeaderCloseButton() { ); } -export interface CardHeaderProps extends MosaicComponentProps<'div'> { - /** Aligns the header's content. @default 'start' */ - align?: CardHeaderAlign; -} - -const Header = React.forwardRef(function CardHeader( - { align = 'start', render, xstyle, children, ...rest }, +const Header = React.forwardRef>(function CardHeader( + { render, xstyle, children, ...rest }, ref, ) { + const align = React.useContext(CardContext)?.align ?? 'start'; const dialog = React.useContext(DialogContext); const hasCloseButton = isInDialog(dialog) && dialog.showCloseButton; return useRender({ @@ -131,11 +142,7 @@ const Header = React.forwardRef(function CardHe render, ref, props: { - ...mergeStyleProps( - themeProps('card-header', { align }), - stylex.props(reset.base, slots.header.base, xstyle), - rest, - ), + ...mergeStyleProps(themeProps('card-header'), stylex.props(reset.base, slots.header.base, xstyle), rest), children: ( <> {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening @@ -168,12 +175,17 @@ const Media = React.forwardRef>(func { render, xstyle, ...rest }, ref, ) { + const align = React.useContext(CardContext)?.align ?? 'start'; return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('card-media'), stylex.props(reset.base, slots.header.media, xstyle), rest), + ...mergeStyleProps( + themeProps('card-media'), + stylex.props(reset.base, slots.header.media, slots.mediaAlign[align], xstyle), + rest, + ), }, }); }); diff --git a/packages/mosaic/src/components/card/index.ts b/packages/mosaic/src/components/card/index.ts index e68e9b40726..bbb91c01e4b 100644 --- a/packages/mosaic/src/components/card/index.ts +++ b/packages/mosaic/src/components/card/index.ts @@ -1,2 +1,2 @@ export { Card } from './card'; -export type { CardBannerProps, CardHeaderAlign, CardHeaderProps, CardProps, CardSize } from './card'; +export type { CardAlign, CardBannerProps, CardProps, CardSize } from './card'; diff --git a/packages/mosaic/src/features/sign-in/index.ts b/packages/mosaic/src/features/sign-in/index.ts new file mode 100644 index 00000000000..ca731555b14 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/index.ts @@ -0,0 +1,9 @@ +export { SignInView } from './sign-in.view'; +export type { + SignInMethod, + SignInOtpChannel, + SignInSocialProvider, + SignInStep, + SignInViewProps, + SignUpDetails, +} from './sign-in.types'; diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts new file mode 100644 index 00000000000..55f7fb354d4 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -0,0 +1,82 @@ +export const signInMessages = { + start: { + title: 'Sign in to {applicationName}', + titleCombined: 'Continue to {applicationName}', + subtitle: 'Welcome back! Please sign in to continue', + subtitleCombined: 'Welcome! Sign in or create an account to continue', + actionText: 'Don’t have an account?', + actionLink: 'Sign up', + }, + socialButton: 'Continue with {provider}', + dividerText: 'or', + password: { + title: 'Enter your password', + subtitle: 'Enter the password associated with your account', + actionLink: 'Forgot password?', + }, + emailCode: { + title: 'Check your email', + subtitle: 'Enter the verification code sent to {identifier}', + formTitle: 'Verification code', + resendText: 'Didn’t receive a code?', + resend: 'Resend', + resendCountdown: 'Resend ({seconds})', + }, + totpMfa: { + title: 'Two-step verification', + subtitle: 'To continue, please enter the verification code generated by your authenticator app', + formTitle: 'Verification code', + }, + backupCodeMfa: { + title: 'Enter a backup code', + subtitle: 'Your backup code is the one you got when setting up two-step authentication.', + }, + alternativeMethods: { + title: 'Use another method', + subtitle: 'Facing issues? You can use any of these methods to sign in.', + actionText: 'Don’t have any of these?', + actionLink: 'Get help', + blockButtonEmailCode: 'Email code to {identifier}', + blockButtonPhoneCode: 'Send SMS code to {identifier}', + blockButtonPassword: 'Sign in with your password', + blockButtonPasskey: 'Sign in with your passkey', + blockButtonTotp: 'Use your authenticator app', + blockButtonBackupCode: 'Use a backup code', + getHelp: { + title: 'Get help', + content: + 'If you’re experiencing difficulty signing into your account, email us and we will work with you to restore access as soon as possible.', + blockButtonEmailSupport: 'Email support', + }, + }, + signUp: { + title: 'Create your account', + subtitle: 'Welcome! Please fill in the details to get started.', + actionText: 'Already have an account?', + actionLink: 'Sign in', + }, + verifyEmail: { + title: 'Verify your email', + subtitle: 'Enter the verification code sent to {identifier}', + formTitle: 'Verification code', + resendText: 'Didn’t receive a code?', + resend: 'Resend', + resendCountdown: 'Resend ({seconds})', + }, + backButton: 'Back', + footerActionLinkUseAnotherMethod: 'Use another method', + formButtonPrimary: 'Continue', + formFieldActionHidePassword: 'Hide password', + formFieldActionShowPassword: 'Show password', + formFieldInputPlaceholderEmailAddress: 'Enter your email address', + formFieldInputPlaceholderPassword: 'Enter your password', + formFieldInputPlaceholderFirstName: 'First name', + formFieldInputPlaceholderLastName: 'Last name', + formFieldLabelEmailAddress: 'Email address', + formFieldLabelPassword: 'Password', + formFieldLabelFirstName: 'First name', + formFieldLabelLastName: 'Last name', + formFieldLabelBackupCode: 'Backup code', + identityPreviewEditButton: 'Edit email address', + verifying: 'Verifying', +} as const; diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts new file mode 100644 index 00000000000..f1c6baf3267 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -0,0 +1,54 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + logo: { + display: 'block', + objectFit: 'contain', + height: space['8'], + maxWidth: '100%', + }, + providerIcon: { + display: 'block', + objectFit: 'contain', + height: space['4'], + width: space['4'], + }, + divider: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + }, + dividerLine: { + backgroundColor: colorVars['--cl-color-border'], + flexGrow: 1, + height: '1px', + }, + identity: { + gap: space['1'], + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + minWidth: 0, + }, + identityText: { + minWidth: 0, + }, + fieldStack: { + gap: space['4'], + display: 'grid', + }, + nameRow: { + gap: space['4'], + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + forgotPassword: { + alignSelf: 'start', + justifySelf: 'start', + }, + 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 new file mode 100644 index 00000000000..7a32d19ca1e --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -0,0 +1,65 @@ +import type { FlowDirection } from '../../components/flow'; +import type { ReverificationMethod, ReverificationOtpChannel } from '../reverification'; + +export type SignInStep = + | 'start' + | 'password' + | 'email-code' + | 'totp' + | 'backup-code' + | 'method-picker' + | 'help' + | 'sign-up' + | 'verify-email'; + +export type SignInMethod = ReverificationMethod; + +export type SignInOtpChannel = ReverificationOtpChannel; + +export interface SignInSocialProvider { + id: string; + name: string; + iconUrl: string; +} + +export interface SignUpDetails { + firstName: string; + lastName: string; + emailAddress: string; + password: string; +} + +export interface SignInViewProps { + step: SignInStep; + direction?: FlowDirection; + applicationName: string; + applicationLogoUrl?: string; + combined: boolean; + socialProviders: readonly SignInSocialProvider[]; + pendingProviderId?: string; + onSelectProvider: (id: string) => void; + identifier: string; + onIdentifierChange: (value: string) => void; + onEditIdentifier: () => void; + onSignUp?: () => void; + value: string; + onValueChange: (value: string) => void; + errorMessage?: string; + isPending: boolean; + onSubmit: () => void; + onForgotPassword?: () => void; + onShowMethods: () => void; + onShowHelp: () => void; + onBack: () => void; + onEmailSupport: () => void; + methods: readonly SignInMethod[]; + pendingMethodId?: string; + onSelectMethod: (id: string) => void; + otpChannel?: SignInOtpChannel; + onResend: () => void; + canResend: boolean; + resendRemainingSeconds?: number; + signUpDetails: SignUpDetails; + signUpErrors?: Partial>; + onSignUpDetailChange: (field: keyof SignUpDetails, value: string) => void; +} diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx new file mode 100644 index 00000000000..edcb630933b --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -0,0 +1,325 @@ +import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; + +import { Flow } from '../../components/flow'; +import type { IconComponent } from '../../icons/glyph'; +import { Envelope } from '../../icons/glyphs/envelope'; +import { Key } from '../../icons/glyphs/key'; +import { SecurityLockSquare } from '../../icons/glyphs/security-lock-square'; +import { SecurityPasskey } from '../../icons/glyphs/security-passkey'; +import { SecurityPhone } from '../../icons/glyphs/security-phone'; +import type { MosaicMessages } from '../../localization'; +import { fill, rich, useMessages } from '../../localization'; +import { tabularNumbersStyle } from '../../styles/typography.styles'; +import { ReverificationBackupCode } from '../reverification/steps/reverification-backup-code'; +import { ReverificationHelp } from '../reverification/steps/reverification-help'; +import { ReverificationMethodPicker } from '../reverification/steps/reverification-method-picker'; +import { ReverificationOTP } from '../reverification/steps/reverification-otp'; +import type { SignInMethod, SignInViewProps } from './sign-in.types'; +import { SignInPassword } from './steps/sign-in-password'; +import { SignInStart } from './steps/sign-in-start'; +import { SignUpDetailsStep } from './steps/sign-up-details'; + +type Messages = MosaicMessages['signIn']; + +const methodIcon = { + password: SecurityLockSquare, + passkey: SecurityPasskey, + email_code: Envelope, + phone_code: SecurityPhone, + totp: SecurityLockSquare, + backup_code: Key, +} as const satisfies Record; + +function methodLabel(method: SignInMethod, m: Messages): string { + const identifier = 'identifier' in method ? method.identifier : ''; + switch (method.strategy) { + case 'password': + return m.alternativeMethods.blockButtonPassword; + case 'passkey': + return m.alternativeMethods.blockButtonPasskey; + case 'email_code': + return fill(m.alternativeMethods.blockButtonEmailCode, { identifier }); + case 'phone_code': + return fill(m.alternativeMethods.blockButtonPhoneCode, { identifier }); + case 'totp': + return m.alternativeMethods.blockButtonTotp; + case 'backup_code': + return m.alternativeMethods.blockButtonBackupCode; + } +} + +export function SignInView({ + step, + direction, + applicationName, + applicationLogoUrl, + combined, + socialProviders, + pendingProviderId, + onSelectProvider, + identifier, + onIdentifierChange, + onEditIdentifier, + onSignUp, + value, + onValueChange, + errorMessage, + isPending, + onSubmit, + onForgotPassword, + onShowMethods, + onShowHelp, + onBack, + onEmailSupport, + methods, + pendingMethodId, + onSelectMethod, + otpChannel, + onResend, + canResend, + resendRemainingSeconds, + signUpDetails, + signUpErrors, + onSignUpDetailChange, + focusRef, +}: SignInViewProps & { focusRef?: Ref }): JSX.Element { + const m = useMessages('signIn'); + + const actions = { + secondaryActionLabel: m.footerActionLinkUseAnotherMethod, + primaryActionLabel: m.formButtonPrimary, + pendingLabel: m.verifying, + }; + const hasAlternatives = methods.length > 0; + const resend = + otpChannel === 'email' || otpChannel === 'phone' + ? { + text: m.emailCode.resendText, + label: resendRemainingSeconds ? ( + + {rich(m.emailCode.resendCountdown, { + values: { + seconds: {resendRemainingSeconds}, + }, + })} + + ) : ( + m.emailCode.resend + ), + disabled: !canResend || isPending, + onClick: onResend, + } + : undefined; + const otpSubmit = (code: string) => { + onValueChange(code); + onSubmit(); + }; + + return ( + + {() => ( + <> + + fill(m.socialButton, { provider }), + dividerText: m.dividerText, + fieldLabel: m.formFieldLabelEmailAddress, + fieldPlaceholder: m.formFieldInputPlaceholderEmailAddress, + primaryActionLabel: m.formButtonPrimary, + pendingLabel: m.verifying, + actionText: m.start.actionText, + actionLink: m.start.actionLink, + }} + socialProviders={socialProviders} + pendingProviderId={pendingProviderId} + onSelectProvider={onSelectProvider} + value={identifier} + errorMessage={errorMessage} + isPending={isPending} + onValueChange={onIdentifierChange} + onSubmit={onSubmit} + onSignUp={combined ? undefined : onSignUp} + /> + + + + + + + + + + + + + + + + + + + + ({ + id: method.id, + label: methodLabel(method, m), + icon: methodIcon[method.strategy], + }))} + pendingMethodId={pendingMethodId} + onSelect={onSelectMethod} + onHelp={onShowHelp} + onBack={onBack} + /> + + + + + + + + + + + + + + + )} + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx new file mode 100644 index 00000000000..583e0ebc99b --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx @@ -0,0 +1,36 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Icon } from '../../../components/icon'; +import { Pen } from '../../../icons/glyphs/pen'; +import { truncationStyles } from '../../../styles/typography.styles'; +import { styles } from '../sign-in.styles'; + +export interface SignInIdentityPreviewProps { + identifier: string; + editLabel: string; + disabled?: boolean; + onEdit?: () => void; +} + +export function SignInIdentityPreview({ identifier, editLabel, disabled = false, onEdit }: SignInIdentityPreviewProps) { + return ( + + {identifier} + {onEdit ? ( + + ) : null} + + ); +} 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 new file mode 100644 index 00000000000..22360fb41f0 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx @@ -0,0 +1,154 @@ +import { useMergeRefs } from '@floating-ui/react'; +import type { FormEvent, Ref } from 'react'; +import { useId, useState } from 'react'; + +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Icon } from '../../../components/icon'; +import { InputGroup } from '../../../components/input-group'; +import { Eye } from '../../../icons/glyphs/eye'; +import { EyeSlash } from '../../../icons/glyphs/eye-slash'; +import { Form } from '../../../primitives/form'; +import { styles } from '../sign-in.styles'; +import { SignInIdentityPreview } from './sign-in-identity-preview'; + +export interface SignInPasswordMessages { + title: string; + description: string; + identityEditLabel: string; + fieldLabel: string; + fieldPlaceholder: string; + showPasswordLabel: string; + hidePasswordLabel: string; + forgotPasswordLabel: string; + secondaryActionLabel: string; + primaryActionLabel: string; + pendingLabel: string; +} + +export interface SignInPasswordProps { + messages: SignInPasswordMessages; + identifier: string; + onEditIdentifier?: () => void; + value: string; + errorMessage?: string; + isPending?: boolean; + onValueChange: (value: string) => void; + onSubmit: () => void; + onForgotPassword?: () => void; + onCancel?: () => void; + focusRef?: Ref; +} + +export function SignInPassword({ + messages, + identifier, + onEditIdentifier, + value, + errorMessage, + isPending = false, + onValueChange, + onSubmit, + onForgotPassword, + onCancel, + focusRef, +}: SignInPasswordProps) { + const formId = useId(); + const [visible, setVisible] = useState(false); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!isPending) { + onSubmit(); + } + }; + + return ( + <> + + {messages.title} + {messages.description} + + + +
+ + {messages.fieldLabel} + + ([focusRef, useFlowAutoFocus()])} + name='password' + type={visible ? 'text' : 'password'} + autoComplete='current-password' + placeholder={messages.fieldPlaceholder} + value={value} + onChange={event => onValueChange(event.target.value)} + /> + + + + + + {errorMessage} + + {onForgotPassword ? ( + + ) : null} + +
+
+ + {onCancel ? ( + + ) : null} + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx new file mode 100644 index 00000000000..af1261a79c2 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx @@ -0,0 +1,178 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { FormEvent, Ref } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Input } from '../../../components/input'; +import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; +import { Text } from '../../../components/text'; +import { Form } from '../../../primitives/form'; +import { styles } from '../sign-in.styles'; +import type { SignInSocialProvider } from '../sign-in.types'; + +export interface SignInStartMessages { + title: string; + description: string; + socialButton: (provider: string) => string; + dividerText: string; + fieldLabel: string; + fieldPlaceholder: string; + primaryActionLabel: string; + pendingLabel: string; + actionText: string; + actionLink: string; +} + +export interface SignInStartProps { + messages: SignInStartMessages; + logo?: { src: string; alt: string }; + socialProviders: readonly SignInSocialProvider[]; + pendingProviderId?: string; + onSelectProvider: (id: string) => void; + value: string; + errorMessage?: string; + isPending?: boolean; + onValueChange: (value: string) => void; + onSubmit: () => void; + onSignUp?: () => void; + focusRef?: Ref; +} + +export function SignInStart({ + messages, + logo, + socialProviders, + pendingProviderId, + onSelectProvider, + value, + errorMessage, + isPending = false, + onValueChange, + onSubmit, + onSignUp, + focusRef, +}: SignInStartProps) { + const formId = useId(); + const busy = isPending || Boolean(pendingProviderId); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!busy) { + onSubmit(); + } + }; + + return ( + <> + + {logo ? ( + + {logo.alt} + + ) : null} + {messages.title} + {messages.description} + + + {socialProviders.length > 0 ? ( + <> + + {socialProviders.map(provider => ( + + } + longLabel={messages.socialButton(provider.name)} + shortLabel={provider.name} + isPending={pendingProviderId === provider.id} + pendingLabel={messages.pendingLabel} + onClick={() => onSelectProvider(provider.id)} + /> + ))} + +
+ + + {messages.dividerText} + + +
+ + ) : null} +
+ + {messages.fieldLabel} + ([focusRef, useFlowAutoFocus()])} + name='identifier' + type='email' + autoComplete='email webauthn' + placeholder={messages.fieldPlaceholder} + value={value} + onChange={event => onValueChange(event.target.value)} + /> + + {errorMessage} + + +
+ {onSignUp ? ( + + {messages.actionText}{' '} + + + ) : null} +
+ + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-up-details.tsx b/packages/mosaic/src/features/sign-in/steps/sign-up-details.tsx new file mode 100644 index 00000000000..6c57416a4e6 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-up-details.tsx @@ -0,0 +1,203 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { FormEvent, Ref } from 'react'; +import { useId, useState } from 'react'; + +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Icon } from '../../../components/icon'; +import { Input } from '../../../components/input'; +import { InputGroup } from '../../../components/input-group'; +import { Text } from '../../../components/text'; +import { Eye } from '../../../icons/glyphs/eye'; +import { EyeSlash } from '../../../icons/glyphs/eye-slash'; +import { Form } from '../../../primitives/form'; +import { styles } from '../sign-in.styles'; +import type { SignUpDetails } from '../sign-in.types'; + +export interface SignUpDetailsMessages { + title: string; + description: string; + firstNameLabel: string; + firstNamePlaceholder: string; + lastNameLabel: string; + lastNamePlaceholder: string; + emailLabel: string; + emailPlaceholder: string; + passwordLabel: string; + passwordPlaceholder: string; + showPasswordLabel: string; + hidePasswordLabel: string; + primaryActionLabel: string; + pendingLabel: string; + actionText: string; + actionLink: string; +} + +export interface SignUpDetailsProps { + messages: SignUpDetailsMessages; + values: SignUpDetails; + errors?: Partial>; + isPending?: boolean; + onValueChange: (field: keyof SignUpDetails, value: string) => void; + onSubmit: () => void; + onSignIn?: () => void; + focusRef?: Ref; +} + +export function SignUpDetailsStep({ + messages, + values, + errors = {}, + isPending = false, + onValueChange, + onSubmit, + onSignIn, + focusRef, +}: SignUpDetailsProps) { + const formId = useId(); + const [visible, setVisible] = useState(false); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!isPending) { + onSubmit(); + } + }; + + return ( + <> + + {messages.title} + {messages.description} + + +
+
+
+ + {messages.firstNameLabel} + ([focusRef, useFlowAutoFocus()])} + name='firstName' + autoComplete='given-name' + placeholder={messages.firstNamePlaceholder} + value={values.firstName} + onChange={event => onValueChange('firstName', event.target.value)} + /> + + {errors.firstName} + + + + {messages.lastNameLabel} + onValueChange('lastName', event.target.value)} + /> + + {errors.lastName} + + +
+ + {messages.emailLabel} + onValueChange('emailAddress', event.target.value)} + /> + + {errors.emailAddress} + + + + {messages.passwordLabel} + + onValueChange('password', event.target.value)} + /> + + + + + + {errors.password} + + +
+
+ {onSignIn ? ( + + {messages.actionText}{' '} + + + ) : null} +
+ + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx index 823d2a3d3fc..04c3d97e258 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button-avatar.feature.test.tsx @@ -104,6 +104,13 @@ async function expectGapAroundBadge(avatar: HTMLElement) { if (!badge) { throw new Error('No badge'); } + await expect + .poll(() => sampleAroundBadge(badge)) + .toEqual({ badge: 'dark', innerGap: 'light', outerGap: 'light', lead: 'dark' }); +} + +async function sampleAroundBadge(badge: HTMLElement) { + const pixels = await sampleViewport(); const box = badge.getBoundingClientRect(); const radius = box.width / 2; const towardLead = document.documentElement.dir === 'rtl' ? 1 : -1; @@ -113,14 +120,12 @@ async function expectGapAroundBadge(avatar: HTMLElement) { y: center.y - distance / Math.SQRT2, }); - const pixels = await sampleViewport(); - - expect({ + return { badge: pixels(along(radius - 2)), innerGap: pixels(along(radius + GAP / 2)), outerGap: pixels(along(radius + GAP - 1)), lead: pixels(along(radius + GAP + 2)), - }).toEqual({ badge: 'dark', innerGap: 'light', outerGap: 'light', lead: 'dark' }); + }; } async function sampleViewport(): Promise<(point: { x: number; y: number }) => 'dark' | 'light'> { diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 7058acae285..a5dae00e912 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -13,6 +13,7 @@ import { requestsTableTabMessages } from '../features/organization-profile/organ import { organizationProfileProfileSectionMessages } from '../features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages'; import { organizationSwitcherMessages } from '../features/organization-switcher/organization-switcher.messages'; import { reverificationMessages } from '../features/reverification/reverification.messages'; +import { signInMessages } from '../features/sign-in/sign-in.messages'; import { taskSetupMfaMessages } from '../features/task-setup-mfa/task-setup-mfa.messages'; import { userButtonMessages } from '../features/user-button/user-button.messages'; import { userProfileMessages } from '../features/user-profile/user-profile.messages'; @@ -83,6 +84,7 @@ export const mosaicMessages = { errors: errorMessages, roles: roleMessages, waitlist: waitlistMessages, + signIn: signInMessages, taskSetupMfa: taskSetupMfaMessages, }; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index a867775986c..e8a5ab7f0c5 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -62,6 +62,9 @@ const docModules: Record> = { waitlist: dynamic(() => import('../stories/waitlist.mdx')), 'task-setup-mfa': dynamic(() => import('../stories/task-setup-mfa.mdx')), }, + authentication: { + 'sign-in': dynamic(() => import('../stories/sign-in.mdx')), + }, blocks: { confirmation: dynamic(() => import('../stories/confirmation.mdx')), destructive: dynamic(() => import('../stories/destructive.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 206779f9238..f25046d3914 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -320,6 +320,24 @@ import { Labelled as SeparatorLabelled, meta as separatorMeta, } from '../stories/separator.stories'; +import { + BackupCode as SignInBackupCode, + Default as SignInDefault, + EmailCode as SignInEmailCode, + meta as signInMeta, + MethodPicker as SignInMethodPicker, + Password as SignInPassword, + PasswordError as SignInPasswordError, + PasswordPending as SignInPasswordPending, + SignUp as SignInSignUp, + Start as SignInStart, + StartError as SignInStartError, + StartManyProviders as SignInStartManyProviders, + StartProviderPending as SignInStartProviderPending, + StartSignInOnly as SignInStartSignInOnly, + Totp as SignInTotp, + VerifyEmail as SignInVerifyEmail, +} from '../stories/sign-in.stories'; import { Elements as SkeletonElements, HoldSpace as SkeletonHoldSpace, @@ -1111,6 +1129,25 @@ const reverificationModule: StoryModule = { MethodPickerPending: ReverificationMethodPickerPending, Help: ReverificationHelp, }; + +const signInModule: StoryModule = { + meta: signInMeta, + Default: SignInDefault, + Start: SignInStart, + StartSignInOnly: SignInStartSignInOnly, + StartManyProviders: SignInStartManyProviders, + StartProviderPending: SignInStartProviderPending, + StartError: SignInStartError, + Password: SignInPassword, + PasswordPending: SignInPasswordPending, + PasswordError: SignInPasswordError, + EmailCode: SignInEmailCode, + Totp: SignInTotp, + BackupCode: SignInBackupCode, + MethodPicker: SignInMethodPicker, + SignUp: SignInSignUp, + VerifyEmail: SignInVerifyEmail, +}; const organizationProfileApiKeysPanelModule: StoryModule = { meta: organizationProfileApiKeysPanelMeta, Default: OrganizationProfileApiKeysPanelDefault, @@ -1168,6 +1205,8 @@ export const registry: StoryModule[] = [ reverificationModule, waitlistModule, taskSetupMfaModule, + // Authentication + signInModule, // Blocks — flows assembled from components, wired by the caller's machine. confirmationModule, destructiveModule, diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx index 56e7846a96b..92cd008ace6 100644 --- a/packages/swingset/src/stories/card.component.mdx +++ b/packages/swingset/src/stories/card.component.mdx @@ -31,7 +31,7 @@ import { Card } from '@clerk/mosaic/components/card'; `Card.Media` goes first in `Card.Header`, above the title, for a logo, icon or image. It leaves 24px below it. -`Card.Header` takes `align='center'` to center its content. +`Card.Root` takes `align='center'` to center the content of every `Card.Header` in the card. `Card.Banner` sits between the header and the content and is always rendered. While its children hold a message, a `Banner` of the given `color` expands into it; once they are empty it collapses again. Give it `role='alert'` so the message is announced. diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index fabfea62d4a..d0240b4ae73 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -116,8 +116,8 @@ export function WithMedia() { export function OAuthConsent() { return ( - - + + + +## Usage + +The view renders one step at a time inside a `Card.Root` and reports every interaction through plain callbacks. `combined` switches the start card to the sign-in-or-up copy and hides the sign-up link. The code, backup code, method picker, and help steps are the reverification feature's step views. + +```tsx +import { Card } from '@clerk/mosaic/components/card'; +import { SignInView } from '@clerk/mosaic/features/sign-in'; + + + +; +``` + +## Panels + +These examples isolate each step and its visual states. + +### Start + + + +#### Sign in only + + + +#### Many providers + + + +#### Provider pending + + + +#### Error + + + +### Password + + + +#### Pending + + + +#### Error + + + +### Email code + + + +### Authenticator app + + + +### Backup code + + + +### Method picker + + + +### Sign up + + + +### Verify email + + diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx new file mode 100644 index 00000000000..5e91c4463dd --- /dev/null +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -0,0 +1,515 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import type { SignInMethod, SignInSocialProvider, SignInStep, SignUpDetails } from '@clerk/mosaic/features/sign-in'; +import { SignInView } from '@clerk/mosaic/features/sign-in'; +import { useRef, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './sign-in.stories?raw'; + +export const meta: StoryMeta = { + group: 'Authentication', + status: 'wip', + substatus: 'needs wire-up', + title: 'SignIn', + label: 'Sign in', + source: 'packages/mosaic/src/features/sign-in/sign-in.view.tsx', +}; + +const APPLICATION_NAME = 'Acme'; +const APPLICATION_LOGO_URL = 'https://avatars.githubusercontent.com/u/49538330?v=4'; + +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const socialProviders: SignInSocialProvider[] = [ + { id: 'oauth_google', name: 'Google', iconUrl: 'https://img.clerk.com/static/google.svg' }, + { id: 'oauth_github', name: 'GitHub', iconUrl: 'https://img.clerk.com/static/github.svg' }, +]; + +const firstFactors: SignInMethod[] = [ + { id: 'password', stage: 'first', strategy: 'password' }, + { id: 'email_code', stage: 'first', strategy: 'email_code', emailAddressId: 'idn_1', identifier: 'j***@acme.com' }, +]; + +const secondFactors: SignInMethod[] = [ + { id: 'totp', stage: 'second', strategy: 'totp' }, + { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, +]; + +const emptySignUp: SignUpDetails = { firstName: '', lastName: '', emailAddress: '', password: '' }; + +type Outcome = { kind: 'signed-in' } | { kind: 'signed-up' } | { kind: 'redirect'; provider: string }; + +function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void }): JSX.Element { + const [step, setStep] = useState('start'); + const [direction, setDirection] = useState<-1 | 1>(1); + const [identifier, setIdentifier] = useState(''); + const [value, setValue] = useState(''); + const valueRef = useRef(value); + const [errorMessage, setErrorMessage] = useState(); + const [isPending, setIsPending] = useState(false); + const [pendingProviderId, setPendingProviderId] = useState(); + const [pendingMethodId, setPendingMethodId] = useState(); + const [methodId, setMethodId] = useState('password'); + const [canResend, setCanResend] = useState(true); + const [signUpDetails, setSignUpDetails] = useState(emptySignUp); + const [signUpErrors, setSignUpErrors] = useState>>(); + const [supportRequested, setSupportRequested] = useState(false); + const [stage, setStage] = useState<'first' | 'second'>('first'); + const pickerReturnRef = useRef('password'); + + const navigate = (next: SignInStep, nextDirection: -1 | 1) => { + setDirection(nextDirection); + setStep(next); + valueRef.current = ''; + setValue(''); + setErrorMessage(undefined); + setCanResend(true); + }; + + const onValueChange = (nextValue: string) => { + valueRef.current = nextValue; + setValue(nextValue); + setErrorMessage(undefined); + }; + + const run = async (work: () => void | Promise) => { + setIsPending(true); + await settleAfter(700); + setIsPending(false); + await work(); + }; + + const submitStart = () => + run(() => { + const email = identifier.trim(); + if (!email) { + setErrorMessage('Enter your email address.'); + return; + } + if (!email.includes('@')) { + setErrorMessage('Enter a valid email address.'); + return; + } + if (email.toLowerCase().includes('new')) { + setSignUpDetails({ ...emptySignUp, emailAddress: email }); + navigate('sign-up', 1); + return; + } + setStage('first'); + setMethodId('password'); + navigate('password', 1); + }); + + const submitPassword = () => + run(() => { + const password = valueRef.current; + if (!password) { + setErrorMessage('Enter your password.'); + return; + } + if (password.toLowerCase() === 'error') { + setErrorMessage('Password is incorrect. Try again, or use another method.'); + return; + } + setStage('second'); + setMethodId('totp'); + navigate('totp', 1); + }); + + const submitCode = (onSuccess: () => void) => + run(() => { + const code = valueRef.current; + if (code.length !== 6) { + setErrorMessage('Enter the complete verification code.'); + return; + } + if (code === '000000') { + setErrorMessage('Incorrect code. Try again.'); + return; + } + onSuccess(); + }); + + const submitBackupCode = () => + run(() => { + const code = valueRef.current; + if (!code.trim()) { + setErrorMessage('Enter a backup code.'); + return; + } + if (code.toLowerCase() === 'error') { + setErrorMessage('Incorrect backup code. Try again.'); + return; + } + onComplete({ kind: 'signed-in' }); + }); + + const submitSignUp = () => + run(() => { + const errors: Partial> = {}; + if (!signUpDetails.firstName.trim()) { + errors.firstName = 'Enter your first name.'; + } + if (!signUpDetails.lastName.trim()) { + errors.lastName = 'Enter your last name.'; + } + if (!signUpDetails.emailAddress.includes('@')) { + errors.emailAddress = 'Enter a valid email address.'; + } + if (signUpDetails.password.length < 8) { + errors.password = 'Your password must contain 8 or more characters.'; + } + if (Object.keys(errors).length > 0) { + setSignUpErrors(errors); + return; + } + setSignUpErrors(undefined); + navigate('verify-email', 1); + }); + + const onSubmit = () => { + switch (step) { + case 'start': + void submitStart(); + return; + case 'password': + void submitPassword(); + return; + case 'email-code': + void submitCode(() => { + setStage('second'); + setMethodId('totp'); + navigate('totp', 1); + }); + return; + case 'totp': + void submitCode(() => onComplete({ kind: 'signed-in' })); + return; + case 'backup-code': + void submitBackupCode(); + return; + case 'sign-up': + void submitSignUp(); + return; + case 'verify-email': + void submitCode(() => onComplete({ kind: 'signed-up' })); + return; + default: + return; + } + }; + + const onSelectProvider = (id: string) => { + void (async () => { + setPendingProviderId(id); + await settleAfter(900); + setPendingProviderId(undefined); + onComplete({ kind: 'redirect', provider: socialProviders.find(p => p.id === id)?.name ?? id }); + })(); + }; + + const stepFor = (id: string): SignInStep => { + if (id === 'email_code') { + return 'email-code'; + } + if (id === 'backup_code') { + return 'backup-code'; + } + if (id === 'totp') { + return 'totp'; + } + return 'password'; + }; + + const onSelectMethod = (id: string) => { + void (async () => { + setPendingMethodId(id); + await settleAfter(700); + setPendingMethodId(undefined); + setMethodId(id); + navigate(stepFor(id), 1); + })(); + }; + + const onShowMethods = () => { + pickerReturnRef.current = step; + navigate('method-picker', 1); + }; + + const onBack = () => { + if (step === 'help') { + navigate('method-picker', -1); + return; + } + if (step === 'method-picker') { + navigate(pickerReturnRef.current, -1); + } + }; + + const onResend = () => { + if (!canResend || isPending) { + return; + } + void (async () => { + setIsPending(true); + await settleAfter(700); + setIsPending(false); + setCanResend(false); + await settleAfter(3_000); + setCanResend(true); + })(); + }; + + const stageMethods = stage === 'first' ? firstFactors : secondFactors; + const otpChannel = step === 'email-code' || step === 'verify-email' ? 'email' : step === 'totp' ? 'totp' : undefined; + + return ( + <> + + { + setIdentifier(next); + setErrorMessage(undefined); + }} + onEditIdentifier={() => { + setStage('first'); + navigate('start', -1); + }} + value={value} + onValueChange={onValueChange} + errorMessage={errorMessage} + isPending={isPending} + onSubmit={onSubmit} + onForgotPassword={onShowMethods} + onShowMethods={onShowMethods} + onShowHelp={() => navigate('help', 1)} + onBack={onBack} + onEmailSupport={() => setSupportRequested(true)} + methods={stageMethods.filter(method => method.id !== methodId)} + pendingMethodId={pendingMethodId} + onSelectMethod={onSelectMethod} + otpChannel={otpChannel} + onResend={onResend} + canResend={canResend} + signUpDetails={signUpDetails} + signUpErrors={signUpErrors} + onSignUpDetailChange={(field, next) => { + setSignUpDetails(current => ({ ...current, [field]: next })); + setSignUpErrors(current => (current ? { ...current, [field]: undefined } : current)); + }} + /> + + {supportRequested ?

Email support requested.

: null} + + ); +} + +function outcomeLabel(outcome: Outcome): string { + switch (outcome.kind) { + case 'signed-in': + return 'Signed in.'; + case 'signed-up': + return 'Account created and signed in.'; + case 'redirect': + return `Redirecting to ${outcome.provider}…`; + } +} + +export function Default(): JSX.Element { + const [runId, setRunId] = useState(0); + const [outcome, setOutcome] = useState(); + + return ( +
+ {outcome ? ( +
+

{outcomeLabel(outcome)}

+ +
+ ) : ( + + )} +
+ ); +} + +function StepPanel({ + step, + combined = true, + identifier = 'jane@acme.com', + errorMessage, + isPending = false, + pendingProviderId, + providers = socialProviders, + stage = 'first', +}: { + step: SignInStep; + combined?: boolean; + identifier?: string; + errorMessage?: string; + isPending?: boolean; + pendingProviderId?: string; + providers?: SignInSocialProvider[]; + stage?: 'first' | 'second'; +}): JSX.Element { + const [value, setValue] = useState(errorMessage && step !== 'start' ? 'incorrect' : ''); + const [details, setDetails] = useState({ ...emptySignUp, emailAddress: 'new@acme.com' }); + const methods = stage === 'first' ? firstFactors.slice(1) : secondFactors.slice(1); + + return ( + + undefined} + identifier={step === 'start' ? value : identifier} + onIdentifierChange={setValue} + onEditIdentifier={() => undefined} + onSignUp={() => undefined} + value={value} + onValueChange={setValue} + errorMessage={errorMessage} + isPending={isPending} + onSubmit={() => undefined} + onForgotPassword={() => undefined} + onShowMethods={() => undefined} + onShowHelp={() => undefined} + onBack={() => undefined} + onEmailSupport={() => undefined} + methods={methods} + onSelectMethod={() => undefined} + otpChannel={step === 'email-code' || step === 'verify-email' ? 'email' : step === 'totp' ? 'totp' : undefined} + onResend={() => undefined} + canResend + signUpDetails={details} + onSignUpDetailChange={(field, next) => setDetails(current => ({ ...current, [field]: next }))} + /> + + ); +} + +export function Start(): JSX.Element { + return ; +} + +export function StartSignInOnly(): JSX.Element { + return ( + + ); +} + +export function StartManyProviders(): JSX.Element { + return ( + + ); +} + +export function StartProviderPending(): JSX.Element { + return ( + + ); +} + +export function StartError(): JSX.Element { + return ( + + ); +} + +export function Password(): JSX.Element { + return ; +} + +export function PasswordPending(): JSX.Element { + return ( + + ); +} + +export function PasswordError(): JSX.Element { + return ( + + ); +} + +export function EmailCode(): JSX.Element { + return ; +} + +export function Totp(): JSX.Element { + return ( + + ); +} + +export function BackupCode(): JSX.Element { + return ( + + ); +} + +export function MethodPicker(): JSX.Element { + return ; +} + +export function SignUp(): JSX.Element { + return ; +} + +export function VerifyEmail(): JSX.Element { + return ; +}