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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user