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:
406
maternal-web/components/settings/BiometricSettings.tsx
Normal file
406
maternal-web/components/settings/BiometricSettings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user