Add biometric authentication enrollment UI

- Create biometric API client with WebAuthn methods
- Add BiometricSettings component for credential management
- Support Face ID, Touch ID, Windows Hello enrollment
- Display list of enrolled credentials with metadata
- Add/remove/rename biometric credentials
- Check browser and platform authenticator support
- Integrate into settings page with animations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 22:30:09 +00:00
parent dddb82579f
commit 6c8a50b910
5 changed files with 570 additions and 563 deletions

View File

@@ -0,0 +1,406 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Divider,
TextField,
Chip,
} from '@mui/material';
import {
Fingerprint,
Add,
Delete,
Edit,
CheckCircle,
Warning,
} from '@mui/icons-material';
import { biometricApi, type BiometricCredential } from '@/lib/api/biometric';
import { startRegistration } from '@simplewebauthn/browser';
import { motion } from 'framer-motion';
import { formatDistanceToNow } from 'date-fns';
export function BiometricSettings() {
const [credentials, setCredentials] = useState<BiometricCredential[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSupported, setIsSupported] = useState(false);
const [isPlatformAvailable, setIsPlatformAvailable] = useState(false);
// Add credential dialog
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [credentialName, setCredentialName] = useState('');
// Edit credential dialog
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [credentialToEdit, setCredentialToEdit] = useState<BiometricCredential | null>(null);
const [editName, setEditName] = useState('');
const [isEditing, setIsEditing] = useState(false);
// Delete credential dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [credentialToDelete, setCredentialToDelete] = useState<BiometricCredential | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
checkSupport();
loadCredentials();
}, []);
const checkSupport = async () => {
const supported = biometricApi.isSupported();
setIsSupported(supported);
if (supported) {
const available = await biometricApi.isPlatformAuthenticatorAvailable();
setIsPlatformAvailable(available);
}
};
const loadCredentials = async () => {
try {
setIsLoading(true);
const response = await biometricApi.getCredentials();
setCredentials(response.credentials);
setError(null);
} catch (err: any) {
console.error('Failed to load biometric credentials:', err);
setError('Failed to load biometric credentials');
} finally {
setIsLoading(false);
}
};
const handleAddCredential = async () => {
try {
setIsAdding(true);
setError(null);
// Get registration options from server
const options = await biometricApi.getRegistrationOptions(credentialName || undefined);
// Start WebAuthn registration (triggers Face ID/Touch ID/Windows Hello)
const registrationResponse = await startRegistration(options);
// Send response to server for verification
await biometricApi.verifyRegistration(registrationResponse, credentialName || undefined);
setSuccessMessage('Biometric credential added successfully!');
setAddDialogOpen(false);
setCredentialName('');
await loadCredentials();
} catch (err: any) {
console.error('Failed to add biometric credential:', err);
if (err.name === 'NotAllowedError') {
setError('Biometric authentication was cancelled');
} else if (err.name === 'NotSupportedError') {
setError('Biometric authentication is not supported on this device');
} else {
setError(err.response?.data?.message || err.message || 'Failed to add biometric credential');
}
} finally {
setIsAdding(false);
}
};
const handleEditCredential = async () => {
if (!credentialToEdit || !editName.trim()) return;
try {
setIsEditing(true);
await biometricApi.updateCredentialName(credentialToEdit.id, editName.trim());
setSuccessMessage('Credential name updated successfully');
setEditDialogOpen(false);
setCredentialToEdit(null);
setEditName('');
await loadCredentials();
} catch (err: any) {
console.error('Failed to update credential name:', err);
setError(err.response?.data?.message || 'Failed to update credential name');
} finally {
setIsEditing(false);
}
};
const handleDeleteCredential = async () => {
if (!credentialToDelete) return;
try {
setIsDeleting(true);
await biometricApi.deleteCredential(credentialToDelete.id);
setSuccessMessage('Biometric credential removed successfully');
setDeleteDialogOpen(false);
setCredentialToDelete(null);
await loadCredentials();
} catch (err: any) {
console.error('Failed to delete credential:', err);
setError(err.response?.data?.message || 'Failed to delete credential');
} finally {
setIsDeleting(false);
}
};
if (isLoading) {
return (
<Card>
<CardContent sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</CardContent>
</Card>
);
}
if (!isSupported) {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Fingerprint color="disabled" />
<Typography variant="h6" fontWeight="600" color="text.secondary">
Biometric Authentication
</Typography>
</Box>
<Alert severity="info">
Biometric authentication is not supported in your browser. Please use a modern browser like Chrome,
Safari, or Edge.
</Alert>
</CardContent>
</Card>
);
}
if (!isPlatformAvailable) {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Fingerprint color="disabled" />
<Typography variant="h6" fontWeight="600" color="text.secondary">
Biometric Authentication
</Typography>
</Box>
<Alert severity="warning" icon={<Warning />}>
No biometric authenticator found on this device. To use Face ID, Touch ID, or Windows Hello, please
ensure your device has biometric hardware enabled.
</Alert>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Fingerprint color="primary" />
<Typography variant="h6" fontWeight="600">
Biometric Authentication
</Typography>
<Chip
label={`${credentials.length} credential${credentials.length !== 1 ? 's' : ''}`}
size="small"
sx={{ ml: 'auto' }}
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Use Face ID, Touch ID, Windows Hello, or other biometric methods to sign in securely without a
password.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{successMessage && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage(null)}>
{successMessage}
</Alert>
)}
{credentials.length === 0 ? (
<Alert severity="info" sx={{ mb: 2 }}>
No biometric credentials enrolled. Add one to enable biometric sign-in.
</Alert>
) : (
<List>
{credentials.map((credential, index) => (
<Box key={credential.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">
{credential.friendlyName || 'Unnamed Credential'}
</Typography>
{credential.backedUp && (
<Chip label="Synced" color="info" size="small" icon={<CheckCircle />} />
)}
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
Device Type: {credential.deviceType || 'Unknown'}
</Typography>
<Typography variant="body2" color="text.secondary">
Added: {formatDistanceToNow(new Date(credential.createdAt))} ago
</Typography>
{credential.lastUsed && (
<Typography variant="body2" color="text.secondary">
Last used: {formatDistanceToNow(new Date(credential.lastUsed))} ago
</Typography>
)}
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
edge="end"
onClick={() => {
setCredentialToEdit(credential);
setEditName(credential.friendlyName || '');
setEditDialogOpen(true);
}}
>
<Edit />
</IconButton>
<IconButton
edge="end"
onClick={() => {
setCredentialToDelete(credential);
setDeleteDialogOpen(true);
}}
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
)}
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setAddDialogOpen(true)}
fullWidth
sx={{ mt: 2 }}
>
Add Biometric Credential
</Button>
</CardContent>
</Card>
{/* Add Credential Dialog */}
<Dialog open={addDialogOpen} onClose={() => !isAdding && setAddDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Add Biometric Credential</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
You'll be prompted to use your device's biometric authentication (Face ID, Touch ID, Windows Hello,
etc.) to create a new credential.
</Typography>
<TextField
label="Credential Name (Optional)"
value={credentialName}
onChange={(e) => setCredentialName(e.target.value)}
fullWidth
disabled={isAdding}
placeholder="e.g., My Laptop, iPhone 15"
helperText="Give this credential a friendly name to identify it later"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddDialogOpen(false)} disabled={isAdding}>
Cancel
</Button>
<Button onClick={handleAddCredential} variant="contained" disabled={isAdding}>
{isAdding ? <CircularProgress size={20} /> : 'Add Credential'}
</Button>
</DialogActions>
</Dialog>
{/* Edit Credential Dialog */}
<Dialog
open={editDialogOpen}
onClose={() => !isEditing && setEditDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Edit Credential Name</DialogTitle>
<DialogContent>
<TextField
label="Credential Name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
fullWidth
disabled={isEditing}
autoFocus
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)} disabled={isEditing}>
Cancel
</Button>
<Button onClick={handleEditCredential} variant="contained" disabled={isEditing || !editName.trim()}>
{isEditing ? <CircularProgress size={20} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Credential Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => !isDeleting && setDeleteDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Remove Biometric Credential?</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This will remove the biometric credential from your account. You won't be able to use it to sign in.
</Alert>
{credentialToDelete && (
<Typography variant="body2">
<strong>Credential:</strong> {credentialToDelete.friendlyName || 'Unnamed Credential'}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button onClick={handleDeleteCredential} variant="contained" color="error" disabled={isDeleting}>
{isDeleting ? <CircularProgress size={20} /> : 'Remove'}
</Button>
</DialogActions>
</Dialog>
</>
);
}