337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
/**
|
|
* GDPR Page
|
|
*
|
|
* Provides access to user data rights (export, portability, deletion).
|
|
*/
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa';
|
|
import api from '../api';
|
|
import { clearUserDataCache } from '../utils/userCache';
|
|
import styles from './GDPR.module.css';
|
|
|
|
import { useLanguage } from '../providers/language/LanguageContext';
|
|
|
|
type ConsentInfo = {
|
|
dataCollected?: Record<string, string>;
|
|
dataProcessing?: Record<string, string>;
|
|
userRights?: Record<string, string>;
|
|
contact?: Record<string, string>;
|
|
};
|
|
|
|
type ActionMessage = {
|
|
type: 'success' | 'error';
|
|
text: string;
|
|
};
|
|
|
|
const downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => {
|
|
const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType });
|
|
const fileUrl = URL.createObjectURL(fileBlob);
|
|
const link = document.createElement('a');
|
|
link.href = fileUrl;
|
|
link.download = fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(fileUrl);
|
|
};
|
|
|
|
export const GDPRPage: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const contactEmail = 'p.motsch@poweron.swiss';
|
|
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
|
|
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
|
|
const [consentError, setConsentError] = useState<string | null>(null);
|
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [isPortabilityExporting, setIsPortabilityExporting] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [actionMessage, setActionMessage] = useState<ActionMessage | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
|
const [isDeleted, setIsDeleted] = useState(false);
|
|
|
|
const isActionLocked = isDeleting || isDeleted;
|
|
|
|
useEffect(() => {
|
|
let isActive = true;
|
|
|
|
const loadConsentInfo = async () => {
|
|
setIsLoadingConsent(true);
|
|
setConsentError(null);
|
|
try {
|
|
const response = await api.get('/api/user/me/consent-info');
|
|
if (isActive) {
|
|
setConsentInfo(response.data as ConsentInfo);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to load GDPR consent info:', error);
|
|
if (isActive) {
|
|
setConsentError('Consent information could not be loaded.');
|
|
}
|
|
} finally {
|
|
if (isActive) {
|
|
setIsLoadingConsent(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadConsentInfo();
|
|
|
|
return () => {
|
|
isActive = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleDataExport = async () => {
|
|
if (isActionLocked) return;
|
|
setIsExporting(true);
|
|
setActionMessage(null);
|
|
try {
|
|
const response = await api.get('/api/user/me/data-export');
|
|
downloadJson(response.data, 'gdpr-data-export.json');
|
|
setActionMessage({ type: 'success', text: 'Data export downloaded.' });
|
|
} catch (error: any) {
|
|
console.error('GDPR export failed:', error);
|
|
setActionMessage({ type: 'error', text: 'Data export failed. Please try again.' });
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const handlePortabilityExport = async () => {
|
|
if (isActionLocked) return;
|
|
setIsPortabilityExporting(true);
|
|
setActionMessage(null);
|
|
try {
|
|
const response = await api.get('/api/user/me/data-portability', {
|
|
headers: { Accept: 'application/ld+json' }
|
|
});
|
|
downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
|
|
setActionMessage({ type: 'success', text: 'Portable export downloaded.' });
|
|
} catch (error: any) {
|
|
console.error('GDPR portability export failed:', error);
|
|
setActionMessage({ type: 'error', text: 'Portable export failed. Please try again.' });
|
|
} finally {
|
|
setIsPortabilityExporting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async () => {
|
|
setActionMessage(null);
|
|
if (deleteConfirmText !== 'LOESCHEN') {
|
|
setActionMessage({ type: 'error', text: 'Please type LOESCHEN to confirm deletion.' });
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await api.delete('/api/user/me/', { params: { confirmDeletion: true } });
|
|
localStorage.removeItem('authToken');
|
|
sessionStorage.removeItem('auth_authority');
|
|
clearUserDataCache();
|
|
setIsDeleted(true);
|
|
setActionMessage({ type: 'success', text: 'Account deleted. Redirecting to login...' });
|
|
window.location.replace('/login');
|
|
} catch (error: any) {
|
|
console.error('GDPR deletion failed:', error);
|
|
setActionMessage({ type: 'error', text: 'Account deletion failed. Please try again.' });
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={styles.gdpr}>
|
|
<header className={styles.header}>
|
|
<div>
|
|
<h1 className={styles.title}>
|
|
<FaShieldAlt className={styles.titleIcon} />
|
|
GDPR / Privacy
|
|
</h1>
|
|
<p className={styles.subtitle}>
|
|
Manage your personal data exports and account deletion.
|
|
</p>
|
|
</div>
|
|
<Link to="/settings" className={styles.backLink}>
|
|
Back to Settings
|
|
</Link>
|
|
</header>
|
|
|
|
<main className={styles.content}>
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('gDPR.yourDataRights')}</h2>
|
|
<div className={styles.actions}>
|
|
<div className={styles.actionCard}>
|
|
<h3>{t('gDPR.accessArticle15')}</h3>
|
|
<p>{t('gDPR.downloadAFullExportOf')}</p>
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={handleDataExport}
|
|
disabled={isExporting || isActionLocked}
|
|
>
|
|
{isExporting ? (
|
|
<span className={styles.buttonSpinner}>
|
|
<FaSpinner />
|
|
Exporting...
|
|
</span>
|
|
) : (
|
|
<>
|
|
<FaDownload />
|
|
Export data
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.actionCard}>
|
|
<h3>{t('gDPR.portabilityArticle20')}</h3>
|
|
<p>{t('gDPR.downloadAMachinereadableJsonldExport')}</p>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={handlePortabilityExport}
|
|
disabled={isPortabilityExporting || isActionLocked}
|
|
>
|
|
{isPortabilityExporting ? (
|
|
<span className={styles.buttonSpinner}>
|
|
<FaSpinner />
|
|
Exporting...
|
|
</span>
|
|
) : (
|
|
<>
|
|
<FaFileExport />
|
|
Export portable data
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.actionCard}>
|
|
<h3>{t('gDPR.erasureArticle17')}</h3>
|
|
<p>{t('gDPR.permanentlyDeleteYourAccountAnd')}</p>
|
|
{!showDeleteConfirm && (
|
|
<button
|
|
className={styles.dangerButton}
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
disabled={isActionLocked}
|
|
>
|
|
<FaTrash />
|
|
Start deletion
|
|
</button>
|
|
)}
|
|
{showDeleteConfirm && (
|
|
<div className={styles.deleteConfirm}>
|
|
<p className={styles.deleteWarning}>
|
|
This action is irreversible. Type <strong>LOESCHEN</strong> to confirm.
|
|
</p>
|
|
<input
|
|
className={styles.deleteInput}
|
|
value={deleteConfirmText}
|
|
onChange={(event) => setDeleteConfirmText(event.target.value)}
|
|
placeholder="LOESCHEN"
|
|
disabled={isDeleting}
|
|
/>
|
|
<div className={styles.deleteActions}>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={() => {
|
|
setShowDeleteConfirm(false);
|
|
setDeleteConfirmText('');
|
|
}}
|
|
disabled={isDeleting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className={styles.dangerButton}
|
|
onClick={handleDeleteAccount}
|
|
disabled={isDeleting || deleteConfirmText !== 'LOESCHEN'}
|
|
>
|
|
{isDeleting ? (
|
|
<span className={styles.buttonSpinner}>
|
|
<FaSpinner />
|
|
Deleting...
|
|
</span>
|
|
) : (
|
|
<>
|
|
<FaTrash />
|
|
Confirm deletion
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{actionMessage && (
|
|
<div
|
|
className={`${styles.message} ${
|
|
actionMessage.type === 'success' ? styles.successMessage : styles.errorMessage
|
|
}`}
|
|
>
|
|
{actionMessage.text}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('gDPR.processingInformation')}</h2>
|
|
{isLoadingConsent && <p className={styles.mutedText}>{t('gDPR.loadingConsentInfo')}</p>}
|
|
{consentError && <p className={styles.errorText}>{consentError}</p>}
|
|
{!isLoadingConsent && !consentError && consentInfo && (
|
|
<div className={styles.infoGrid}>
|
|
<div className={styles.infoBlock}>
|
|
<h3>{t('gDPR.dataCollected')}</h3>
|
|
<ul>
|
|
{Object.entries(consentInfo.dataCollected || {}).map(([key, value]) => (
|
|
<li key={key}>
|
|
<strong>{key}:</strong> {value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className={styles.infoBlock}>
|
|
<h3>{t('gDPR.processing')}</h3>
|
|
<ul>
|
|
{Object.entries(consentInfo.dataProcessing || {}).map(([key, value]) => (
|
|
<li key={key}>
|
|
<strong>{key}:</strong> {value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className={styles.infoBlock}>
|
|
<h3>{t('gDPR.yourRights')}</h3>
|
|
<ul>
|
|
{Object.entries(consentInfo.userRights || {}).map(([key, value]) => (
|
|
<li key={key}>
|
|
<strong>{key}:</strong> {value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className={styles.infoBlock}>
|
|
<h3>Contact</h3>
|
|
<ul>
|
|
{Object.entries({
|
|
...(consentInfo.contact || {}),
|
|
email: contactEmail,
|
|
}).map(([key, value]) => (
|
|
<li key={key}>
|
|
<strong>{key}:</strong> {value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default GDPRPage;
|