**Pages Localized:** - Login page: All UI strings (titles, labels, buttons, links) - Dashboard page: Welcome message, quick actions, daily summary, predictions - AppShell: Connection status and presence indicators - MobileNav: Menu items and app branding - TabBar: Bottom navigation labels **Translation Files:** - Created dashboard.json for all 5 languages (en, es, fr, pt, zh) - Enhanced common.json with navigation and connection strings - Updated i18n config to include dashboard namespace **Languages Supported:** - English, Spanish, French, Portuguese, Chinese (Simplified) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
Box,
|
|
TextField,
|
|
Button,
|
|
Typography,
|
|
Paper,
|
|
InputAdornment,
|
|
IconButton,
|
|
Divider,
|
|
Alert,
|
|
CircularProgress,
|
|
Link as MuiLink,
|
|
} from '@mui/material';
|
|
import { Visibility, VisibilityOff, Google, Apple, Fingerprint } from '@mui/icons-material';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { motion } from 'framer-motion';
|
|
import * as z from 'zod';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { MFAVerificationDialog } from '@/components/auth/MFAVerificationDialog';
|
|
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
|
import { biometricApi } from '@/lib/api/biometric';
|
|
import { startAuthentication } from '@simplewebauthn/browser';
|
|
import Link from 'next/link';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email('Invalid email address'),
|
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
});
|
|
|
|
type LoginFormData = z.infer<typeof loginSchema>;
|
|
|
|
export default function LoginPage() {
|
|
const { t } = useTranslation('auth');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
|
|
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
|
|
const [mfaRequired, setMfaRequired] = useState(false);
|
|
const [mfaData, setMfaData] = useState<{ userId: string; mfaMethod: 'totp' | 'email' } | null>(null);
|
|
const { login } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
watch,
|
|
} = useForm<LoginFormData>({
|
|
resolver: zodResolver(loginSchema),
|
|
});
|
|
|
|
const email = watch('email');
|
|
|
|
// Check biometric support on mount
|
|
useEffect(() => {
|
|
checkBiometricSupport();
|
|
}, []);
|
|
|
|
const checkBiometricSupport = async () => {
|
|
const supported = biometricApi.isSupported();
|
|
if (supported) {
|
|
const available = await biometricApi.isPlatformAuthenticatorAvailable();
|
|
setIsBiometricSupported(available);
|
|
}
|
|
};
|
|
|
|
const handleBiometricLogin = async () => {
|
|
setError(null);
|
|
setIsBiometricLoading(true);
|
|
|
|
try {
|
|
// Get authentication options from server
|
|
const options = await biometricApi.getAuthenticationOptions(email || undefined);
|
|
|
|
// Start WebAuthn authentication (triggers Face ID/Touch ID/Windows Hello)
|
|
const authenticationResponse = await startAuthentication(options);
|
|
|
|
// Send response to server for verification and get tokens
|
|
const result = await biometricApi.verifyAuthentication(
|
|
authenticationResponse,
|
|
email || undefined,
|
|
{
|
|
deviceId: authenticationResponse.id.substring(0, 10),
|
|
platform: navigator.userAgent,
|
|
}
|
|
);
|
|
|
|
// Store tokens and navigate
|
|
tokenStorage.setTokens(result.data.tokens.accessToken, result.data.tokens.refreshToken);
|
|
router.push('/');
|
|
} catch (err: any) {
|
|
console.error('Biometric login failed:', 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 || 'Biometric login failed. Please try again.');
|
|
}
|
|
} finally {
|
|
setIsBiometricLoading(false);
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (data: LoginFormData) => {
|
|
setError(null);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await login(data);
|
|
// Navigation is handled in the login function
|
|
} catch (err: any) {
|
|
// Check if MFA is required
|
|
if (err.response?.data?.mfaRequired) {
|
|
setMfaRequired(true);
|
|
setMfaData({
|
|
userId: err.response.data.userId,
|
|
mfaMethod: err.response.data.mfaMethod,
|
|
});
|
|
} else {
|
|
setError(err.message || 'Failed to login. Please check your credentials.');
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleMFAVerified = (tokens: { accessToken: string; refreshToken: string }, user: any) => {
|
|
// Store tokens and navigate
|
|
tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken);
|
|
setMfaRequired(false);
|
|
router.push('/');
|
|
};
|
|
|
|
const handleMFACancel = () => {
|
|
setMfaRequired(false);
|
|
setMfaData(null);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
minHeight: '100vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
px: 3,
|
|
py: 6,
|
|
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
|
}}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 4,
|
|
borderRadius: 4,
|
|
maxWidth: 440,
|
|
mx: 'auto',
|
|
background: 'rgba(255, 255, 255, 0.95)',
|
|
backdropFilter: 'blur(10px)',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="h4"
|
|
component="h1"
|
|
gutterBottom
|
|
align="center"
|
|
fontWeight="600"
|
|
color="primary.main"
|
|
>
|
|
{t('login.title')}
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
align="center"
|
|
color="text.secondary"
|
|
sx={{ mb: 3 }}
|
|
>
|
|
{t('login.subtitle')}
|
|
</Typography>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
|
<TextField
|
|
fullWidth
|
|
label={t('login.email')}
|
|
type="email"
|
|
margin="normal"
|
|
error={!!errors.email}
|
|
helperText={errors.email?.message}
|
|
{...register('email')}
|
|
disabled={isLoading}
|
|
inputProps={{ autoComplete: 'username' }}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label={t('login.password')}
|
|
type={showPassword ? 'text' : 'password'}
|
|
margin="normal"
|
|
error={!!errors.password}
|
|
helperText={errors.password?.message}
|
|
{...register('password')}
|
|
disabled={isLoading}
|
|
inputProps={{ autoComplete: 'current-password' }}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
edge="end"
|
|
disabled={isLoading}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
>
|
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
|
|
<Box sx={{ textAlign: 'right', mt: 1 }}>
|
|
<MuiLink
|
|
component={Link}
|
|
href="/forgot-password"
|
|
variant="body2"
|
|
sx={{ cursor: 'pointer', textDecoration: 'none' }}
|
|
>
|
|
{t('login.forgotPassword')}
|
|
</MuiLink>
|
|
</Box>
|
|
|
|
<Button
|
|
fullWidth
|
|
type="submit"
|
|
variant="contained"
|
|
size="large"
|
|
disabled={isLoading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{isLoading ? (
|
|
<CircularProgress size={24} color="inherit" />
|
|
) : (
|
|
t('login.submit')
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 3 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('login.or')}
|
|
</Typography>
|
|
</Divider>
|
|
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={<Google />}
|
|
size="large"
|
|
disabled={isLoading}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
{t('login.continueWithGoogle')}
|
|
</Button>
|
|
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={<Apple />}
|
|
size="large"
|
|
disabled={isLoading}
|
|
>
|
|
{t('login.continueWithApple')}
|
|
</Button>
|
|
|
|
{isBiometricSupported && (
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={isBiometricLoading ? <CircularProgress size={20} /> : <Fingerprint />}
|
|
size="large"
|
|
disabled={isLoading || isBiometricLoading}
|
|
onClick={handleBiometricLogin}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
{isBiometricLoading ? t('login.biometric.authenticating') : t('login.biometric.useFaceId')}
|
|
</Button>
|
|
)}
|
|
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('login.noAccount')}{' '}
|
|
<Link href="/register" passHref legacyBehavior>
|
|
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
|
{t('login.signUp')}
|
|
</MuiLink>
|
|
</Link>
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
</motion.div>
|
|
|
|
{/* MFA Verification Dialog */}
|
|
{mfaRequired && mfaData && (
|
|
<MFAVerificationDialog
|
|
open={mfaRequired}
|
|
userId={mfaData.userId}
|
|
mfaMethod={mfaData.mfaMethod}
|
|
onVerified={handleMFAVerified}
|
|
onCancel={handleMFACancel}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|