feat(CC-057): add Consent Management page with toggle switches per purpose + history
This commit is contained in:
parent
cba4574fa2
commit
0870dab760
1 changed files with 185 additions and 0 deletions
185
src/app/dashboard/privacy/consents/page.tsx
Normal file
185
src/app/dashboard/privacy/consents/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue