feat(CC-060): add GDPR Export & Deletion page (data download + deletion request flow)
This commit is contained in:
parent
b89bd39673
commit
4b93b4234c
1 changed files with 249 additions and 0 deletions
249
src/app/dashboard/privacy/export/page.tsx
Normal file
249
src/app/dashboard/privacy/export/page.tsx
Normal 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 să 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue