diff --git a/.changeset/calm-pandas-verify.md b/.changeset/calm-pandas-verify.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-pandas-verify.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index a336f3e4aa2..76364d8c068 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,6 +39,12 @@ const docModules: Record> = { }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), + 'reverification-password': dynamic(() => import('../stories/reverification-password.mdx')), + 'reverification-passkey': dynamic(() => import('../stories/reverification-passkey.mdx')), + 'reverification-otp': dynamic(() => import('../stories/reverification-otp.mdx')), + 'reverification-backup-code': dynamic(() => import('../stories/reverification-backup-code.mdx')), + 'reverification-method-picker': dynamic(() => import('../stories/reverification-method-picker.mdx')), + 'reverification-help': dynamic(() => import('../stories/reverification-help.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 068f9aecd95..4a4eb30b090 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -74,7 +74,6 @@ import { Disabled as OtpComponentDisabled, Error as OtpComponentError, meta as otpComponentMeta, - Sizes as OtpComponentSizes, Success as OtpComponentSuccess, } from '../stories/otp.component.stories'; import { meta as otpMeta } from '../stories/otp.stories'; @@ -85,6 +84,31 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; +import { + Default as ReverificationBackupCodeDefault, + meta as reverificationBackupCodeMeta, +} from '../stories/reverification-backup-code.stories'; +import { + Default as ReverificationHelpDefault, + meta as reverificationHelpMeta, +} from '../stories/reverification-help.stories'; +import { + Default as ReverificationMethodPickerDefault, + meta as reverificationMethodPickerMeta, +} from '../stories/reverification-method-picker.stories'; +import { + Authenticator as ReverificationOTPAuthenticator, + Default as ReverificationOTPDefault, + meta as reverificationOTPMeta, +} from '../stories/reverification-otp.stories'; +import { + Default as ReverificationPasskeyDefault, + meta as reverificationPasskeyMeta, +} from '../stories/reverification-passkey.stories'; +import { + Default as ReverificationPasswordDefault, + meta as reverificationPasswordMeta, +} from '../stories/reverification-password.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, @@ -267,7 +291,6 @@ const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: Men const otpComponentModule: StoryModule = { meta: otpComponentMeta, Default: OtpComponentDefault, - Sizes: OtpComponentSizes, Success: OtpComponentSuccess, Error: OtpComponentError, Disabled: OtpComponentDisabled, @@ -395,6 +418,31 @@ const destructiveModule: StoryModule = { WithError: DestructiveWithError, }; +const reverificationPasswordModule: StoryModule = { + meta: reverificationPasswordMeta, + Default: ReverificationPasswordDefault, +}; +const reverificationPasskeyModule: StoryModule = { + meta: reverificationPasskeyMeta, + Default: ReverificationPasskeyDefault, +}; +const reverificationOTPModule: StoryModule = { + meta: reverificationOTPMeta, + Default: ReverificationOTPDefault, + Authenticator: ReverificationOTPAuthenticator, +}; +const reverificationBackupCodeModule: StoryModule = { + meta: reverificationBackupCodeMeta, + Default: ReverificationBackupCodeDefault, +}; +const reverificationMethodPickerModule: StoryModule = { + meta: reverificationMethodPickerMeta, + Default: ReverificationMethodPickerDefault, +}; +const reverificationHelpModule: StoryModule = { + meta: reverificationHelpMeta, + Default: ReverificationHelpDefault, +}; export const registry: StoryModule[] = [ // User Button userButtonModule, @@ -419,6 +467,12 @@ export const registry: StoryModule[] = [ userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. destructiveModule, + reverificationPasswordModule, + reverificationPasskeyModule, + reverificationOTPModule, + reverificationBackupCodeModule, + reverificationMethodPickerModule, + reverificationHelpModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index cda03df4463..a5329801279 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -32,10 +32,7 @@ function knobsAsProps(props: Record) { export function Default(props: Record) { return ( - + Login to your account Enter your email below to login to your account diff --git a/packages/swingset/src/stories/otp.component.mdx b/packages/swingset/src/stories/otp.component.mdx index 09a9b41694f..7602e48afa2 100644 --- a/packages/swingset/src/stories/otp.component.mdx +++ b/packages/swingset/src/stories/otp.component.mdx @@ -72,13 +72,6 @@ Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes, ## Examples -### Sizes - - - ### Success ) { ); } -export function Sizes() { - return ( -
- - - -
- ); -} - export function Success() { return ( diff --git a/packages/swingset/src/stories/reverification-backup-code.mdx b/packages/swingset/src/stories/reverification-backup-code.mdx new file mode 100644 index 00000000000..c029106ce75 --- /dev/null +++ b/packages/swingset/src/stories/reverification-backup-code.mdx @@ -0,0 +1,7 @@ +import * as Stories from './reverification-backup-code.stories'; + +# ReverificationBackupCode + +The backup-code card content used by the reverification flow. + + diff --git a/packages/swingset/src/stories/reverification-backup-code.stories.tsx b/packages/swingset/src/stories/reverification-backup-code.stories.tsx new file mode 100644 index 00000000000..e13338c7a83 --- /dev/null +++ b/packages/swingset/src/stories/reverification-backup-code.stories.tsx @@ -0,0 +1,39 @@ +import { ReverificationBackupCode } from '@clerk/ui/mosaic/blocks/reverification'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-backup-code.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationBackupCode', + label: 'Backup code', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx', +}; + +export function Default() { + const [value, setValue] = React.useState(''); + + return ( + + undefined} + onCancel={() => setValue('')} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-help.mdx b/packages/swingset/src/stories/reverification-help.mdx new file mode 100644 index 00000000000..acf705cc09f --- /dev/null +++ b/packages/swingset/src/stories/reverification-help.mdx @@ -0,0 +1,7 @@ +import * as Stories from './reverification-help.stories'; + +# ReverificationHelp + +The help card content used by the reverification flow. + + diff --git a/packages/swingset/src/stories/reverification-help.stories.tsx b/packages/swingset/src/stories/reverification-help.stories.tsx new file mode 100644 index 00000000000..d61b6a922be --- /dev/null +++ b/packages/swingset/src/stories/reverification-help.stories.tsx @@ -0,0 +1,33 @@ +import { ReverificationHelp } from '@clerk/ui/mosaic/blocks/reverification'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-help.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationHelp', + label: 'Help', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx', +}; + +export function Default() { + return ( + + undefined} + onBack={() => undefined} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-method-picker.mdx b/packages/swingset/src/stories/reverification-method-picker.mdx new file mode 100644 index 00000000000..8e61126b4a0 --- /dev/null +++ b/packages/swingset/src/stories/reverification-method-picker.mdx @@ -0,0 +1,7 @@ +import * as Stories from './reverification-method-picker.stories'; + +# ReverificationMethodPicker + +The alternative-method picker content used by the reverification flow. + + diff --git a/packages/swingset/src/stories/reverification-method-picker.stories.tsx b/packages/swingset/src/stories/reverification-method-picker.stories.tsx new file mode 100644 index 00000000000..4ecceab9d56 --- /dev/null +++ b/packages/swingset/src/stories/reverification-method-picker.stories.tsx @@ -0,0 +1,44 @@ +import { ReverificationMethodPicker } from '@clerk/ui/mosaic/blocks/reverification'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-method-picker.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationMethodPicker', + label: 'Method picker', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx', +}; + +export function Default() { + const [pendingMethodId, setPendingMethodId] = React.useState(); + + return ( + + undefined} + onBack={() => setPendingMethodId(undefined)} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-otp.mdx b/packages/swingset/src/stories/reverification-otp.mdx new file mode 100644 index 00000000000..39e83f34b61 --- /dev/null +++ b/packages/swingset/src/stories/reverification-otp.mdx @@ -0,0 +1,9 @@ +import * as Stories from './reverification-otp.stories'; + +# ReverificationOTP + +The one-time-code card content used by the reverification flow. + + + + diff --git a/packages/swingset/src/stories/reverification-otp.stories.tsx b/packages/swingset/src/stories/reverification-otp.stories.tsx new file mode 100644 index 00000000000..fb1bd8aef16 --- /dev/null +++ b/packages/swingset/src/stories/reverification-otp.stories.tsx @@ -0,0 +1,66 @@ +import { ReverificationOTP } from '@clerk/ui/mosaic/blocks/reverification'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-otp.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationOTP', + label: 'OTP', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx', +}; + +export function Default() { + const [value, setValue] = React.useState(''); + + return ( + + setValue(''), + }} + onValueChange={setValue} + onSubmit={() => undefined} + onCancel={() => setValue('')} + /> + + ); +} + +export function Authenticator() { + const [value, setValue] = React.useState(''); + + return ( + + undefined} + onCancel={() => setValue('')} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-passkey.mdx b/packages/swingset/src/stories/reverification-passkey.mdx new file mode 100644 index 00000000000..74d9133211b --- /dev/null +++ b/packages/swingset/src/stories/reverification-passkey.mdx @@ -0,0 +1,7 @@ +import * as Stories from './reverification-passkey.stories'; + +# ReverificationPasskey + +The passkey card content used by the reverification flow. + + diff --git a/packages/swingset/src/stories/reverification-passkey.stories.tsx b/packages/swingset/src/stories/reverification-passkey.stories.tsx new file mode 100644 index 00000000000..a0e5f48a475 --- /dev/null +++ b/packages/swingset/src/stories/reverification-passkey.stories.tsx @@ -0,0 +1,34 @@ +import { ReverificationPasskey } from '@clerk/ui/mosaic/blocks/reverification'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-passkey.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationPasskey', + label: 'Passkey', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx', +}; + +export function Default() { + return ( + + undefined} + onCancel={() => undefined} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-password.mdx b/packages/swingset/src/stories/reverification-password.mdx new file mode 100644 index 00000000000..f2d8705d7a7 --- /dev/null +++ b/packages/swingset/src/stories/reverification-password.mdx @@ -0,0 +1,7 @@ +import * as Stories from './reverification-password.stories'; + +# ReverificationPassword + +The password card content used by the reverification flow. + + diff --git a/packages/swingset/src/stories/reverification-password.stories.tsx b/packages/swingset/src/stories/reverification-password.stories.tsx new file mode 100644 index 00000000000..b7835a4e384 --- /dev/null +++ b/packages/swingset/src/stories/reverification-password.stories.tsx @@ -0,0 +1,40 @@ +import { ReverificationPassword } from '@clerk/ui/mosaic/blocks/reverification'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { ReverificationStoryCard } from './reverification-story-card'; + +export { default as __source } from './reverification-password.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationPassword', + label: 'Password', + navigation: { category: 'Reverification' }, + source: 'packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx', +}; + +export function Default() { + const [value, setValue] = React.useState(''); + + return ( + + undefined} + onCancel={() => setValue('')} + /> + + ); +} diff --git a/packages/swingset/src/stories/reverification-story-card.tsx b/packages/swingset/src/stories/reverification-story-card.tsx new file mode 100644 index 00000000000..66c2049cb54 --- /dev/null +++ b/packages/swingset/src/stories/reverification-story-card.tsx @@ -0,0 +1,6 @@ +import { Card } from '@clerk/ui/mosaic/components/card'; +import type { ReactNode } from 'react'; + +export function ReverificationStoryCard({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/packages/ui/src/mosaic/blocks/reverification/index.ts b/packages/ui/src/mosaic/blocks/reverification/index.ts new file mode 100644 index 00000000000..88fb8763027 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/index.ts @@ -0,0 +1,16 @@ +export { ReverificationBackupCode } from './reverification-backup-code'; +export type { ReverificationBackupCodeMessages, ReverificationBackupCodeProps } from './reverification-backup-code'; +export { ReverificationHelp } from './reverification-help'; +export type { ReverificationHelpMessages, ReverificationHelpProps } from './reverification-help'; +export { ReverificationMethodPicker } from './reverification-method-picker'; +export type { + ReverificationMethod, + ReverificationMethodPickerMessages, + ReverificationMethodPickerProps, +} from './reverification-method-picker'; +export { ReverificationOTP } from './reverification-otp'; +export type { ReverificationOtpMessages, ReverificationOTPProps, ReverificationOtpResend } from './reverification-otp'; +export { ReverificationPasskey } from './reverification-passkey'; +export type { ReverificationPasskeyMessages, ReverificationPasskeyProps } from './reverification-passkey'; +export { ReverificationPassword } from './reverification-password'; +export type { ReverificationPasswordMessages, ReverificationPasswordProps } from './reverification-password'; diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx new file mode 100644 index 00000000000..a38a7820596 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx @@ -0,0 +1,103 @@ +import type { FormEvent } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; + +export interface ReverificationBackupCodeMessages { + title: string; + description: string; + fieldLabel: string; + secondaryActionLabel: string; + primaryActionLabel: string; + pendingLabel: string; +} + +export interface ReverificationBackupCodeProps { + messages: ReverificationBackupCodeMessages; + value: string; + errorMessage?: string; + isPending?: boolean; + onValueChange: (value: string) => void; + onSubmit: () => void; + onCancel?: () => void; +} + +export function ReverificationBackupCode({ + messages, + value, + errorMessage, + isPending = false, + onValueChange, + onSubmit, + onCancel, +}: ReverificationBackupCodeProps) { + const formId = useId(); + const canSubmit = value.trim().length > 0; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit && !isPending) { + onSubmit(); + } + }; + + return ( + <> + + {messages.title} + {messages.description} + + +
+ + {messages.fieldLabel} + onValueChange(event.target.value)} + /> + {errorMessage ? {errorMessage} : null} + +
+
+ + {onCancel ? ( + + ) : null} + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx new file mode 100644 index 00000000000..11a883fcff6 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx @@ -0,0 +1,44 @@ +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; + +export interface ReverificationHelpMessages { + title: string; + description: string; + backButton: string; + supportButton: string; +} + +export interface ReverificationHelpProps { + messages: ReverificationHelpMessages; + onEmailSupport: () => void; + onBack: () => void; +} + +export function ReverificationHelp({ messages, onEmailSupport, onBack }: ReverificationHelpProps) { + return ( + <> + + {messages.title} + {messages.description} + + + + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx new file mode 100644 index 00000000000..ccb5a3c87a5 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx @@ -0,0 +1,105 @@ +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import type { IconProps } from '../../components/icon'; +import { Icon } from '../../components/icon'; +import { Item } from '../../components/item'; +import { Spinner } from '../../components/spinner'; +import { Text } from '../../components/text'; + +export interface ReverificationMethod { + id: string; + label: string; + description?: string; + icon?: IconProps['name']; +} + +export interface ReverificationMethodPickerMessages { + title: string; + description: string; + backButton: string; + helpText: string; + helpButton: string; +} + +export interface ReverificationMethodPickerProps { + messages: ReverificationMethodPickerMessages; + methods: readonly ReverificationMethod[]; + pendingMethodId?: string; + onSelect: (methodId: string) => void; + onHelp: () => void; + onBack?: () => void; +} + +export function ReverificationMethodPicker({ + messages, + methods, + pendingMethodId, + onSelect, + onHelp, + onBack, +}: ReverificationMethodPickerProps) { + return ( + <> + + {messages.title} + {messages.description} + + + + {methods.map(method => { + const isPending = pendingMethodId === method.id; + return ( + onSelect(method.id)} + /> + } + > + {method.icon ? ( + + + + ) : null} + + {method.label} + {method.description ? {method.description} : null} + + {isPending ? : } + + ); + })} + + {onBack ? ( + + ) : null} + + + + {messages.helpText} + + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx new file mode 100644 index 00000000000..1d66bdf80b6 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx @@ -0,0 +1,128 @@ +import type { FormEvent } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Otp } from '../../components/otp'; + +export interface ReverificationOtpMessages { + title: string; + description: string; + fieldLabel: string; + secondaryActionLabel: string; + primaryActionLabel: string; + pendingLabel: string; +} + +export interface ReverificationOtpResend { + label: string; + disabled?: boolean; + onClick: () => void; +} + +export interface ReverificationOTPProps { + messages: ReverificationOtpMessages; + value: string; + length?: number; + errorMessage?: string; + isPending?: boolean; + resend?: ReverificationOtpResend; + onValueChange: (value: string) => void; + onComplete?: (value: string) => void; + onSubmit: () => void; + onCancel?: () => void; +} + +export function ReverificationOTP({ + messages, + value, + length = 6, + errorMessage, + isPending = false, + resend, + onValueChange, + onComplete, + onSubmit, + onCancel, +}: ReverificationOTPProps) { + const formId = useId(); + const canSubmit = value.length === length; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit && !isPending) { + onSubmit(); + } + }; + + return ( + <> + + {messages.title} + {messages.description} + + +
+ + {messages.fieldLabel} + { + if (!isPending) { + onComplete?.(code); + } + }} + /> + {errorMessage ? {errorMessage} : null} + {resend ? ( + + ) : null} + +
+
+ + {onCancel ? ( + + ) : null} + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx new file mode 100644 index 00000000000..e34b49635e8 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx @@ -0,0 +1,69 @@ +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Text } from '../../components/text'; + +export interface ReverificationPasskeyMessages { + title: string; + description: string; + secondaryActionLabel: string; + primaryActionLabel: string; + pendingLabel: string; +} + +export interface ReverificationPasskeyProps { + messages: ReverificationPasskeyMessages; + errorMessage?: string; + isPending?: boolean; + onVerify: () => void; + onCancel?: () => void; +} + +export function ReverificationPasskey({ + messages, + errorMessage, + isPending = false, + onVerify, + onCancel, +}: ReverificationPasskeyProps) { + return ( + <> + + {messages.title} + {messages.description} + + {errorMessage ? ( + + + {errorMessage} + + + ) : null} + + {onCancel ? ( + + ) : null} + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx new file mode 100644 index 00000000000..b1dba085738 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx @@ -0,0 +1,103 @@ +import type { FormEvent } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; + +export interface ReverificationPasswordMessages { + title: string; + description: string; + fieldLabel: string; + fieldPlaceholder: string; + secondaryActionLabel: string; + primaryActionLabel: string; + pendingLabel: string; +} + +export interface ReverificationPasswordProps { + messages: ReverificationPasswordMessages; + value: string; + errorMessage?: string; + isPending?: boolean; + onValueChange: (value: string) => void; + onSubmit: () => void; + onCancel?: () => void; +} + +export function ReverificationPassword({ + messages, + value, + errorMessage, + isPending = false, + onValueChange, + onSubmit, + onCancel, +}: ReverificationPasswordProps) { + const formId = useId(); + const canSubmit = value.length > 0; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit && !isPending) { + onSubmit(); + } + }; + + return ( + <> + + {messages.title} + {messages.description} + + +
+ + {messages.fieldLabel} + onValueChange(event.target.value)} + /> + {errorMessage ? {errorMessage} : null} + +
+
+ + {onCancel ? ( + + ) : null} + + {messages.primaryActionLabel} + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts new file mode 100644 index 00000000000..df5ca8335e8 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts @@ -0,0 +1,84 @@ +export const reverificationBase = { + alternativeMethods: { + actionLink: 'Get help', + actionText: 'Don’t have any of these?', + blockButton__backupCode: 'Use a backup code', + blockButton__emailCode: 'Email code to {identifier}', + blockButton__passkey: 'Use your passkey', + blockButton__password: 'Continue with your password', + blockButton__phoneCode: 'Send SMS code to {identifier}', + blockButton__totp: 'Use your authenticator app', + getHelp: { + blockButton__emailSupport: 'Email support', + description: + 'If you have trouble verifying your account, email us and we will work with you to restore access as soon as possible.', + title: 'Get help', + }, + description: 'Facing issues? You can use any of these methods for verification.', + title: 'Use another method', + }, + backupCodeMfa: { + description: 'Enter the backup code you received when setting up two-step authentication', + title: 'Enter a backup code', + }, + emailCode: { + description: 'Enter the code sent to your email to continue', + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + title: 'Verification required', + }, + passkey: { + blockButton__passkey: 'Use your passkey', + description: + 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', + title: 'Use your passkey', + }, + password: { + actionLink: 'Use another method', + description: 'Enter your current password to continue', + title: 'Verification required', + }, + phoneCode: { + description: 'Enter the code sent to your phone to continue', + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + title: 'Verification required', + }, + phoneCodeMfa: { + description: 'Enter the code sent to your phone to continue', + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + title: 'Verification required', + }, + totpMfa: { + description: 'Enter the code generated by your authenticator app to continue', + formTitle: 'Verification code', + title: 'Verification required', + }, + backButton: 'Back', + footerActionLink__useAnotherMethod: 'Use another method', + formButtonPrimary: 'Continue', + formButtonReset: 'Cancel', + formFieldInputPlaceholder__password: 'Enter your password', + formFieldLabel__backupCode: 'Backup code', + formFieldLabel__password: 'Password', + identityPreviewEditButton__emailAddress: 'Edit email address', + identityPreviewEditButton__identifier: 'Edit identifier', + identityPreviewEditButton__phoneNumber: 'Edit phone number', + unstable__errors__generic: 'Something went wrong. Please try again.', + /** Announced while an attempt is in flight; the button's own label stays visible. */ + verifying: 'Verifying', +}; + +/** Substitutes `{name}`-style placeholders. Replaced by the localization layer's own formatter. */ +export function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)); +} + +/** + * Picks a plural form and fills `{count}`. English has the two forms below; the localization layer + * selects across all six categories with `Intl.PluralRules`. + */ +export function plural(forms: { one: string; other: string }, count: number): string { + return fill(count === 1 ? forms.one : forms.other, { count }); +} diff --git a/packages/ui/src/mosaic/components/card/card.markers.stylex.ts b/packages/ui/src/mosaic/components/card/card.markers.stylex.ts new file mode 100644 index 00000000000..af68532b2b9 --- /dev/null +++ b/packages/ui/src/mosaic/components/card/card.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const cardContentMarker = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts index 803a97ff991..47d2e044add 100644 --- a/packages/ui/src/mosaic/components/card/card.styles.ts +++ b/packages/ui/src/mosaic/components/card/card.styles.ts @@ -1,12 +1,14 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { cardContentMarker } from './card.markers.stylex'; export const root = stylex.create({ base: { color: colorVars['--cl-color-card-foreground'], display: 'flex', flexDirection: 'column', + maxWidth: '26.25rem', width: '100%', }, card: { @@ -85,7 +87,10 @@ export const footer = stylex.create({ justifyContent: 'space-between', borderTopColor: colorVars['--cl-color-border'], borderTopStyle: 'solid', - borderTopWidth: '1px', + borderTopWidth: { + default: '0px', + [stylex.when.siblingBefore(':where(*)', cardContentMarker)]: '1px', + }, width: '100%', }, }); diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx index 8dbdea009ca..968d99b5b84 100644 --- a/packages/ui/src/mosaic/components/card/card.tsx +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -10,6 +10,7 @@ import { Button } from '../button'; import { ClerkLogo } from '../clerk-logo'; import { Dialog, DialogContext } from '../dialog'; import { Icon } from '../icon'; +import { cardContentMarker } from './card.markers.stylex'; import * as slots from './card.styles'; type CardElevation = 'card' | 'flush' | 'overlay'; @@ -180,7 +181,12 @@ const Content = React.forwardRef>(fu render, ref, props: { - ...mergeStyleProps(themeProps('card-content'), stylex.props(reset.base, slots.content.base), className, style), + ...mergeStyleProps( + themeProps('card-content'), + stylex.props(reset.base, slots.content.base, cardContentMarker), + className, + style, + ), ...rest, }, }); diff --git a/packages/ui/src/mosaic/components/otp/index.ts b/packages/ui/src/mosaic/components/otp/index.ts index 906d579e34a..05d9ca26816 100644 --- a/packages/ui/src/mosaic/components/otp/index.ts +++ b/packages/ui/src/mosaic/components/otp/index.ts @@ -1,2 +1,2 @@ export { Otp } from './otp'; -export type { OtpProps, OtpSize, OtpStatus } from './otp'; +export type { OtpProps, OtpStatus } from './otp'; diff --git a/packages/ui/src/mosaic/components/otp/otp.styles.ts b/packages/ui/src/mosaic/components/otp/otp.styles.ts index 1235840dad4..7996578d182 100644 --- a/packages/ui/src/mosaic/components/otp/otp.styles.ts +++ b/packages/ui/src/mosaic/components/otp/otp.styles.ts @@ -17,17 +17,23 @@ const positiveFocusShadow = `0 0 0 3px light-dark( export const styles = stylex.create({ root: { + gap: space['2'], alignItems: 'center', display: 'flex', - width: 'fit-content', + width: '100%', }, slot: { + borderRadius: radiusVars['--cl-radius-lg'], caretColor: colorVars['--cl-color-primary'], color: colorVars['--cl-color-card-foreground'], fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-lg-size'], fontVariantNumeric: 'tabular-nums', fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-lg-leading'], textAlign: 'center', + height: space['14'], + width: '100%', }, // The success cell restates the focus shadow so a verified code keeps its colour // while focused, mirroring how the shared surface handles `aria-invalid`. @@ -49,42 +55,3 @@ export const styles = stylex.create({ minWidth: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, }, }); - -export const rootSizes = stylex.create({ - sm: { gap: space['1.5'] }, - md: { gap: space['2'] }, - lg: { gap: space['2'] }, -}); - -export const slotSizes = stylex.create({ - sm: { - borderRadius: radiusVars['--cl-radius-md'], - fontSize: { - default: typeScaleVars['--cl-text-sm-size'], - '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, - }, - lineHeight: typeScaleVars['--cl-text-sm-leading'], - height: space['8'], - width: space['8'], - }, - md: { - borderRadius: radiusVars['--cl-radius-lg'], - fontSize: { - default: typeScaleVars['--cl-text-base-size'], - '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-base-size']})`, - }, - lineHeight: typeScaleVars['--cl-text-base-leading'], - height: space['10'], - width: space['10'], - }, - lg: { - borderRadius: radiusVars['--cl-radius-lg'], - fontSize: { - default: typeScaleVars['--cl-text-lg-size'], - '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-lg-size']})`, - }, - lineHeight: typeScaleVars['--cl-text-lg-leading'], - height: space['12'], - width: space['12'], - }, -}); diff --git a/packages/ui/src/mosaic/components/otp/otp.test.tsx b/packages/ui/src/mosaic/components/otp/otp.test.tsx index c4b8d057790..510e6e39fce 100644 --- a/packages/ui/src/mosaic/components/otp/otp.test.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.test.tsx @@ -9,7 +9,7 @@ import { Otp } from './otp'; const slots = () => screen.getAllByRole('textbox'); describe('Mosaic Otp', () => { - it('renders one slot per character and applies the default variants', () => { + it('renders one slot per character and applies the default status', () => { render( { const group = screen.getByRole('group', { name: 'Verification code' }); expect(group).toHaveClass('cl-otp'); - expect(group).toHaveAttribute('data-size', 'md'); expect(group).toHaveAttribute('data-status', 'neutral'); expect(slots()).toHaveLength(6); for (const slot of slots()) { expect(slot).toHaveClass('cl-otp-slot'); - expect(slot).toHaveAttribute('data-size', 'md'); } }); @@ -50,20 +48,6 @@ describe('Mosaic Otp', () => { expect(slots()).toHaveLength(6); }); - it.each(['sm', 'md', 'lg'] as const)('reflects the %s size on the group and every slot', size => { - render( - , - ); - expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-size', size); - for (const slot of slots()) { - expect(slot).toHaveAttribute('data-size', size); - } - }); - it('marks every slot invalid when the status is error', () => { render( { /** The number of boxes in the code. @default 6 */ length?: number; - size?: OtpSize; /** Colours every slot for the verification outcome. Defaults to the enclosing `Field`'s validity. */ status?: OtpStatus; } -function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { +function OtpSlots({ status }: { status: OtpStatus }) { const { slots, disabled } = Primitive.useOtp(); return slots.map(slot => ( @@ -31,13 +28,12 @@ function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { index={slot.index} aria-invalid={status === 'error' ? true : undefined} {...mergeStyleProps( - themeProps('otp-slot', { size, status, disabled }), + themeProps('otp-slot', { status, disabled }), stylex.props( reset.base, inputStyles.base, styles.slot, styles.touchTarget, - slotSizes[size], status === 'success' && styles.success, disabled && inputStyles.disabled, ), @@ -52,7 +48,6 @@ function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { */ export function Otp({ length = 6, - size = 'md', status: statusProp, disabled: disabledProp, 'aria-invalid': ariaInvalidProp, @@ -75,17 +70,11 @@ export function Otp({ {...rest} length={length} disabled={disabled} - {...mergeStyleProps( - themeProps('otp', { size, status, disabled }), - stylex.props(reset.base, styles.root, rootSizes[size]), - )} + {...mergeStyleProps(themeProps('otp', { status, disabled }), stylex.props(reset.base, styles.root))} aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy} aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy} > - + ); } diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 482ad30ea6e..1c47fa8110e 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -68,7 +68,7 @@ export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; export { Otp } from '../components/otp'; -export type { OtpProps, OtpSize, OtpStatus } from '../components/otp'; +export type { OtpProps, OtpStatus } from '../components/otp'; export type { MenuItemProps, MenuLabelProps,