From a2976ed7c80a65d5bef02f39b45b2e93be743bab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?BITARD=20Micha=C3=ABl?= <michael.bitard@beta.gouv.fr>
Date: Tue, 22 Apr 2025 09:24:06 +0000
Subject: [PATCH] =?UTF-8?q?feat(action):=20les=20utilisateurs=20super=20pe?=
 =?UTF-8?q?uvent=20voir=20les=20modifications=20effectu=C3=A9es=20sur=20Ca?=
 =?UTF-8?q?mino=20(pub/pnm-public/camino!1696)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/api/src/api/rest/logs.queries.ts     |  17 +-
 .../api/src/api/rest/logs.queries.types.ts    |   2 +
 .../api/src/api/rest/mutations.queries.ts     |  73 +++++
 .../src/api/rest/mutations.queries.types.ts   |  52 ++++
 .../api/rest/mutations.test.integration.ts    |  87 ++++++
 packages/api/src/api/rest/mutations.ts        |  59 ++++
 .../migrations/20250408141930_better-logs.ts  |  71 +++++
 packages/api/src/server/rest.ts               |  22 +-
 packages/common/src/date.test.ts              |   4 +
 packages/common/src/date.ts                   |   2 +
 packages/common/src/filters.ts                |   1 +
 packages/common/src/mutations.ts              |  26 ++
 .../common/src/permissions/mutation.test.ts   |   7 +
 packages/common/src/permissions/mutation.ts   |   3 +
 packages/common/src/rest.ts                   |   5 +
 packages/ui/src/components/_common/liste.tsx  |   9 +-
 .../journaux/journaux-api-client.ts           |   7 +
 .../ui/src/components/mutation.stories.tsx    |  36 +++
 .../mutation.stories_snapshots_Default.html   |   9 +
 .../mutation.stories_snapshots_Loading.html   |   5 +
 .../mutation.stories_snapshots_WithError.html |   7 +
 packages/ui/src/components/mutation.tsx       |  63 +++++
 .../ui/src/components/mutations.stories.tsx   |  66 +++++
 .../mutations.stories_snapshots_Default.html  |  73 +++++
 .../mutations.stories_snapshots_Loading.html  | 125 +++++++++
 ...mutations.stories_snapshots_WithError.html | 127 +++++++++
 packages/ui/src/components/mutations.tsx      | 261 ++++++++++++++++++
 ...der.stories_snapshots_CanOpenAnnuaire.html |   1 +
 .../page/header.stories_snapshots_Super.html  |   1 +
 packages/ui/src/components/page/menu.ts       |   3 +-
 .../page/plan.stories_snapshots_Super.html    |   1 +
 packages/ui/src/router/index.ts               |  23 +-
 packages/ui/src/router/routes.ts              |  23 +-
 33 files changed, 1255 insertions(+), 16 deletions(-)
 create mode 100644 packages/api/src/api/rest/mutations.queries.ts
 create mode 100644 packages/api/src/api/rest/mutations.queries.types.ts
 create mode 100644 packages/api/src/api/rest/mutations.test.integration.ts
 create mode 100644 packages/api/src/api/rest/mutations.ts
 create mode 100644 packages/api/src/knex/migrations/20250408141930_better-logs.ts
 create mode 100644 packages/common/src/mutations.ts
 create mode 100644 packages/common/src/permissions/mutation.test.ts
 create mode 100644 packages/common/src/permissions/mutation.ts
 create mode 100644 packages/ui/src/components/mutation.stories.tsx
 create mode 100644 packages/ui/src/components/mutation.stories_snapshots_Default.html
 create mode 100644 packages/ui/src/components/mutation.stories_snapshots_Loading.html
 create mode 100644 packages/ui/src/components/mutation.stories_snapshots_WithError.html
 create mode 100644 packages/ui/src/components/mutation.tsx
 create mode 100644 packages/ui/src/components/mutations.stories.tsx
 create mode 100644 packages/ui/src/components/mutations.stories_snapshots_Default.html
 create mode 100644 packages/ui/src/components/mutations.stories_snapshots_Loading.html
 create mode 100644 packages/ui/src/components/mutations.stories_snapshots_WithError.html
 create mode 100644 packages/ui/src/components/mutations.tsx

diff --git a/packages/api/src/api/rest/logs.queries.ts b/packages/api/src/api/rest/logs.queries.ts
index 44c44e40d..de942e3dc 100644
--- a/packages/api/src/api/rest/logs.queries.ts
+++ b/packages/api/src/api/rest/logs.queries.ts
@@ -6,18 +6,27 @@ import { IInsertLogInternalQuery } from './logs.queries.types'
 import { UtilisateurId } from 'camino-common/src/roles'
 import { CaminoError } from 'camino-common/src/zod-tools'
 import { Effect } from 'effect'
+import { LogMethod } from 'camino-common/src/mutations'
+import { CaminoRequest } from './express-type'
 
 type Log = {
   utilisateur_id: UtilisateurId
   method: string
   path: string
   body: any
+  camino_path: string
+  camino_variables: Record<string, string>
 }
-export const addLog = (pool: Pool, utilisateur_id: UtilisateurId, method: string, path: string, body: unknown): Effect.Effect<void[], CaminoError<EffectDbQueryAndValidateErrors>> =>
-  effectDbQueryAndValidate(insertLogInternal, { utilisateur_id, method, path, body: JSON.stringify(body) }, pool, z.void())
+export const addLog = (
+  pool: Pool,
+  utilisateur_id: UtilisateurId,
+  method: LogMethod,
+  req: Pick<CaminoRequest, 'url' | 'route' | 'body' | 'params'>
+): Effect.Effect<void[], CaminoError<EffectDbQueryAndValidateErrors>> =>
+  effectDbQueryAndValidate(insertLogInternal, { utilisateur_id, method, path: req.url, body: JSON.stringify(req.body), camino_path: req.route.path, camino_variables: req.params }, pool, z.void())
 
 const insertLogInternal = sql<Redefine<IInsertLogInternalQuery, Log, void>>`
-insert into logs (utilisateur_id, path, method, body)
-    values ($ utilisateur_id !, $path !, $ method !, $ body)
+insert into logs (utilisateur_id, path, method, body, camino_path, camino_variables)
+    values ($ utilisateur_id !, $path !, $ method !, $ body, $camino_path!, $camino_variables)
 ;
 `
