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.
415 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|