feat(CC-060): add GDPR Export & Deletion page (data download + deletion request flow)

This commit is contained in:
admin-valentin 2026-08-01 21:09:53 +00:00
parent b89bd39673
commit 4b93b4234c

View file

@ -0,0 +1,249 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface ExportCounts {
organizations: number;
tasks: number;
goals: number;
decisions: number;
transactions: number;
aiRequests: number;
consentRecords: number;
notifications: number;
researchBriefs: number;
savedSegments: number;
opportunities: number;
}
interface ExportData {
exportedAt: string;
tenantId: string;
userId: string;
schema: string;
counts: ExportCounts;
data: Record<string, unknown[]>;
}
interface ConsentRecord {
id: string;
purpose: string;
grantedAt: string;
revokedAt: string | null;
}
interface DeletionResult {
submitted: boolean;
alreadyPending: boolean;
submittedAt: string;
}
const ENTITY_LABELS: Record<string, string> = {
organizations: 'Organizații',
tasks: 'Taskuri',
goals: 'Obiective',
decisions: 'Decizii',
transactions: 'Tranzacții',
aiRequests: 'Cereri AI',
consentRecords: 'Consimțăminte',
notifications: 'Notificări',
researchBriefs: 'Research Briefs',
savedSegments: 'Segmente',
opportunities: 'Oportunități',
};
function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
export default function ExportPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [deletionReason, setDeletionReason] = useState('');
const [showDeletionForm, setShowDeletionForm] = useState(false);
const [exportPreview, setExportPreview] = useState<ExportCounts | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const { data: consents = [] } = useQuery({
queryKey: ['consents', tenantId],
queryFn: () => apiFetch<ConsentRecord[]>('/v1/consents', { tenantId }),
enabled: Boolean(tenantId),
});
const deletionPending = consents.some(
(c) => c.purpose === 'account_deletion_requested' && !c.revokedAt,
);
const handleExport = async () => {
if (!tenantId) return;
setIsDownloading(true);
try {
const data = await apiFetch<ExportData>('/v1/export/data', { tenantId });
setExportPreview(data.counts);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
downloadJson(data, `ceo-os-export-${ts}.json`);
} finally {
setIsDownloading(false);
}
};
const { mutate: submitDeletion, isPending: isDeletionPending, data: deletionResult } = useMutation({
mutationFn: () =>
apiFetch<DeletionResult>('/v1/export/deletion-request', {
method: 'POST',
body: { reason: deletionReason },
tenantId,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['consents', tenantId] });
setShowDeletionForm(false);
},
});
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Export & Ștergere Date</h1>
<p className="text-sm text-ink-faint mt-1">
Dreptul tău GDPR la portabilitate și ștergere (Art. 20, Art. 17)
</p>
</div>
{/* Export section */}
<div className="card p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-ink">Descarcă datele tale</h2>
<p className="text-xs text-ink-faint mt-1 leading-relaxed">
Primești un fișier JSON cu toate datele din CEO OS organizații, taskuri, obiective,
decizii, tranzacții, consimțăminte și mai mult.
</p>
</div>
<button
onClick={handleExport}
disabled={isDownloading || !tenantId}
className="shrink-0 btn btn-primary text-xs px-4 py-2 disabled:opacity-50"
>
{isDownloading ? 'Se exportă…' : '⬇ Export JSON'}
</button>
</div>
{exportPreview && (
<div className="border-t border-border/50 pt-4 space-y-2">
<p className="text-xs font-medium text-ink-faint uppercase tracking-wider">
Export realizat cu succes
</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{Object.entries(exportPreview).map(([key, count]) => (
<div key={key} className="flex justify-between bg-muted/30 rounded px-2 py-1.5 text-xs">
<span className="text-ink-faint">{ENTITY_LABELS[key] ?? key}</span>
<span className="font-medium text-ink">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* GDPR rights info */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{[
{ icon: '📋', title: 'Art. 15 — Acces', desc: 'Dreptul de a cunoaște ce date deținem despre tine.' },
{ icon: '✏️', title: 'Art. 16 — Rectificare', desc: 'Corectează datele inexacte direct din platformă.' },
{ icon: '🗑️', title: 'Art. 17 — Ștergere', desc: 'Solicită ștergerea completă a contului și datelor.' },
].map(({ icon, title, desc }) => (
<div key={title} className="rounded-xl border bg-card p-4 space-y-1">
<p className="text-base">{icon}</p>
<p className="text-xs font-semibold text-ink">{title}</p>
<p className="text-xs text-ink-faint leading-relaxed">{desc}</p>
</div>
))}
</div>
{/* Deletion request section */}
<div className="card p-6 space-y-4">
<div>
<h2 className="text-sm font-semibold text-ink">Cerere de ștergere cont</h2>
<p className="text-xs text-ink-faint mt-1 leading-relaxed">
Solicită ștergerea permanentă a contului și tuturor datelor asociate.
Cererea va fi procesată în maxim 30 de zile conform GDPR Art. 17.
</p>
</div>
{deletionPending || deletionResult?.submitted ? (
<div className="rounded-lg bg-signal-danger/5 border border-signal-danger/20 p-4">
<p className="text-sm font-medium text-signal-danger">Cerere de ștergere în curs</p>
<p className="text-xs text-ink-faint mt-1">
Cererea ta a fost înregistrată și va fi procesată în 30 de zile.
Dacă dorești anulezi, contactează suportul.
</p>
</div>
) : showDeletionForm ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-ink block mb-1">
Motivul ștergerii (opțional)
</label>
<textarea
value={deletionReason}
onChange={(e) => setDeletionReason(e.target.value)}
rows={3}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="Descrie motivul ștergerii contului…"
/>
</div>
<div className="rounded-lg bg-signal-danger/5 border border-signal-danger/20 p-3">
<p className="text-xs text-signal-danger font-medium">
Această acțiune este ireversibilă. Toate datele vor fi șterse permanent.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => submitDeletion()}
disabled={isDeletionPending}
className="btn text-xs px-4 py-2 bg-signal-danger text-white rounded-lg hover:bg-signal-danger/90 disabled:opacity-50"
>
{isDeletionPending ? 'Se trimite…' : 'Confirm ștergerea'}
</button>
<button
onClick={() => setShowDeletionForm(false)}
className="text-xs text-ink-faint hover:text-ink"
>
Anulează
</button>
</div>
</div>
) : (
<button
onClick={() => setShowDeletionForm(true)}
className="text-xs text-signal-danger hover:underline"
>
Solicită ștergerea contului
</button>
)}
</div>
{/* Footer notice */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-4">
<p className="text-xs text-ink-faint leading-relaxed">
<strong className="text-ink">Contact DPO:</strong> Pentru solicitări GDPR complexe sau
reclamații, contactează responsabilul cu protecția datelor la{' '}
<span className="font-mono">privacy@boardmind.dev</span>.
Răspundem în maxim 30 zile calendaristice.
</p>
</div>
</div>
);
}