Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
79bf707
feat(onboarding): boutons d'aide « i » sur les modules + guides
D4kooo May 29, 2026
a6d3b03
fix(a11y): accessibility, theming & perf pass across all modules
D4kooo May 31, 2026
baa5aa2
chore(deps): override tmp + postcss (correctifs audit)
D4kooo Jun 1, 2026
b4a05c4
feat(projects): projet = dossier + RAG scopé (docs + conversations)
D4kooo Jun 1, 2026
0d57b9d
feat(chat): vérité des coûts & contrôle d'exécution (sprint prod-read…
D4kooo Jun 1, 2026
95ea27c
fix(rag): intégrité & transparence du RAG (sprint prod-ready P3)
D4kooo Jun 1, 2026
d1210d7
feat(rag): transparence de l'indexation + réindexation (sprint prod-r…
D4kooo Jun 1, 2026
e4f78ab
feat(documents): import multi-fichiers, feedback de rejet, déplacemen…
D4kooo Jun 1, 2026
fb420aa
feat(chat): citations Légifrance & Pappers cliquables (sprint prod-re…
D4kooo Jun 1, 2026
4802921
feat(chat): trail d'audit multi-agents persistant + export JSON (P2 H…
D4kooo Jun 1, 2026
360762b
feat(chat): afficher les compétences (skills) appliquées (P2 H4)
D4kooo Jun 1, 2026
ca35ca2
feat(tabular): export CSV, format de colonne, timeout des lignes coin…
D4kooo Jun 1, 2026
38c8928
feat(tabular): ré-extraction ciblée (ligne/colonne) + ajout de docume…
D4kooo Jun 1, 2026
991b13d
feat(settings): connecteurs honnêtes + prix par modèle (P5 R4/H23)
D4kooo Jun 1, 2026
077d66c
feat(onboarding): checklist de readiness + test connexion providers s…
D4kooo Jun 1, 2026
3ae96ef
feat(connectors): test de connexion PISTE/Pappers (P5 R5)
D4kooo Jun 1, 2026
31cd0d6
feat(settings): OVH liste curée + MCP auto-sync à la création (P5 H26…
D4kooo Jun 1, 2026
f454728
feat(shell): filets loading/error/not-found/global-error + fix a11y p…
D4kooo Jun 1, 2026
ec84bac
feat(ui): renommer « Bureau »→« Board » et « Workflows »→« Trames » +…
D4kooo Jun 1, 2026
87941d7
feat(board): implémenter les agents Rédacteur + Légifrance (P6 H12b/H12)
D4kooo Jun 1, 2026
87c5248
fix(board): canvas honnête — handles masqués, drag-réordonnancement s…
D4kooo Jun 1, 2026
532ec0e
feat(admin): journal d'audit exploitable — filtres, pagination, meta,…
D4kooo Jun 1, 2026
3b2bdfb
fix(a11y): focus-ring harmonisé (ring-3) + feedback admin annoncé (ro…
D4kooo Jun 1, 2026
7eae23e
feat(board): allowlist d'outils en multi-select (P6 H11)
D4kooo Jun 1, 2026
c35af0d
refactor(ui): unify relative-time + empty-state into shared primitives
D4kooo Jun 1, 2026
37f8513
fix(a11y): lift compounded low-contrast micro-labels off the 9px floor
D4kooo Jun 1, 2026
5e2ff18
fix(settings): surface toggle failures instead of swallowing them (H25)
D4kooo Jun 1, 2026
4c67b33
refactor(nav): single source of truth for primary navigation (H29)
D4kooo Jun 1, 2026
1272df7
feat(documents): compare two versions of a document (H19)
D4kooo Jun 1, 2026
e17ed4b
feat(board): council round-awareness + synthesis-failure fallback (H10)
D4kooo Jun 1, 2026
9ce88e9
feat(board): vue-liste verticale du board sur mobile (H7)
D4kooo Jun 1, 2026
bfd9781
feat(board): RAG par agent — backbone hérite/aucun (Lot 1a)
D4kooo Jun 2, 2026
73ac0bd
feat(board): RAG par agent — portées dossiers / documents (Lot 1b)
D4kooo Jun 2, 2026
1022baa
feat(board): RAG par agent — badge de transparence sur le nœud (Lot 1c)
D4kooo Jun 2, 2026
354f336
feat(board): changement de rôle in-place d'un agent (Lot 2a)
D4kooo Jun 2, 2026
175beb6
feat(board): température par agent (Lot 2b)
D4kooo Jun 2, 2026
4d7376c
feat(board): panneau d'ordre d'exécution explicite (Lot 3)
D4kooo Jun 2, 2026
be0383b
fix(chat): accès permanent à la Salle de délibération depuis chaque m…
D4kooo Jun 2, 2026
ff1f6d6
merge: réconcilie dataring/main (squashes #4/#12) dans feat/board-cus…
D4kooo Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 186 additions & 55 deletions src/app/(app)/admin/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import { desc, eq } from "drizzle-orm";
import { and, count, desc, eq, gte, lte } from "drizzle-orm";
import Link from "next/link";
import { IconDownload } from "@tabler/icons-react";
import { db } from "@/db";
import { auditLog, users } from "@/db/schema";
import { requireAdmin } from "@/lib/auth/permissions";
import { labelForAction, AUDIT_ACTION_OPTIONS } from "@/lib/audit/labels";
import { EmptyState } from "@/components/empty-state";

const ACTION_LABEL: Record<string, string> = {
"user.create": "Utilisateur créé",
"user.update": "Utilisateur modifié",
"user.disable": "Utilisateur désactivé",
"user.enable": "Utilisateur réactivé",
"user.delete": "Utilisateur supprimé",
"user.role": "Rôle modifié",
"provider.add": "Clé provider ajoutée",
"provider.delete": "Clé provider supprimée",
"provider.toggle": "Clé provider activée/désactivée",
"connector.add": "Connecteur ajouté",
"connector.delete": "Connecteur supprimé",
"doc.delete": "Document supprimé",
"cabinet.update": "Configuration cabinet modifiée",
"auth.login": "Connexion",
"auth.login.failed": "Échec de connexion",
};
const PAGE_SIZE = 50;

export default async function AdminAuditPage() {
type SP = { action?: string; from?: string; to?: string; page?: string };

export default async function AdminAuditPage({
searchParams,
}: {
searchParams: Promise<SP>;
}) {
await requireAdmin();
const sp = await searchParams;

const page = Math.max(0, Number.parseInt(sp.page ?? "0", 10) || 0);
const action = sp.action && sp.action !== "all" ? sp.action : null;
const from = sp.from ? new Date(sp.from) : null;
const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null;

const conds = [];
if (action) conds.push(eq(auditLog.action, action));
if (from && !Number.isNaN(from.getTime()))
conds.push(gte(auditLog.createdAt, from));
if (to && !Number.isNaN(to.getTime())) conds.push(lte(auditLog.createdAt, to));
const where = conds.length > 0 ? and(...conds) : undefined;

const [{ total }] = await db
.select({ total: count() })
.from(auditLog)
.where(where);

const rows = await db
.select({
id: auditLog.id,
Expand All @@ -35,56 +48,174 @@ export default async function AdminAuditPage() {
})
.from(auditLog)
.leftJoin(users, eq(users.id, auditLog.userId))
.where(where)
.orderBy(desc(auditLog.createdAt))
.limit(200);
.limit(PAGE_SIZE)
.offset(page * PAGE_SIZE);

const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const filterQs = new URLSearchParams();
if (action) filterQs.set("action", action);
if (sp.from) filterQs.set("from", sp.from);
if (sp.to) filterQs.set("to", sp.to);
const exportQs = filterQs.toString();
const pageQs = (p: number) => {
const q = new URLSearchParams(filterQs);
if (p > 0) q.set("page", String(p));
const s = q.toString();
return s ? `/admin/audit?${s}` : "/admin/audit";
};

return (
<main className="mx-auto w-full max-w-5xl px-6 py-10 md:px-8 md:py-12">
<header className="mb-10">
<h1 className="font-heading text-3xl tracking-tight">
Journal d&apos;audit
</h1>
<p className="mt-2 text-muted-foreground max-w-2xl">
200 dernières actions enregistrées : créations/modifications de
comptes, MAJ providers, suppressions, événements d&apos;authentification.
Append-only.
</p>
<header className="mb-8 flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="font-heading text-3xl tracking-tight">
Journal d&apos;audit
</h1>
<p className="mt-2 text-muted-foreground max-w-2xl">
{total} action{total > 1 ? "s" : ""} enregistrée{total > 1 ? "s" : ""}.
Append-only — créations/modifications de comptes, providers,
connecteurs, suppressions, authentification.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={`/api/admin/audit/export?format=csv${exportQs ? `&${exportQs}` : ""}`}
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-sm transition-colors hover:bg-accent"
>
<IconDownload className="size-4" />
CSV
</a>
<a
href={`/api/admin/audit/export?format=json${exportQs ? `&${exportQs}` : ""}`}
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-sm transition-colors hover:bg-accent"
>
<IconDownload className="size-4" />
JSON
</a>
</div>
</header>

{/* Filtres — formulaire GET, server component */}
<form
method="get"
className="mb-6 flex flex-wrap items-end gap-3 border-y border-border py-4"
>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Action
<select
name="action"
defaultValue={action ?? "all"}
className="h-9 rounded-md border border-input bg-card px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/40"
>
<option value="all">Toutes</option>
{AUDIT_ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Du
<input
type="date"
name="from"
defaultValue={sp.from ?? ""}
className="h-9 rounded-md border border-input bg-card px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/40"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Au
<input
type="date"
name="to"
defaultValue={sp.to ?? ""}
className="h-9 rounded-md border border-input bg-card px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/40"
/>
</label>
<button
type="submit"
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm text-primary-foreground transition-opacity hover:opacity-90"
>
Filtrer
</button>
{(action || sp.from || sp.to) && (
<Link
href="/admin/audit"
className="inline-flex h-9 items-center px-2 text-sm text-muted-foreground hover:text-foreground"
>
Réinitialiser
</Link>
)}
</form>

{rows.length === 0 ? (
<div className="border border-dashed border-border rounded-lg p-10 text-center">
<p className="font-heading text-lg">Journal vide.</p>
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
Les actions admin et les événements de sécurité seront enregistrés
ici dès qu&apos;ils auront lieu.
</p>
</div>
<EmptyState
title={
total === 0 ? "Journal vide." : "Aucun résultat pour ces filtres."
}
>
{total === 0
? "Les actions admin et les événements de sécurité seront enregistrés ici dès qu'ils auront lieu."
: "Élargissez la période ou changez l'action filtrée."}
</EmptyState>
) : (
<ul className="divide-y divide-border border-y border-border">
{rows.map((r) => (
<li
key={r.id}
className="py-3 grid grid-cols-[auto_1fr_auto] gap-x-6 items-baseline"
>
<span className="font-heading text-sm tracking-tight whitespace-nowrap">
{ACTION_LABEL[r.action] ?? r.action}
</span>
<span className="text-sm text-muted-foreground truncate min-w-0">
{r.actorName ?? r.actorEmail ?? <em>système</em>}
{r.target && (
<>
{" "}
<span className="text-xs">→ {r.target}</span>
</>
)}
</span>
<time className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{new Date(r.createdAt).toLocaleString("fr-FR")}
</time>
<li key={r.id} className="py-3">
<div className="grid grid-cols-[auto_1fr_auto] gap-x-6 items-baseline">
<span className="font-heading text-sm tracking-tight whitespace-nowrap">
{labelForAction(r.action)}
</span>
<span className="text-sm text-muted-foreground truncate min-w-0">
{r.actorName ?? r.actorEmail ?? <em>système</em>}
{r.target && <span className="text-xs"> → {r.target}</span>}
</span>
<time className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{new Date(r.createdAt).toLocaleString("fr-FR")}
</time>
</div>
{r.meta != null && Object.keys(r.meta as object).length > 0 && (
<p className="mt-1 text-[11px] text-muted-foreground/80 font-mono break-all">
{Object.entries(r.meta as Record<string, unknown>)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(" · ")}
</p>
)}
</li>
))}
</ul>
)}

{totalPages > 1 && (
<nav className="mt-6 flex items-center justify-between text-sm">
{page > 0 ? (
<Link
href={pageQs(page - 1)}
className="inline-flex h-9 items-center rounded-md border border-border px-3 hover:bg-accent transition-colors"
>
← Précédent
</Link>
) : (
<span />
)}
<span className="text-xs text-muted-foreground tabular-nums">
Page {page + 1} / {totalPages}
</span>
{page + 1 < totalPages ? (
<Link
href={pageQs(page + 1)}
className="inline-flex h-9 items-center rounded-md border border-border px-3 hover:bg-accent transition-colors"
>
Suivant →
</Link>
) : (
<span />
)}
</nav>
)}
</main>
);
}
2 changes: 1 addition & 1 deletion src/app/(app)/admin/cabinet/cabinet-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function CabinetForm({
maxLength={1000}
defaultValue={initial?.legalDisclaimer ?? ""}
placeholder="Document généré par Louis. Ne constitue pas un conseil juridique personnalisé sans validation par un avocat."
className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<p className="text-xs text-muted-foreground">
Ajoutée en dernière page des documents générés. Vous pouvez la
Expand Down
3 changes: 2 additions & 1 deletion src/app/(app)/admin/users/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@/db/schema";
import { requireAdmin } from "@/lib/auth/permissions";
import { aggregateCosts, formatTotals } from "@/lib/providers/pricing";
import { labelForAction } from "@/lib/audit/labels";
import { Badge } from "@/components/ui/badge";

export default async function AdminUserDetailPage({
Expand Down Expand Up @@ -261,7 +262,7 @@ export default async function AdminUserDetailPage({
className="py-3 flex items-start justify-between gap-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-mono truncate">{a.action}</p>
<p className="text-sm truncate">{labelForAction(a.action)}</p>
{a.target && (
<p className="text-[11px] text-muted-foreground truncate">
{a.target}
Expand Down
27 changes: 10 additions & 17 deletions src/app/(app)/admin/users/user-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
IconTrash,
} from "@tabler/icons-react";
import { Badge } from "@/components/ui/badge";
import { formatRelativeFr } from "@/lib/format/time";
import {
DropdownMenu,
DropdownMenuContent,
Expand Down Expand Up @@ -63,20 +64,6 @@ export type UserStats = {

export type UserEntryWithStats = Entry & { stats: UserStats };

function formatRelativeFr(d: Date | string | null): string {
if (!d) return "jamais utilisé";
const date = typeof d === "string" ? new Date(d) : d;
const ms = Date.now() - date.getTime();
const m = Math.floor(ms / 60_000);
if (m < 1) return "à l'instant";
if (m < 60) return `il y a ${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `il y a ${h} h`;
const days = Math.floor(h / 24);
if (days < 30) return `il y a ${days} j`;
if (days < 365) return `il y a ${Math.floor(days / 30)} mois`;
return date.toLocaleDateString("fr-FR");
}

function formatEurFromCents(cents: number | null): string {
if (cents == null) return "—";
Expand All @@ -86,7 +73,7 @@ function formatEurFromCents(cents: number | null): string {
function StatCell({ label, value }: { label: string; value: number }) {
return (
<div className="text-right">
<div className="text-[9px] uppercase tracking-wider opacity-70">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-0.5 font-medium text-foreground">{value}</div>
Expand Down Expand Up @@ -218,7 +205,13 @@ export function UserRow({
: "jamais utilisé"}
</div>
{feedback && (
<div className="text-xs text-success mt-1">{feedback}</div>
<div
role="status"
aria-live="polite"
className="text-xs text-success mt-1"
>
{feedback}
</div>
)}

{/* Résumé compact des stats sur mobile : la rangée détaillée
Expand Down Expand Up @@ -273,7 +266,7 @@ export function UserRow({
<StatCell label="Docs" value={entry.stats.docCount} />
<StatCell label="Projets" value={entry.stats.projectCount} />
<div className="text-right">
<div className="text-[9px] uppercase tracking-wider opacity-70">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Ce mois
</div>
<div
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/admin/users/users-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function UsersTable({ rows, currentUserId, nowMs }: Props) {
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher par email ou nom…"
aria-label="Rechercher un utilisateur"
className="w-full rounded-md border border-input bg-background pl-8 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
className="w-full rounded-md border border-input bg-background pl-8 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<div className="flex items-center gap-1">
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/board/[id]/add-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ type Role =
const ROLE_OPTIONS: Role[] = [
"default-chat",
"research",
"legifrance",
"citator",
"reviewer",
"drafting",
"orchestrator",
];

Expand Down
Loading
Loading