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