Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
## Error Handling System - Add centralized error handling utilities (errorHandler.ts) - Create reusable error components (ErrorMessage, ErrorToast) - Implement multilingual error support (preserves backend error messages in 5 languages) - Update 15+ forms and components with consistent error handling - Auth forms: login, register, forgot-password - Family management: family page, join family dialog - Child management: child dialog - All tracking forms: feeding, sleep, diaper, medicine, growth, activity ## Production Build Fixes - Fix backend TypeScript errors: InviteCode.uses → InviteCode.useCount (5 instances) - Remove non-existent savedFamily variable from registration response - Fix admin panel TypeScript errors: SimpleMDE toolbar type, PieChart label type ## User Experience Improvements - Auto-uppercase invite code and share code inputs - Visual feedback for case conversion with helper text - Improved form validation with error codes ## CI/CD Pipeline - Create comprehensive production deployment checklist (PRODUCTION_DEPLOYMENT_CHECKLIST.md) - Add automated pre-deployment check script (pre-deploy-check.sh) - Validates frontend, backend, and admin panel builds - Checks git status, branch, and sync state - Verifies environment files and migrations - Add quick start deployment guide (DEPLOYMENT_QUICK_START.md) - Add production deployment automation template (deploy-production.sh) ## Cleanup - Remove outdated push notifications documentation files - Remove outdated PWA implementation plan 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
359 lines
11 KiB
TypeScript
359 lines
11 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';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
|
|
|
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 theme = useTheme();
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const { error, showError, clearError, hasError } = useErrorMessage();
|
|
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 () => {
|
|
clearError();
|
|
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') {
|
|
showError({ message: 'Biometric authentication was cancelled', code: 'BIOMETRIC_CANCELLED' });
|
|
} else if (err.name === 'NotSupportedError') {
|
|
showError({ message: 'Biometric authentication is not supported on this device', code: 'BIOMETRIC_NOT_SUPPORTED' });
|
|
} else {
|
|
showError(err);
|
|
}
|
|
} finally {
|
|
setIsBiometricLoading(false);
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (data: LoginFormData) => {
|
|
clearError();
|
|
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 {
|
|
showError(err);
|
|
}
|
|
} 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, ${theme.palette.primary.light} 0%, ${theme.palette.secondary.light} 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>
|
|
|
|
{hasError && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
|
|
{formatErrorMessage(error)}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
|
<TextField
|
|
fullWidth
|
|
label={t('login.email')}
|
|
type="email"
|
|
margin="normal"
|
|
error={!!errors.email}
|
|
helperText={errors.email?.message}
|
|
{...register('email')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
autoComplete: 'username',
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.email,
|
|
'aria-describedby': errors.email ? 'email-error' : undefined,
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.email ? 'email-error' : undefined,
|
|
role: errors.email ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label={t('login.password')}
|
|
type={showPassword ? 'text' : 'password'}
|
|
margin="normal"
|
|
error={!!errors.password}
|
|
helperText={errors.password?.message}
|
|
{...register('password')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
autoComplete: 'current-password',
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.password,
|
|
'aria-describedby': errors.password ? 'password-error' : undefined,
|
|
}}
|
|
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>
|
|
),
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.password ? 'password-error' : undefined,
|
|
role: errors.password ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
<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')}{' '}
|
|
<MuiLink component={Link} href="/register" sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
|
{t('login.signUp')}
|
|
</MuiLink>
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
</motion.div>
|
|
|
|
{/* MFA Verification Dialog */}
|
|
{mfaRequired && mfaData && (
|
|
<MFAVerificationDialog
|
|
open={mfaRequired}
|
|
userId={mfaData.userId}
|
|
mfaMethod={mfaData.mfaMethod}
|
|
onVerified={handleMFAVerified}
|
|
onCancel={handleMFACancel}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|