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