2026-04-27 14:53:05 +02:00
import { useState } from 'react' ;
2026-04-27 17:01:58 +02:00
import { useLogto } from '@logto/react' ;
2026-04-27 14:53:05 +02:00
import { errorMessage } from '../../api/client' ;
import {
Alert ,
Button ,
Card ,
2026-04-27 18:10:47 +02:00
FormField ,
2026-04-27 14:53:05 +02:00
Input ,
2026-04-27 18:10:47 +02:00
Modal ,
2026-04-27 14:53:05 +02:00
useToast ,
} from '@cameleer/design-system' ;
import {
useAccountMfaStatus ,
useAccountPasskeyList ,
useAccountRenamePasskey ,
useAccountDeletePasskey ,
} from '../../api/account-hooks' ;
2026-04-27 17:01:58 +02:00
import { registerPasskey } from '../../api/logto-account-api' ;
2026-04-27 14:53:05 +02:00
import styles from '../../styles/platform.module.css' ;
export function PasskeyNudgeBanner() {
const { data : status } = useAccountMfaStatus ( ) ;
const [ dismissed , setDismissed ] = useState ( false ) ;
const lastDismissed = localStorage . getItem ( 'passkey_nudge_dismissed' ) ;
const recentlyDismissed = lastDismissed && ( Date . now ( ) - Number ( lastDismissed ) ) < 30 * 24 * 60 * 60 * 1000 ;
if ( dismissed || recentlyDismissed || ! status || status . passkeyEnrolled ) return null ;
function handleDismiss() {
localStorage . setItem ( 'passkey_nudge_dismissed' , String ( Date . now ( ) ) ) ;
setDismissed ( true ) ;
}
return (
< Alert variant = "info" title = "Sign in faster with a passkey" >
< p style = { { margin : '4px 0 12px' } } >
Use your fingerprint , face , or security key instead of typing a code every time .
< / p >
< Button size = "sm" variant = "secondary" onClick = { handleDismiss } > Not now < / Button >
< / Alert >
) ;
}
export function PasskeySection() {
const { toast } = useToast ( ) ;
2026-04-27 17:01:58 +02:00
const { getAccessToken } = useLogto ( ) ;
const { data : passkeys , isLoading , refetch } = useAccountPasskeyList ( ) ;
2026-04-27 14:53:05 +02:00
const renamePasskey = useAccountRenamePasskey ( ) ;
const deletePasskey = useAccountDeletePasskey ( ) ;
const [ editingId , setEditingId ] = useState < string | null > ( null ) ;
const [ editName , setEditName ] = useState ( '' ) ;
const [ confirmDeleteId , setConfirmDeleteId ] = useState < string | null > ( null ) ;
2026-04-27 17:01:58 +02:00
const [ registering , setRegistering ] = useState ( false ) ;
2026-04-27 18:10:47 +02:00
const [ showPasswordModal , setShowPasswordModal ] = useState ( false ) ;
const [ regPassword , setRegPassword ] = useState ( '' ) ;
const [ regError , setRegError ] = useState < string | null > ( null ) ;
2026-04-27 14:53:05 +02:00
function parseAgent ( agent : string | null ) : string {
if ( ! agent ) return 'Unknown device' ;
if ( agent . includes ( 'Chrome' ) ) return agent . includes ( 'Windows' ) ? 'Chrome on Windows' : agent . includes ( 'Mac' ) ? 'Chrome on macOS' : agent . includes ( 'Android' ) ? 'Chrome on Android' : 'Chrome' ;
if ( agent . includes ( 'Safari' ) && ! agent . includes ( 'Chrome' ) ) return agent . includes ( 'iPhone' ) ? 'Safari on iPhone' : 'Safari on macOS' ;
if ( agent . includes ( 'Firefox' ) ) return 'Firefox' ;
if ( agent . includes ( 'Edge' ) ) return 'Edge' ;
return 'Browser' ;
}
function startRename ( id : string , currentName : string | null ) {
setEditingId ( id ) ;
setEditName ( currentName ? ? '' ) ;
}
async function handleRename ( id : string ) {
try {
await renamePasskey . mutateAsync ( { id , name : editName } ) ;
setEditingId ( null ) ;
toast ( { title : 'Passkey renamed' , variant : 'success' } ) ;
} catch ( err ) {
toast ( { title : 'Failed to rename passkey' , description : errorMessage ( err ) , variant : 'error' } ) ;
}
}
2026-04-27 18:10:47 +02:00
function openRegister() {
setRegPassword ( '' ) ;
setRegError ( null ) ;
setShowPasswordModal ( true ) ;
}
async function handleRegister ( e : React.FormEvent ) {
e . preventDefault ( ) ;
setRegError ( null ) ;
2026-04-27 17:01:58 +02:00
setRegistering ( true ) ;
try {
await registerPasskey ( async ( ) = > {
const token = await getAccessToken ( ) ;
if ( ! token ) throw new Error ( 'Not authenticated' ) ;
return token ;
2026-04-27 18:10:47 +02:00
} , regPassword ) ;
setShowPasswordModal ( false ) ;
2026-04-27 17:01:58 +02:00
await refetch ( ) ;
toast ( { title : 'Passkey registered' , variant : 'success' } ) ;
} catch ( err ) {
// User cancelled the WebAuthn prompt — not an error
2026-04-27 18:10:47 +02:00
if ( err instanceof Error && err . name === 'NotAllowedError' ) {
setRegistering ( false ) ;
return ;
}
setRegError ( errorMessage ( err ) ) ;
2026-04-27 17:01:58 +02:00
} finally {
setRegistering ( false ) ;
}
}
2026-04-27 14:53:05 +02:00
async function handleDelete ( id : string ) {
try {
await deletePasskey . mutateAsync ( id ) ;
setConfirmDeleteId ( null ) ;
toast ( { title : 'Passkey removed' , variant : 'success' } ) ;
} catch ( err ) {
toast ( { title : 'Failed to remove passkey' , description : errorMessage ( err ) , variant : 'error' } ) ;
}
}
if ( isLoading ) return null ;
const credentials = passkeys ? ? [ ] ;
return (
2026-04-27 18:10:47 +02:00
< >
2026-04-27 14:53:05 +02:00
< Card title = "Passkeys" >
< p className = { styles . description } style = { { marginTop : 0 } } >
Use your fingerprint , face , or security key to sign in faster .
< / p >
2026-04-27 17:01:58 +02:00
< div style = { { marginBottom : 12 } } >
2026-04-27 18:10:47 +02:00
< Button variant = "primary" onClick = { openRegister } >
2026-04-27 17:01:58 +02:00
Register passkey
< / Button >
< / div >
2026-04-27 14:53:05 +02:00
{ credentials . length === 0 ? (
< p style = { { color : 'var(--text-muted)' , fontSize : '0.875rem' } } >
2026-04-27 17:01:58 +02:00
No passkeys registered yet .
2026-04-27 14:53:05 +02:00
< / p >
) : (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 12 } } >
{ credentials . map ( ( pk ) = > (
< div key = { pk . id } style = { { display : 'flex' , alignItems : 'center' , gap : 12 , padding : '8px 0' , borderBottom : '1px solid var(--border)' } } >
< div style = { { flex : 1 } } >
{ editingId === pk . id ? (
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< Input value = { editName } onChange = { ( e ) = > setEditName ( e . target . value ) } placeholder = "Passkey name" style = { { maxWidth : 200 } } / >
< Button size = "sm" variant = "primary" onClick = { ( ) = > handleRename ( pk . id ) } loading = { renamePasskey . isPending } > Save < / Button >
< Button size = "sm" variant = "secondary" onClick = { ( ) = > setEditingId ( null ) } > Cancel < / Button >
< / div >
) : (
< >
< div style = { { fontWeight : 500 } } > { pk . name || 'Unnamed passkey' } < / div >
< div style = { { fontSize : '0.75rem' , color : 'var(--text-muted)' } } >
{ parseAgent ( pk . agent ) } & middot ; Added { pk . createdAt ? new Date ( pk . createdAt ) . toLocaleDateString ( ) : 'unknown' }
< / div >
< / >
) }
< / div >
{ editingId !== pk . id && (
< div style = { { display : 'flex' , gap : 8 } } >
< Button size = "sm" variant = "secondary" onClick = { ( ) = > startRename ( pk . id , pk . name ) } > Rename < / Button >
{ confirmDeleteId === pk . id ? (
< >
< Button size = "sm" variant = "danger" onClick = { ( ) = > handleDelete ( pk . id ) } loading = { deletePasskey . isPending } > Confirm < / Button >
< Button size = "sm" variant = "secondary" onClick = { ( ) = > setConfirmDeleteId ( null ) } > Cancel < / Button >
< / >
) : (
< Button size = "sm" variant = "danger" onClick = { ( ) = > setConfirmDeleteId ( pk . id ) } > Remove < / Button >
) }
< / div >
) }
< / div >
) ) }
< / div >
) }
< / Card >
2026-04-27 18:10:47 +02:00
< Modal
open = { showPasswordModal }
onClose = { ( ) = > { if ( ! registering ) setShowPasswordModal ( false ) ; } }
title = "Confirm identity"
size = "sm"
>
< p className = { styles . description } style = { { marginTop : 0 } } >
Enter your password to register a new passkey .
< / p >
{ regError && < Alert variant = "error" > { regError } < / Alert > }
< form onSubmit = { handleRegister } style = { { display : 'flex' , flexDirection : 'column' , gap : 16 } } >
< FormField label = "Password" htmlFor = "passkey-reg-password" >
< Input
id = "passkey-reg-password"
type = "password"
value = { regPassword }
onChange = { ( e ) = > setRegPassword ( e . target . value ) }
placeholder = "Enter your password"
autoFocus
autoComplete = "current-password"
/ >
< / FormField >
< div style = { { display : 'flex' , gap : 8 } } >
< Button type = "submit" variant = "primary" loading = { registering } disabled = { ! regPassword } >
Continue
< / Button >
< Button type = "button" variant = "secondary" onClick = { ( ) = > setShowPasswordModal ( false ) } disabled = { registering } >
Cancel
< / Button >
< / div >
< / form >
< / Modal >
< / >
2026-04-27 14:53:05 +02:00
) ;
}