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.
This commit is contained in:
2025-10-02 17:17:06 +00:00
parent afab67da9f
commit 3335255710
5 changed files with 634 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import {
Box,
TextField,
@@ -32,12 +32,39 @@ const registerSchema = z.object({
.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 and conditions',
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>;
@@ -47,16 +74,50 @@ export default function RegisterPage() {
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);
@@ -66,6 +127,9 @@ export default function RegisterPage() {
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) {
@@ -208,33 +272,115 @@ export default function RegisterPage() {
}}
/>
<FormControlLabel
control={
<Checkbox
{...register('agreeToTerms')}
disabled={isLoading}
/>
}
label={
<Typography variant="body2" color="text.secondary">
I agree to the{' '}
<MuiLink href="/terms" target="_blank">
Terms of Service
</MuiLink>{' '}
and{' '}
<MuiLink href="/privacy" target="_blank">
Privacy Policy
</MuiLink>
</Typography>
}
sx={{ mt: 2 }}
<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 },
}}
/>
{errors.agreeToTerms && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
{errors.agreeToTerms.message}
</Typography>
{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"

View File

@@ -11,6 +11,8 @@ import { MFASettings } from '@/components/settings/MFASettings';
import { SessionsManagement } from '@/components/settings/SessionsManagement';
import { DeviceTrustManagement } from '@/components/settings/DeviceTrustManagement';
import { BiometricSettings } from '@/components/settings/BiometricSettings';
import { DataExport } from '@/components/settings/DataExport';
import { AccountDeletion } from '@/components/settings/AccountDeletion';
import { motion } from 'framer-motion';
export default function SettingsPage() {
@@ -265,11 +267,33 @@ export default function SettingsPage() {
</Box>
</motion.div>
{/* Privacy & Compliance - Data Export */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.39 }}
>
<Box sx={{ mb: 3 }}>
<DataExport />
</Box>
</motion.div>
{/* Privacy & Compliance - Account Deletion */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.42 }}
>
<Box sx={{ mb: 3 }}>
<AccountDeletion />
</Box>
</motion.div>
{/* Account Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
transition={{ duration: 0.4, delay: 0.45 }}
>
<Card>
<CardContent>

View File

@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
Alert,
CircularProgress,
Chip,
} from '@mui/material';
import { DeleteForever, Cancel, Warning } from '@mui/icons-material';
import { complianceApi, DeletionRequest } from '@/lib/api/compliance';
import { useAuth } from '@/lib/auth/AuthContext';
export function AccountDeletion() {
const { logout } = useAuth();
const [deletionRequest, setDeletionRequest] = useState<DeletionRequest | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [reason, setReason] = useState('');
const [cancellationReason, setCancellationReason] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
checkDeletionStatus();
}, []);
const checkDeletionStatus = async () => {
setIsCheckingStatus(true);
try {
const status = await complianceApi.getDeletionStatus();
setDeletionRequest(status);
} catch (err) {
console.error('Failed to check deletion status:', err);
} finally {
setIsCheckingStatus(false);
}
};
const handleRequestDeletion = async () => {
setIsLoading(true);
setError(null);
setSuccess(null);
try {
const request = await complianceApi.requestAccountDeletion(reason);
setDeletionRequest(request);
setSuccess(
`Account deletion scheduled for ${new Date(request.scheduledDeletionAt).toLocaleDateString()}. You can cancel this request within 30 days.`
);
setIsDialogOpen(false);
setReason('');
} catch (err: any) {
console.error('Failed to request deletion:', err);
setError(err.response?.data?.message || 'Failed to request account deletion. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleCancelDeletion = async () => {
setIsLoading(true);
setError(null);
setSuccess(null);
try {
await complianceApi.cancelAccountDeletion(cancellationReason);
setDeletionRequest(null);
setSuccess('Account deletion request has been cancelled successfully.');
setIsCancelDialogOpen(false);
setCancellationReason('');
} catch (err: any) {
console.error('Failed to cancel deletion:', err);
setError(err.response?.data?.message || 'Failed to cancel account deletion. Please try again.');
} finally {
setIsLoading(false);
}
};
if (isCheckingStatus) {
return (
<Card>
<CardContent>
<Box display="flex" justifyContent="center" alignItems="center" py={3}>
<CircularProgress size={30} />
</Box>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardContent>
<Typography variant="h6" fontWeight="600" gutterBottom color="error">
Danger Zone
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
{deletionRequest?.status === 'pending' ? (
<Box>
<Alert severity="warning" icon={<Warning />} sx={{ mb: 2 }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Account Deletion Scheduled
</Typography>
<Typography variant="body2">
Your account is scheduled for deletion on{' '}
<strong>{new Date(deletionRequest.scheduledDeletionAt).toLocaleDateString()}</strong>.
You can cancel this request at any time before that date.
</Typography>
{deletionRequest.reason && (
<Typography variant="body2" sx={{ mt: 1 }}>
Reason: {deletionRequest.reason}
</Typography>
)}
</Alert>
<Box display="flex" gap={2}>
<Button
variant="outlined"
startIcon={<Cancel />}
onClick={() => setIsCancelDialogOpen(true)}
disabled={isLoading}
>
Cancel Deletion Request
</Button>
<Chip
label={`${Math.ceil((new Date(deletionRequest.scheduledDeletionAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))} days remaining`}
color="warning"
size="small"
/>
</Box>
</Box>
) : (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Permanently delete your account and all associated data. This action cannot be undone after the 30-day grace period.
This complies with GDPR's Right to Erasure.
</Typography>
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
<strong>Warning:</strong> Deleting your account will remove all your data including children profiles, activities,
photos, and AI conversations. This data cannot be recovered.
</Typography>
<Button
variant="outlined"
color="error"
startIcon={<DeleteForever />}
onClick={() => setIsDialogOpen(true)}
>
Delete My Account
</Button>
</Box>
)}
</CardContent>
</Card>
{/* Delete Account Dialog */}
<Dialog open={isDialogOpen} onClose={() => !isLoading && setIsDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Delete Account</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Are you sure you want to delete your account? This will permanently remove all your data after a 30-day grace period.
</DialogContentText>
<DialogContentText sx={{ mb: 2 }} color="error">
<strong>This action will delete:</strong>
</DialogContentText>
<ul style={{ marginTop: 0 }}>
<li>Your profile and account information</li>
<li>All children profiles and their data</li>
<li>All activity tracking records</li>
<li>All photos and attachments</li>
<li>All AI conversation history</li>
<li>All family memberships</li>
</ul>
<TextField
fullWidth
multiline
rows={3}
label="Reason for deletion (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Help us improve by telling us why you're leaving..."
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsDialogOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleRequestDeletion}
color="error"
variant="contained"
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={20} /> : <DeleteForever />}
>
{isLoading ? 'Processing...' : 'Delete Account'}
</Button>
</DialogActions>
</Dialog>
{/* Cancel Deletion Dialog */}
<Dialog
open={isCancelDialogOpen}
onClose={() => !isLoading && setIsCancelDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Cancel Account Deletion</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Are you sure you want to cancel your account deletion request? Your account will remain active.
</DialogContentText>
<TextField
fullWidth
multiline
rows={2}
label="Reason for cancellation (optional)"
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
placeholder="Tell us what made you change your mind..."
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsCancelDialogOpen(false)} disabled={isLoading}>
Close
</Button>
<Button
onClick={handleCancelDeletion}
color="primary"
variant="contained"
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={20} /> : <Cancel />}
>
{isLoading ? 'Processing...' : 'Cancel Deletion'}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import {
Card,
CardContent,
Typography,
Button,
Box,
CircularProgress,
Alert,
} from '@mui/material';
import { Download } from '@mui/icons-material';
import { complianceApi } from '@/lib/api/compliance';
export function DataExport() {
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleExport = async () => {
setIsExporting(true);
setError(null);
setSuccess(null);
try {
await complianceApi.downloadUserData();
setSuccess('Your data has been downloaded successfully!');
} catch (err: any) {
console.error('Failed to export data:', err);
setError(err.response?.data?.message || 'Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
};
return (
<Card>
<CardContent>
<Typography variant="h6" fontWeight="600" gutterBottom>
Data Export
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Download all your data in JSON format. This includes your profile, children, activities, and AI conversations.
This complies with GDPR's Right to Data Portability.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
<Button
variant="outlined"
startIcon={isExporting ? <CircularProgress size={20} /> : <Download />}
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? 'Exporting...' : 'Download My Data'}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,99 @@
import { apiClient } from './client';
export interface UserDataExport {
user: {
id: string;
name: string;
email: string;
dateOfBirth?: string;
createdAt: string;
};
families: Array<{
id: string;
name: string;
role: string;
}>;
children: Array<{
id: string;
name: string;
dateOfBirth: string;
gender: string;
}>;
activities: Array<{
id: string;
type: string;
timestamp: string;
data: any;
}>;
aiConversations: Array<{
id: string;
createdAt: string;
messages: Array<{
role: string;
content: string;
timestamp: string;
}>;
}>;
}
export interface DeletionRequest {
id: string;
requestedAt: string;
scheduledDeletionAt: string;
status: 'pending' | 'cancelled' | 'completed';
reason?: string;
}
export const complianceApi = {
/**
* Export all user data (GDPR Right to Data Portability)
*/
exportUserData: async (): Promise<UserDataExport> => {
const response = await apiClient.get<{ data: UserDataExport }>('/compliance/data-export');
return response.data.data;
},
/**
* Download user data as JSON file
*/
downloadUserData: async (): Promise<void> => {
const data = await complianceApi.exportUserData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `maternal-app-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
/**
* Request account deletion with 30-day grace period (GDPR Right to Erasure)
*/
requestAccountDeletion: async (reason?: string): Promise<DeletionRequest> => {
const response = await apiClient.post<{ data: DeletionRequest }>('/compliance/request-deletion', {
reason,
});
return response.data.data;
},
/**
* Cancel pending account deletion request
*/
cancelAccountDeletion: async (cancellationReason?: string): Promise<DeletionRequest> => {
const response = await apiClient.post<{ data: DeletionRequest }>('/compliance/cancel-deletion', {
cancellationReason,
});
return response.data.data;
},
/**
* Get pending deletion request status
*/
getDeletionStatus: async (): Promise<DeletionRequest | null> => {
const response = await apiClient.get<{ data: DeletionRequest | null }>('/compliance/deletion-status');
return response.data.data;
},
};