diff --git a/packages/api/src/api/rest/logs.queries.types.ts b/packages/api/src/api/rest/logs.queries.types.ts
index 9f70bcdef..b774b3ffd 100644
--- a/packages/api/src/api/rest/logs.queries.types.ts
+++ b/packages/api/src/api/rest/logs.queries.types.ts
@@ -4,6 +4,8 @@ export type Json = null | boolean | number | string | Json[] | { [key: string]:
 /** 'InsertLogInternal' parameters type */
 export interface IInsertLogInternalParams {
   body?: Json | null | void;
+  camino_path: string;
+  camino_variables?: Json | null | void;
   method: string;
   path: string;
   utilisateur_id: string;
diff --git a/packages/api/src/api/rest/mutations.queries.ts b/packages/api/src/api/rest/mutations.queries.ts
new file mode 100644
index 000000000..90149f595
--- /dev/null
+++ b/packages/api/src/api/rest/mutations.queries.ts
@@ -0,0 +1,73 @@
+import { sql } from '@pgtyped/runtime'
+import { DBNotFound, EffectDbQueryAndValidateErrors, Redefine, dbNotFoundError, effectDbQueryAndValidate } from '../../pg-database.js'
+import { Pool } from 'pg'
+import { Effect } from 'effect'
+import { CaminoError } from 'camino-common/src/zod-tools.js'
+import { logsMethodValidator, MutationId, mutationIdValidator } from 'camino-common/src/mutations.js'
+import { IGetMutationDbQuery, IGetMutationsDbQuery } from './mutations.queries.types.js'
+import { z } from 'zod'
+import { utilisateurIdValidator } from 'camino-common/src/roles.js'
+
+const mutationDbValidator = z.object({
+  id: mutationIdValidator,
+  datetime: z.date(),
+  method: logsMethodValidator,
+  path: z.string(),
+  utilisateur_nom: z.string(),
+  utilisateur_prenom: z.string(),
+  utilisateur_id: utilisateurIdValidator,
+  camino_path: z.string(),
+  camino_variables: z.record(z.string(), z.string()),
+})
+
+type MutationDb = z.infer<typeof mutationDbValidator>
+
+export const getMutations = (pool: Pool, email: string | undefined): Effect.Effect<MutationDb[], CaminoError<EffectDbQueryAndValidateErrors>> =>
+  effectDbQueryAndValidate(getMutationsDb, { email: `%${email ?? ''}%` }, pool, mutationDbValidator)
+
+const getMutationsDb = sql<Redefine<IGetMutationsDbQuery, { email: string | undefined }, MutationDb>>`
+SELECT
+  l.id,
+  datetime,
+  path,
+  method,
+  u.nom as utilisateur_nom,
+  u.prenom as utilisateur_prenom,
+  u.id as utilisateur_id,
+  camino_path,
+  camino_variables
+FROM logs l
+JOIN utilisateurs u ON u.id = l.utilisateur_id
+WHERE ($email::TEXT = '%%' OR u.email LIKE $email)
+ORDER BY datetime desc
+`
+
+export type GetMutationErrors = EffectDbQueryAndValidateErrors | DBNotFound
+
+const getMutationDbValidator = mutationDbValidator.extend({ body: z.object({}).passthrough() })
+type GetMutationDb = z.infer<typeof getMutationDbValidator>
+export const getMutation = (pool: Pool, mutationId: MutationId): Effect.Effect<GetMutationDb, CaminoError<GetMutationErrors>> =>
+  effectDbQueryAndValidate(getMutationDb, { mutationId }, pool, getMutationDbValidator).pipe(
+    Effect.filterOrFail(
+      result => result.length === 1,
+      () => ({ message: dbNotFoundError })
+    ),
+    Effect.map(result => result[0])
+  )
+
+const getMutationDb = sql<Redefine<IGetMutationDbQuery, { mutationId: MutationId }, GetMutationDb>>`
+SELECT
+  l.id,
+  datetime,
+  path,
+  method,
+  body,
+  u.nom as utilisateur_nom,
+  u.prenom as utilisateur_prenom,
+  u.id as utilisateur_id,
+  camino_path,
+  camino_variables
+FROM logs l
+JOIN utilisateurs u ON u.id = l.utilisateur_id
+WHERE l.id = $mutationId!
+`
diff --git a/packages/api/src/api/rest/mutations.queries.types.ts b/packages/api/src/api/rest/mutations.queries.types.ts
new file mode 100644
index 000000000..c2b7bd734
--- /dev/null
+++ b/packages/api/src/api/rest/mutations.queries.types.ts
@@ -0,0 +1,52 @@
+/** Types generated for queries found in "src/api/rest/mutations.queries.ts" */
+export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
+
+/** 'GetMutationsDb' parameters type */
+export interface IGetMutationsDbParams {
+  email?: string | null | void;
+}
+
+/** 'GetMutationsDb' return type */
+export interface IGetMutationsDbResult {
+  camino_path: string;
+  camino_variables: Json;
+  datetime: Date;
+  id: string;
+  method: string;
+  path: string | null;
+  utilisateur_id: string;
+  utilisateur_nom: string | null;
+  utilisateur_prenom: string | null;
+}
+
+/** 'GetMutationsDb' query type */
+export interface IGetMutationsDbQuery {
+  params: IGetMutationsDbParams;
+  result: IGetMutationsDbResult;
+}
+
+/** 'GetMutationDb' parameters type */
+export interface IGetMutationDbParams {
+  mutationId: string;
+}
+
+/** 'GetMutationDb' return type */
+export interface IGetMutationDbResult {
+  body: Json | null;
+  camino_path: string;
+  camino_variables: Json;
+  datetime: Date;
+  id: string;
+  method: string;
+  path: string | null;
+  utilisateur_id: string;
+  utilisateur_nom: string | null;
+  utilisateur_prenom: string | null;
+}
+
+/** 'GetMutationDb' query type */
+export interface IGetMutationDbQuery {
+  params: IGetMutationDbParams;
+  result: IGetMutationDbResult;
+}
+
diff --git a/packages/api/src/api/rest/mutations.test.integration.ts b/packages/api/src/api/rest/mutations.test.integration.ts
new file mode 100644
index 000000000..e8be1c0f0
--- /dev/null
+++ b/packages/api/src/api/rest/mutations.test.integration.ts
@@ -0,0 +1,87 @@
+import { dbManager } from '../../../tests/db-manager'
+import { restNewCall } from '../../../tests/_utils/index'
+import { afterAll, beforeAll, describe, test, expect, vi, beforeEach } from 'vitest'
+import type { Pool } from 'pg'
+import { ADMINISTRATION_IDS } from 'camino-common/src/static/administrations'
+import { mutationIdValidator } from 'camino-common/src/mutations'
+import { createUtilisateur } from '../../database/queries/utilisateurs.queries'
+import { getCurrent } from 'camino-common/src/date'
+import { newUtilisateurId } from '../../database/models/_format/id-create'
+import { callAndExit } from '../../tools/fp-tools'
+import { addLog } from './logs.queries'
+import { HTTP_STATUS } from 'camino-common/src/http'
+
+console.info = vi.fn()
+console.warn = vi.fn()
+console.error = vi.fn()
+
+beforeEach(() => {
+  vi.resetAllMocks()
+})
+
+let dbPool: Pool
+
+beforeAll(async () => {
+  const { pool } = await dbManager.populateDb()
+  dbPool = pool
+
+  const userId = newUtilisateurId('userIdForMutation')
+  await createUtilisateur(dbPool, {
+    role: 'defaut',
+    id: userId,
+    email: '',
+    nom: '',
+    prenom: '',
+    telephone_fixe: null,
+    telephone_mobile: null,
+    date_creation: getCurrent(),
+    keycloak_id: '',
+  })
+  await callAndExit(
+    addLog(dbPool, userId, 'post', {
+      params: {},
+      url: '/rest/anyurl',
+      body: {},
+      route: { path: 'path' },
+    })
+  )
+})
+
+afterAll(async () => {
+  await dbManager.closeKnex()
+})
+
+describe('getMutations', () => {
+  test('ne peut pas récupérer les mutations si non super', async () => {
+    const tested = await restNewCall(dbPool, '/rest/mutations', {}, { role: 'admin', administrationId: ADMINISTRATION_IDS['DGALN/DEB/EARM2'] })
+
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "message": "Droits insuffisants",
+        "status": 403,
+      }
+    `)
+  })
+  test('récupère les mutations, puis une seule', async () => {
+    let tested = await restNewCall(dbPool, '/rest/mutations', {}, { role: 'super' })
+
+    expect(tested.body, JSON.stringify(tested.body)).toHaveLength(1)
+    const mutationId = tested.body[0].id
+    tested = await restNewCall(dbPool, '/rest/mutations/:mutationId', { mutationId: mutationId }, { role: 'super' })
+    expect(tested.statusCode, JSON.stringify(tested.body)).toBe(HTTP_STATUS.OK)
+  })
+})
+
+describe('getMutation', () => {
+  test('ne peut pas récupérer une mutation si non super', async () => {
+    const mutationId = mutationIdValidator.parse('3b1d61bc-1f62-445c-a18f-22ca95b82fc3')
+    const tested = await restNewCall(dbPool, '/rest/mutations/:mutationId', { mutationId: mutationId }, { role: 'admin', administrationId: ADMINISTRATION_IDS['DGALN/DEB/EARM2'] })
+
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "message": "Droits insuffisants",
+        "status": 403,
+      }
+    `)
+  })
+})
diff --git a/packages/api/src/api/rest/mutations.ts b/packages/api/src/api/rest/mutations.ts
new file mode 100644
index 000000000..301518814
--- /dev/null
+++ b/packages/api/src/api/rest/mutations.ts
@@ -0,0 +1,59 @@
+import { HTTP_STATUS } from 'camino-common/src/http'
+import { RestNewGetCall } from '../../server/rest'
+import { CaminoApiError } from '../../types'
+import { EffectDbQueryAndValidateErrors } from '../../pg-database'
+import { Effect, Match } from 'effect'
+import { GetMutation, Mutation } from 'camino-common/src/mutations'
+import { canReadMutation } from 'camino-common/src/permissions/mutation'
+import { getMutation, GetMutationErrors, getMutations } from './mutations.queries'
+
+export const getRestMutations: RestNewGetCall<'/rest/mutations'> = (rootPipe): Effect.Effect<Mutation[], CaminoApiError<EffectDbQueryAndValidateErrors | typeof droitsInsuffisants>> =>
+  rootPipe.pipe(
+    Effect.filterOrFail(
+      ({ user }) => canReadMutation(user),
+      () => ({ message: droitsInsuffisants })
+    ),
+    Effect.flatMap(({ pool, searchParams }) => getMutations(pool, searchParams.emails)),
+    Effect.map(result => result.map(value => ({ ...value, datetime: value.datetime.toISOString() }))),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.whenOr('Droits insuffisants', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
+        })),
+        Match.exhaustive
+      )
+    )
+  )
+
+const droitsInsuffisants = 'Droits insuffisants' as const
+export const getRestMutation: RestNewGetCall<'/rest/mutations/:mutationId'> = (rootPipe): Effect.Effect<GetMutation, CaminoApiError<GetMutationErrors | typeof droitsInsuffisants>> =>
+  rootPipe.pipe(
+    Effect.filterOrFail(
+      ({ user }) => canReadMutation(user),
+      () => ({ message: droitsInsuffisants })
+    ),
+    Effect.flatMap(({ pool, params }) => getMutation(pool, params.mutationId)),
+    Effect.map(value => ({ ...value, datetime: value.datetime.toISOString(), body: JSON.stringify(value.body) })),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
+        })),
+        Match.whenOr('Droits insuffisants', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.whenOr('Élément non trouvé dans la base de données', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.NOT_FOUND,
+        })),
+        Match.exhaustive
+      )
+    )
+  )
diff --git a/packages/api/src/knex/migrations/20250408141930_better-logs.ts b/packages/api/src/knex/migrations/20250408141930_better-logs.ts
new file mode 100644
index 000000000..24509c871
--- /dev/null
+++ b/packages/api/src/knex/migrations/20250408141930_better-logs.ts
@@ -0,0 +1,71 @@
+import { NewPostRestRoutes, NewPutRestRoutes, DeleteRestRoutes, NewDeleteRestRoutes } from 'camino-common/src/rest'
+import { Knex } from 'knex'
+
+// On peut trouver toutes les routes qui mutent avec PostRestRoutes | NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes
+export const up = async (knex: Knex): Promise<void> => {
+  await knex.raw('alter table logs add column id uuid DEFAULT gen_random_uuid() PRIMARY KEY')
+  await knex.raw('alter table logs add column camino_path varchar')
+  await knex.raw('alter table logs add column camino_variables jsonb')
+
+  const logs: { rows: { id: string; datetime: Date; path: string; method: string; body: string; utilisateur_id: string }[] } = await knex.raw('select * from logs')
+
+  for (const log of logs.rows) {
+    let camino_path: NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes
+    let camino_variables: Record<string, string> = {}
+    if (log.path.endsWith('/permission')) {
+      camino_path = '/rest/utilisateurs/:id/permission'
+      camino_variables = { id: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/abonne')) {
+      camino_path = '/rest/titres/:titreId/abonne'
+      camino_variables = { titreId: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/titreLiaisons')) {
+      camino_path = '/rest/titres/:id/titreLiaisons'
+      camino_variables = { id: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/activites/')) {
+      camino_path = '/rest/activites/:activiteId'
+      camino_variables = { activiteId: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/depot')) {
+      camino_path = '/rest/etapes/:etapeId/depot'
+      camino_variables = { etapeId: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/activiteTypeEmails/delete')) {
+      camino_path = '/rest/administrations/:administrationId/activiteTypeEmails/delete'
+      camino_variables = { administrationId: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/activiteTypeEmails')) {
+      camino_path = '/rest/administrations/:administrationId/activiteTypeEmails'
+      camino_variables = { administrationId: log.path.split('/')[3] }
+    } else if (log.path.endsWith('/documents')) {
+      camino_path = '/rest/entreprises/:entrepriseId/documents'
+      camino_variables = { entrepriseId: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/entreprises/') && log.path.includes('/documents/')) {
+      camino_path = '/rest/entreprises/:entrepriseId/documents/:entrepriseDocumentId'
+      camino_variables = { entrepriseId: log.path.split('/')[3], entrepriseDocumentId: log.path.split('/')[5] }
+    } else if (log.path.startsWith('/rest/entreprises/')) {
+      camino_path = '/rest/entreprises/:entrepriseId'
+      camino_variables = { entrepriseId: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/titres/')) {
+      camino_path = '/rest/titres/:titreId'
+      camino_variables = { titreId: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/demarches/')) {
+      camino_path = '/rest/demarches/:demarcheIdOrSlug'
+      camino_variables = { demarcheIdOrSlug: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/etapes/')) {
+      camino_path = '/rest/etapes/:etapeIdOrSlug'
+      camino_variables = { etapeIdOrSlug: log.path.split('/')[3] }
+    } else if (log.path.startsWith('/rest/geojson/import/')) {
+      camino_path = '/rest/geojson/import/:geoSystemeId'
+    } else if (log.path.startsWith('/rest/geojson_forages/import/')) {
+      camino_path = '/rest/geojson_forages/import/:geoSystemeId'
+    } else if (log.path.startsWith('/rest/geojson_points/import/')) {
+      camino_path = '/rest/geojson_points/import/:geoSystemeId'
+    } else {
+      camino_path = log.path as NewPostRestRoutes
+    }
+
+    await knex.raw('UPDATE logs SET camino_path=?, camino_variables=? WHERE id=?', [camino_path, camino_variables, log.id])
+  }
+
+  await knex.raw('ALTER TABLE logs ALTER COLUMN camino_path set not null')
+  await knex.raw('ALTER TABLE logs ALTER COLUMN camino_variables set not null')
+}
+
+export const down = (): void => {}
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 353fd1af8..2844cba4b 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -54,9 +54,11 @@ import { titreDemandeCreer } from '../api/rest/titre-demande'
 import { config } from '../config/index'
 import { addLog } from '../api/rest/logs.queries'
 import { HTTP_STATUS } from 'camino-common/src/http'
-import { zodParseEffectTyped } from '../tools/fp-tools'
+import { callAndExit, zodParseEffectTyped } from '../tools/fp-tools'
 import { Cause, Effect, Exit, Option, pipe } from 'effect'
 import { quickAccessSearch } from '../api/rest/quick-access'
+import { getRestMutation, getRestMutations } from '../api/rest/mutations'
+import { LogMethod } from 'camino-common/src/mutations'
 
 interface IRestResolverResult {
   nom: string
@@ -238,6 +240,8 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   '/rest/activites/:activiteId': { getCall: getActivite, newPutCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] },
   '/rest/activitesSuper': { newGetCall: getActivitesForTDBSuper, ...CaminoRestRoutes['/rest/activitesSuper'] },
   '/rest/communes': { newGetCall: getCommunes, ...CaminoRestRoutes['/rest/communes'] },
+  '/rest/mutations': { newGetCall: getRestMutations, ...CaminoRestRoutes['/rest/mutations'] },
+  '/rest/mutations/:mutationId': { newGetCall: getRestMutation, ...CaminoRestRoutes['/rest/mutations/:mutationId'] },
   '/rest/geojson/import/:geoSystemeId': { newPostCall: geojsonImport, ...CaminoRestRoutes['/rest/geojson/import/:geoSystemeId'] },
   '/rest/geojson_points/import/:geoSystemeId': { newPostCall: geojsonImportPoints, ...CaminoRestRoutes['/rest/geojson_points/import/:geoSystemeId'] },
   '/rest/geojson_forages/import/:geoSystemeId': { newPostCall: geojsonImportForages, ...CaminoRestRoutes['/rest/geojson_forages/import/:geoSystemeId'] },
@@ -364,7 +368,7 @@ export const restWithPool = (dbPool: Pool): Router => {
                   Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }))
                 )
               ),
-              Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req.url, req.body)),
+              Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req)),
               Effect.mapBoth({
                 onFailure: caminoError => {
                   console.warn(`problem with route ${route}: ${caminoError.message}`)
@@ -435,7 +439,7 @@ export const restWithPool = (dbPool: Pool): Router => {
                   Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }))
                 )
               ),
-              Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req.url, req.body)),
+              Effect.tap(({ user }) => addLog(dbPool, user.id, 'put', req)),
               Effect.mapBoth({
                 onFailure: caminoError => {
                   console.warn(`problem with route ${route}: ${caminoError.message}`)
@@ -508,10 +512,16 @@ export const restWithPool = (dbPool: Pool): Router => {
                   )
                 )
               }),
