Complete Phase 1 accessibility implementation with comprehensive WCAG 2.1 Level AA compliance foundation. **Accessibility Tools Setup:** - ESLint jsx-a11y plugin with 18 accessibility rules - Axe-core for runtime accessibility testing in dev mode - jest-axe for automated testing - Accessibility utility functions (9 functions) **Core Features:** - Skip navigation link (WCAG 2.4.1 Bypass Blocks) - 45+ ARIA attributes across 15 components - Keyboard navigation fixes (Quick Actions now keyboard accessible) - Focus management on route changes with screen reader announcements - Color contrast WCAG AA compliance (4.5:1+ ratio, tested with Axe) - Proper heading hierarchy (h1→h2) across all pages - Semantic landmarks (header, nav, main) **Components Enhanced:** - 6 dialogs with proper ARIA labels (Child, InviteMember, DeleteConfirm, RemoveMember, JoinFamily, MFAVerification) - Voice input with aria-live regions - Navigation components with semantic landmarks - Quick Action cards with keyboard support **WCAG Success Criteria Met (8):** - 1.3.1 Info and Relationships (Level A) - 2.1.1 Keyboard (Level A) - 2.4.1 Bypass Blocks (Level A) - 4.1.2 Name, Role, Value (Level A) - 1.4.3 Contrast Minimum (Level AA) - 2.4.3 Focus Order (Level AA) - 2.4.6 Headings and Labels (Level AA) - 2.4.7 Focus Visible (Level AA) **Files Created (7):** - .eslintrc.json - ESLint accessibility config - components/providers/AxeProvider.tsx - Dev-time testing - components/common/SkipNavigation.tsx - Skip link - lib/accessibility.ts - Utility functions - hooks/useFocusManagement.ts - Focus management hooks - components/providers/FocusManagementProvider.tsx - Provider - docs/ACCESSIBILITY_PROGRESS.md - Progress tracking **Files Modified (17):** - Frontend: 20 components/pages with accessibility improvements - Backend: ai-rate-limit.service.ts (del → delete method) - Docs: implementation-gaps.md updated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
332 lines
9.8 KiB
TypeScript
332 lines
9.8 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';
|
|
|
|
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 [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"
|
|
>
|
|
Welcome Back 👋
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
align="center"
|
|
color="text.secondary"
|
|
sx={{ mb: 3 }}
|
|
>
|
|
Sign in to continue tracking your child's journey
|
|
</Typography>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
|
<TextField
|
|
fullWidth
|
|
label="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="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 }}>
|
|
<Link href="/forgot-password" passHref legacyBehavior>
|
|
<MuiLink variant="body2" sx={{ cursor: 'pointer' }}>
|
|
Forgot password?
|
|
</MuiLink>
|
|
</Link>
|
|
</Box>
|
|
|
|
<Button
|
|
fullWidth
|
|
type="submit"
|
|
variant="contained"
|
|
size="large"
|
|
disabled={isLoading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{isLoading ? (
|
|
<CircularProgress size={24} color="inherit" />
|
|
) : (
|
|
'Sign In'
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 3 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
OR
|
|
</Typography>
|
|
</Divider>
|
|
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={<Google />}
|
|
size="large"
|
|
disabled={isLoading}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
Continue with Google
|
|
</Button>
|
|
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={<Apple />}
|
|
size="large"
|
|
disabled={isLoading}
|
|
>
|
|
Continue with Apple
|
|
</Button>
|
|
|
|
{isBiometricSupported && (
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
startIcon={isBiometricLoading ? <CircularProgress size={20} /> : <Fingerprint />}
|
|
size="large"
|
|
disabled={isLoading || isBiometricLoading}
|
|
onClick={handleBiometricLogin}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
{isBiometricLoading ? 'Authenticating...' : 'Sign in with Biometrics'}
|
|
</Button>
|
|
)}
|
|
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Don't have an account?{' '}
|
|
<Link href="/register" passHref legacyBehavior>
|
|
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
|
Sign up
|
|
</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>
|
|
);
|
|
}
|