From dd4c1c1f29d1a0fb1a6de3f12a2a9168d7cf590b Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 21 Oct 2026 12:35:06 -0400 Subject: [PATCH 1/5] feat(mosaic): mock the combined sign-in flow --- .changeset/mosaic-sign-in-mock.md | 2 + packages/mosaic/src/features/sign-in/index.ts | 9 + .../src/features/sign-in/sign-in.messages.ts | 79 +++ .../src/features/sign-in/sign-in.styles.ts | 58 ++ .../src/features/sign-in/sign-in.types.ts | 64 +++ .../src/features/sign-in/sign-in.view.tsx | 307 +++++++++++ .../steps/sign-in-identity-preview.tsx | 41 ++ .../sign-in/steps/sign-in-password.tsx | 151 ++++++ .../features/sign-in/steps/sign-in-start.tsx | 198 +++++++ .../sign-in/steps/sign-up-details.tsx | 199 +++++++ packages/mosaic/src/localization/registry.ts | 2 + .../swingset/src/components/DocsViewer.tsx | 3 + packages/swingset/src/lib/registry.ts | 39 ++ packages/swingset/src/stories/sign-in.mdx | 101 ++++ .../swingset/src/stories/sign-in.stories.tsx | 512 ++++++++++++++++++ 15 files changed, 1765 insertions(+) create mode 100644 .changeset/mosaic-sign-in-mock.md create mode 100644 packages/mosaic/src/features/sign-in/index.ts create mode 100644 packages/mosaic/src/features/sign-in/sign-in.messages.ts create mode 100644 packages/mosaic/src/features/sign-in/sign-in.styles.ts create mode 100644 packages/mosaic/src/features/sign-in/sign-in.types.ts create mode 100644 packages/mosaic/src/features/sign-in/sign-in.view.tsx create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-up-details.tsx create mode 100644 packages/swingset/src/stories/sign-in.mdx create mode 100644 packages/swingset/src/stories/sign-in.stories.tsx 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/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..6f84c5f5754 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -0,0 +1,79 @@ +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', + }, + socialButtonsBlockButton: 'Continue with {provider}', + socialButtonsIconButton: 'Sign in 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', + resendButton: 'Didn’t receive a code? Resend', + }, + 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', + blockButton__emailCode: 'Email code to {identifier}', + blockButton__phoneCode: 'Send SMS code to {identifier}', + blockButton__password: 'Sign in with your password', + blockButton__passkey: 'Sign in with your passkey', + blockButton__totp: 'Use your authenticator app', + blockButton__backupCode: '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.', + blockButton__emailSupport: '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', + resendButton: 'Didn’t receive a code? Resend', + }, + backButton: 'Back', + footerActionLink__useAnotherMethod: 'Use another method', + formButtonPrimary: 'Continue', + formFieldAction__hidePassword: 'Hide password', + formFieldAction__showPassword: 'Show password', + formFieldInputPlaceholder__emailAddress: 'Enter your email address', + formFieldInputPlaceholder__password: 'Enter your password', + formFieldInputPlaceholder__firstName: 'First name', + formFieldInputPlaceholder__lastName: 'Last name', + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__password: 'Password', + formFieldLabel__firstName: 'First name', + formFieldLabel__lastName: 'Last name', + formFieldLabel__backupCode: '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..0c768dbd55d --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -0,0 +1,58 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + socialBlock: { + gap: space['2'], + display: 'grid', + }, + socialIcons: { + gap: space['2'], + display: 'grid', + gridAutoColumns: 'minmax(0, 1fr)', + gridAutoFlow: 'column', + }, + 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..f935d639654 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -0,0 +1,64 @@ +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; + 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..68ecfed493b --- /dev/null +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -0,0 +1,307 @@ +import type { Ref } from 'react'; + +import { Flow } from '../../components/flow'; +import type { IconName } from '../../icons/registry'; +import type { MosaicMessages } from '../../localization'; +import { fill, useMessages } from '../../localization'; +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: 'security-lock-square', + passkey: 'security-passkey', + email_code: 'envelope', + phone_code: 'security-phone', + totp: 'security-lock-square', + 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.blockButton__password; + case 'passkey': + return m.alternativeMethods.blockButton__passkey; + case 'email_code': + return fill(m.alternativeMethods.blockButton__emailCode, { identifier }); + case 'phone_code': + return fill(m.alternativeMethods.blockButton__phoneCode, { identifier }); + case 'totp': + return m.alternativeMethods.blockButton__totp; + case 'backup_code': + return m.alternativeMethods.blockButton__backupCode; + } +} + +export function SignInView({ + step, + direction, + applicationName, + 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.footerActionLink__useAnotherMethod, + primaryActionLabel: m.formButtonPrimary, + pendingLabel: m.verifying, + }; + const hasAlternatives = methods.length > 0; + const resendLabel = m.emailCode.resendButton; + const resend = + otpChannel === 'email' || otpChannel === 'phone' + ? { + label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, + disabled: !canResend || isPending, + onClick: onResend, + } + : undefined; + const otpSubmit = (code: string) => { + onValueChange(code); + onSubmit(); + }; + + return ( + + {() => ( + <> + + fill(m.socialButtonsBlockButton, { provider }), + socialIconButton: provider => fill(m.socialButtonsIconButton, { provider }), + dividerText: m.dividerText, + fieldLabel: m.formFieldLabel__emailAddress, + fieldPlaceholder: m.formFieldInputPlaceholder__emailAddress, + 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..8c254b26a1e --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx @@ -0,0 +1,41 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../../components/button'; +import { Icon } from '../../../components/icon'; +import { Text } from '../../../components/text'; +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..542ebc8f638 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx @@ -0,0 +1,151 @@ +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 { 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..4dd50bb5988 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx @@ -0,0 +1,198 @@ +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 { Text } from '../../../components/text'; +import { styles } from '../sign-in.styles'; +import type { SignInSocialProvider } from '../sign-in.types'; + +const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; + +export interface SignInStartMessages { + title: string; + description: string; + socialBlockButton: (provider: string) => string; + socialIconButton: (provider: string) => string; + dividerText: string; + fieldLabel: string; + fieldPlaceholder: string; + primaryActionLabel: string; + pendingLabel: string; + actionText: string; + actionLink: string; +} + +export interface SignInStartProps { + messages: SignInStartMessages; + 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; +} + +function SocialButtons({ + messages, + providers, + pendingProviderId, + disabled, + onSelect, +}: { + messages: SignInStartMessages; + providers: readonly SignInSocialProvider[]; + pendingProviderId?: string; + disabled: boolean; + onSelect: (id: string) => void; +}) { + const asBlock = providers.length <= SOCIAL_BUTTON_BLOCK_THRESHOLD; + return ( +
+ {providers.map(provider => ( + onSelect(provider.id)} + > + + {asBlock ? messages.socialBlockButton(provider.name) : null} + + ))} +
+ ); +} + +export function SignInStart({ + messages, + 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 ( + <> + + {messages.title} + {messages.description} + + + {socialProviders.length > 0 ? ( + <> + +
+ + + {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..e43dcadee13 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-up-details.tsx @@ -0,0 +1,199 @@ +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 { 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/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index c458b3c2ff8..d9b6b739b47 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -10,6 +10,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 { userButtonMessages } from '../features/user-button/user-button.messages'; import { userProfileMessages } from '../features/user-profile/user-profile.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; @@ -46,6 +47,7 @@ export const mosaicMessages = { organizationProfileInviteMembers: organizationProfileInviteMembersMessages, profile: profileMessages, reverification: reverificationMessages, + signIn: signInMessages, userButton: userButtonMessages, userProfile: userProfileMessages, userProfileActiveDevices: userProfileActiveDevicesMessages, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 5c6762de0a0..aa37bf4189e 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -60,6 +60,9 @@ const docModules: Record> = { reverification: { reverification: dynamic(() => import('../stories/reverification.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 7140bff3333..0accd12b5da 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -286,6 +286,24 @@ import { Placeholder as SelectComponentPlaceholder, } from '../stories/select.component.stories'; import { meta as selectMeta } from '../stories/select.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, @@ -1001,6 +1019,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, @@ -1054,6 +1091,8 @@ export const registry: StoryModule[] = [ apiKeysTableModule, // Reverification reverificationModule, + // Authentication + signInModule, // Blocks — flows assembled from components, wired by the caller's machine. confirmationModule, destructiveModule, diff --git a/packages/swingset/src/stories/sign-in.mdx b/packages/swingset/src/stories/sign-in.mdx new file mode 100644 index 00000000000..94567dcba53 --- /dev/null +++ b/packages/swingset/src/stories/sign-in.mdx @@ -0,0 +1,101 @@ +import * as Stories from './sign-in.stories'; + +# SignIn + +The example pauses briefly in each pending state. An email containing `new` takes the sign-up path. Enter `error` in the password or backup code field, or `000000` for a code, to render an attempt error. + +## Example + + + +## 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..be392b531eb --- /dev/null +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -0,0 +1,512 @@ +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 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 stageRef = useRef<'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; + } + stageRef.current = '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; + } + stageRef.current = '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(() => { + stageRef.current = '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 = stageRef.current === 'first' ? firstFactors : secondFactors; + const otpChannel = step === 'email-code' || step === 'verify-email' ? 'email' : step === 'totp' ? 'totp' : undefined; + + return ( + <> + + { + setIdentifier(next); + setErrorMessage(undefined); + }} + onEditIdentifier={() => { + stageRef.current = '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 ; +} From 54a337eae17a3e63600ec338b50d7f85c240ec1c Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Tue, 3 Nov 2026 10:17:28 -0500 Subject: [PATCH 2/5] fix(mosaic): name sign-in message keys in camelCase --- .../src/features/sign-in/sign-in.messages.ts | 38 +++++++------- .../src/features/sign-in/sign-in.view.tsx | 50 +++++++++---------- 2 files changed, 44 insertions(+), 44 deletions(-) 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 c7f5880a38c..05fbaefb014 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -37,17 +37,17 @@ export const signInMessages = { subtitle: 'Facing issues? You can use any of these methods to sign in.', actionText: 'Don’t have any of these?', actionLink: 'Get help', - blockButton__emailCode: 'Email code to {identifier}', - blockButton__phoneCode: 'Send SMS code to {identifier}', - blockButton__password: 'Sign in with your password', - blockButton__passkey: 'Sign in with your passkey', - blockButton__totp: 'Use your authenticator app', - blockButton__backupCode: 'Use a backup code', + 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.', - blockButton__emailSupport: 'Email support', + blockButtonEmailSupport: 'Email support', }, }, signUp: { @@ -65,19 +65,19 @@ export const signInMessages = { resendCountdown: 'Resend ({seconds})', }, backButton: 'Back', - footerActionLink__useAnotherMethod: 'Use another method', + footerActionLinkUseAnotherMethod: 'Use another method', formButtonPrimary: 'Continue', - formFieldAction__hidePassword: 'Hide password', - formFieldAction__showPassword: 'Show password', - formFieldInputPlaceholder__emailAddress: 'Enter your email address', - formFieldInputPlaceholder__password: 'Enter your password', - formFieldInputPlaceholder__firstName: 'First name', - formFieldInputPlaceholder__lastName: 'Last name', - formFieldLabel__emailAddress: 'Email address', - formFieldLabel__password: 'Password', - formFieldLabel__firstName: 'First name', - formFieldLabel__lastName: 'Last name', - formFieldLabel__backupCode: 'Backup code', + 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.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index c48c668d8ca..1a6d0033e2d 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -35,17 +35,17 @@ function methodLabel(method: SignInMethod, m: Messages): string { const identifier = 'identifier' in method ? method.identifier : ''; switch (method.strategy) { case 'password': - return m.alternativeMethods.blockButton__password; + return m.alternativeMethods.blockButtonPassword; case 'passkey': - return m.alternativeMethods.blockButton__passkey; + return m.alternativeMethods.blockButtonPasskey; case 'email_code': - return fill(m.alternativeMethods.blockButton__emailCode, { identifier }); + return fill(m.alternativeMethods.blockButtonEmailCode, { identifier }); case 'phone_code': - return fill(m.alternativeMethods.blockButton__phoneCode, { identifier }); + return fill(m.alternativeMethods.blockButtonPhoneCode, { identifier }); case 'totp': - return m.alternativeMethods.blockButton__totp; + return m.alternativeMethods.blockButtonTotp; case 'backup_code': - return m.alternativeMethods.blockButton__backupCode; + return m.alternativeMethods.blockButtonBackupCode; } } @@ -86,7 +86,7 @@ export function SignInView({ const m = useMessages('signIn'); const actions = { - secondaryActionLabel: m.footerActionLink__useAnotherMethod, + secondaryActionLabel: m.footerActionLinkUseAnotherMethod, primaryActionLabel: m.formButtonPrimary, pendingLabel: m.verifying, }; @@ -132,8 +132,8 @@ export function SignInView({ socialBlockButton: provider => fill(m.socialButtonsBlockButton, { provider }), socialIconButton: provider => fill(m.socialButtonsIconButton, { provider }), dividerText: m.dividerText, - fieldLabel: m.formFieldLabel__emailAddress, - fieldPlaceholder: m.formFieldInputPlaceholder__emailAddress, + fieldLabel: m.formFieldLabelEmailAddress, + fieldPlaceholder: m.formFieldInputPlaceholderEmailAddress, primaryActionLabel: m.formButtonPrimary, pendingLabel: m.verifying, actionText: m.start.actionText, @@ -158,10 +158,10 @@ export function SignInView({ title: m.password.title, description: m.password.subtitle, identityEditLabel: m.identityPreviewEditButton, - fieldLabel: m.formFieldLabel__password, - fieldPlaceholder: m.formFieldInputPlaceholder__password, - showPasswordLabel: m.formFieldAction__showPassword, - hidePasswordLabel: m.formFieldAction__hidePassword, + fieldLabel: m.formFieldLabelPassword, + fieldPlaceholder: m.formFieldInputPlaceholderPassword, + showPasswordLabel: m.formFieldActionShowPassword, + hidePasswordLabel: m.formFieldActionHidePassword, forgotPasswordLabel: m.password.actionLink, ...actions, }} @@ -222,7 +222,7 @@ export function SignInView({ messages={{ title: m.backupCodeMfa.title, description: m.backupCodeMfa.subtitle, - fieldLabel: m.formFieldLabel__backupCode, + fieldLabel: m.formFieldLabelBackupCode, ...actions, }} value={value} @@ -262,7 +262,7 @@ export function SignInView({ title: m.alternativeMethods.getHelp.title, description: m.alternativeMethods.getHelp.content, backButton: m.backButton, - supportButton: m.alternativeMethods.getHelp.blockButton__emailSupport, + supportButton: m.alternativeMethods.getHelp.blockButtonEmailSupport, }} onEmailSupport={onEmailSupport} onBack={onBack} @@ -275,16 +275,16 @@ export function SignInView({ messages={{ title: m.signUp.title, description: m.signUp.subtitle, - firstNameLabel: m.formFieldLabel__firstName, - firstNamePlaceholder: m.formFieldInputPlaceholder__firstName, - lastNameLabel: m.formFieldLabel__lastName, - lastNamePlaceholder: m.formFieldInputPlaceholder__lastName, - emailLabel: m.formFieldLabel__emailAddress, - emailPlaceholder: m.formFieldInputPlaceholder__emailAddress, - passwordLabel: m.formFieldLabel__password, - passwordPlaceholder: m.formFieldInputPlaceholder__password, - showPasswordLabel: m.formFieldAction__showPassword, - hidePasswordLabel: m.formFieldAction__hidePassword, + firstNameLabel: m.formFieldLabelFirstName, + firstNamePlaceholder: m.formFieldInputPlaceholderFirstName, + lastNameLabel: m.formFieldLabelLastName, + lastNamePlaceholder: m.formFieldInputPlaceholderLastName, + emailLabel: m.formFieldLabelEmailAddress, + emailPlaceholder: m.formFieldInputPlaceholderEmailAddress, + passwordLabel: m.formFieldLabelPassword, + passwordPlaceholder: m.formFieldInputPlaceholderPassword, + showPasswordLabel: m.formFieldActionShowPassword, + hidePasswordLabel: m.formFieldActionHidePassword, primaryActionLabel: m.formButtonPrimary, pendingLabel: m.verifying, actionText: m.signUp.actionText, From 8d1195133c72c863fc70fc6c82b9fb0420c93201 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Tue, 3 Nov 2026 10:49:42 -0500 Subject: [PATCH 3/5] test(mosaic): retry the UserButton badge gap check until the popup settles --- .../__tests__/user-button-avatar.feature.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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'> { From 7f3c394821490d4003950de2aee2686665fe3f97 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Tue, 3 Nov 2026 12:21:01 -0500 Subject: [PATCH 4/5] refactor(mosaic): build the sign-in start step on ProviderButton and Card.Media --- .../src/features/sign-in/sign-in.messages.ts | 3 +- .../src/features/sign-in/sign-in.styles.ts | 14 ++- .../src/features/sign-in/sign-in.types.ts | 1 + .../src/features/sign-in/sign-in.view.tsx | 5 +- .../features/sign-in/steps/sign-in-start.tsx | 85 +++++++------------ packages/swingset/src/stories/sign-in.mdx | 2 + .../swingset/src/stories/sign-in.stories.tsx | 3 + 7 files changed, 47 insertions(+), 66 deletions(-) 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 05fbaefb014..55f7fb354d4 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -7,8 +7,7 @@ export const signInMessages = { actionText: 'Don’t have an account?', actionLink: 'Sign up', }, - socialButtonsBlockButton: 'Continue with {provider}', - socialButtonsIconButton: 'Sign in with {provider}', + socialButton: 'Continue with {provider}', dividerText: 'or', 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 0c768dbd55d..f1c6baf3267 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -3,15 +3,11 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ - socialBlock: { - gap: space['2'], - display: 'grid', - }, - socialIcons: { - gap: space['2'], - display: 'grid', - gridAutoColumns: 'minmax(0, 1fr)', - gridAutoFlow: 'column', + logo: { + display: 'block', + objectFit: 'contain', + height: space['8'], + maxWidth: '100%', }, providerIcon: { display: 'block', 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 f935d639654..7a32d19ca1e 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -33,6 +33,7 @@ export interface SignInViewProps { step: SignInStep; direction?: FlowDirection; applicationName: string; + applicationLogoUrl?: string; combined: boolean; socialProviders: readonly SignInSocialProvider[]; pendingProviderId?: string; 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 1a6d0033e2d..edcb630933b 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -53,6 +53,7 @@ export function SignInView({ step, direction, applicationName, + applicationLogoUrl, combined, socialProviders, pendingProviderId, @@ -126,11 +127,11 @@ export function SignInView({ fill(m.socialButtonsBlockButton, { provider }), - socialIconButton: provider => fill(m.socialButtonsIconButton, { provider }), + socialButton: provider => fill(m.socialButton, { provider }), dividerText: m.dividerText, fieldLabel: m.formFieldLabelEmailAddress, fieldPlaceholder: m.formFieldInputPlaceholderEmailAddress, 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 index 116b9ad7e5b..af1261a79c2 100644 --- a/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-start.tsx @@ -8,18 +8,16 @@ 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'; -const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; - export interface SignInStartMessages { title: string; description: string; - socialBlockButton: (provider: string) => string; - socialIconButton: (provider: string) => string; + socialButton: (provider: string) => string; dividerText: string; fieldLabel: string; fieldPlaceholder: string; @@ -31,6 +29,7 @@ export interface SignInStartMessages { export interface SignInStartProps { messages: SignInStartMessages; + logo?: { src: string; alt: string }; socialProviders: readonly SignInSocialProvider[]; pendingProviderId?: string; onSelectProvider: (id: string) => void; @@ -43,50 +42,9 @@ export interface SignInStartProps { focusRef?: Ref; } -function SocialButtons({ - messages, - providers, - pendingProviderId, - disabled, - onSelect, -}: { - messages: SignInStartMessages; - providers: readonly SignInSocialProvider[]; - pendingProviderId?: string; - disabled: boolean; - onSelect: (id: string) => void; -}) { - const asBlock = providers.length <= SOCIAL_BUTTON_BLOCK_THRESHOLD; - return ( -
- {providers.map(provider => ( - onSelect(provider.id)} - > - - {asBlock ? messages.socialBlockButton(provider.name) : null} - - ))} -
- ); -} - export function SignInStart({ messages, + logo, socialProviders, pendingProviderId, onSelectProvider, @@ -111,19 +69,40 @@ export function SignInStart({ 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)} + /> + ))} +
@@ -34,6 +35,7 @@ import { SignInView } from '@clerk/mosaic/features/sign-in'; step={step} combined applicationName='Acme' + applicationLogoUrl={logoUrl} socialProviders={providers} {...callbacks} /> diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index b166192709c..32f36c20b62 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -18,6 +18,7 @@ export const meta: StoryMeta = { }; 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)); @@ -271,6 +272,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void step={step} direction={direction} applicationName={APPLICATION_NAME} + applicationLogoUrl={APPLICATION_LOGO_URL} combined socialProviders={socialProviders} pendingProviderId={pendingProviderId} @@ -381,6 +383,7 @@ function StepPanel({ Date: Tue, 3 Nov 2026 14:25:00 -0500 Subject: [PATCH 5/5] refactor(mosaic): set card alignment on Card.Root and move the sign-in identifier into the header (#10612) --- .changeset/mosaic-card-root-align.md | 2 + .../src/components/card/card.browser.test.tsx | 33 ++++++++++++- .../mosaic/src/components/card/card.styles.ts | 9 +++- .../mosaic/src/components/card/card.test.tsx | 10 ++-- packages/mosaic/src/components/card/card.tsx | 48 ++++++++++++------- packages/mosaic/src/components/card/index.ts | 2 +- .../steps/sign-in-identity-preview.tsx | 14 ++---- .../sign-in/steps/sign-in-password.tsx | 4 +- .../swingset/src/stories/card.component.mdx | 2 +- .../src/stories/card.component.stories.tsx | 4 +- packages/swingset/src/stories/sign-in.mdx | 2 +- .../swingset/src/stories/sign-in.stories.tsx | 4 +- 12 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 .changeset/mosaic-card-root-align.md 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/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 46a612d8032..bc5f79297fd 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/steps/sign-in-identity-preview.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-identity-preview.tsx index 489536ba26f..583e0ebc99b 100644 --- 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 @@ -1,8 +1,8 @@ import * as stylex from '@stylexjs/stylex'; import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; import { Icon } from '../../../components/icon'; -import { Text } from '../../../components/text'; import { Pen } from '../../../icons/glyphs/pen'; import { truncationStyles } from '../../../styles/typography.styles'; import { styles } from '../sign-in.styles'; @@ -16,14 +16,8 @@ export interface SignInIdentityPreviewProps { export function SignInIdentityPreview({ identifier, editLabel, disabled = false, onEdit }: SignInIdentityPreviewProps) { return ( -
- - {identifier} - + + {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 index a88df4a9163..22360fb41f0 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 @@ -70,14 +70,14 @@ export function SignInPassword({ {messages.title} {messages.description} - - +
+
- + + + void return ( <> - + +