2026-04-27 14:53:05 +02:00
import { useState } from 'react' ;
import { errorMessage } from '../../api/client' ;
import {
Alert ,
Button ,
Card ,
Input ,
useToast ,
} from '@cameleer/design-system' ;
import {
useAccountMfaStatus ,
useAccountPasskeyList ,
useAccountRenamePasskey ,
useAccountDeletePasskey ,
} from '../../api/account-hooks' ;
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 >
) ;
}
2026-04-27 22:36:21 +02:00
export function PasskeySection ( { bare } : { bare? : boolean } ) {
2026-04-27 14:53:05 +02:00
const { toast } = useToast ( ) ;
2026-04-27 18:33:46 +02:00
const { data : passkeys , isLoading } = 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 ) ;
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' } ) ;
}
}
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 ? ? [ ] ;
2026-04-27 22:36:21 +02:00
const content = (
< >
2026-04-27 14:53:05 +02:00
< p className = { styles . description } style = { { marginTop : 0 } } >
Use your fingerprint , face , or security key to sign in faster .
< / p >
{ credentials . length === 0 ? (
< p style = { { color : 'var(--text-muted)' , fontSize : '0.875rem' } } >
2026-04-27 18:33:46 +02:00
No passkeys registered . You can register a passkey during sign - in .
2026-04-27 14:53:05 +02:00
< / p >
) : (
2026-04-27 22:36:21 +02:00
< div style = { { maxHeight : 240 , overflowY : 'auto' , display : 'flex' , flexDirection : 'column' , gap : 12 } } >
2026-04-27 14:53:05 +02:00
{ 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 >
) }
2026-04-27 22:36:21 +02:00
< / >
2026-04-27 14:53:05 +02:00
) ;
2026-04-27 22:36:21 +02:00
return bare ? content : < Card title = "Passkeys" > { content } < / Card > ;
2026-04-27 14:53:05 +02:00
}