Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { AlertTriangle, CheckCircle2, Eraser, RefreshCw, ShieldCheck } from 'lucide-react';
|
|
|
|
import {
|
|
getRetentionPreview,
|
|
getRetentionRules,
|
|
getRightsRequests,
|
|
runRetentionPurge,
|
|
type RetentionLine,
|
|
type RetentionRule,
|
|
type RightsRequest,
|
|
} from '@/lib/api/compliance';
|
|
import {
|
|
CONTROLLER,
|
|
SUBPROCESSORS,
|
|
missingControllerFields,
|
|
} from '@/lib/legal/processing';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Callout } from '@/components/ui/callout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { PageHeader } from '@/components/ui/PageHeader';
|
|
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
|
|
import { useConfirm } from '@/components/ui/use-confirm';
|
|
import { useToast } from '@/components/ui/toast';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
|
|
/**
|
|
* Console de conformite.
|
|
*
|
|
* Elle repond a trois questions qu'on ne pouvait poser nulle part : que
|
|
* declare-t-on, que conserve-t-on encore au-dela de la duree annoncee, et
|
|
* a-t-on trace des demandes de droits. Le premier bloc n'est pas decoratif :
|
|
* une politique de confidentialite sans identite du responsable est
|
|
* incomplete au sens de l'article 13, et c'est ici qu'on le voit.
|
|
*/
|
|
export default function CompliancePage() {
|
|
const t = useTranslations('admin.compliance');
|
|
const tProcessor = useTranslations('marketing.privacy.processors');
|
|
const tBasis = useTranslations('marketing.privacy.recipientsTable.bases');
|
|
const locale = useLocale();
|
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
const confirm = useConfirm();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const rules = useQuery({ queryKey: ['retention-rules'], queryFn: getRetentionRules });
|
|
const preview = useQuery({ queryKey: ['retention-preview'], queryFn: getRetentionPreview });
|
|
const requests = useQuery({ queryKey: ['rights-requests'], queryFn: getRightsRequests });
|
|
|
|
const purge = useMutation({
|
|
mutationFn: runRetentionPurge,
|
|
onSuccess: report => {
|
|
const removed = report.lines.reduce((sum, line) => sum + line.expired, 0);
|
|
toast.success(t('purge.done', { count: removed }));
|
|
queryClient.invalidateQueries({ queryKey: ['retention-preview'] });
|
|
queryClient.invalidateQueries({ queryKey: ['rights-requests'] });
|
|
},
|
|
onError: () => toast.error(t('purge.failed')),
|
|
});
|
|
|
|
const missing = missingControllerFields();
|
|
const dateTime = (value: string) =>
|
|
new Date(value).toLocaleString(dateLocale, { dateStyle: 'medium', timeStyle: 'short' });
|
|
|
|
const expiredTotal =
|
|
preview.data?.lines.reduce((sum: number, line: RetentionLine) => sum + line.expired, 0) ?? 0;
|
|
|
|
const askPurge = async () => {
|
|
const ok = await confirm({
|
|
title: t('purge.confirmTitle'),
|
|
description: t('purge.confirmBody', { count: expiredTotal }),
|
|
confirmLabel: t('purge.action'),
|
|
destructive: true,
|
|
});
|
|
if (ok) purge.mutate();
|
|
};
|
|
|
|
if (rules.isPending || preview.isPending) return <PageSpinner />;
|
|
if (rules.isError || preview.isError) {
|
|
return (
|
|
<ErrorState
|
|
title={t('errorTitle')}
|
|
description={t('errorBody')}
|
|
onRetry={() => {
|
|
rules.refetch();
|
|
preview.refetch();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title={t('title')}
|
|
description={t('description')}
|
|
actions={
|
|
<Button variant="outline" onClick={() => preview.refetch()}>
|
|
<RefreshCw />
|
|
{t('refresh')}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{/* Mentions obligatoires manquantes ------------------------------- */}
|
|
{missing.length > 0 ? (
|
|
<Callout variant="warning" title={t('identity.incompleteTitle')}>
|
|
<p>{t('identity.incompleteBody')}</p>
|
|
<ul className="mt-2 list-disc pl-5">
|
|
{missing.map(field => (
|
|
<li key={field}>{t(`identity.fields.${field}`)}</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-2 text-body-xs text-neutral-500">{t('identity.where')}</p>
|
|
</Callout>
|
|
) : (
|
|
<Callout variant="success" title={t('identity.completeTitle')}>
|
|
{t('identity.completeBody', { name: CONTROLLER.tradingName })}
|
|
</Callout>
|
|
)}
|
|
|
|
{/* Conservation --------------------------------------------------- */}
|
|
<Card>
|
|
<CardHeader className="flex-row items-center justify-between gap-4 pb-3">
|
|
<div>
|
|
<CardTitle>{t('retention.title')}</CardTitle>
|
|
<p className="mt-1 text-body-sm text-neutral-500">{t('retention.subtitle')}</p>
|
|
</div>
|
|
<Button
|
|
variant={expiredTotal > 0 ? 'default' : 'outline'}
|
|
disabled={expiredTotal === 0 || purge.isPending}
|
|
onClick={askPurge}
|
|
>
|
|
<Eraser />
|
|
{t('purge.action')}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* La purge automatique supprime definitivement des lignes :
|
|
son etat doit se lire sans avoir a ouvrir la configuration. */}
|
|
<Callout
|
|
variant={preview.data.enabled ? 'info' : 'warning'}
|
|
className="mb-4"
|
|
title={
|
|
preview.data.enabled ? t('retention.autoOn') : t('retention.autoOff')
|
|
}
|
|
>
|
|
{preview.data.enabled ? t('retention.autoOnBody') : t('retention.autoOffBody')}
|
|
</Callout>
|
|
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('retention.table')}</TableHead>
|
|
<TableHead>{t('retention.duration')}</TableHead>
|
|
<TableHead>{t('retention.onErasure')}</TableHead>
|
|
<TableHead className="text-right">{t('retention.expired')}</TableHead>
|
|
<TableHead>{t('retention.basis')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rules.data.rules.map((rule: RetentionRule) => {
|
|
const line = preview.data.lines.find(item => item.table === rule.table);
|
|
return (
|
|
<TableRow key={rule.table}>
|
|
<TableCell className="font-mono text-body-xs">{rule.table}</TableCell>
|
|
<TableCell>
|
|
{rule.months === null
|
|
? t('retention.accountLifetime')
|
|
: t('retention.months', { count: rule.months })}
|
|
</TableCell>
|
|
<TableCell>{t(`retention.actions.${rule.onErasure}`)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{line ? (
|
|
<span
|
|
className={
|
|
line.expired > 0 ? 'font-semibold text-amber-600' : 'text-neutral-400'
|
|
}
|
|
>
|
|
{line.expired}
|
|
</span>
|
|
) : (
|
|
<span className="text-neutral-300">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="max-w-md text-body-xs text-neutral-500">
|
|
{rule.basis}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Registre des sous-traitants ------------------------------------ */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>{t('processors.title')}</CardTitle>
|
|
<p className="mt-1 text-body-sm text-neutral-500">{t('processors.subtitle')}</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('processors.name')}</TableHead>
|
|
<TableHead>{t('processors.role')}</TableHead>
|
|
<TableHead>{t('processors.location')}</TableHead>
|
|
<TableHead>{t('processors.basis')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{SUBPROCESSORS.map(processor => (
|
|
<TableRow key={processor.key}>
|
|
<TableCell className="font-medium text-brand-navy">
|
|
{processor.name}
|
|
</TableCell>
|
|
<TableCell className="max-w-sm text-body-sm text-neutral-600">
|
|
{tProcessor(`${processor.key}.role`)}
|
|
</TableCell>
|
|
<TableCell className="text-body-sm text-neutral-600">
|
|
{tProcessor(`${processor.key}.location`)}
|
|
</TableCell>
|
|
<TableCell className="text-body-sm text-neutral-600">
|
|
{tBasis(processor.transferBasis)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Demandes de droits --------------------------------------------- */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>{t('requests.title')}</CardTitle>
|
|
<p className="mt-1 text-body-sm text-neutral-500">{t('requests.subtitle')}</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{requests.data && requests.data.requests.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('requests.date')}</TableHead>
|
|
<TableHead>{t('requests.action')}</TableHead>
|
|
<TableHead>{t('requests.subject')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{requests.data.requests.map((entry: RightsRequest, index: number) => (
|
|
<TableRow key={`${entry.timestamp}-${index}`}>
|
|
<TableCell className="whitespace-nowrap text-body-sm">
|
|
{dateTime(entry.timestamp)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="inline-flex items-center gap-1.5 text-body-sm">
|
|
{entry.action === 'gdpr_erasure_executed' ? (
|
|
<Eraser className="size-3.5 text-amber-600" aria-hidden="true" />
|
|
) : (
|
|
<CheckCircle2 className="size-3.5 text-success" aria-hidden="true" />
|
|
)}
|
|
{t(`requests.actions.${entry.action}`)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-body-xs text-neutral-500">
|
|
{entry.user_email}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
size="sm"
|
|
icon={ShieldCheck}
|
|
title={t('requests.emptyTitle')}
|
|
description={t('requests.emptyBody')}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<p className="flex items-start gap-2 px-1 text-body-xs text-neutral-500">
|
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
|
{t('disclaimer')}
|
|
</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|