Compare commits
No commits in common. "8446f879b676b303fdb2891388f88ff7e43f5fea" and "5185dfb405fa5c176af03101fe73376b8c85f7ec" have entirely different histories.
8446f879b6
...
5185dfb405
@ -1,309 +0,0 @@
|
|||||||
'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -66,7 +66,7 @@ describe('buildAppNav', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildAdminNav', () => {
|
describe('buildAdminNav', () => {
|
||||||
it('expose les neuf destinations d administration', () => {
|
it('expose les huit destinations d administration', () => {
|
||||||
const groups = buildAdminNav({ t, tShell });
|
const groups = buildAdminNav({ t, tShell });
|
||||||
const keys = groups.flatMap(g => g.items).map(i => i.key);
|
const keys = groups.flatMap(g => g.items).map(i => i.key);
|
||||||
expect(keys).toEqual([
|
expect(keys).toEqual([
|
||||||
@ -77,7 +77,6 @@ describe('buildAdminNav', () => {
|
|||||||
'csvRates',
|
'csvRates',
|
||||||
'blog',
|
'blog',
|
||||||
'assistant',
|
'assistant',
|
||||||
'compliance',
|
|
||||||
'logs',
|
'logs',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -178,12 +177,6 @@ export function buildAdminNav({ t, tShell }: AdminNavParams): NavGroup[] {
|
|||||||
label: tShell('groups.system'),
|
label: tShell('groups.system'),
|
||||||
items: [
|
items: [
|
||||||
{ key: 'assistant', label: t('items.assistant'), href: '/admin/assistant', icon: Bot },
|
{ key: 'assistant', label: t('items.assistant'), href: '/admin/assistant', icon: Bot },
|
||||||
{
|
|
||||||
key: 'compliance',
|
|
||||||
label: t('items.compliance'),
|
|
||||||
href: '/admin/compliance',
|
|
||||||
icon: ShieldCheck,
|
|
||||||
},
|
|
||||||
{ key: 'logs', label: t('items.logs'), href: '/admin/logs', icon: ScrollText },
|
{ key: 'logs', label: t('items.logs'), href: '/admin/logs', icon: ScrollText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Conformité — vue d'administration.
|
|
||||||
*
|
|
||||||
* Les durées de conservation viennent du serveur, pas d'une copie côté client :
|
|
||||||
* ce sont celles que le code applique réellement, et c'est précisément ce
|
|
||||||
* qu'une console de conformité doit montrer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { get, post } from './client';
|
|
||||||
|
|
||||||
export interface RetentionRule {
|
|
||||||
table: string;
|
|
||||||
onErasure: 'delete' | 'anonymise' | 'keep';
|
|
||||||
months: number | null;
|
|
||||||
timestampColumn: string | null;
|
|
||||||
keepWhere: string | null;
|
|
||||||
basis: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RetentionLine {
|
|
||||||
table: string;
|
|
||||||
months: number;
|
|
||||||
/** Lignes ayant dépassé la durée de conservation. */
|
|
||||||
expired: number;
|
|
||||||
oldest: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RetentionReport {
|
|
||||||
/** La purge automatique est-elle activée sur cet environnement ? */
|
|
||||||
enabled: boolean;
|
|
||||||
runAt: string;
|
|
||||||
lines: RetentionLine[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RightsRequest {
|
|
||||||
action: string;
|
|
||||||
status: string;
|
|
||||||
user_id: string;
|
|
||||||
user_email: string;
|
|
||||||
organization_id: string;
|
|
||||||
metadata: Record<string, unknown> | null;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRetentionRules(): Promise<{ rules: RetentionRule[] }> {
|
|
||||||
return get('/api/v1/gdpr/retention');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRetentionPreview(): Promise<RetentionReport> {
|
|
||||||
return get('/api/v1/gdpr/admin/retention/preview');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Supprime définitivement les lignes arrivées à échéance. */
|
|
||||||
export async function runRetentionPurge(): Promise<RetentionReport> {
|
|
||||||
return post('/api/v1/gdpr/admin/retention/purge', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRightsRequests(): Promise<{ requests: RightsRequest[] }> {
|
|
||||||
return get('/api/v1/gdpr/admin/requests');
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user