11import { FC , useEffect , useState } from 'react' ;
2- import { VerifyOTPRequest } from '@authorizerdev/authorizer-js' ;
2+ import { VerifyOTPRequest , isWebauthnSupported } from '@authorizerdev/authorizer-js' ;
33import '../styles/default.css' ;
44
55import { ButtonAppearance , MessageType , Views } from '../constants' ;
@@ -8,6 +8,9 @@ import { StyledButton, StyledFooter, StyledLink } from '../styledComponents';
88import { Message } from './Message' ;
99import { TotpDataType } from '../types' ;
1010import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner' ;
11+ import { AuthorizerMfaLocked } from './AuthorizerMfaLocked' ;
12+ import { IconPasskey } from '../icons/mfa' ;
13+ import { resolveAuthStep } from '../utils/mfaTriage' ;
1114
1215interface InputDataType {
1316 otp : string | null ;
@@ -29,7 +32,18 @@ export const AuthorizerVerifyOtp: FC<{
2932 phone_number ?: string ;
3033 urlProps ?: Record < string , any > ;
3134 is_totp ?: boolean ;
32- } > = ( { setView, onLogin, email, phone_number, urlProps, is_totp } ) => {
35+ offerWebauthnVerify ?: boolean ;
36+ hasCodeFactor ?: boolean ;
37+ } > = ( {
38+ setView,
39+ onLogin,
40+ email,
41+ phone_number,
42+ urlProps,
43+ is_totp,
44+ offerWebauthnVerify,
45+ hasCodeFactor,
46+ } ) => {
3347 const [ error , setError ] = useState ( `` ) ;
3448 const [ successMessage , setSuccessMessage ] = useState ( `` ) ;
3549 const [ loading , setLoading ] = useState ( false ) ;
@@ -49,6 +63,76 @@ export const AuthorizerVerifyOtp: FC<{
4963 }
5064 } , [ ] ) ;
5165
66+ const [ webauthnError , setWebauthnError ] = useState ( `` ) ;
67+ const [ webauthnLoading , setWebauthnLoading ] = useState ( false ) ;
68+ const [ webauthnLocked , setWebauthnLocked ] = useState ( false ) ;
69+ const passkeySupported = isWebauthnSupported ( ) ;
70+
71+ // A cancelled ceremony surfaces as NotAllowedError/AbortError (same
72+ // browser behavior AuthorizerPasskeyLogin already handles) - dismiss
73+ // silently and let the user fall back to the code form when one exists.
74+ const isUserDismissed = ( e ?: { code ?: string } ) : boolean =>
75+ e ?. code === `NotAllowedError` || e ?. code === `AbortError` ;
76+
77+ const onVerifyWithPasskey = async ( ) => {
78+ setWebauthnError ( `` ) ;
79+ try {
80+ setWebauthnLoading ( true ) ;
81+ const { data : res , errors } = await authorizerRef . loginWithPasskey ( email ) ;
82+ if ( errors && errors . length ) {
83+ if ( ! isUserDismissed ( errors [ 0 ] ) ) {
84+ setWebauthnError ( errors [ 0 ] ?. message || `` ) ;
85+ }
86+ return ;
87+ }
88+ // Route through resolveAuthStep rather than treating any truthy `res`
89+ // as success - webauthn_login_verify can return the same withheld
90+ // (null access_token) shape any other login endpoint can, the exact
91+ // bug the MFA redesign fixed at every other call site.
92+ const step = resolveAuthStep ( res , errors || [ ] ) ;
93+ if ( step . kind === 'error' ) {
94+ setWebauthnError ( step . message ) ;
95+ return ;
96+ }
97+ if ( step . kind === 'locked' ) {
98+ setWebauthnLocked ( true ) ;
99+ return ;
100+ }
101+ if ( step . kind === 'offer' ) {
102+ // Unexpected here - this screen only renders once a factor is
103+ // already verified, so a first-time-offer response would mean the
104+ // server's state disagrees with what got us to this screen. Surface
105+ // it as an error rather than silently completing or guessing at a
106+ // setup UI.
107+ setWebauthnError ( `Unable to verify with passkey. Please try again.` ) ;
108+ return ;
109+ }
110+ if ( step . kind === 'verify' ) {
111+ setWebauthnError (
112+ `Additional verification is still required after passkey. Please use the code form below instead.` ,
113+ ) ;
114+ return ;
115+ }
116+ // step.kind === 'complete'
117+ setError ( `` ) ;
118+ setAuthData ( {
119+ user : step . response . user || null ,
120+ token : step . response ,
121+ config,
122+ loading : false ,
123+ } ) ;
124+ if ( onLogin ) {
125+ onLogin ( step . response ) ;
126+ }
127+ } catch ( err ) {
128+ if ( ! isUserDismissed ( err as { code ?: string } ) ) {
129+ setWebauthnError ( ( err as Error ) . message ) ;
130+ }
131+ } finally {
132+ setWebauthnLoading ( false ) ;
133+ }
134+ } ;
135+
52136 const onInputChange = async ( field : string , value : string ) => {
53137 setFormData ( { ...formData , [ field ] : value } ) ;
54138 } ;
@@ -188,6 +272,25 @@ export const AuthorizerVerifyOtp: FC<{
188272 ) ;
189273 }
190274
275+ if ( webauthnLocked ) {
276+ return < AuthorizerMfaLocked /> ;
277+ }
278+
279+ // A code-based factor (TOTP or a verified email/SMS OTP authenticator)
280+ // was offered alongside webauthn: show the passkey button first, with the
281+ // code form as a fallback below it. The code form is only hidden when no
282+ // code factor exists at all - resolveAuthStep guarantees 'verify' never
283+ // has all of totp/webauthn/email/mobile false, so !hasCodeFactor already
284+ // implies webauthn is the sole option, regardless of passkeySupported
285+ // (an unsupported browser still shouldn't render a dead-end code form -
286+ // passkeyOnlyUnsupported below covers that case with an explanation).
287+ const showCodeForm = ! ! hasCodeFactor ;
288+ // Passkey is the user's only MFA factor but this browser can't do WebAuthn:
289+ // no code factor exists to fall back to, so the code form below would be
290+ // a dead end.
291+ const passkeyOnlyUnsupported =
292+ ! ! offerWebauthnVerify && ! passkeySupported && ! hasCodeFactor ;
293+
191294 return (
192295 < >
193296 { successMessage && (
@@ -204,53 +307,105 @@ export const AuthorizerVerifyOtp: FC<{
204307 onClose = { isLockedOut ? undefined : onErrorClose }
205308 />
206309 ) }
207- < p style = { { textAlign : 'center' , margin : '10px 0px' } } >
208- Please enter the OTP sent to your email or phone number or authenticator
209- </ p >
210- < br />
211- < form onSubmit = { onSubmit } name = "authorizer-mfa-otp-form" >
212- < div className = "styled-form-group" >
213- < label className = "form-input-label" htmlFor = "authorizer-verify-otp" >
214- < span > * </ span > OTP (One Time Password)
215- </ label >
216- < input
217- name = "otp"
218- id = "authorizer-verify-otp"
219- className = { `form-input-field ${
220- errorData . otp ? 'input-error-content' : ''
221- } `}
222- placeholder = "e.g.- AB123C"
223- type = "password"
224- autoComplete = "one-time-code"
225- value = { formData . otp || '' }
226- onChange = { ( e ) => onInputChange ( 'otp' , e . target . value ) }
227- disabled = { isLockedOut }
228- />
229- { errorData . otp && (
230- < div className = "form-input-error" > { errorData . otp } </ div >
231- ) }
232- { is_totp && (
233- < Message
234- type = { MessageType . Info }
235- text = { `If you have lost access to your device, please enter recovery code that were shared while enabling Multifactor Authentication.` }
236- extraStyles = { {
237- color : 'var(--authorizer-text-color)' ,
310+ { webauthnError && (
311+ < Message
312+ type = { MessageType . Error }
313+ text = { webauthnError }
314+ onClose = { ( ) => setWebauthnError ( `` ) }
315+ />
316+ ) }
317+ { offerWebauthnVerify && passkeySupported && (
318+ < >
319+ < p style = { { textAlign : 'center' , margin : '10px 0px' } } >
320+ Verify with your passkey
321+ </ p >
322+ < StyledButton
323+ type = "button"
324+ appearance = { ButtonAppearance . Default }
325+ disabled = { webauthnLoading }
326+ onClick = { onVerifyWithPasskey }
327+ >
328+ < span
329+ style = { {
330+ display : 'inline-flex' ,
331+ alignItems : 'center' ,
332+ justifyContent : 'center' ,
333+ gap : '8px' ,
238334 } }
239- />
335+ >
336+ < IconPasskey />
337+ { webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey` }
338+ </ span >
339+ </ StyledButton >
340+ < br />
341+ </ >
342+ ) }
343+ { passkeyOnlyUnsupported && (
344+ < Message
345+ type = { MessageType . Info }
346+ text = { `This browser doesn't support passkeys. Please try again on a device or browser that does.` }
347+ extraStyles = { {
348+ color : 'var(--authorizer-text-color)' ,
349+ } }
350+ />
351+ ) }
352+ { showCodeForm && (
353+ < >
354+ { offerWebauthnVerify && passkeySupported && (
355+ < p style = { { textAlign : 'center' , margin : '10px 0px' } } >
356+ Or enter a code instead
357+ </ p >
240358 ) }
241- </ div >
242- < br />
243- < StyledButton
244- type = "submit"
245- disabled = { loading || ! formData . otp || ! ! errorData . otp || isLockedOut }
246- appearance = { ButtonAppearance . Primary }
247- >
248- { loading ? `Processing ...` : `Submit` }
249- </ StyledButton >
250- </ form >
359+ < p style = { { textAlign : 'center' , margin : '10px 0px' } } >
360+ Please enter the OTP sent to your email or phone number or authenticator
361+ </ p >
362+ < br />
363+ < form onSubmit = { onSubmit } name = "authorizer-mfa-otp-form" >
364+ < div className = "styled-form-group" >
365+ < label className = "form-input-label" htmlFor = "authorizer-verify-otp" >
366+ < span > * </ span > OTP (One Time Password)
367+ </ label >
368+ < input
369+ name = "otp"
370+ id = "authorizer-verify-otp"
371+ className = { `form-input-field ${
372+ errorData . otp ? 'input-error-content' : ''
373+ } `}
374+ placeholder = "e.g.- AB123C"
375+ type = "password"
376+ autoComplete = "one-time-code"
377+ value = { formData . otp || '' }
378+ onChange = { ( e ) => onInputChange ( 'otp' , e . target . value ) }
379+ disabled = { isLockedOut }
380+ />
381+ { errorData . otp && (
382+ < div className = "form-input-error" > { errorData . otp } </ div >
383+ ) }
384+ { is_totp && (
385+ < Message
386+ type = { MessageType . Info }
387+ text = { `If you have lost access to your device, please enter recovery code that were shared while enabling Multifactor Authentication.` }
388+ extraStyles = { {
389+ color : 'var(--authorizer-text-color)' ,
390+ } }
391+ />
392+ ) }
393+ </ div >
394+ < br />
395+ < StyledButton
396+ type = "submit"
397+ disabled = { loading || ! formData . otp || ! ! errorData . otp || isLockedOut }
398+ appearance = { ButtonAppearance . Primary }
399+ >
400+ { loading ? `Processing ...` : `Submit` }
401+ </ StyledButton >
402+ </ form >
403+ </ >
404+ ) }
251405 { setView && (
252406 < StyledFooter >
253407 { ! is_totp &&
408+ showCodeForm &&
254409 ( sendingOtp ? (
255410 < div style = { { marginBottom : '10px' } } > Sending ...</ div >
256411 ) : (
0 commit comments