feat(CC-057): add Consent Management page with toggle switches per purpose + history

This commit is contained in:
admin-valentin 2026-08-01 21:00:10 +00:00
parent cba4574fa2
commit 0870dab760

View file

@ -0,0 +1,185 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface ConsentRecord {
id: string;
purpose: string;
grantedAt: string;
revokedAt: string | null;
}
const PURPOSES = [
{
id: 'ai_analysis',
label: 'Analiză AI a datelor personale',
desc: 'Permite AI Gateway să proceseze datele din CEO OS pentru recomandări, rezumate și analiză predictivă.',
category: 'AI & Automatizare',
},
{
id: 'intelligence_enrichment',
label: 'Îmbogățire cu date externe',
desc: 'Permite corelarea profilului tău de business cu date externe (B2B intelligence, legal, market).',
category: 'AI & Automatizare',
},
{
id: 'notification_analytics',
label: 'Analiticp notificări',
desc: 'Urmărire date de interacțiune cu notificările pentru optimizarea prioritizării și frecvenței.',
category: 'Notificări',
},
{
id: 'data_export',
label: 'Export și portabilitate date',
desc: 'Permite exportul datelor tale în formate portabile (CSV, JSON) pentru migrare sau audit extern.',
category: 'Portabilitate',
},
{
id: 'third_party_integration',
label: 'Integrări cu servicii terțe',
desc: 'Transmiterea selectivă a datelor către servicii integrate (ERP, contabilitate, CRM) pentru sincronizare.',
category: 'Integrări',
},
{
id: 'research_processing',
label: 'Procesare Research Briefs',
desc: 'Generarea automată de rezumate și analiză AI pentru research briefs legate de organizații.',
category: 'AI & Automatizare',
},
];
export default function ConsentsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const { data: records = [], isLoading } = useQuery({
queryKey: ['consents', tenantId],
queryFn: () => apiFetch<ConsentRecord[]>('/v1/consents', { tenantId }),
enabled: Boolean(tenantId),
});
const activePurposes = new Set(
records.filter((r) => !r.revokedAt).map((r) => r.purpose),
);
const { mutate: toggleConsent, isPending } = useMutation({
mutationFn: ({ purpose, active }: { purpose: string; active: boolean }) =>
apiFetch<ConsentRecord | { revoked: boolean }>(
active ? '/v1/consents/revoke' : '/v1/consents/grant',
{ method: 'POST', body: { purpose }, tenantId },
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['consents', tenantId] }),
});
const categories = [...new Set(PURPOSES.map((p) => p.category))];
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Consimțăminte</h1>
<p className="text-sm text-ink-faint mt-1">
Controlezi exact ce procesări de date ai autorizat. Poți revoca oricând.
</p>
</div>
{isLoading ? (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
) : (
<div className="space-y-6">
{categories.map((category) => (
<div key={category}>
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint mb-3">
{category}
</h2>
<div className="space-y-3">
{PURPOSES.filter((p) => p.category === category).map((purpose) => {
const isActive = activePurposes.has(purpose.id);
const record = records.find((r) => r.purpose === purpose.id && !r.revokedAt);
return (
<div
key={purpose.id}
className="rounded-xl border bg-card p-4 flex items-start gap-4"
>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-ink">{purpose.label}</p>
{isActive && (
<span className="text-[10px] bg-signal-ok/10 text-signal-ok font-medium px-1.5 py-0.5 rounded-full">
ACTIV
</span>
)}
</div>
<p className="text-xs text-ink-faint leading-relaxed">{purpose.desc}</p>
{record && (
<p className="text-[10px] text-ink-faint">
Acordat la {new Date(record.grantedAt).toLocaleDateString('ro-RO', {
day: 'numeric', month: 'long', year: 'numeric',
})}
</p>
)}
</div>
<button
disabled={isPending}
onClick={() => toggleConsent({ purpose: purpose.id, active: isActive })}
className={`shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50
${isActive ? 'bg-signal-ok' : 'bg-border'}`}
role="switch"
aria-checked={isActive}
aria-label={purpose.label}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform
${isActive ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
</div>
);
})}
</div>
</div>
))}
{/* History */}
{records.filter((r) => r.revokedAt).length > 0 && (
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint mb-3">
Istoricul consimțămintelor
</h2>
<div className="rounded-xl border bg-card overflow-hidden divide-y divide-border/50">
{records.filter((r) => r.revokedAt).map((r) => (
<div key={r.id} className="px-4 py-3 flex items-center justify-between">
<div>
<p className="text-xs font-medium text-ink-faint">
{PURPOSES.find((p) => p.id === r.purpose)?.label ?? r.purpose}
</p>
<p className="text-[10px] text-ink-faint/60">
Acordat {new Date(r.grantedAt).toLocaleDateString('ro-RO')}
{' · '}Revocat {new Date(r.revokedAt!).toLocaleDateString('ro-RO')}
</p>
</div>
<span className="text-[10px] bg-signal-danger/10 text-signal-danger font-medium px-1.5 py-0.5 rounded-full">
REVOCAT
</span>
</div>
))}
</div>
</div>
)}
<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">Drepturile tale:</strong> Poți retrage orice consimțământ în orice moment.
Revocarea nu afectează procesările anterioare efectuate legal. Datele procesate pe baza consimțământului
revocat nu sunt șterse automat trimite o cerere de ștergere din secțiunea Export & Ștergere.
</p>
</div>
</div>
)}
</div>
);
}