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
This commit is contained in:
parent
5185dfb405
commit
2ab6b682a3
309
apps/frontend/app/[locale]/admin/compliance/page.tsx
Normal file
309
apps/frontend/app/[locale]/admin/compliance/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -66,7 +66,7 @@ describe('buildAppNav', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildAdminNav', () => {
|
describe('buildAdminNav', () => {
|
||||||
it('expose les huit destinations d administration', () => {
|
it('expose les neuf 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,6 +77,7 @@ describe('buildAdminNav', () => {
|
|||||||
'csvRates',
|
'csvRates',
|
||||||
'blog',
|
'blog',
|
||||||
'assistant',
|
'assistant',
|
||||||
|
'compliance',
|
||||||
'logs',
|
'logs',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -177,6 +178,12 @@ 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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
60
apps/frontend/src/lib/api/compliance.ts
Normal file
60
apps/frontend/src/lib/api/compliance.ts
Normal 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');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user