Compare commits

..

10 Commits

Author SHA1 Message Date
David
8446f879b6 merge: integrer feat/conformite-rgpd 2026-09-07 21:42:36 +02:00
David
a491bdb79d merge: integrer feat/notifications 2026-09-07 21:42:36 +02:00
David
f418443d0c merge: integrer fix/messages-erreur-api 2026-09-07 21:42:36 +02:00
David
0a64382c06 merge: integrer feat/mcp-capacites 2026-09-07 21:42:36 +02:00
David
8486f38a90 merge: integrer feat/suppression-reservation 2026-09-07 21:42:36 +02:00
David
1ec36d2a35 merge: integrer feat/assistant-ia 2026-09-07 21:42:36 +02:00
David
b235e3f382 merge: integrer feat/admin-offre-platinium 2026-09-07 21:42:36 +02:00
David
7df9fd41c1 merge: integrer feat/mise-en-prod 2026-09-07 21:42:36 +02:00
David
c4c70862c1 merge: integrer chore/outillage-et-format 2026-09-07 21:42:36 +02:00
David
2ab6b682a3 feat(ui): console de conformite pour l administration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:58 +02:00
4 changed files with 378 additions and 1 deletions

View File

@ -0,0 +1,309 @@
'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>
</>
);
}

View File

@ -66,7 +66,7 @@ describe('buildAppNav', () => {
});
describe('buildAdminNav', () => {
it('expose les huit destinations d administration', () => {
it('expose les neuf destinations d administration', () => {
const groups = buildAdminNav({ t, tShell });
const keys = groups.flatMap(g => g.items).map(i => i.key);
expect(keys).toEqual([
@ -77,6 +77,7 @@ describe('buildAdminNav', () => {
'csvRates',
'blog',
'assistant',
'compliance',
'logs',
]);
});

View File

@ -9,6 +9,7 @@ import {
Package,
ScrollText,
Search,
ShieldCheck,
Users,
type LucideIcon,
} from 'lucide-react';
@ -177,6 +178,12 @@ export function buildAdminNav({ t, tShell }: AdminNavParams): NavGroup[] {
label: tShell('groups.system'),
items: [
{ 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 },
],
},

View File

@ -0,0 +1,60 @@
/**
* 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');
}