diff --git a/.changeset/mosaic-sign-in-choice.md b/.changeset/mosaic-sign-in-choice.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-sign-in-choice.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts index 044f3a35b7b..9941421b022 100644 --- a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts +++ b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts @@ -34,6 +34,8 @@ describe('stepKey', () => { [{ kind: 'code', purpose: 'verify-email', channel: 'email', identifier: 'new@acme.com' }, 'code:verify-email'], [{ kind: 'choice', purpose: 'methods', options: [] }, 'choice:methods'], [{ kind: 'choice', purpose: 'reset-methods', options: [] }, 'choice:reset-methods'], + [{ kind: 'choice', purpose: 'accounts', options: [] }, 'choice:accounts'], + [{ kind: 'choice', purpose: 'wallets', options: [] }, 'choice:wallets'], [{ kind: 'notice', purpose: 'help' }, 'notice:help'], [ { kind: 'code', purpose: 'reset-password', channel: 'email', identifier: 'jane@acme.com' }, diff --git a/packages/mosaic/src/features/sign-in/index.ts b/packages/mosaic/src/features/sign-in/index.ts index a28cda2df2d..59bfc7b4b8d 100644 --- a/packages/mosaic/src/features/sign-in/index.ts +++ b/packages/mosaic/src/features/sign-in/index.ts @@ -1,5 +1,6 @@ export { SignInView } from './sign-in.view'; export type { + SignInAccount, SignInActionBlockedDetails, SignInFactor, SignInIdentifierField, 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 197abc20d67..37c2a73d7cf 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -33,6 +33,16 @@ export const signInMessages = { description: 'Enter your phone number to get a verification code on {provider}.', label: '{provider} phone number', }, + wallets: { + title: 'Sign in with Solana', + description: 'Select a wallet below to sign in', + }, + accounts: { + title: 'Choose an account', + description: 'Select the account with which you wish to continue.', + addAccount: 'Add account', + signOutAll: 'Sign out of all accounts', + }, password: { title: 'Enter your password', description: 'Enter the password associated with your account', 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 8b01564f1ef..c7b74e95d94 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -15,6 +15,11 @@ export const styles = stylex.create({ height: space['4'], width: space['4'], }, + buttonStack: { + gap: space['2'], + display: 'flex', + flexDirection: 'column', + }, divider: { gap: space['4'], alignItems: 'center', diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index a86e640c2f0..7fbd5fd0dca 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -38,6 +38,8 @@ export type SignInStep = } | { kind: 'choice'; purpose: 'methods' | 'reset-methods'; options: readonly SignInFactor[] } | { kind: 'choice'; purpose: 'enterprise-connections'; options: readonly SignInProvider[] } + | { kind: 'choice'; purpose: 'accounts'; options: readonly SignInAccount[] } + | { kind: 'choice'; purpose: 'wallets'; options: readonly SignInProvider[] } | { kind: 'notice'; purpose: 'help' } | { kind: 'notice'; purpose: 'forgot-password'; identifier: string } | { kind: 'notice'; purpose: 'no-methods' | 'password-compromised' | 'enterprise-sso' } @@ -54,6 +56,13 @@ export interface SignInActionBlockedDetails { traceId?: string; } +export interface SignInAccount { + id: string; + name: string; + identifier: string; + imageUrl?: string; +} + export interface SignInProvider { id: string; name: string; @@ -107,6 +116,10 @@ export interface SignInViewProps { onEmailSupport: () => void; pendingFactorId?: string; onSelectFactor: (id: string) => void; + pendingAccountId?: string; + onSelectAccount?: (id: string) => void; + onAddAccount?: () => void; + onSignOutAll?: () => void; onResend?: () => void; canResend: boolean; resendRemainingSeconds?: number; 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 1b25f76cbd9..c03b2280861 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -80,6 +80,10 @@ export function SignInView({ onEmailSupport, pendingFactorId, onSelectFactor, + pendingAccountId, + onSelectAccount, + onAddAccount, + onSignOutAll, onResend, canResend, resendRemainingSeconds, @@ -215,7 +219,15 @@ export function SignInView({ ) : null} - + {current.kind === 'choice' ? ( ) : null} diff --git a/packages/mosaic/src/features/sign-in/steps/choice-step.tsx b/packages/mosaic/src/features/sign-in/steps/choice-step.tsx index 33c29754495..b36637d2d35 100644 --- a/packages/mosaic/src/features/sign-in/steps/choice-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/choice-step.tsx @@ -8,8 +8,11 @@ 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 { SignInFactor, SignInStep } from '../sign-in.types'; +import type { SignInFactor, SignInProvider, SignInStep } from '../sign-in.types'; +import { SignInAccounts } from './sign-in-accounts'; +import { SignInAlternativeMethods } from './sign-in-alternative-methods'; import { SignInEnterpriseConnections } from './sign-in-enterprise-connections'; +import { SignInWallets } from './sign-in-wallets'; const factorIcon = { password: SecurityLockSquare, @@ -32,9 +35,15 @@ interface ChoiceStepProps { onBack: () => void; logo?: { src: string; alt: string }; globalErrorMessage?: string; + socialProviders: readonly SignInProvider[]; pendingProviderId?: string; onSelectProvider: (id: string) => void; onCancel?: () => void; + pendingAccountId?: string; + isDisabled: boolean; + onSelectAccount?: (id: string) => void; + onAddAccount?: () => void; + onSignOutAll?: () => void; focusRef?: Ref; } @@ -48,11 +57,45 @@ export function ChoiceStep({ onBack, logo, globalErrorMessage, + socialProviders, pendingProviderId, onSelectProvider, onCancel, + pendingAccountId, + isDisabled, + onSelectAccount, + onAddAccount, + onSignOutAll, focusRef, }: ChoiceStepProps) { + if (step.purpose === 'wallets') { + return ( + + ); + } + + if (step.purpose === 'accounts') { + return ( + onSelectAccount?.(id)} + onAddAccount={onAddAccount} + onSignOutAll={onSignOutAll} + /> + ); + } + if (step.purpose === 'enterprise-connections') { return ( ({ + id: factor.id, + label: factorLabel(factor), + icon: factorIcon[factor.strategy], + })); + + if (step.purpose === 'methods') { + return ( + + ); + } + return ( ({ - id: factor.id, - label: factorLabel(factor), - icon: factorIcon[factor.strategy], - }))} + methods={methods} pendingMethodId={pendingFactorId} onSelect={onSelectFactor} onHelp={onShowHelp} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index 2b117cfc6d6..94250260cb7 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -11,7 +11,6 @@ import { useFlowAutoFocus } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; import { PhoneInput } from '../../../components/phone-input'; -import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; import { Text } from '../../../components/text'; import { fill, useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; @@ -23,9 +22,10 @@ import type { SignInSubmitEvent, SignInValues, } from '../sign-in.types'; -import { identifierFieldCopy, splitLastUsedProvider, startPrompt } from '../sign-in.utils'; +import { identifierFieldCopy, startPrompt } from '../sign-in.utils'; import { SignInAlternativeChannel } from './sign-in-alternative-channel'; import { SignInCaptcha } from './sign-in-captcha'; +import { SignInProviderButtons } from './sign-in-provider-buttons'; type IdentifierStepData = Extract; @@ -268,34 +268,15 @@ function SignInProviders({ if (providers.length === 0) { return null; } - const { lastUsed: lastUsedProvider, others: otherProviders } = splitLastUsedProvider(providers, lastUsedProviderId); - const providerButton = (provider: SignInProvider, badge?: string) => ( - - } - longLabel={fill(m.start.socialButton, { provider: provider.name })} - shortLabel={provider.name} - badge={badge} - isPending={pendingProviderId === provider.id} - pendingLabel={m.verifying} - onClick={() => onSelectProvider(provider.id)} - /> - ); - return ( <> - - {otherProviders.map(provider => providerButton(provider))} - +
void; + onAddAccount?: () => void; + onSignOutAll?: () => void; + focusRef?: Ref; +} + +export function SignInAccounts({ + accounts, + globalErrorMessage, + pendingAccountId, + isDisabled, + onSelectAccount, + onAddAccount, + onSignOutAll, + focusRef, +}: SignInAccountsProps) { + const m = useMessages('signIn'); + const firstRowRef = useMergeRefs([focusRef, useFlowAutoFocus()]); + + return ( + <> + + {m.accounts.title} + {m.accounts.description} + + + {globalErrorMessage} + + + + {accounts.map((account, index) => ( + onSelectAccount(account.id)} + /> + } + > + + + {account.imageUrl ? ( + + ) : null} + + + + + {account.name} + {account.identifier} + + + {pendingAccountId === account.id ? ( + + ) : ( + + )} + + + ))} + {onAddAccount ? ( + + } + > + + + + + + + {m.accounts.addAccount} + + + ) : null} + {onSignOutAll ? ( + + ) : null} + + + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-methods.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-methods.tsx new file mode 100644 index 00000000000..8a681412b2f --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-methods.tsx @@ -0,0 +1,133 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; + +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Icon } from '../../../components/icon'; +import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; +import { Text } from '../../../components/text'; +import type { IconComponent } from '../../../icons/glyph'; +import { useMessages } from '../../../localization'; +import { styles } from '../sign-in.styles'; +import type { SignInProvider } from '../sign-in.types'; +import { SignInProviderButtons } from './sign-in-provider-buttons'; + +export interface SignInMethodOption { + id: string; + label: string; + icon: IconComponent; +} + +interface SignInAlternativeMethodsProps { + title: string; + description: string; + methods: readonly SignInMethodOption[]; + pendingMethodId?: string; + onSelectMethod: (id: string) => void; + socialProviders: readonly SignInProvider[]; + pendingProviderId?: string; + onSelectProvider: (id: string) => void; + isDisabled: boolean; + globalErrorMessage?: string; + onBack: () => void; + onShowHelp: () => void; + focusRef?: Ref; +} + +export function SignInAlternativeMethods({ + title, + description, + methods, + pendingMethodId, + onSelectMethod, + socialProviders, + pendingProviderId, + onSelectProvider, + isDisabled, + globalErrorMessage, + onBack, + onShowHelp, + focusRef, +}: SignInAlternativeMethodsProps) { + const m = useMessages('signIn'); + const firstMethodRef = useMergeRefs([focusRef, useFlowAutoFocus()]); + const busy = isDisabled || Boolean(pendingMethodId); + + return ( + <> + + {title} + {description} + + + {globalErrorMessage} + + +
+ {socialProviders.length > 0 ? ( + + ) : null} + + {methods.map((method, index) => ( + } + longLabel={method.label} + shortLabel={method.label} + isPending={pendingMethodId === method.id} + pendingLabel={m.verifying} + onClick={() => onSelectMethod(method.id)} + /> + ))} + +
+ + + + + {m.alternativeMethods.helpPrompt}{' '} + + +
+ + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-provider-buttons.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-provider-buttons.tsx new file mode 100644 index 00000000000..1c1d6627e1f --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-provider-buttons.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; +import { fill, useMessages } from '../../../localization'; +import { styles } from '../sign-in.styles'; +import type { SignInProvider } from '../sign-in.types'; +import { splitLastUsedProvider } from '../sign-in.utils'; + +interface SignInProviderButtonsProps { + providers: readonly SignInProvider[]; + pendingProviderId?: string; + lastUsedProviderId?: string; + isDisabled: boolean; + onSelectProvider: (id: string) => void; +} + +export function SignInProviderButtons({ + providers, + pendingProviderId, + lastUsedProviderId, + isDisabled, + onSelectProvider, +}: SignInProviderButtonsProps) { + const m = useMessages('signIn'); + const { lastUsed, others } = splitLastUsedProvider(providers, lastUsedProviderId); + const providerButton = (provider: SignInProvider, badge?: string) => ( + + } + longLabel={fill(m.start.socialButton, { provider: provider.name })} + shortLabel={provider.name} + badge={badge} + isPending={pendingProviderId === provider.id} + pendingLabel={m.verifying} + onClick={() => onSelectProvider(provider.id)} + /> + ); + + return ( + + {others.map(provider => providerButton(provider))} + + ); +} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-wallets.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-wallets.tsx new file mode 100644 index 00000000000..8a8c8acda4e --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-wallets.tsx @@ -0,0 +1,86 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; + +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; +import { Text } from '../../../components/text'; +import { useMessages } from '../../../localization'; +import { styles } from '../sign-in.styles'; +import type { SignInProvider } from '../sign-in.types'; + +interface SignInWalletsProps { + wallets: readonly SignInProvider[]; + globalErrorMessage?: string; + pendingWalletId?: string; + onSelectWallet: (id: string) => void; + onBack: () => void; + focusRef?: Ref; +} + +export function SignInWallets({ + wallets, + globalErrorMessage, + pendingWalletId, + onSelectWallet, + onBack, + focusRef, +}: SignInWalletsProps) { + const m = useMessages('signIn'); + const firstRef = useMergeRefs([focusRef, useFlowAutoFocus()]); + const pending = Boolean(pendingWalletId); + + return ( + <> + + {m.wallets.title} + {m.wallets.description} + + + {globalErrorMessage} + + + + {wallets.map((wallet, index) => ( + + } + longLabel={wallet.name} + shortLabel={wallet.name} + isPending={pendingWalletId === wallet.id} + pendingLabel={m.verifying} + onClick={() => onSelectWallet(wallet.id)} + /> + ))} + + + + + + + ); +} diff --git a/packages/swingset/src/stories/sign-in.mdx b/packages/swingset/src/stories/sign-in.mdx index 106f4e5fe52..088adb8649b 100644 --- a/packages/swingset/src/stories/sign-in.mdx +++ b/packages/swingset/src/stories/sign-in.mdx @@ -198,7 +198,7 @@ Each section is one component in `features/sign-in/steps/`, and each example und ### Choice step -`choice-step.tsx` draws `{ kind: 'choice' }` with `VerificationMethodPicker`: other sign-in methods, or where to send a password reset code. +`choice-step.tsx` draws `{ kind: 'choice' }`. Other sign-in methods are full-width buttons under the social providers, as in legacy; where to send a password reset code uses `VerificationMethodPicker`. Enterprise connections, accounts and wallets each have their own screen. #### Methods diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index 9007176b0fc..3b4e73a714a 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -2,6 +2,7 @@ import { Button } from '@clerk/mosaic/components/button'; import type { FieldsConfig } from '@clerk/mosaic/components/form'; import { useForm } from '@clerk/mosaic/components/form'; import type { + SignInAccount, SignInFactor, SignInIdentifierField, SignInProvider, @@ -471,6 +472,7 @@ function StepPanel({ onCancel, lastUsedProviderId, onJoinWaitlist, + onSelectAccount, }: { step: SignInStep; combined?: boolean; @@ -487,6 +489,7 @@ function StepPanel({ onCancel?: () => void; lastUsedProviderId?: string; onJoinWaitlist?: () => void; + onSelectAccount?: (id: string) => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -522,6 +525,9 @@ function StepPanel({ onEditIdentifier={() => undefined} onSignUp={combined ? undefined : () => undefined} onJoinWaitlist={onJoinWaitlist} + onSelectAccount={onSelectAccount} + onAddAccount={() => undefined} + onSignOutAll={() => undefined} onForgotPassword={() => undefined} onCancel={ onCancel ?? @@ -798,7 +804,24 @@ export function CodeVerifyEmail() { } export function ChoiceMethods() { - return ; + return ( + + ); +} + +export function ChoiceMethodsWithProviders() { + return ( + + ); } export function ChoiceEnterpriseConnections() { @@ -817,6 +840,34 @@ export function ChoiceEnterpriseConnections() { ); } +const accounts: SignInAccount[] = [ + { + id: 'sess_1', + name: 'Cameron Walker', + identifier: 'cameron@acme.com', + imageUrl: 'https://randomuser.me/api/portraits/men/32.jpg', + }, + { id: 'sess_2', name: 'Jane Doe', identifier: 'jane@acme.com' }, +]; + +export function ChoiceAccounts() { + return ( + undefined} + /> + ); +} + +const wallets: SignInProvider[] = [ + { id: 'phantom', name: 'Phantom', iconUrl: 'https://img.clerk.com/static/phantom.svg' }, + { id: 'metamask', name: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, +]; + +export function ChoiceWallets() { + return ; +} + export function ChoiceResetMethods() { return ; }