import { useState, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Separator } from '@/components/ui/separator'; import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useAuth } from '@/hooks/useAuth'; import type { PasskeyCredential } from '@/types'; function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return 'Never'; const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 30) return `${diffDays}d ago`; return date.toLocaleDateString(); } function formatDate(dateStr: string | null): string { if (!dateStr) return ''; return new Date(dateStr).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric', }); } function detectDeviceName(): string { const ua = navigator.userAgent; let browser = 'Browser'; if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome'; else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari'; else if (ua.includes('Firefox')) browser = 'Firefox'; else if (ua.includes('Edg')) browser = 'Edge'; let os = ''; if (ua.includes('Mac')) os = 'macOS'; else if (ua.includes('Windows')) os = 'Windows'; else if (ua.includes('Linux')) os = 'Linux'; else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS'; else if (ua.includes('Android')) os = 'Android'; return os ? `${os} \u2014 ${browser}` : browser; } interface DeleteConfirmProps { credential: PasskeyCredential; onDelete: (id: number, password: string) => void; isDeleting: boolean; } function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) { const [showPasswordDialog, setShowPasswordDialog] = useState(false); const [password, setPassword] = useState(''); const { confirming, handleClick } = useConfirmAction(() => { setShowPasswordDialog(true); }); const handleSubmitDelete = () => { onDelete(credential.id, password); setPassword(''); setShowPasswordDialog(false); }; return ( <> Remove passkey Enter your password to remove "{credential.name}".
setPassword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }} autoFocus />
); } export default function PasskeySection() { const queryClient = useQueryClient(); const { passwordlessEnabled, allowPasswordless } = useAuth(); // Registration state const [registerDialogOpen, setRegisterDialogOpen] = useState(false); const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password'); const [registerPassword, setRegisterPassword] = useState(''); const [passkeyName, setPasskeyName] = useState(''); const [pendingCredential, setPendingCredential] = useState<{ credential: string; challenge_token: string; } | null>(null); // Passwordless enable state const [enableDialogOpen, setEnableDialogOpen] = useState(false); const [enablePassword, setEnablePassword] = useState(''); // Passwordless disable state const [disableDialogOpen, setDisableDialogOpen] = useState(false); const passkeysQuery = useQuery({ queryKey: ['passkeys'], queryFn: async () => { const { data } = await api.get('/auth/passkeys'); return data; }, }); const registerMutation = useMutation({ mutationFn: async ({ password }: { password: string }) => { const { startRegistration } = await import('@simplewebauthn/browser'); // Step 1: Get registration options (requires password V-02) const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password }); // Step 2: Browser WebAuthn ceremony setCeremonyState('waiting'); const credential = await startRegistration(beginResp.options); return { credential: JSON.stringify(credential), challenge_token: beginResp.challenge_token, }; }, onSuccess: (data) => { setPendingCredential(data); setPasskeyName(detectDeviceName()); setCeremonyState('naming'); }, onError: (error: unknown) => { if (error instanceof Error && error.name === 'NotAllowedError') { toast.info('Passkey setup cancelled'); } else if (error instanceof Error && error.name === 'AbortError') { toast.info('Cancelled'); } else { toast.error(getErrorMessage(error, 'Failed to create passkey')); } setRegisterDialogOpen(false); resetRegisterState(); }, }); const completeMutation = useMutation({ mutationFn: async ({ credential, challenge_token, name }: { credential: string; challenge_token: string; name: string; }) => { const { data } = await api.post('/auth/passkeys/register/complete', { credential, challenge_token, name, }); return data; }, onSuccess: () => { toast.success('Passkey registered'); queryClient.invalidateQueries({ queryKey: ['passkeys'] }); queryClient.invalidateQueries({ queryKey: ['auth'] }); setRegisterDialogOpen(false); resetRegisterState(); }, onError: (error: unknown) => { toast.error(getErrorMessage(error, 'Failed to save passkey')); }, }); const deleteMutation = useMutation({ mutationFn: async ({ id, password }: { id: number; password: string }) => { await api.delete(`/auth/passkeys/${id}`, { data: { password } }); }, onSuccess: () => { toast.success('Passkey removed'); queryClient.invalidateQueries({ queryKey: ['passkeys'] }); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, onError: (error: unknown) => { toast.error(getErrorMessage(error, 'Failed to remove passkey')); }, }); const enablePasswordlessMutation = useMutation({ mutationFn: async ({ password }: { password: string }) => { const { data } = await api.put('/auth/passkeys/passwordless/enable', { password }); return data; }, onSuccess: () => { toast.success('Passwordless login enabled'); queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['passkeys'] }); setEnableDialogOpen(false); setEnablePassword(''); }, onError: (error: unknown) => { toast.error(getErrorMessage(error, 'Failed to enable passwordless login')); }, }); const disablePasswordlessMutation = useMutation({ mutationFn: async () => { const { startAuthentication } = await import('@simplewebauthn/browser'); const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {}); const credential = await startAuthentication(beginResp.options); const { data } = await api.put('/auth/passkeys/passwordless/disable', { credential: JSON.stringify(credential), challenge_token: beginResp.challenge_token, }); return data; }, onSuccess: () => { toast.success('Passwordless login disabled'); queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['passkeys'] }); setDisableDialogOpen(false); }, onError: (error: unknown) => { if (error instanceof Error && error.name === 'NotAllowedError') { toast.error('Passkey not recognized'); } else if (error instanceof Error && error.name === 'AbortError') { toast.info('Cancelled'); } else { toast.error(getErrorMessage(error, 'Failed to disable passwordless login')); } setDisableDialogOpen(false); // W-03: Invalidate to resync switch state after failed/cancelled ceremony queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); const resetRegisterState = useCallback(() => { setCeremonyState('password'); setRegisterPassword(''); setPasskeyName(''); setPendingCredential(null); }, []); const handleStartRegister = () => { resetRegisterState(); setRegisterDialogOpen(true); }; const handlePasswordSubmit = () => { if (!registerPassword) return; registerMutation.mutate({ password: registerPassword }); }; const handleSaveName = () => { if (!pendingCredential || !passkeyName.trim()) return; completeMutation.mutate({ ...pendingCredential, name: passkeyName.trim(), }); }; const handleDelete = (id: number, password: string) => { deleteMutation.mutate({ id, password }); }; const passkeys = passkeysQuery.data ?? []; const hasPasskeys = passkeys.length > 0; return (

Sign in with your fingerprint, face, or security key

{hasPasskeys && ( {passkeys.length} registered )}
{hasPasskeys && (
    {passkeys.map((pk) => (
  • {pk.name} {pk.backed_up && ( )}
    Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
  • ))}
)} {/* Passwordless login section — hidden when admin hasn't enabled the feature */} {(allowPasswordless || passwordlessEnabled) && } {(allowPasswordless || passwordlessEnabled) && (

Skip the password prompt and unlock the app using a passkey only.

{passkeys.length < 2 && !passwordlessEnabled && (

Requires at least 2 registered passkeys as a fallback.

)}
{ if (checked) { setEnablePassword(''); setEnableDialogOpen(true); } else { setDisableDialogOpen(true); disablePasswordlessMutation.mutate(); } }} disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending} aria-label="Toggle passwordless login" />
)} {/* Enable passwordless dialog */} { if (!open) { setEnableDialogOpen(false); setEnablePassword(''); } }}>
Enable Passwordless Login
Confirm your password to enable passkey-only login.
setEnablePassword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && enablePassword) { enablePasswordlessMutation.mutate({ password: enablePassword }); } }} autoFocus />
{/* Disable passwordless dialog */} { if (!open && !disablePasswordlessMutation.isPending) { setDisableDialogOpen(false); } }}>
Disable Passwordless Login
Verify with your passkey to disable passwordless login.
{disablePasswordlessMutation.isPending ? ( <>

Follow your browser's prompt to verify your passkey

) : (

Ready to verify your passkey

)}
{/* Registration ceremony dialog */} { if (!open) { setRegisterDialogOpen(false); resetRegisterState(); } }} >
{ceremonyState === 'password' && 'Add a passkey'} {ceremonyState === 'waiting' && 'Creating passkey'} {ceremonyState === 'naming' && 'Name your passkey'}
{ceremonyState === 'password' && ( <> Enter your password to add a passkey to your account.
setRegisterPassword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }} autoFocus />
)} {ceremonyState === 'waiting' && (

Follow your browser's prompt to create a passkey

)} {ceremonyState === 'naming' && ( <>
setPasskeyName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }} maxLength={100} autoFocus />

Give this passkey a name to help you identify it later.

)}
); }