+              Effect.tap(({ user }) => addLog(dbPool, user.id, 'delete', req)),
               Effect.mapBoth({
                 onFailure: caminoError => {
                   console.warn(`problem with route ${route}: ${caminoError.message}`)
-                  res.status(caminoError.status).json(caminoError)
+
+                  if (!('status' in caminoError)) {
+                    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json(caminoError)
+                  } else {
+                    res.status(caminoError.status).json(caminoError)
+                  }
                 },
                 onSuccess: () => {
                   res.sendStatus(HTTP_STATUS.NO_CONTENT)
@@ -569,13 +579,13 @@ const restCatcher = (expressCall: ExpressRoute) => async (req: CaminoRequest, re
     next(e)
   }
 }
-const restCatcherWithMutation = (method: string, expressCall: ExpressRoute, pool: Pool) => async (req: CaminoRequest, res: express.Response, next: express.NextFunction) => {
+const restCatcherWithMutation = (method: LogMethod, expressCall: ExpressRoute, pool: Pool) => async (req: CaminoRequest, res: express.Response, next: express.NextFunction) => {
   const user = req.auth
   try {
     if (!user) {
       res.sendStatus(HTTP_STATUS.FORBIDDEN)
     } else {
-      await pipe(addLog(pool, user.id, method, req.url, req.body), Effect.runPromise)
+      await callAndExit(addLog(pool, user.id, method, req))
       await expressCall(req, res, next)
     }
   } catch (e) {
diff --git a/packages/common/src/date.test.ts b/packages/common/src/date.test.ts
index 2485d5c25..039094f41 100644
--- a/packages/common/src/date.test.ts
+++ b/packages/common/src/date.test.ts
@@ -16,6 +16,7 @@ import {
   caminoDateValidator,
   CaminoDate,
   getDay,
+  dateTimeFormat,
 } from './date'
 import { test, expect } from 'vitest'
 
@@ -31,6 +32,9 @@ test('dateFormat', () => {
   expect(dateFormat(null)).toBe('')
   expect(dateFormat(undefined)).toBe('')
 })
+test('dateTimeFormat', () => {
+  expect(dateTimeFormat(new Date('1995-12-17T03:24:00Z'))).toMatchInlineSnapshot(`"17/12/1995 04:24:00"`)
+})
 
 test('getAnnee', () => {
   expect(getAnnee(toCaminoDate('2022-12-01'))).toBe('2022')
diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts
index 976f5cfcc..37095b1ff 100644
--- a/packages/common/src/date.ts
+++ b/packages/common/src/date.ts
@@ -62,6 +62,8 @@ export const dateFormat = (date: CaminoDate | null | undefined): CaminoDateForma
   return isNullOrUndefinedOrEmpty(date) ? ('' as CaminoDateFormated) : (`${date.substring(8)}-${date.substring(5, 7)}-${date.substring(0, 4)}` as CaminoDateFormated)
 }
 
+export const dateTimeFormat = (date: Date): string => date.toLocaleString('fr-FR', { timeZone: 'Europe/Paris', dateStyle: 'short', timeStyle: 'medium' })
+
 export const getCurrent = (): CaminoDate => toCaminoDate(new Date())
 export const getCurrentAnnee = (): CaminoAnnee => getAnnee(getCurrent())
 
diff --git a/packages/common/src/filters.ts b/packages/common/src/filters.ts
index 085962948..7f0f7ee9f 100644
--- a/packages/common/src/filters.ts
+++ b/packages/common/src/filters.ts
@@ -276,6 +276,7 @@ export const activitesFiltresNames = [
 
 export const entreprisesFiltresNames = ['nomsEntreprise'] as const satisfies readonly CaminoFiltre[]
 
+export const mutationsFiltresNames = ['emails'] as const satisfies readonly CaminoFiltre[]
 export const utilisateursFiltresNames = ['nomsUtilisateurs', 'emails', 'roles', 'administrationIds', 'entreprisesIds'] as const satisfies readonly CaminoFiltre[]
 
 const baseDownloadFormats = ['csv', 'xlsx', 'ods'] as const satisfies readonly DownloadFormat[]
diff --git a/packages/common/src/mutations.ts b/packages/common/src/mutations.ts
new file mode 100644
index 000000000..04c251ca8
--- /dev/null
+++ b/packages/common/src/mutations.ts
@@ -0,0 +1,26 @@
+/* v8 ignore next 8 */
+import { z } from 'zod'
+import { utilisateurIdValidator } from './roles'
+
+const LOGS_METHODS = ['post', 'delete', 'put'] as const
+
+export const logsMethodValidator = z.enum(LOGS_METHODS)
+export type LogMethod = z.infer<typeof logsMethodValidator>
+
+export const mutationIdValidator = z.string().uuid().brand('MUTATION_ID')
+export type MutationId = z.infer<typeof mutationIdValidator>
+export const mutationValidator = z.object({
+  id: mutationIdValidator,
+  datetime: z.string().datetime(),
+  method: logsMethodValidator,
+  path: z.string(),
+  utilisateur_nom: z.string(),
+  utilisateur_prenom: z.string(),
+  utilisateur_id: utilisateurIdValidator,
+  camino_path: z.string(),
+  camino_variables: z.record(z.string(), z.string()),
+})
+export type Mutation = z.infer<typeof mutationValidator>
+
+export const getMutationValidator = mutationValidator.extend({ body: z.string() })
+export type GetMutation = z.infer<typeof getMutationValidator>
diff --git a/packages/common/src/permissions/mutation.test.ts b/packages/common/src/permissions/mutation.test.ts
new file mode 100644
index 000000000..5f14f2fd8
--- /dev/null
+++ b/packages/common/src/permissions/mutation.test.ts
@@ -0,0 +1,7 @@
+import { test, expect } from 'vitest'
+import { testBlankUser } from '../tests-utils'
+import { canReadMutation } from './mutation'
+
+test('canReadMutation', () => {
+  expect(canReadMutation({ ...testBlankUser, role: 'super' })).toBe(true)
+})
diff --git a/packages/common/src/permissions/mutation.ts b/packages/common/src/permissions/mutation.ts
new file mode 100644
index 000000000..0705fb007
--- /dev/null
+++ b/packages/common/src/permissions/mutation.ts
@@ -0,0 +1,3 @@
+import { isSuper, User } from '../roles'
+
+export const canReadMutation = (user: User): boolean => isSuper(user)
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index b2117f08e..732b5eb9b 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -68,6 +68,7 @@ import { titreIdOrSlugValidator, titreIdValidator } from './validators/titres'
 import { administrationIdValidator } from './static/administrations'
 import { administrationActiviteTypeEmailValidator } from './administrations'
 import { flattenEtapeValidator, restEtapeCreationValidator, restEtapeModificationValidator } from './etape-form'
+import { getMutationValidator, mutationIdValidator, mutationValidator } from './mutations'
 
 type CaminoRoute<T extends string> = { params: ZodObjectParsUrlParams<T> } & {
   get?: { output: ZodType; searchParams?: ZodType }
@@ -129,6 +130,8 @@ const IDS = [
   '/rest/geojson_points/import/:geoSystemeId',
   '/rest/geojson_forages/import/:geoSystemeId',
   '/rest/communes',
+  '/rest/mutations',
+  '/rest/mutations/:mutationId',
   '/deconnecter',
   '/changerMotDePasse',
   // NE PAS TOUCHER CES ROUTES, UTILISÉES PAR D'AUTRES
@@ -253,6 +256,8 @@ export const CaminoRestRoutes = {
     params: noParamsValidator,
   },
   '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } },
+  '/rest/mutations': { params: noParamsValidator, newGet: { output: z.array(mutationValidator), searchParams: z.object({ emails: z.string().optional() }) } },
+  '/rest/mutations/:mutationId': { params: z.object({ mutationId: mutationIdValidator }), newGet: { output: getMutationValidator } },
   '/rest/geojson/import/:geoSystemeId': {
     params: geoSystemIdParamsValidator,
     newPost: { input: geojsonImportBodyValidator, output: geojsonInformationsValidator },
diff --git a/packages/ui/src/components/_common/liste.tsx b/packages/ui/src/components/_common/liste.tsx
index 6a6843e34..c757e75cc 100644
--- a/packages/ui/src/components/_common/liste.tsx
+++ b/packages/ui/src/components/_common/liste.tsx
@@ -12,6 +12,7 @@ import { Entreprise } from 'camino-common/src/entreprise'
 import { CaminoRouteLocation } from '@/router/routes'
 import { CaminoRouter } from '@/typings/vue-router'
 import { PageWithFilters } from './page-with-filters'
+import { CaminoError } from 'camino-common/src/zod-tools'
 
 export type Params<ColumnId extends string> = {
   colonne: ColumnId
@@ -34,7 +35,7 @@ type ToColumnId<List> = List extends [infer First, ...infer Rest] ? (First exten
 type Props<ColumnId extends string, Columns> = {
   listeFiltre: ListeFiltreProps
   colonnes: Readonly<Columns>
-  getData: (params: Params<ToColumnId<Columns>[number]>) => Promise<{ values: TableRow<ColumnId>[]; total: number }>
+  getData: (params: Params<ToColumnId<Columns>[number]>) => Promise<{ values: TableRow<ColumnId>[]; total: number } | CaminoError<string>>
   route: CaminoRouteLocation
 } & PageContentHeaderProps
 
@@ -61,7 +62,11 @@ export const Liste = defineComponent(<ColumnId extends string, Columns extends C
     tableData.value = { status: 'LOADING' }
     try {
       const loaded = await props.getData(params)
-      tableData.value = { status: 'LOADED', value: { rows: loaded.values, total: loaded.total } }
+      if ('message' in loaded) {
+        tableData.value = { status: 'NEW_ERROR', error: loaded }
+      } else {
+        tableData.value = { status: 'LOADED', value: { rows: loaded.values, total: loaded.total } }
+      }
     } catch (e: any) {
       console.error('error', e)
       tableData.value = {
diff --git a/packages/ui/src/components/journaux/journaux-api-client.ts b/packages/ui/src/components/journaux/journaux-api-client.ts
index d60cbf986..3b7f05627 100644
--- a/packages/ui/src/components/journaux/journaux-api-client.ts
+++ b/packages/ui/src/components/journaux/journaux-api-client.ts
@@ -1,12 +1,19 @@
 import { apiGraphQLFetch } from '@/api/_client'
+import { newGetWithJson } from '@/api/client-rest'
 import { Journaux, JournauxQueryParams } from 'camino-common/src/journaux'
+import { GetMutation, Mutation, MutationId } from 'camino-common/src/mutations'
+import { CaminoError } from 'camino-common/src/zod-tools'
 import gql from 'graphql-tag'
 
 export interface JournauxApiClient {
   getJournaux: (params: JournauxQueryParams) => Promise<Journaux>
+  getMutations: (email: string | undefined) => Promise<Mutation[] | CaminoError<string>>
+  getMutation: (mutationId: MutationId) => Promise<GetMutation | CaminoError<string>>
 }
 
 export const journauxApiClient: JournauxApiClient = {
+  getMutations: email => newGetWithJson('/rest/mutations', {}, { emails: email }),
+  getMutation: mutationId => newGetWithJson('/rest/mutations/:mutationId', { mutationId }),
   // TODO 2023-06-22 check with zod?
   getJournaux: async (params: JournauxQueryParams): Promise<Journaux> =>
     apiGraphQLFetch(gql`
diff --git a/packages/ui/src/components/mutation.stories.tsx b/packages/ui/src/components/mutation.stories.tsx
new file mode 100644
index 000000000..b63b793e7
--- /dev/null
+++ b/packages/ui/src/components/mutation.stories.tsx
@@ -0,0 +1,36 @@
+import { Meta, StoryFn } from '@storybook/vue3'
+import { ApiClient } from '@/api/api-client'
+import { GetMutation, mutationIdValidator } from 'camino-common/src/mutations'
+import { utilisateurIdValidator } from 'camino-common/src/roles'
+import { PureMutation } from './mutation'
+
+const meta: Meta = {
+  title: 'Components/Mutation',
+  // @ts-ignore @storybook/vue3 n'aime pas les composants tsx
+  component: PureMutation,
+}
+export default meta
+
+const mutationId = mutationIdValidator.parse('fce835ee-a857-4fc5-9721-c542060222b3')
+const element: GetMutation = {
+  id: mutationId,
+  datetime: '2025-04-07T19:00:35.824Z',
+  method: 'post',
+  path: '/rest/etapes/idEtape/depot',
+  utilisateur_nom: 'Nom',
+  utilisateur_prenom: 'Prénom',
+  utilisateur_id: utilisateurIdValidator.parse('anotherUserId'),
+  camino_path: '/rest/etapes/:etapeId/depot',
+  camino_variables: { etapeId: 'idEtape' },
+  body: JSON.stringify({ test: 12 }),
+}
+
+const apiClient: Pick<ApiClient, 'getMutation'> = {
+  getMutation: async () => {
+    return element
+  },
+}
+
+export const Loading: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={{ ...apiClient, getMutation: () => new Promise(() => ({})) }} />
+export const WithError: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={{ ...apiClient, getMutation: () => Promise.resolve({ message: 'une erreur' }) }} />
+export const Default: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={apiClient} />
diff --git a/packages/ui/src/components/mutation.stories_snapshots_Default.html b/packages/ui/src/components/mutation.stories_snapshots_Default.html
new file mode 100644
index 000000000..2b4f5b9f9
--- /dev/null
+++ b/packages/ui/src/components/mutation.stories_snapshots_Default.html
@@ -0,0 +1,9 @@
+<div class="fr-pt-8w fr-pb-4w" style="display: flex; gap: 2rem; flex-direction: column;">
+  <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Date</span><span class="fr-col-12 fr-col-sm">07/04/2025 21:00:35</span></div>
+  <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Utilisateur</span><span class="fr-col-12 fr-col-sm">Nom Prénom</span></div>
+  <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Path</span><span class="fr-col-12 fr-col-sm">/rest/etapes/idEtape/depot</span></div>
+  <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Méthode</span><span class="fr-col-12 fr-col-sm">Post</span></div>
+  <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Body</span><span class="fr-col-12 fr-col-sm"><pre>{
+    "test": 12
+}</pre></span></div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutation.stories_snapshots_Loading.html b/packages/ui/src/components/mutation.stories_snapshots_Loading.html
new file mode 100644
index 000000000..c5f6bb573
--- /dev/null
+++ b/packages/ui/src/components/mutation.stories_snapshots_Loading.html
@@ -0,0 +1,5 @@
+<div class="_top-level_3306d0" style="display: flex; justify-content: center;">
+  <!---->
+  <!---->
+  <div class="_spinner_3306d0"></div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutation.stories_snapshots_WithError.html b/packages/ui/src/components/mutation.stories_snapshots_WithError.html
new file mode 100644
index 000000000..b772b20dc
--- /dev/null
+++ b/packages/ui/src/components/mutation.stories_snapshots_WithError.html
@@ -0,0 +1,7 @@
+<div class="" style="display: flex; justify-content: center;">
+  <!---->
+  <div class="fr-alert fr-alert--error fr-alert--sm" role="alert">
+    <p>Une erreur</p>
+  </div>
+  <!---->
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutation.tsx b/packages/ui/src/components/mutation.tsx
new file mode 100644
index 000000000..c09de64ff
--- /dev/null
+++ b/packages/ui/src/components/mutation.tsx
@@ -0,0 +1,63 @@
+import { defineComponent, inject, onMounted } from 'vue'
+import { AsyncData, asyncDataAutomaticLoad } from '@/api/client-rest'
+import { GetMutation, MutationId, mutationIdValidator } from 'camino-common/src/mutations'
+import { LoadingElement } from './_ui/functional-loader'
+import { useState } from '@/utils/vue-tsx-utils'
+import { JournauxApiClient, journauxApiClient } from './journaux/journaux-api-client'
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
+import { CaminoAccessError } from './error'
+import { userKey } from '@/moi'
+import { fr } from '@codegouvfr/react-dsfr'
+import { LabelWithValue } from './_ui/label-with-value'
+import { dateTimeFormat } from 'camino-common/src/date'
+
+export const Mutation = defineComponent(() => {
+  const route = useRoute<'mutation'>()
+  const user = inject(userKey)
+
+  const mutationId = computed<MutationId | null>(() => {
+    const mutationId = route.params.mutationId
+    const validated = mutationIdValidator.safeParse(mutationId)
+
+    if (validated.success) {
+      return validated.data
+    }
+
+    return null
+  })
+
+  return () => <>{isNotNullNorUndefined(mutationId.value) ? <PureMutation apiClient={journauxApiClient} mutationId={mutationId.value} /> : <CaminoAccessError user={user} />}</>
+})
+
+interface Props {
+  apiClient: Pick<JournauxApiClient, 'getMutation'>
+  mutationId: MutationId
+}
+export const PureMutation = defineComponent<Props>(props => {
+  const [mutation, setMutation] = useState<AsyncData<GetMutation>>({ status: 'LOADING' })
+  onMounted(async () => {
+    await asyncDataAutomaticLoad(() => props.apiClient.getMutation(props.mutationId), setMutation)
+  })
+
+  return () => (
+    <LoadingElement
+      data={mutation.value}
+      renderItem={item => (
+        <>
+          <div class={fr.cx('fr-pt-8w', 'fr-pb-4w')} style={{ display: 'flex', gap: '2rem', flexDirection: 'column' }}>
+            <LabelWithValue title="Date" text={dateTimeFormat(new Date(item.datetime))} />
+            <LabelWithValue title="Utilisateur" text={`${item.utilisateur_nom} ${item.utilisateur_prenom}`} />
+            <LabelWithValue title="Path" text={item.path} />
+            <LabelWithValue title="Méthode" text={item.method} />
+            <LabelWithValue title="Body" item={<pre>{JSON.stringify(JSON.parse(item.body), null, 4)}</pre>} />
+          </div>
+        </>
+      )}
+    />
+  )
+})
+
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureMutation.props = ['apiClient', 'mutationId']
diff --git a/packages/ui/src/components/mutations.stories.tsx b/packages/ui/src/components/mutations.stories.tsx
new file mode 100644
index 000000000..20ca2fe23
--- /dev/null
+++ b/packages/ui/src/components/mutations.stories.tsx
@@ -0,0 +1,66 @@
+import { Meta, StoryFn } from '@storybook/vue3'
+import { ApiClient } from '@/api/api-client'
+import { action } from '@storybook/addon-actions'
+import { CaminoRouteLocation } from '@/router/routes'
+import { CaminoRouter } from '@/typings/vue-router'
+import { PureMutations } from './mutations'
+import { Mutation, mutationIdValidator } from 'camino-common/src/mutations'
+import { utilisateurIdValidator } from 'camino-common/src/roles'
+
+const meta: Meta = {
+  title: 'Components/Mutations',
+  // @ts-ignore @storybook/vue3 n'aime pas les composants tsx
+  component: PureMutations,
+}
+export default meta
+
+const elements: Mutation[] = [
+  {
+    id: mutationIdValidator.parse('a118f2bb-232f-4363-8689-42151c66bfbb'),
+    datetime: '',
+    method: 'post',
+    path: '',
+    utilisateur_nom: '',
+    utilisateur_prenom: '',
+    utilisateur_id: utilisateurIdValidator.parse('idUser'),
+    camino_path: '',
+    camino_variables: {},
+  },
+
+  {
+    id: mutationIdValidator.parse('fce835ee-a857-4fc5-9721-c542060222b3'),
+    datetime: '2025-04-07T19:00:35.824Z',
+    method: 'post',
+    path: '/rest/etapes/idEtape/depot',
+    utilisateur_nom: 'Nom',
+    utilisateur_prenom: 'Prénom',
+    utilisateur_id: utilisateurIdValidator.parse('anotherUserId'),
+    camino_path: '/rest/etapes/:etapeId/depot',
+    camino_variables: { etapeId: 'idEtape' },
+  },
+]
+
+const updateUrlQueryAction = action('updateUrlQuery')
+const apiClient: Pick<ApiClient, 'titresRechercherByNom' | 'getTitresByIds' | 'getMutations'> = {
+  titresRechercherByNom: () => {
+    return Promise.resolve({ elements: [] })
+  },
+  getTitresByIds: () => {
+    return Promise.resolve({ elements: [] })
+  },
+  getMutations: async () => {
+    return elements
+  },
+}
+const currentRoute: CaminoRouteLocation = { name: 'mutations', params: {}, query: {} }
+const updateUrlQuery: Pick<CaminoRouter, 'push'> = {
+  push: value => {
+    updateUrlQueryAction(value)
+    return Promise.resolve()
+  },
+}
+export const Loading: StoryFn = () => <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={{ ...apiClient, getMutations: () => new Promise(() => ({})) }} />
+export const WithError: StoryFn = () => (
+  <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={{ ...apiClient, getMutations: () => Promise.resolve({ message: 'une erreur' }) }} />
+)
+export const Default: StoryFn = () => <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={apiClient} />
diff --git a/packages/ui/src/components/mutations.stories_snapshots_Default.html b/packages/ui/src/components/mutations.stories_snapshots_Default.html
new file mode 100644
index 000000000..ed6e474d2
--- /dev/null
+++ b/packages/ui/src/components/mutations.stories_snapshots_Default.html
@@ -0,0 +1,73 @@
+<div class="fr-container--fluid" style="overflow: visible;">
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite">
+      <div class="fr-grid-row">
+        <div class="fr-col-12 fr-col-md-6">
+          <h1>Mutations</h1>
+        </div>
+        <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+          <!---->
+          <!---->
+        </div>
+      </div>
+      <div>
+        <div>
+          <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;">
+            <div class="fr-table__wrapper" style="width: auto;">
+              <div class="fr-table__container">
+                <div class="fr-table__content">
+                  <table style="display: table; width: 100%;">
+                    <caption>Mutations</caption>
+                    <thead>
+                      <tr>
+                        <th scope="col">Date</th>
+                        <th scope="col">Utilisateur</th>
+                        <th scope="col">Explication</th>
+                        <th scope="col">Action</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td><a href="/mocked-href" title="Aller à la mutation a118f2bb-232f-4363-8689-42151c66bfbb" aria-label="Aller à la mutation a118f2bb-232f-4363-8689-42151c66bfbb">Invalid Date</a></td>
+                        <td><span class=""> </span></td>
+                        <td>Pas d'explication</td>
+                        <td><span class="">post -&gt; </span></td>
+                      </tr>
+                      <tr>
+                        <td><a href="/mocked-href" title="Aller à la mutation fce835ee-a857-4fc5-9721-c542060222b3" aria-label="Aller à la mutation fce835ee-a857-4fc5-9721-c542060222b3">07/04/2025 21:00:35</a></td>
+                        <td><span class="">Nom Prénom</span></td>
+                        <td>Finalisation <a href="/mocked-href" title="Aller vers l'étape idEtape" aria-label="Aller vers l'étape idEtape">d'une étape</a></td>
+                        <td><span class="">post -&gt; /rest/etapes/idEtape/depot</span></td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!---->
+      </div>
+    </div>
+    <div class="fr-col-12 fr-col-md-3" style="order: -1;">
+      <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w">
+        <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres (2 résultats)</h1>
+        <div style="display: flex; flex-direction: column;">
+          <div>
+            <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div>
+            <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0">
+              <div class="fr-input-group" style="margin-bottom: 0px;">
+                <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value="">
+                <!---->
+              </div>
+              <!---->
+              <!---->
+              <!---->
+            </div>
+          </div>
+        </div>
+        <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div>
+      </form>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutations.stories_snapshots_Loading.html b/packages/ui/src/components/mutations.stories_snapshots_Loading.html
new file mode 100644
index 000000000..db288ecb2
--- /dev/null
+++ b/packages/ui/src/components/mutations.stories_snapshots_Loading.html
@@ -0,0 +1,125 @@
+<div class="fr-container--fluid" style="overflow: visible;">
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite">
+      <div class="fr-grid-row">
+        <div class="fr-col-12 fr-col-md-6">
+          <h1>Mutations</h1>
+        </div>
+        <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+          <!---->
+          <!---->
+        </div>
+      </div>
+      <div>
+        <div>
+          <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;">
+            <div class="fr-table__wrapper" style="width: auto;">
+              <div class="fr-table__container">
+                <div class="fr-table__content">
+                  <table style="display: table; width: 100%;">
+                    <caption>Mutations</caption>
+                    <thead>
+                      <tr>
+                        <th scope="col">Date</th>
+                        <th scope="col">Utilisateur</th>
+                        <th scope="col">Explication</th>
+                        <th scope="col">Action</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="_top-level_3306d0" style="display: flex; justify-content: center;">
+          <!---->
+          <!---->
+          <div class="_spinner_3306d0"></div>
+        </div>
+      </div>
+    </div>
+    <div class="fr-col-12 fr-col-md-3" style="order: -1;">
+      <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w">
+        <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres ...</h1>
+        <div style="display: flex; flex-direction: column;">
+          <div>
+            <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div>
+            <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0">
+              <div class="fr-input-group" style="margin-bottom: 0px;">
+                <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value="">
+                <!---->
+              </div>
+              <!---->
+              <!---->
+              <!---->
+            </div>
+          </div>
+        </div>
+        <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div>
+      </form>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutations.stories_snapshots_WithError.html b/packages/ui/src/components/mutations.stories_snapshots_WithError.html
new file mode 100644
index 000000000..5d2941c4e
--- /dev/null
+++ b/packages/ui/src/components/mutations.stories_snapshots_WithError.html
@@ -0,0 +1,127 @@
+<div class="fr-container--fluid" style="overflow: visible;">
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite">
+      <div class="fr-grid-row">
+        <div class="fr-col-12 fr-col-md-6">
+          <h1>Mutations</h1>
+        </div>
+        <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+          <!---->
+          <!---->
+        </div>
+      </div>
+      <div>
+        <div>
+          <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;">
+            <div class="fr-table__wrapper" style="width: auto;">
+              <div class="fr-table__container">
+                <div class="fr-table__content">
+                  <table style="display: table; width: 100%;">
+                    <caption>Mutations</caption>
+                    <thead>
+                      <tr>
+                        <th scope="col">Date</th>
+                        <th scope="col">Utilisateur</th>
+                        <th scope="col">Explication</th>
+                        <th scope="col">Action</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                      <tr>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                        <td>...</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="" style="display: flex; justify-content: center;">
+          <!---->
+          <div class="fr-alert fr-alert--error fr-alert--sm" role="alert">
+            <p>Une erreur</p>
+          </div>
+          <!---->
+        </div>
+      </div>
+    </div>
+    <div class="fr-col-12 fr-col-md-3" style="order: -1;">
+      <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w">
+        <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres ...</h1>
+        <div style="display: flex; flex-direction: column;">
+          <div>
+            <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div>
+            <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0">
+              <div class="fr-input-group" style="margin-bottom: 0px;">
+                <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value="">
+                <!---->
+              </div>
+              <!---->
+              <!---->
+              <!---->
+            </div>
+          </div>
+        </div>
+        <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div>
+      </form>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/mutations.tsx b/packages/ui/src/components/mutations.tsx
new file mode 100644
index 000000000..11d5b61f0
--- /dev/null
+++ b/packages/ui/src/components/mutations.tsx
@@ -0,0 +1,261 @@
+import { defineComponent, FunctionalComponent } from 'vue'
+import { Column, TableRow } from './_ui/table'
+import { useRouter } from 'vue-router'
+import { Liste, Params } from './_common/liste'
+import { ApiClient, apiClient } from '@/api/api-client'
+
+import { CaminoRouteLocation, routesDefinitions } from '@/router/routes'
+import { CaminoRouter } from '@/typings/vue-router'
+import { Mutation } from 'camino-common/src/mutations'
+import { DeleteRestRoutes, NewDeleteRestRoutes, NewPostRestRoutes, NewPutRestRoutes } from 'camino-common/src/rest'
+import { CaminoRouterLink } from '@/router/camino-router-link'
+import { dateTimeFormat } from 'camino-common/src/date'
+
+export const Mutations = defineComponent(() => {
+  const router = useRouter()
+  return () => <PureMutations apiClient={apiClient} updateUrlQuery={router} currentRoute={router.currentRoute.value} />
+})
+
+interface Props {
+  apiClient: Pick<ApiClient, 'getMutations' | 'titresRechercherByNom' | 'getTitresByIds'>
+  currentRoute: CaminoRouteLocation
+  updateUrlQuery: Pick<CaminoRouter, 'push'>
+}
+
+const colonnesData = [
+  { id: 'date', contentTitle: 'Date', noSort: true },
+  { id: 'utilisateur', contentTitle: 'Utilisateur', noSort: true },
+  { id: 'explication', contentTitle: 'Explication', noSort: true },
+  { id: 'detail', contentTitle: 'Action', noSort: true },
+] as const satisfies Column[]
+
+type ColonneId = (typeof colonnesData)[number]['id']
+
+// ici on veut avoir un match exhaustif de toutes les routes connues, mais les anciennes routes doivent quand même fonctionner sans faire planter l'application
+const exhaustiveCheckPass = (_param: never): never => {
+  return "Pas d'explication" as never
+}
+
+const PathToExplanation: FunctionalComponent<{ mutation: Mutation }> = props => {
+  const baseText = props.mutation.method === 'delete' ? 'Suppression ' : props.mutation.method === 'put' ? 'Modification ' : 'Création '
+  const caminoPath = props.mutation.camino_path as NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes
+  switch (caminoPath) {
+    case '/rest/activites/:activiteId':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'activité ${props.mutation.camino_variables.activiteId}`}
+            isDisabled={false}
+            to={{ name: 'activite', params: { activiteId: props.mutation.camino_variables.activiteId } }}
+          >
+            d'une activité
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/demarches':
+      return <>{baseText} d'une démarche</>
+    case '/rest/etapes':
+      return <>{baseText} d'une étape</>
+    case '/rest/titres':
+      return <>{baseText} d'un titre</>
+    case '/rest/entreprises':
+      return <>{baseText} d'une entreprise</>
+    case '/rest/demarches/:demarcheIdOrSlug':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers la démarche ${props.mutation.camino_variables.demarcheIdOrSlug}`}
+            isDisabled={false}
+            to={{ name: 'demarche', params: { demarcheId: props.mutation.camino_variables.demarcheIdOrSlug } }}
+          >
+            d'une démarche
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/titres/:titreId':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.titreId}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.titreId } }}>
+            d'un titre
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/etapes/:etapeIdOrSlug':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'étape ${props.mutation.camino_variables.etapeIdOrSlug}`}
+            isDisabled={false}
+            to={{ name: 'etape', params: { id: props.mutation.camino_variables.etapeIdOrSlug } }}
+          >
+            d'une étape
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/etapes/:etapeId/depot':
+      return (
+        <>
+          Finalisation{' '}
+          <CaminoRouterLink title={`Aller vers l'étape ${props.mutation.camino_variables.etapeId}`} isDisabled={false} to={{ name: 'etape', params: { id: props.mutation.camino_variables.etapeId } }}>
+            d'une étape
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/entreprises/:entrepriseId':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'entreprise ${props.mutation.camino_variables.entrepriseId}`}
+            isDisabled={false}
+            to={{ name: 'entreprise', params: { id: props.mutation.camino_variables.entrepriseId } }}
+          >
+            d'une entreprise
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/entreprises/:entrepriseId/documents':
+    case '/rest/entreprises/:entrepriseId/documents/:entrepriseDocumentId':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'entreprise ${props.mutation.camino_variables.entrepriseId}`}
+            isDisabled={false}
+            to={{ name: 'entreprise', params: { id: props.mutation.camino_variables.entrepriseId } }}
+          >
+            d'un document d'entreprise
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/titres/:id/titreLiaisons':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.id}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.id } }}>
+            d'un lien entre deux titres
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/administrations/:administrationId/activiteTypeEmails':
+    case '/rest/administrations/:administrationId/activiteTypeEmails/delete':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'administration ${props.mutation.camino_variables.administrationId}`}
+            isDisabled={false}
+            to={{ name: 'administration', params: { id: props.mutation.camino_variables.administrationId } }}
+          >
+            d'un email pour l'activité d'une administration
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/titres/:titreId/abonne':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.titreId}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.titreId } }}>
+            d'un abonnement sur un titre
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/utilisateurs/:id/permission':
+      return (
+        <>
+          {baseText}{' '}
+          <CaminoRouterLink
+            title={`Aller vers l'utilisateur ${props.mutation.camino_variables.id}`}
+            isDisabled={false}
+            to={{ name: 'utilisateur', params: { id: props.mutation.camino_variables.id } }}
+          >
+            des permissions d'un utilisateur
+          </CaminoRouterLink>
+        </>
+      )
+    case '/rest/utilisateur/generateQgisToken':
+    case '/rest/geojson/import/:geoSystemeId':
+    case '/rest/geojson_forages/import/:geoSystemeId':
+    case '/rest/geojson_points/import/:geoSystemeId':
+      return <></>
+    default:
+      return exhaustiveCheckPass(caminoPath)
+  }
+}
+
+const lignes = (mutations: Mutation[]): TableRow<ColonneId>[] => {
+  return mutations.map(mutation => {
+    const columns: TableRow<ColonneId>['columns'] = {
+      date: {
+        type: 'jsx',
+        jsxElement: (
+          <CaminoRouterLink title={`Aller à la mutation ${mutation.id}`} isDisabled={false} to={{ name: 'mutation', params: { mutationId: mutation.id } }}>
+            {dateTimeFormat(new Date(mutation.datetime))}
+          </CaminoRouterLink>
+        ),
+        value: mutation.datetime,
+      },
+      utilisateur: {
+        type: 'text',
+        value: `${mutation.utilisateur_nom} ${mutation.utilisateur_prenom}`,
+      },
+      explication: {
+        type: 'jsx',
+        jsxElement: <PathToExplanation mutation={mutation} />,
+        value: mutation.id,
+      },
+      detail: {
+        type: 'text',
+        value: `${mutation.method} -> ${mutation.path}`,
+      },
+    }
+
+    return {
+      id: mutation.id,
+      link: null,
+      columns,
+    }
+  })
+}
+
+const filteredPaths = [
+  '/rest/utilisateur/generateQgisToken',
+  '/rest/geojson/import/:geoSystemeId',
+  '/rest/geojson_forages/import/:geoSystemeId',
+  '/rest/geojson_points/import/:geoSystemeId',
+] as const satisfies (NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes)[]
+export const PureMutations = defineComponent<Props>(props => {
+  const getData = async (event: Params<string>) => {
+    const values = await props.apiClient.getMutations(event.filtres?.emails)
+    if ('message' in values) {
+      return values
+    }
+
+    const filteredValue = values.filter(value => !filteredPaths.includes(value.camino_path))
+
+    return { total: filteredValue.length, values: lignes(filteredValue) }
+  }
+
+  return () => (
+    <Liste
+      listeFiltre={{
+        filtres: routesDefinitions[props.currentRoute.name].meta.filtres,
+        apiClient: props.apiClient,
+        updateUrlQuery: props.updateUrlQuery,
+        entreprises: [],
+      }}
+      renderButton={null}
+      download={null}
+      colonnes={colonnesData}
+      route={props.currentRoute}
+      getData={getData}
+      nom="Mutations"
+    />
+  )
+})
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureMutations.props = ['apiClient', 'currentRoute', 'updateUrlQuery']
diff --git a/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html b/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html
index 14bd9b262..bfcc07ec2 100644
--- a/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html
+++ b/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html
@@ -56,6 +56,7 @@
             </div>
           </li>
           <li class="fr-nav__item"><a class="fr-nav__link" to="{name:journaux}" target="_self" type="primary">Journaux</a></li>
+          <li class="fr-nav__item"><a class="fr-nav__link" to="{name:mutations}" target="_self" type="primary">Mutations</a></li>
         </ul>
       </nav>
     </div>
diff --git a/packages/ui/src/components/page/header.stories_snapshots_Super.html b/packages/ui/src/components/page/header.stories_snapshots_Super.html
index 14bd9b262..bfcc07ec2 100644
--- a/packages/ui/src/components/page/header.stories_snapshots_Super.html
+++ b/packages/ui/src/components/page/header.stories_snapshots_Super.html
@@ -56,6 +56,7 @@
             </div>
           </li>
           <li class="fr-nav__item"><a class="fr-nav__link" to="{name:journaux}" target="_self" type="primary">Journaux</a></li>
+          <li class="fr-nav__item"><a class="fr-nav__link" to="{name:mutations}" target="_self" type="primary">Mutations</a></li>
         </ul>
       </nav>
     </div>
diff --git a/packages/ui/src/components/page/menu.ts b/packages/ui/src/components/page/menu.ts
index 9f26161f3..cd1c0b053 100644
--- a/packages/ui/src/components/page/menu.ts
+++ b/packages/ui/src/components/page/menu.ts
@@ -13,6 +13,7 @@ const links = {
   UTILISATEURS: { label: 'Utilisateurs', path: 'utilisateurs' },
   ADMINISTRATIONS: { label: 'Administrations', path: 'administrations' },
   JOURNAUX: { label: 'Journaux', path: 'journaux' },
+  MUTATIONS: { label: 'Mutations', path: 'mutations' },
   TABLEAU_DE_BORD: { label: 'Tableau de bord', path: 'dashboard' },
 } as const satisfies Record<string, Link>
 
@@ -24,7 +25,7 @@ export const linksByRole = (user: User): Record<Role, (Link | Annuaire)[]> => {
   const linkActivites: Link[] = canReadActivites(user) ? [links.ACTIVITES] : []
 
   return {
-    super: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE, links.JOURNAUX],
+    super: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE, links.JOURNAUX, links.MUTATIONS],
     admin: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE],
     editeur: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE],
     lecteur: [links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, links.STATISTIQUES, ANNUAIRE],
diff --git a/packages/ui/src/components/page/plan.stories_snapshots_Super.html b/packages/ui/src/components/page/plan.stories_snapshots_Super.html
index 0d3b91c0c..fc410ce82 100644
--- a/packages/ui/src/components/page/plan.stories_snapshots_Super.html
+++ b/packages/ui/src/components/page/plan.stories_snapshots_Super.html
@@ -14,5 +14,6 @@
       </ul>
     </li>
     <li><a href="/mocked-href" title="Journaux" aria-label="Journaux">Journaux</a></li>
+    <li><a href="/mocked-href" title="Mutations" aria-label="Mutations">Mutations</a></li>
   </ul>
 </div>
\ No newline at end of file
diff --git a/packages/ui/src/router/index.ts b/packages/ui/src/router/index.ts
index 59aadfbc7..7289bb2d2 100644
--- a/packages/ui/src/router/index.ts
+++ b/packages/ui/src/router/index.ts
@@ -115,13 +115,25 @@ const Journaux = async () => {
   return Journaux
 }
 
+const Mutations = async () => {
+  const { Mutations } = await import('../components/mutations')
+
+  return Mutations
+}
+
+const Mutation = async () => {
+  const { Mutation } = await import('../components/mutation')
+
+  return Mutation
+}
+
 const About = async () => {
   const { About } = await import('../components/content/about')
 
   return About
 }
 
-export type MenuSection = 'dashboard' | 'titres' | 'demarches' | 'travaux' | 'activites' | 'administrations' | 'entreprises' | 'utilisateurs' | 'statistiques' | 'journaux'
+export type MenuSection = 'dashboard' | 'titres' | 'demarches' | 'travaux' | 'activites' | 'administrations' | 'entreprises' | 'utilisateurs' | 'statistiques' | 'journaux' | 'mutations'
 
 declare module 'vue-router' {
   interface RouteMeta {
@@ -221,6 +233,15 @@ const routes = {
     ...routesDefinitions.journaux,
     component: Journaux,
   },
+
+  mutations: {
+    ...routesDefinitions.mutations,
+    component: Mutations,
+  },
+  mutation: {
+    ...routesDefinitions.mutation,
+    component: Mutation,
+  },
   // url /stats : demande de Samuel
   // pour avoir une uniformité entre toutes les start-ups
   statistiquesbetagouv: {
diff --git a/packages/ui/src/router/routes.ts b/packages/ui/src/router/routes.ts
index 2864313da..9682391f9 100644
--- a/packages/ui/src/router/routes.ts
+++ b/packages/ui/src/router/routes.ts
@@ -1,4 +1,4 @@
-import { activitesFiltresNames, CaminoFiltre, entreprisesFiltresNames, titresFiltresNames, utilisateursFiltresNames } from 'camino-common/src/filters'
+import { activitesFiltresNames, CaminoFiltre, entreprisesFiltresNames, mutationsFiltresNames, titresFiltresNames, utilisateursFiltresNames } from 'camino-common/src/filters'
 import type { LocationQueryRaw, RouteRecordRaw } from 'vue-router'
 import { RouteMeta } from 'vue-router'
 const demarchesFiltres = [
@@ -31,7 +31,7 @@ const travauxFiltres = [
 const administrationsFiltres: readonly CaminoFiltre[] = ['nomsAdministration', 'administrationTypesIds'] as const
 
 // prettier-ignore
-const ROUTES = ['dashboard','statsDGTM','titres','titreCreation','titre','demarches','demarche','travaux','etape','etapeCreation','etapeEdition','resultatMiseEnConcurrence','utilisateurs','utilisateur','entreprises','entreprise','administrations','administration','activites','activite','activiteEdition','statistiques','journaux','statistiquesbetagouv','aPropos','plan', 'homepage','erreur' ] as const
+const ROUTES = ['dashboard','statsDGTM','titres','titreCreation','titre','demarches','demarche','travaux','etape','etapeCreation','etapeEdition','resultatMiseEnConcurrence','utilisateurs','utilisateur','entreprises','entreprise','administrations','administration','activites','activite','activiteEdition','statistiques','journaux','mutations','mutation','statistiquesbetagouv','aPropos','plan', 'homepage','erreur' ] as const
 type CaminoRoute<T extends CaminoRouteNames> = Pick<RouteRecordRaw, 'path'> & { name: T; meta: RouteMeta }
 export const routesDefinitions = {
   dashboard: {
@@ -215,6 +215,7 @@ export const routesDefinitions = {
       filtres: [],
     },
   },
+
   activiteEdition: {
     path: '/activites/:activiteId/edition',
     name: 'activiteEdition',
@@ -242,6 +243,24 @@ export const routesDefinitions = {
       filtres: ['titresIds'],
     },
   },
+  mutations: {
+    path: '/mutations',
+    name: 'mutations',
+    meta: {
+      menuSection: 'mutations',
+      title: 'Mutations',
+      filtres: mutationsFiltresNames,
+    },
+  },
+  mutation: {
+    path: '/mutation/:mutationId',
+    name: 'mutation',
+    meta: {
+      title: 'Détail de la mutation {{ mutationId }}',
+      menuSection: 'mutations',
+      filtres: [],
+    },
+  },
   // url /stats : demande de Samuel
   // pour avoir une uniformité entre toutes les start-ups
   statistiquesbetagouv: {
-- 
GitLab