-
Notifications
You must be signed in to change notification settings - Fork 469
feat(ui): Add reverification block #9577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c61f7e4
3de0179
395add6
c7f1fea
34f9152
750d89b
79abc60
d5b3f60
761cded
7345ae1
a3e8a58
f4302c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import * as Stories from './reverification.stories'; | ||
|
|
||
| # Reverification | ||
|
|
||
| The interaction that asks a user to prove who they are before a sensitive action. `Reverification` renders the standalone interaction; `ReverificationDialogContent` adds dialog header and footer chrome without owning a dialog root. | ||
|
|
||
| ## Example | ||
|
|
||
| The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationView`, whose controller drives method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation against stubbed operations. | ||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={Stories} | ||
| composition={[ | ||
| { name: 'Dialog', href: '/components/dialog', layer: 'Components' }, | ||
| { name: 'Card', href: '/components/card', layer: 'Components' }, | ||
| { name: 'Field', href: '/components/field', layer: 'Components' }, | ||
| { name: 'Input', href: '/components/input', layer: 'Components' }, | ||
| { name: 'Button', href: '/components/button', layer: 'Components' }, | ||
| { name: 'Heading', href: '/components/heading', layer: 'Components' }, | ||
| { name: 'Text', href: '/components/text', layer: 'Components' }, | ||
| ]} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| The block holds nothing. Every label and enabled/disabled decision belongs to the caller, so a step renders identically whether it was reached from a controller or from a story. `step` picks which one is showing. | ||
|
|
||
| ```tsx | ||
| import { Reverification } from '@clerk/ui/mosaic/blocks/reverification'; | ||
|
|
||
| <Reverification | ||
| step='verify' | ||
| field={{ label: 'Password', kind: 'password', value, disabled: false, onChange: setValue }} | ||
| canSubmit={value.length > 0} | ||
| isPending={isPending} | ||
| onSubmit={submit} | ||
| />; | ||
| ``` | ||
|
|
||
| For dialog use, render `ReverificationDialogContent` inside the owning `Dialog.Root`. It composes the same interaction with the title, description, close control, and actions. The action sits in the footer outside the field's form, so pressing Enter submits the same way the button does. | ||
| A reverification is raised by something the user has already started — deleting an account, revoking a session — so it | ||
| opens over the dialog that asked, not over the page. That makes it a stacked surface, and per the | ||
| [Dialog](/components/dialog) page's "Nested dialogs and stacks", the thing that opens is always a `prompt`. | ||
|
|
||
| `ReverificationDialogContent` deliberately does not choose a size or create a portal. The owning dialog decides whether | ||
| the surface is root-level or stacked. | ||
|
Comment on lines
+41
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Clarify which component the stacking guidance applies to. Lines 42-44 state that the surface is always a A reader who follows this page and mounts 🤖 Prompt for AI Agents |
||
|
|
||
| ## Props | ||
|
|
||
| Shared by every step: | ||
|
|
||
| | Prop | Type | Description | | ||
| | ------- | ----------------------------------- | -------------------------------------------------------------------------------------- | | ||
| | `step` | `'choose' \| 'verify' \| 'message'` | Which step is showing. Picks the rest of the props. | | ||
| | `error` | `string` | Optional. A failure that belongs to the step rather than to a field. Read as an alert. | | ||
|
|
||
| `step='choose'` — pick a method: | ||
|
|
||
| | Prop | Type | Description | | ||
| | ---------------- | ---------------------- | ------------------------------------------------------------------- | | ||
| | `methods` | `{ id, label }[]` | The methods to offer. `id` is opaque and handed straight back. | | ||
| | `onSelectMethod` | `(id: string) => void` | Asks the caller to switch to that method. | | ||
| | `back` | `{ label, onClick }` | Optional. Returns to the method the user came from. | | ||
| | `help` | `{ text, action }` | Support prompt and action for a user who has none of these methods. | | ||
|
|
||
| `step='verify'` — satisfy one method: | ||
|
|
||
| | Prop | Type | Description | | ||
| | -------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | ||
| | `identifier` | `string` | Optional. Where the code went, e.g. a redacted phone number. | | ||
| | `field` | `{ label, kind, value, disabled, error?, onChange }` | Optional. Omit for a method with nothing to type, such as a passkey. `kind` is `'code' \| 'password' \| 'text'`. | | ||
| | `resend` | `{ label, disabled, onResend }` | Optional. The caller composes the label, countdown included. | | ||
| | `submitLabel` | `string` | The primary button's label. | | ||
| | `pendingLabel` | `string` | Accessible name for the pending indicator on that button. | | ||
| | `canSubmit` | `boolean` | Holds the action inert until the caller says the answer is submittable. | | ||
| | `isPending` | `boolean` | Renders the action pending and blocks a second submit. | | ||
| | `onSubmit` | `() => void` | Asks the caller to check the answer. Reached by the button or by Enter. | | ||
| | `cancelLabel` | `string` | The dismiss button's label. | | ||
| | `alternative` | `{ label, onClick }` | Optional. Navigates to another verification method within the card content. | | ||
| | `help` | `{ text, action }` | Optional. Support escalation rendered separately in the card footer. | | ||
|
|
||
| `step='message'` — a dead end: | ||
|
|
||
| | Prop | Type | Description | | ||
| | ----------- | -------------------- | ------------------------------------------------------------- | | ||
| | `action` | `{ label, onClick }` | The way forward — reaching a human. The primary button. | | ||
| | `secondary` | `{ label, onClick }` | Optional. A secondary action such as returning or cancelling. | | ||
|
|
||
| ## Driving it from a controller | ||
|
|
||
| `ReverificationView` wires the block to `reverificationController`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. | ||
|
|
||
| `onComplete` is awaited: the dialog stays up and pending until it resolves, so the session is active before whatever asked for reverification runs again. If it rejects, the verified result is retained and the user can retry completion without answering the factor again. | ||
|
|
||
| ```tsx | ||
| import { ReverificationView } from '@clerk/ui/mosaic/blocks/reverification'; | ||
|
|
||
| <ReverificationView | ||
| initialChallenge={{ status: 'needs_first_factor', factors, initialFactor }} | ||
| prepare={factor => session.prepareFirstFactorVerification(factor)} | ||
| attempt={attempt => session.attemptFirstFactorVerification(attempt)} | ||
| onComplete={async result => { | ||
| await setActive({ session: result.sessionId }); | ||
| afterVerification(); | ||
| }} | ||
| onCancel={closeModal} | ||
| supportEmail={supportEmail} | ||
| />; | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| import type { | ||
| ReverificationAttempt, | ||
| ReverificationAttemptResult, | ||
| ReverificationChallenge, | ||
| ReverificationCompleteResult, | ||
| ReverificationEmailCodeFactor, | ||
| ReverificationFirstFactor, | ||
| ReverificationFirstFactorPhoneCodeFactor, | ||
| ReverificationPasskeyFactor, | ||
| ReverificationPasswordFactor, | ||
| ReverificationPreparationFactor, | ||
| ReverificationSecondFactor, | ||
| ReverificationSecondFactorPhoneCodeFactor, | ||
| } from '@clerk/ui/mosaic/blocks/reverification'; | ||
| import { ReverificationView } from '@clerk/ui/mosaic/blocks/reverification'; | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import React from 'react'; | ||
|
|
||
| import type { StoryMeta } from '@/lib/types'; | ||
|
|
||
| export { default as __source } from './reverification.stories?raw'; | ||
|
|
||
| export const meta: StoryMeta = { | ||
| group: 'Blocks', | ||
| title: 'Reverification', | ||
| source: 'packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx', | ||
| }; | ||
|
|
||
| const passwordFactor: ReverificationPasswordFactor = { | ||
| stage: 'first', | ||
| strategy: 'password', | ||
| }; | ||
|
|
||
| const emailFactor: ReverificationEmailCodeFactor = { | ||
| stage: 'first', | ||
| strategy: 'email_code', | ||
| emailAddressId: 'email_1', | ||
| safeIdentifier: 'a••••@clerk.dev', | ||
| }; | ||
|
|
||
| const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { | ||
| stage: 'first', | ||
| strategy: 'phone_code', | ||
| phoneNumberId: 'phone_1', | ||
| safeIdentifier: '••••4242', | ||
| }; | ||
|
|
||
| const passkeyFactor: ReverificationPasskeyFactor = { | ||
| stage: 'first', | ||
| strategy: 'passkey', | ||
| }; | ||
|
|
||
| const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { | ||
| stage: 'second', | ||
| strategy: 'phone_code', | ||
| phoneNumberId: 'phone_2', | ||
| safeIdentifier: '••••8675', | ||
| }; | ||
|
|
||
| const secondFactors: ReverificationSecondFactor[] = [ | ||
| secondPhoneFactor, | ||
| { stage: 'second', strategy: 'totp' }, | ||
| { stage: 'second', strategy: 'backup_code' }, | ||
| ]; | ||
|
|
||
| const firstFactors: ReverificationFirstFactor[] = [passwordFactor, emailFactor, firstPhoneFactor, passkeyFactor]; | ||
|
|
||
| // Only the launch buttons need these. The dialog names a method from its own messages. | ||
| const factorStoryDetails = (factor: ReverificationFirstFactor | ReverificationSecondFactor) => { | ||
| switch (factor.strategy) { | ||
| case 'email_code': | ||
| return { id: factor.emailAddressId, name: 'email code' }; | ||
| case 'phone_code': | ||
| return { id: factor.phoneNumberId, name: 'SMS code' }; | ||
| case 'totp': | ||
| return { id: factor.strategy, name: 'authenticator app' }; | ||
| case 'backup_code': | ||
| return { id: factor.strategy, name: 'backup code' }; | ||
| default: | ||
| return { id: factor.strategy, name: factor.strategy }; | ||
| } | ||
| }; | ||
|
|
||
| interface Scenario { | ||
| id: string; | ||
| label: string; | ||
| challenge: ReverificationChallenge; | ||
| continuesToSecondFactor?: boolean; | ||
| } | ||
|
|
||
| const scenarios: Scenario[] = [ | ||
| { | ||
| id: 'choose-first', | ||
| label: 'First factor — choose method', | ||
| challenge: { status: 'needs_first_factor', factors: firstFactors }, | ||
| }, | ||
| ...firstFactors.map(factor => { | ||
| const details = factorStoryDetails(factor); | ||
| return { | ||
| id: `first-${details.id}`, | ||
| label: `First factor — ${details.name}`, | ||
| challenge: { status: 'needs_first_factor' as const, factors: firstFactors, initialFactor: factor }, | ||
| }; | ||
| }), | ||
| { | ||
| id: 'first-then-second', | ||
| label: 'First factor → second factor', | ||
| challenge: { status: 'needs_first_factor', factors: firstFactors, initialFactor: passwordFactor }, | ||
| continuesToSecondFactor: true, | ||
| }, | ||
| { | ||
| id: 'choose-second', | ||
| label: 'Second factor — choose method', | ||
| challenge: { status: 'needs_second_factor', factors: secondFactors }, | ||
| }, | ||
| ...secondFactors.map(factor => { | ||
| const details = factorStoryDetails(factor); | ||
| return { | ||
| id: `second-${details.id}`, | ||
| label: `Second factor — ${details.name}`, | ||
| challenge: { status: 'needs_second_factor' as const, factors: secondFactors, initialFactor: factor }, | ||
| }; | ||
| }), | ||
| ]; | ||
|
|
||
| const settleAfter = (ms: number) => new Promise<void>(resolve => window.setTimeout(resolve, ms)); | ||
|
|
||
| function ControllerDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onFinished: () => void }) { | ||
| const prepare = React.useCallback(async (_factor: ReverificationPreparationFactor) => { | ||
| await settleAfter(600); | ||
| }, []); | ||
| const attempt = React.useCallback( | ||
| async (attemptValue: ReverificationAttempt): Promise<ReverificationAttemptResult> => { | ||
| await settleAfter(800); | ||
| if (scenario.continuesToSecondFactor && attemptValue.factor.stage === 'first') { | ||
| return { status: 'needs_second_factor', factors: secondFactors }; | ||
| } | ||
| return { status: 'complete', sessionId: 'sess_story' }; | ||
| }, | ||
| [scenario.continuesToSecondFactor], | ||
| ); | ||
| // The view finishes in a final state, so the story unmounts it to make the demo repeatable. | ||
| // Deferred a tick because the controller reports cancellation from inside its own transition. | ||
| const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); | ||
| // Stands in for activating the session, which the dialog waits out before it closes. | ||
| const onComplete = React.useCallback( | ||
| async (_result: ReverificationCompleteResult) => { | ||
| await settleAfter(800); | ||
| finish(); | ||
| }, | ||
| [finish], | ||
| ); | ||
|
|
||
| return ( | ||
| <ReverificationView | ||
| initialChallenge={scenario.challenge} | ||
| prepare={prepare} | ||
| attempt={attempt} | ||
| onComplete={onComplete} | ||
| onCancel={finish} | ||
| supportEmail='support@clerk.dev' | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function Default() { | ||
| const [active, setActive] = React.useState<{ scenario: Scenario; runId: number } | null>(null); | ||
| const runIdRef = React.useRef(0); | ||
|
|
||
| const openScenario = (scenario: Scenario) => { | ||
| runIdRef.current += 1; | ||
| setActive({ scenario, runId: runIdRef.current }); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> | ||
| {scenarios.map(scenario => ( | ||
| <Button | ||
| key={scenario.id} | ||
| aria-haspopup='dialog' | ||
| variant='outline' | ||
| onClick={() => openScenario(scenario)} | ||
| > | ||
| {scenario.label} | ||
| </Button> | ||
| ))} | ||
| </div> | ||
| {active ? ( | ||
| <ControllerDrivenDialog | ||
| key={active.runId} | ||
| scenario={active.scenario} | ||
| onFinished={() => setActive(null)} | ||
| /> | ||
| ) : null} | ||
| </> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: clerk/javascript
Length of output: 3606
Use an exported
Blocksexample name.The
Blocksrow namesReverificationDialog, but the story title isReverification, and the barrel exportsReverification,ReverificationDialogContent, andReverificationView. Update the row to useReverification.🤖 Prompt for AI Agents