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:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -32,12 +32,39 @@ const registerSchema = z.object({
|
|||||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||||
confirmPassword: z.string(),
|
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, {
|
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, {
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
path: ['confirmPassword'],
|
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>;
|
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||||
@@ -47,16 +74,50 @@ export default function RegisterPage() {
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [userAge, setUserAge] = useState<number | null>(null);
|
||||||
|
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
|
||||||
const { register: registerUser } = useAuth();
|
const { register: registerUser } = useAuth();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<RegisterFormData>({
|
} = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(registerSchema),
|
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) => {
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -66,6 +127,9 @@ export default function RegisterPage() {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
dateOfBirth: data.dateOfBirth,
|
||||||
|
parentalEmail: data.parentalEmail || undefined,
|
||||||
|
coppaConsentGiven: data.coppaConsent || false,
|
||||||
});
|
});
|
||||||
// Navigation to onboarding is handled in the register function
|
// Navigation to onboarding is handled in the register function
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -208,33 +272,115 @@ export default function RegisterPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControlLabel
|
<TextField
|
||||||
control={
|
fullWidth
|
||||||
<Checkbox
|
label="Date of Birth"
|
||||||
{...register('agreeToTerms')}
|
type="date"
|
||||||
disabled={isLoading}
|
margin="normal"
|
||||||
/>
|
error={!!errors.dateOfBirth}
|
||||||
}
|
helperText={errors.dateOfBirth?.message || 'Required for COPPA compliance (users under 13 cannot register)'}
|
||||||
label={
|
{...register('dateOfBirth')}
|
||||||
<Typography variant="body2" color="text.secondary">
|
disabled={isLoading}
|
||||||
I agree to the{' '}
|
InputLabelProps={{
|
||||||
<MuiLink href="/terms" target="_blank">
|
shrink: true,
|
||||||
Terms of Service
|
}}
|
||||||
</MuiLink>{' '}
|
InputProps={{
|
||||||
and{' '}
|
sx: { borderRadius: 3 },
|
||||||
<MuiLink href="/privacy" target="_blank">
|
}}
|
||||||
Privacy Policy
|
|
||||||
</MuiLink>
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
/>
|
/>
|
||||||
{errors.agreeToTerms && (
|
|
||||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
|
{userAge !== null && userAge < 13 && (
|
||||||
{errors.agreeToTerms.message}
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
</Typography>
|
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
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { MFASettings } from '@/components/settings/MFASettings';
|
|||||||
import { SessionsManagement } from '@/components/settings/SessionsManagement';
|
import { SessionsManagement } from '@/components/settings/SessionsManagement';
|
||||||
import { DeviceTrustManagement } from '@/components/settings/DeviceTrustManagement';
|
import { DeviceTrustManagement } from '@/components/settings/DeviceTrustManagement';
|
||||||
import { BiometricSettings } from '@/components/settings/BiometricSettings';
|
import { BiometricSettings } from '@/components/settings/BiometricSettings';
|
||||||
|
import { DataExport } from '@/components/settings/DataExport';
|
||||||
|
import { AccountDeletion } from '@/components/settings/AccountDeletion';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -265,11 +267,33 @@ export default function SettingsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</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 */}
|
{/* Account Actions */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4, delay: 0.4 }}
|
transition={{ duration: 0.4, delay: 0.45 }}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
267
maternal-web/components/settings/AccountDeletion.tsx
Normal file
267
maternal-web/components/settings/AccountDeletion.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
maternal-web/components/settings/DataExport.tsx
Normal file
71
maternal-web/components/settings/DataExport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
maternal-web/lib/api/compliance.ts
Normal file
99
maternal-web/lib/api/compliance.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user