diff --git a/apps/frontend/app/[locale]/admin/compliance/page.tsx b/apps/frontend/app/[locale]/admin/compliance/page.tsx new file mode 100644 index 0000000..1ebae78 --- /dev/null +++ b/apps/frontend/app/[locale]/admin/compliance/page.tsx @@ -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 ; + if (rules.isError || preview.isError) { + return ( + { + rules.refetch(); + preview.refetch(); + }} + /> + ); + } + + return ( + <> + preview.refetch()}> + + {t('refresh')} + + } + /> + +
+ {/* Mentions obligatoires manquantes ------------------------------- */} + {missing.length > 0 ? ( + +

{t('identity.incompleteBody')}

+
    + {missing.map(field => ( +
  • {t(`identity.fields.${field}`)}
  • + ))} +
+

{t('identity.where')}

+
+ ) : ( + + {t('identity.completeBody', { name: CONTROLLER.tradingName })} + + )} + + {/* Conservation --------------------------------------------------- */} + + +
+ {t('retention.title')} +

{t('retention.subtitle')}

+
+ +
+ + {/* La purge automatique supprime definitivement des lignes : + son etat doit se lire sans avoir a ouvrir la configuration. */} + + {preview.data.enabled ? t('retention.autoOnBody') : t('retention.autoOffBody')} + + +
+ + + + {t('retention.table')} + {t('retention.duration')} + {t('retention.onErasure')} + {t('retention.expired')} + {t('retention.basis')} + + + + {rules.data.rules.map((rule: RetentionRule) => { + const line = preview.data.lines.find(item => item.table === rule.table); + return ( + + {rule.table} + + {rule.months === null + ? t('retention.accountLifetime') + : t('retention.months', { count: rule.months })} + + {t(`retention.actions.${rule.onErasure}`)} + + {line ? ( + 0 ? 'font-semibold text-amber-600' : 'text-neutral-400' + } + > + {line.expired} + + ) : ( + — + )} + + + {rule.basis} + + + ); + })} + +
+
+
+
+ + {/* Registre des sous-traitants ------------------------------------ */} + + + {t('processors.title')} +

{t('processors.subtitle')}

+
+ +
+ + + + {t('processors.name')} + {t('processors.role')} + {t('processors.location')} + {t('processors.basis')} + + + + {SUBPROCESSORS.map(processor => ( + + + {processor.name} + + + {tProcessor(`${processor.key}.role`)} + + + {tProcessor(`${processor.key}.location`)} + + + {tBasis(processor.transferBasis)} + + + ))} + +
+
+
+
+ + {/* Demandes de droits --------------------------------------------- */} + + + {t('requests.title')} +

{t('requests.subtitle')}

+
+ + {requests.data && requests.data.requests.length > 0 ? ( +
+ + + + {t('requests.date')} + {t('requests.action')} + {t('requests.subject')} + + + + {requests.data.requests.map((entry: RightsRequest, index: number) => ( + + + {dateTime(entry.timestamp)} + + + + {entry.action === 'gdpr_erasure_executed' ? ( + + + + {entry.user_email} + + + ))} + +
+
+ ) : ( + + )} +
+
+ +

+

+
+ + ); +} diff --git a/apps/frontend/src/__tests__/components/nav-config.test.ts b/apps/frontend/src/__tests__/components/nav-config.test.ts index 298749d..6bac4d5 100644 --- a/apps/frontend/src/__tests__/components/nav-config.test.ts +++ b/apps/frontend/src/__tests__/components/nav-config.test.ts @@ -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', ]); }); diff --git a/apps/frontend/src/components/shell/nav-config.ts b/apps/frontend/src/components/shell/nav-config.ts index 17fa150..c8a3a79 100644 --- a/apps/frontend/src/components/shell/nav-config.ts +++ b/apps/frontend/src/components/shell/nav-config.ts @@ -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 }, ], }, diff --git a/apps/frontend/src/lib/api/compliance.ts b/apps/frontend/src/lib/api/compliance.ts new file mode 100644 index 0000000..2e20df4 --- /dev/null +++ b/apps/frontend/src/lib/api/compliance.ts @@ -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 | null; + timestamp: string; +} + +export async function getRetentionRules(): Promise<{ rules: RetentionRule[] }> { + return get('/api/v1/gdpr/retention'); +} + +export async function getRetentionPreview(): Promise { + return get('/api/v1/gdpr/admin/retention/preview'); +} + +/** Supprime définitivement les lignes arrivées à échéance. */ +export async function runRetentionPurge(): Promise { + return post('/api/v1/gdpr/admin/retention/purge', {}); +} + +export async function getRightsRequests(): Promise<{ requests: RightsRequest[] }> { + return get('/api/v1/gdpr/admin/requests'); +}