Files
maternal-app/maternal-web/app/(auth)/register/page.tsx
Andrei 3335255710 feat(compliance): Implement COPPA/GDPR compliance UI
Frontend Compliance Features:
- Created compliance API client (data export, account deletion, deletion status)
- Added DataExport component with download functionality
- Added AccountDeletion component with 30-day grace period UI
- Updated Settings page with Privacy & Compliance sections

COPPA Age Verification:
- Added date of birth field to registration
- Age calculation with COPPA compliance (under 13 blocked)
- Parental email and consent for users 13-17
- Dynamic form validation based on age

Privacy & Terms:
- Separate checkboxes for Terms of Service and Privacy Policy
- Required acceptance for registration
- Links to policy pages

Completes GDPR Right to Data Portability and Right to Erasure.
Completes COPPA parental consent requirements.
2025-10-02 17:17:06 +00:00

415 lines
14 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Paper,
InputAdornment,
IconButton,
Alert,
CircularProgress,
Link as MuiLink,
Checkbox,
FormControlLabel,
} from '@mui/material';
import { Visibility, VisibilityOff } 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 Link from 'next/link';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
parentalEmail: z.string().email('Invalid email address').optional().or(z.literal('')),
agreeToTerms: z.boolean().refine(val => val === true, {
message: 'You must agree to the Terms of Service',
}),
agreeToPrivacy: z.boolean().refine(val => val === true, {
message: 'You must agree to the Privacy Policy',
}),
coppaConsent: z.boolean().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
}).refine((data) => {
// Check if user is under 18 and requires parental consent
const birthDate = new Date(data.dateOfBirth);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear() -
(today.getMonth() < birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() < birthDate.getDate()) ? 1 : 0);
if (age < 13) {
return false; // Users under 13 cannot register (COPPA compliance)
}
if (age >= 13 && age < 18) {
// Users 13-17 need parental email and consent
return !!data.parentalEmail && data.parentalEmail.length > 0 && data.coppaConsent === true;
}
return true;
}, {
message: 'Users under 13 cannot create an account. Users 13-17 require parental consent and email.',
path: ['dateOfBirth'],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [userAge, setUserAge] = useState<number | null>(null);
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
const { register: registerUser } = useAuth();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
agreeToTerms: false,
agreeToPrivacy: false,
coppaConsent: false,
},
});
// Watch date of birth to calculate age and show parental consent if needed
const dateOfBirth = watch('dateOfBirth');
// Calculate age when date of birth changes
const calculateAge = (dob: string): number | null => {
if (!dob) return null;
const birthDate = new Date(dob);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear() -
(today.getMonth() < birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() < birthDate.getDate()) ? 1 : 0);
return age;
};
// Update age and parental consent requirement when DOB changes
React.useEffect(() => {
if (dateOfBirth) {
const age = calculateAge(dateOfBirth);
setUserAge(age);
setRequiresParentalConsent(age !== null && age >= 13 && age < 18);
} else {
setUserAge(null);
setRequiresParentalConsent(false);
}
}, [dateOfBirth]);
const onSubmit = async (data: RegisterFormData) => {
setError(null);
setIsLoading(true);
try {
await registerUser({
name: data.name,
email: data.email,
password: data.password,
dateOfBirth: data.dateOfBirth,
parentalEmail: data.parentalEmail || undefined,
coppaConsentGiven: data.coppaConsent || false,
});
// Navigation to onboarding is handled in the register function
} catch (err: any) {
setError(err.message || 'Failed to register. Please try again.');
} finally {
setIsLoading(false);
}
};
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"
gutterBottom
align="center"
fontWeight="600"
color="primary.main"
>
Create Account
</Typography>
<Typography
variant="body2"
align="center"
color="text.secondary"
sx={{ mb: 3 }}
>
Start your journey to organized parenting
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<TextField
fullWidth
label="Full Name"
margin="normal"
error={!!errors.name}
helperText={errors.name?.message}
{...register('name')}
disabled={isLoading}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<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: 'new-password' }}
InputProps={{
sx: { borderRadius: 3 },
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
disabled={isLoading}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
margin="normal"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword?.message}
{...register('confirmPassword')}
disabled={isLoading}
inputProps={{ autoComplete: 'new-password' }}
InputProps={{
sx: { borderRadius: 3 },
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
disabled={isLoading}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="Date of Birth"
type="date"
margin="normal"
error={!!errors.dateOfBirth}
helperText={errors.dateOfBirth?.message || 'Required for COPPA compliance (users under 13 cannot register)'}
{...register('dateOfBirth')}
disabled={isLoading}
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
{userAge !== null && userAge < 13 && (
<Alert severity="error" sx={{ mt: 2 }}>
Users under 13 years old cannot create an account per COPPA regulations.
</Alert>
)}
{requiresParentalConsent && (
<Box sx={{ mt: 2 }}>
<Alert severity="info" sx={{ mb: 2 }}>
As you are under 18, parental consent is required to create an account.
</Alert>
<TextField
fullWidth
label="Parent/Guardian Email"
type="email"
margin="normal"
error={!!errors.parentalEmail}
helperText={errors.parentalEmail?.message || 'We will send a consent email to your parent/guardian'}
{...register('parentalEmail')}
disabled={isLoading}
InputProps={{
sx: { borderRadius: 3 },
}}
/>
<FormControlLabel
control={
<Checkbox
{...register('coppaConsent')}
disabled={isLoading}
/>
}
label={
<Typography variant="body2" color="text.secondary">
I confirm that I have my parent/guardian's permission to create this account
</Typography>
}
sx={{ mt: 1 }}
/>
{errors.coppaConsent && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
Parental consent is required for users under 18
</Typography>
)}
</Box>
)}
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={
<Checkbox
{...register('agreeToTerms')}
disabled={isLoading}
/>
}
label={
<Typography variant="body2" color="text.secondary">
I agree to the{' '}
<MuiLink href="/terms" target="_blank" underline="hover">
Terms of Service
</MuiLink>
</Typography>
}
/>
{errors.agreeToTerms && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5, ml: 4 }}>
{errors.agreeToTerms.message}
</Typography>
)}
<FormControlLabel
control={
<Checkbox
{...register('agreeToPrivacy')}
disabled={isLoading}
/>
}
label={
<Typography variant="body2" color="text.secondary">
I agree to the{' '}
<MuiLink href="/privacy" target="_blank" underline="hover">
Privacy Policy
</MuiLink>
</Typography>
}
/>
{errors.agreeToPrivacy && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5, ml: 4 }}>
{errors.agreeToPrivacy.message}
</Typography>
)}
</Box>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Create Account'
)}
</Button>
</Box>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link href="/login" passHref legacyBehavior>
<MuiLink sx={{ cursor: 'pointer', fontWeight: 600 }}>
Sign in
</MuiLink>
</Link>
</Typography>
</Box>
</Paper>
</motion.div>
</Box>
);
}