This commit implements comprehensive localization for high-priority components: ## Tracking Pages (4 files) - Localized feeding, sleep, diaper, and medicine tracking pages - Replaced hardcoded strings with translation keys from tracking namespace - Added useTranslation hook integration - All form labels, buttons, and messages now support multiple languages ## Child Dialog Components (2 files) - Localized ChildDialog (add/edit child form) - Localized DeleteConfirmDialog - Added new translation keys to children.json for dialog content - Includes validation messages and action buttons ## Date/Time Localization (14 files + new hook) - Created useLocalizedDate hook wrapping date-fns with locale support - Supports 5 languages: English, Spanish, French, Portuguese, Chinese - Updated all date formatting across: * Tracking pages (feeding, sleep, diaper, medicine) * Activity pages (activities, history, track activity) * Settings components (sessions, biometric, device trust) * Analytics components (insights, growth, sleep chart, feeding graph) - Date displays automatically adapt to user's language (e.g., "2 hours ago" → "hace 2 horas") ## Translation Updates - Enhanced children.json with dialog section containing: * Form field labels (name, birthDate, gender, photoUrl) * Action buttons (add, update, delete, cancel, saving, deleting) * Delete confirmation messages * Validation error messages Files changed: 17 files (+164, -113) Languages supported: en, es, fr, pt-BR, zh-CN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
'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 { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
|
|
|
export function BiometricSettings() {
|
|
const { formatDistanceToNow } = useLocalizedDate();
|
|
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>
|
|
</>
|
|
);
|
|
}
|