Compare commits
10 Commits
5185dfb405
...
8446f879b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8446f879b6 | ||
|
|
a491bdb79d | ||
|
|
f418443d0c | ||
|
|
0a64382c06 | ||
|
|
8486f38a90 | ||
|
|
1ec36d2a35 | ||
|
|
b235e3f382 | ||
|
|
7df9fd41c1 | ||
|
|
c4c70862c1 | ||
|
|
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', () => {
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
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