Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0333270089 | |||
| 832751d1ed | |||
| c119db0b02 | |||
| a52c35e082 | |||
| a1af125c78 | |||
| 8e59e9fd6a |
@@ -64,6 +64,7 @@
|
||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
||||
- No-op saves preserve existing validations
|
||||
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
|
||||
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
@@ -207,6 +208,8 @@
|
||||
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
|
||||
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
|
||||
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
|
||||
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. **CORS** : `X-Device-Id` doit rester dans `nelmio_cors.allow_headers` (front/API cross-origin → préflight, sinon le navigateur bloque toutes les requêtes). Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
|
||||
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
|
||||
- Documentation: `doc/audit-logging.md`
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -5,6 +5,14 @@ framework:
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
# Trusted proxies — REQUIRED for a correct client IP in the activity log
|
||||
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
|
||||
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
|
||||
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
|
||||
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
|
||||
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ nelmio_cors:
|
||||
origin_regex: true
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization']
|
||||
allow_headers: ['Content-Type', 'Authorization', 'X-Device-Id']
|
||||
allow_credentials: true
|
||||
expose_headers: ['Link', 'Content-Disposition']
|
||||
max_age: 3600
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||
App\Repository\Contract\AuditLogReadRepositoryInterface: '@App\Repository\AuditLogRepository'
|
||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.121'
|
||||
app.version: '0.1.124'
|
||||
|
||||
+17
-5
@@ -40,16 +40,28 @@ Chaque entrée contient :
|
||||
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
|
||||
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
|
||||
- **createdAt** : horodatage de l'action
|
||||
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
|
||||
- `userAgent` : User-Agent brut de la requête
|
||||
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
|
||||
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
|
||||
|
||||
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
|
||||
|
||||
> ⚠️ **CORS** : le front et l'API sont sur des origines distinctes ; le header `X-Device-Id` ajouté à chaque requête déclenche un préflight CORS. Il **doit** figurer dans `nelmio_cors.allow_headers` (`config/packages/nelmio_cors.yaml`), sinon le navigateur bloque toutes les requêtes API.
|
||||
|
||||
## Filtres disponibles
|
||||
|
||||
- Par employé
|
||||
- Par plage de dates (date affectée)
|
||||
- Par type d'entité
|
||||
- Par employé (affecté) — champ texte, recherche partielle sur nom/prénom (insensible à la casse)
|
||||
- Par période (date affectée) — sélecteur de plage
|
||||
- Par type(s) d'entité (multi-sélection)
|
||||
- Par action(s) (multi-sélection)
|
||||
- Par utilisateur / compte — champ texte, recherche partielle (insensible à la casse)
|
||||
- Par IP (recherche partielle)
|
||||
- Par appareil (recherche partielle sur le libellé ou le device id)
|
||||
|
||||
## Pagination
|
||||
Pagination : `perPage` (10 / 25 / 50 / 100, défaut 10) + `page`.
|
||||
|
||||
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
|
||||
L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements).
|
||||
|
||||
## Convention
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Enregistrement des heures — envoi des seules lignes modifiées
|
||||
|
||||
## Problème corrigé (perte de données par écrasement « à l'aveugle »)
|
||||
|
||||
L'écran **Heures** (et **Heures Conducteurs**) présente une grille d'une journée avec
|
||||
**tous** les employés du périmètre. L'enregistrement (`POST /work-hours/bulk-upsert`,
|
||||
`WorkHourBulkUpsertProcessor`) a une sémantique **upsert par (employé, date)** où une
|
||||
**entrée vide supprime** la ligne existante (« une ligne vide supprime l'enregistrement »).
|
||||
|
||||
Avant correctif, `handleSave` (front) envoyait une entrée pour **chaque** employé visible non
|
||||
verrouillé, à partir de l'état en mémoire de la grille. Conséquence en cas de **concurrence** :
|
||||
|
||||
1. Un admin ouvre l'écran ; la ligne d'un salarié (ex. utilisateur `ROLE_SELF`) est vide.
|
||||
2. Ce salarié saisit ses heures dans sa propre session → ligne créée en BDD, **non validée**
|
||||
(donc non verrouillée).
|
||||
3. L'admin, sur sa grille **périmée**, saisit les heures d'**autres** employés et enregistre.
|
||||
4. Le payload contient une entrée **vide** pour le salarié (état périmé). Le backend relit la
|
||||
BDD (ligne désormais remplie), constate « entrée vide ≠ existant » → **supprime** la ligne
|
||||
fraîchement saisie. Perte de données.
|
||||
|
||||
## Correctif (suivi des lignes modifiées côté front)
|
||||
|
||||
`hydrateRows` capture désormais un **instantané** des lignes telles que chargées depuis le
|
||||
serveur (`loadedRows`, clone indépendant de `rows`). À l'enregistrement, `handleSave` ne
|
||||
transmet **que les lignes dont l'état courant diffère de l'instantané chargé** :
|
||||
|
||||
```ts
|
||||
const entries = candidates
|
||||
.map((employee) => ({
|
||||
current: buildEntry(employee, rows.value[employee.id] ?? emptyRow()),
|
||||
original: buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow()),
|
||||
}))
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- Une ligne **intouchée** n'est jamais transmise → jamais supprimée, même si un autre
|
||||
utilisateur l'a saisie/modifiée entre-temps. **C'est le correctif du bug.**
|
||||
- Une ligne **vidée volontairement** par l'utilisateur diffère de l'instantané → transmise
|
||||
vide → supprimée (comportement métier conservé).
|
||||
- Une ligne **remplie** diffère → transmise → créée/mise à jour.
|
||||
|
||||
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
|
||||
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
|
||||
|
||||
## Limite connue (hors périmètre de ce correctif)
|
||||
|
||||
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
|
||||
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
|
||||
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
|
||||
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
|
||||
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
|
||||
@@ -0,0 +1,904 @@
|
||||
# Contexte forensique dans le journal d'activité — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Capter automatiquement IP, libellé appareil, User-Agent brut et identifiant d'appareil persistant sur chaque entrée du journal d'activité, et les exposer en API lecture, pour différencier les intervenants derrière un compte partagé (ex. « Usine »).
|
||||
|
||||
**Architecture:** Point de capture unique côté back (`AuditLogger::log()` + `RequestStack`) → aucun processor modifié. 4 colonnes nullable ajoutées à `audit_logs`. Un service `UserAgentParser` dérive un libellé lisible. Côté front, un device ID persistant (`localStorage`) est envoyé en header `X-Device-Id` sur toutes les requêtes API.
|
||||
|
||||
**Tech Stack:** Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TypeScript (ofetch).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend = source de vérité ; le front ne fait qu'envoyer le header et afficher. (CLAUDE.md)
|
||||
- Toute écriture d'audit DOIT passer par `AuditLogger` — ne pas dupliquer la capture ailleurs. (CLAUDE.md, doc/audit-logging.md)
|
||||
- Migrations Doctrine : toujours un `down()` fonctionnel. PostgreSQL. (CLAUDE.md)
|
||||
- DTO PHP ↔ DTO TS alignés. (CLAUDE.md)
|
||||
- Tout changement fonctionnel met à jour `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` dans la même intervention. (CLAUDE.md — règles obligatoires)
|
||||
- **Ne PAS lancer `npm run build`** sauf demande explicite. (mémoire feedback utilisateur)
|
||||
- Code (variables, commentaires) en anglais ; UI/libellés en français.
|
||||
- Format de message de commit imposé par le hook : `<type> : <message>` (espace AVANT le `:`). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.
|
||||
- Lancer un test ciblé : `make test FILES=<chemin>`. Conteneur PHP : `php-sirh-fpm`. Le pre-commit hook lance déjà tout PHPUnit + php-cs-fixer.
|
||||
- **Hors périmètre (étape suivante, ne PAS toucher) :** l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes, filtre par appareil). On se contente d'exposer les champs dans l'API.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Backend**
|
||||
- `src/Service/UserAgentParser.php` — *créer*. Parse un User-Agent en libellé court `Type · OS · Navigateur`.
|
||||
- `tests/Service/UserAgentParserTest.php` — *créer*.
|
||||
- `src/Entity/AuditLog.php` — *modifier*. +4 propriétés + accesseurs.
|
||||
- `migrations/Version20260624120000.php` — *créer*. +4 colonnes nullable sur `audit_logs`.
|
||||
- `src/Service/AuditLogger.php` — *modifier*. Injecte `RequestStack` + `UserAgentParser`, peuple les 4 champs.
|
||||
- `tests/Service/AuditLoggerTest.php` — *créer*.
|
||||
- `src/State/AuditLogProvider.php` — *modifier*. Expose les 4 champs dans le JSON.
|
||||
- `tests/State/AuditLogProviderTest.php` — *créer*.
|
||||
- `config/packages/framework.yaml` — *modifier*. Bloc `trusted_proxies` documenté (commenté).
|
||||
|
||||
**Frontend**
|
||||
- `frontend/composables/useDeviceId.ts` — *créer*. Device ID persistant.
|
||||
- `frontend/composables/useApi.ts` — *modifier*. Injecte le header `X-Device-Id` (intercepteur `onRequest`).
|
||||
- `frontend/services/dto/audit-log.ts` — *modifier*. +4 champs optionnels.
|
||||
|
||||
**Docs**
|
||||
- `doc/audit-logging.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md` — *modifier*.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Service `UserAgentParser`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Service/UserAgentParser.php`
|
||||
- Test: `tests/Service/UserAgentParserTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: rien.
|
||||
- Produces: `UserAgentParser::parse(?string $userAgent): ?string` → libellé `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`), ou `null` si UA vide/null. Consommé par Task 3.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Service/UserAgentParserTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\UserAgentParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class UserAgentParserTest extends TestCase
|
||||
{
|
||||
private UserAgentParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new UserAgentParser();
|
||||
}
|
||||
|
||||
public function testNullAndEmptyReturnNull(): void
|
||||
{
|
||||
self::assertNull($this->parser->parse(null));
|
||||
self::assertNull($this->parser->parse(''));
|
||||
self::assertNull($this->parser->parse(' '));
|
||||
}
|
||||
|
||||
public function testChromeOnWindows(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testEdgeBeatsChrome(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
|
||||
self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testSafariOnIphoneIsMobileIos(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testChromeOnAndroid(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testFirefoxOnLinux(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0';
|
||||
self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testSafariOnMac(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||
self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testIpadIsTablet(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testUnknownUaFallsBack(): void
|
||||
{
|
||||
self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
|
||||
Expected: FAIL — `Class "App\Service\UserAgentParser" not found`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `src/Service/UserAgentParser.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
|
||||
* User-Agent string, used to add forensic context to audit log entries.
|
||||
* Heuristic on purpose — enough to tell a phone from a desktop and identify
|
||||
* OS/browser families on shared accounts.
|
||||
*/
|
||||
class UserAgentParser
|
||||
{
|
||||
public function parse(?string $userAgent): ?string
|
||||
{
|
||||
if (null === $userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ua = trim($userAgent);
|
||||
if ('' === $ua) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' · ', [
|
||||
$this->detectType($ua),
|
||||
$this->detectOs($ua),
|
||||
$this->detectBrowser($ua),
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectType(string $ua): string
|
||||
{
|
||||
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
|
||||
return 'Tablette';
|
||||
}
|
||||
|
||||
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
|
||||
return 'Mobile';
|
||||
}
|
||||
|
||||
return 'Ordinateur';
|
||||
}
|
||||
|
||||
private function detectOs(string $ua): string
|
||||
{
|
||||
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
|
||||
// Android before Linux (Android UAs contain "Linux").
|
||||
return match (true) {
|
||||
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
|
||||
1 === preg_match('/Android/i', $ua) => 'Android',
|
||||
1 === preg_match('/Windows/i', $ua) => 'Windows',
|
||||
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
|
||||
1 === preg_match('/Linux/i', $ua) => 'Linux',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
|
||||
private function detectBrowser(string $ua): string
|
||||
{
|
||||
// Order matters: Edge/Opera contain "Chrome" and "Safari";
|
||||
// Chrome contains "Safari". Match the most specific first.
|
||||
return match (true) {
|
||||
1 === preg_match('/Edg/i', $ua) => 'Edge',
|
||||
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
|
||||
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
|
||||
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
|
||||
1 === preg_match('/Safari/i', $ua) => 'Safari',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
|
||||
Expected: PASS (8 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/UserAgentParser.php tests/Service/UserAgentParserTest.php
|
||||
git commit -m "feat(audit) : ajoute UserAgentParser (libellé appareil lisible)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Colonnes `audit_logs` + entité
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/AuditLog.php`
|
||||
- Create: `migrations/Version20260624120000.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (sur `AuditLog`) : `getIpAddress(): ?string` / `setIpAddress(?string): self` ; `getUserAgent(): ?string` / `setUserAgent(?string): self` ; `getDeviceLabel(): ?string` / `setDeviceLabel(?string): self` ; `getDeviceId(): ?string` / `setDeviceId(?string): self`. Consommés par Task 3 et Task 4.
|
||||
|
||||
- [ ] **Step 1: Add the 4 mapped properties to the entity**
|
||||
|
||||
In `src/Entity/AuditLog.php`, after the `affectedDate` property block (currently ends line 47, before `createdAt` declared line 49–50), insert:
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'string', length: 45, nullable: true)]
|
||||
private ?string $ipAddress = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $userAgent = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $deviceLabel = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $deviceId = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the accessors**
|
||||
|
||||
In `src/Entity/AuditLog.php`, after `setAffectedDate()` (ends line 156) and before `getCreatedAt()` (line 158), insert:
|
||||
|
||||
```php
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function setIpAddress(?string $ipAddress): self
|
||||
{
|
||||
$this->ipAddress = $ipAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(?string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceLabel(): ?string
|
||||
{
|
||||
return $this->deviceLabel;
|
||||
}
|
||||
|
||||
public function setDeviceLabel(?string $deviceLabel): self
|
||||
{
|
||||
$this->deviceLabel = $deviceLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceId(): ?string
|
||||
{
|
||||
return $this->deviceId;
|
||||
}
|
||||
|
||||
public function setDeviceId(?string $deviceId): self
|
||||
{
|
||||
$this->deviceId = $deviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the migration**
|
||||
|
||||
Create `migrations/Version20260624120000.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260624120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Apply the migration and verify the mapping**
|
||||
|
||||
Run: `make migration-migrate`
|
||||
Expected: migration `Version20260624120000` applied, no error.
|
||||
|
||||
Then verify the Doctrine mapping matches the DB:
|
||||
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console doctrine:schema:validate`
|
||||
Expected: `[OK] The mapping files are correct.` (the "database is in sync" line must also be OK for `audit_logs`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/AuditLog.php migrations/Version20260624120000.php
|
||||
git commit -m "feat(audit) : colonnes contexte forensique sur audit_logs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Capture du contexte dans `AuditLogger`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Service/AuditLogger.php`
|
||||
- Create: `tests/Service/AuditLoggerTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UserAgentParser::parse()` (Task 1) ; setters de `AuditLog` (Task 2) ; `Symfony\Component\HttpFoundation\RequestStack`.
|
||||
- Produces: signature publique de `AuditLogger::log()` **inchangée** (la capture est interne, automatique). Le constructeur gagne 2 dépendances autowirées.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Service/AuditLoggerTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\UserAgentParser;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLoggerTest extends TestCase
|
||||
{
|
||||
public function testCapturesRequestContext(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null); // -> username "system"
|
||||
|
||||
$request = Request::create('/api/work_hours', 'POST');
|
||||
$request->server->set('REMOTE_ADDR', '203.0.113.7');
|
||||
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
$request->headers->set('X-Device-Id', 'device-abc');
|
||||
|
||||
$stack = new RequestStack();
|
||||
$stack->push($request);
|
||||
|
||||
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertInstanceOf(AuditLog::class, $persisted);
|
||||
self::assertSame('203.0.113.7', $persisted->getIpAddress());
|
||||
self::assertSame('device-abc', $persisted->getDeviceId());
|
||||
self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel());
|
||||
self::assertNotNull($persisted->getUserAgent());
|
||||
}
|
||||
|
||||
public function testTruncatesOverlongDeviceId(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$request = Request::create('/api/work_hours', 'POST');
|
||||
$request->headers->set('X-Device-Id', str_repeat('x', 200));
|
||||
$stack = new RequestStack();
|
||||
$stack->push($request);
|
||||
|
||||
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertSame(64, mb_strlen((string) $persisted->getDeviceId()));
|
||||
}
|
||||
|
||||
public function testNoRequestLeavesContextNull(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertNull($persisted->getIpAddress());
|
||||
self::assertNull($persisted->getUserAgent());
|
||||
self::assertNull($persisted->getDeviceLabel());
|
||||
self::assertNull($persisted->getDeviceId());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
|
||||
Expected: FAIL — `AuditLogger::__construct()` expects 2 args (too few given), or `getIpAddress()` undefined if run before Task 2.
|
||||
|
||||
- [ ] **Step 3: Update the service**
|
||||
|
||||
Replace the full contents of `src/Service/AuditLogger.php` with:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
readonly class AuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private UserAgentParser $userAgentParser,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
?Employee $employee,
|
||||
string $action,
|
||||
string $entityType,
|
||||
?int $entityId,
|
||||
string $description,
|
||||
?array $changes = null,
|
||||
?DateTimeImmutable $affectedDate = null,
|
||||
): void {
|
||||
$user = $this->security->getUser();
|
||||
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$ipAddress = null;
|
||||
$userAgent = null;
|
||||
$deviceId = null;
|
||||
|
||||
if (null !== $request) {
|
||||
$ipAddress = $request->getClientIp();
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
$deviceId = $request->headers->get('X-Device-Id');
|
||||
// The device id comes from an untrusted client header; cap it to the column width.
|
||||
if (null !== $deviceId) {
|
||||
$deviceId = mb_substr($deviceId, 0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
$auditLog = new AuditLog();
|
||||
$auditLog
|
||||
->setEmployee($employee)
|
||||
->setUsername($username)
|
||||
->setAction($action)
|
||||
->setEntityType($entityType)
|
||||
->setEntityId($entityId)
|
||||
->setDescription($description)
|
||||
->setChanges($changes)
|
||||
->setAffectedDate($affectedDate)
|
||||
->setIpAddress($ipAddress)
|
||||
->setUserAgent($userAgent)
|
||||
->setDeviceLabel($this->userAgentParser->parse($userAgent))
|
||||
->setDeviceId($deviceId)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($auditLog);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Run the full backend suite (no regression)**
|
||||
|
||||
Run: `make test`
|
||||
Expected: OK — all tests green (existing processors that use `AuditLogger` are autowired, so the 2 new constructor args resolve automatically).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/AuditLogger.php tests/Service/AuditLoggerTest.php
|
||||
git commit -m "feat(audit) : capture IP/appareil/user-agent dans AuditLogger"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Exposition des champs dans l'API lecture
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/AuditLogProvider.php:53-64` (le tableau `$items[]`)
|
||||
- Modify: `frontend/services/dto/audit-log.ts`
|
||||
- Create: `tests/State/AuditLogProviderTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: getters de `AuditLog` (Task 2).
|
||||
- Produces: chaque item JSON du endpoint `GET /audit-logs` porte désormais `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` (string|null). Le DTO TS `AuditLog` gagne ces 4 champs optionnels.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/State/AuditLogProviderTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\State\AuditLogProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLogProviderTest extends TestCase
|
||||
{
|
||||
public function testProvideExposesForensicFields(): void
|
||||
{
|
||||
$log = (new AuditLog())
|
||||
->setUsername('usine')
|
||||
->setAction('create')
|
||||
->setEntityType('work_hour')
|
||||
->setDescription('desc')
|
||||
->setIpAddress('203.0.113.7')
|
||||
->setUserAgent('UA-string')
|
||||
->setDeviceLabel('Mobile · Android · Chrome')
|
||||
->setDeviceId('device-abc')
|
||||
;
|
||||
|
||||
$repo = $this->createMock(AuditLogRepository::class);
|
||||
$repo->method('countByFilters')->willReturn(1);
|
||||
$repo->method('findByFilters')->willReturn([$log]);
|
||||
|
||||
$stack = new RequestStack();
|
||||
$stack->push(Request::create('/api/audit-logs', 'GET'));
|
||||
|
||||
$provider = new AuditLogProvider($stack, $repo);
|
||||
$response = $provider->provide($this->createMock(Operation::class));
|
||||
|
||||
$data = json_decode((string) $response->getContent(), true);
|
||||
$item = $data['items'][0];
|
||||
|
||||
self::assertSame('203.0.113.7', $item['ipAddress']);
|
||||
self::assertSame('UA-string', $item['userAgent']);
|
||||
self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
|
||||
self::assertSame('device-abc', $item['deviceId']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
|
||||
Expected: FAIL — `Undefined array key "ipAddress"`.
|
||||
|
||||
- [ ] **Step 3: Add the fields to the provider output**
|
||||
|
||||
In `src/State/AuditLogProvider.php`, in the `$items[] = [ ... ]` block, add the 4 keys after `'affectedDate' => ...` and before `'createdAt' => ...`:
|
||||
|
||||
```php
|
||||
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||
'ipAddress' => $log->getIpAddress(),
|
||||
'userAgent' => $log->getUserAgent(),
|
||||
'deviceLabel' => $log->getDeviceLabel(),
|
||||
'deviceId' => $log->getDeviceId(),
|
||||
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Align the frontend DTO**
|
||||
|
||||
Replace the full contents of `frontend/services/dto/audit-log.ts` with:
|
||||
|
||||
```ts
|
||||
export type AuditLog = {
|
||||
id: number
|
||||
employeeName: string | null
|
||||
employeeId: number | null
|
||||
username: string
|
||||
action: string
|
||||
entityType: string
|
||||
description: string
|
||||
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||
affectedDate: string | null
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
deviceLabel: string | null
|
||||
deviceId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/State/AuditLogProvider.php tests/State/AuditLogProviderTest.php frontend/services/dto/audit-log.ts
|
||||
git commit -m "feat(audit) : expose le contexte forensique dans l'API lecture"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Device ID persistant côté front
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/composables/useDeviceId.ts`
|
||||
- Modify: `frontend/composables/useApi.ts` (intercepteur `onRequest` dans `$fetch.create`, lignes 79-170)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: rien.
|
||||
- Produces: `useDeviceId(): string | null` (auto-importé Nuxt). Renvoie l'UUID stocké dans `localStorage['sirh-device-id']` (créé si absent), ou `null` côté serveur (SSR). `useApi` ajoute le header `X-Device-Id` sur toutes les requêtes API quand l'ID est disponible.
|
||||
|
||||
- [ ] **Step 1: Create the composable**
|
||||
|
||||
Create `frontend/composables/useDeviceId.ts`:
|
||||
|
||||
```ts
|
||||
// Stable per-device identifier used to add forensic context to audit logs.
|
||||
// Persisted in localStorage so the same browser/device reuses it across sessions.
|
||||
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
|
||||
// user of the same browser shares one id (intended: it distinguishes devices).
|
||||
|
||||
const STORAGE_KEY = 'sirh-device-id'
|
||||
let cached: string | null = null
|
||||
|
||||
export const useDeviceId = (): string | null => {
|
||||
if (!import.meta.client) {
|
||||
return null
|
||||
}
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
let id = localStorage.getItem(STORAGE_KEY)
|
||||
if (!id) {
|
||||
id = crypto.randomUUID()
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
cached = id
|
||||
return id
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, disabled) — degrade gracefully.
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Inject the header in the shared fetch client**
|
||||
|
||||
In `frontend/composables/useApi.ts`, the client is created at line 79 with `$fetch.create({ baseURL, retry: 0, credentials: 'include', onResponse(...) {...}, onResponseError(...) {...} })`. Add an `onRequest` interceptor as the first option inside that object (right after `credentials: 'include',`):
|
||||
|
||||
```ts
|
||||
const client = $fetch.create({
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onRequest({ options }) {
|
||||
const deviceId = useDeviceId()
|
||||
if (deviceId) {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
headers.set('X-Device-Id', deviceId)
|
||||
options.headers = headers
|
||||
}
|
||||
},
|
||||
onResponse({ options, response }) {
|
||||
```
|
||||
|
||||
This covers every call — both `request()` (GET/POST/PUT/PATCH/DELETE) and the `getBlob` path (`client.raw`), since both go through this single client.
|
||||
|
||||
- [ ] **Step 3: Verify (no build — review only)**
|
||||
|
||||
Do NOT run `npm run build` (project rule). Verify by re-reading the diff:
|
||||
- `useDeviceId.ts` returns `null` on server (`import.meta.client` guard) and never throws.
|
||||
- In `useApi.ts`, `onRequest` is a sibling key of `onResponse` inside `$fetch.create({...})`, the braces/commas are balanced, and `options.headers` is reassigned to the merged `Headers`.
|
||||
|
||||
Expected: header `X-Device-Id` will be present on all `/api/*` requests once running.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/composables/useDeviceId.ts frontend/composables/useApi.ts
|
||||
git commit -m "feat(audit) : envoie un device id persistant sur les requêtes API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Config `trusted_proxies` documentée
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/packages/framework.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: rien. Produces: rien (config commentée, comportement inchangé tant que non activée).
|
||||
|
||||
- [ ] **Step 1: Add the documented (commented) block**
|
||||
|
||||
In `config/packages/framework.yaml`, inside the top-level `framework:` block (after `session: true`, before the `#esi: true` line), insert:
|
||||
|
||||
```yaml
|
||||
# Trusted proxies — REQUIRED for a correct client IP in the activity log
|
||||
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
|
||||
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
|
||||
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
|
||||
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
|
||||
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify config still loads**
|
||||
|
||||
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console cache:clear`
|
||||
Expected: cache cleared, no YAML/config error.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add config/packages/framework.yaml
|
||||
git commit -m "docs(audit) : documente trusted_proxies pour l'IP du journal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Documentation (règles obligatoires)
|
||||
|
||||
**Files:**
|
||||
- Modify: `doc/audit-logging.md`
|
||||
- Modify: `frontend/data/documentation-content.ts`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:** N/A.
|
||||
|
||||
- [ ] **Step 1: Update `doc/audit-logging.md`**
|
||||
|
||||
In the "Données stockées par entrée" section, add the 4 new fields and a note. Append these lines to that list (after `affectedDate` / `createdAt`):
|
||||
|
||||
```markdown
|
||||
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
|
||||
- `userAgent` : User-Agent brut de la requête
|
||||
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
|
||||
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
|
||||
|
||||
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the in-app documentation**
|
||||
|
||||
In `frontend/data/documentation-content.ts`, locate the audit-log / "Journal des actions" article (admin level). Add to its content a block explaining the new forensic columns. Add this block to that article's `blocks` array (follow the existing `DocBlock` shape used in the file — typically `{ type: 'paragraph', text: '...' }` and `{ type: 'list', items: [...] }`):
|
||||
|
||||
```ts
|
||||
{ type: 'paragraph', text: "Chaque entrée du journal enregistre aussi un contexte technique automatique pour distinguer les intervenants sur un compte partagé (ex. « Usine ») :" },
|
||||
{
|
||||
type: 'list',
|
||||
items: [
|
||||
"Adresse IP de la connexion",
|
||||
"Appareil / système / navigateur (ex. « Mobile · Android · Chrome »)",
|
||||
"Identifiant d'appareil : un même appareil garde le même identifiant entre les sessions (distingue les appareils, pas les personnes)",
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
(If the exact `DocBlock` field names differ — check `frontend/types/documentation.ts` — adapt the keys to match; keep the French copy.)
|
||||
|
||||
- [ ] **Step 3: Update `CLAUDE.md`**
|
||||
|
||||
In `CLAUDE.md`, in the `## Audit Logging` section, add a bullet:
|
||||
|
||||
```markdown
|
||||
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify docs reference real symbols**
|
||||
|
||||
Run: `grep -rn "UserAgentParser\|X-Device-Id\|sirh-device-id" doc/audit-logging.md CLAUDE.md src/ frontend/composables/`
|
||||
Expected: references resolve to the files created in Tasks 1, 3, 5 (no typos).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/audit-logging.md frontend/data/documentation-content.ts CLAUDE.md
|
||||
git commit -m "docs(audit) : documente le contexte forensique du journal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (auteur du plan)
|
||||
|
||||
**Spec coverage :**
|
||||
- Capture 4 signaux via point unique → Task 3 ✓
|
||||
- 4 colonnes nullable + migration `down()` → Task 2 ✓
|
||||
- `UserAgentParser` maison → Task 1 ✓
|
||||
- Device id front (localStorage) + header sur toutes requêtes → Task 5 ✓
|
||||
- Exposition API lecture + DTO TS aligné → Task 4 ✓
|
||||
- `trusted_proxies` documenté/conservateur → Task 6 ✓
|
||||
- Docs (doc + in-app + CLAUDE.md) + tests → Tasks 1,3,4,7 ✓
|
||||
- Écran `audit-logs.vue` explicitement hors périmètre → respecté (aucune tâche ne le touche) ✓
|
||||
|
||||
**Placeholder scan :** aucun TBD/TODO ; tout le code est fourni. La seule souplesse explicite : Task 7 Step 2 demande d'adapter aux noms de champs réels de `DocBlock` (avec instruction de vérifier `frontend/types/documentation.ts`).
|
||||
|
||||
**Type consistency :** getters/setters de Task 2 (`getIpAddress`/`getUserAgent`/`getDeviceLabel`/`getDeviceId`) réutilisés à l'identique dans Tasks 3 et 4. Clés JSON (`ipAddress`/`userAgent`/`deviceLabel`/`deviceId`) identiques entre provider (Task 4 Step 3), test (Task 4 Step 1) et DTO TS (Task 4 Step 5). Header `X-Device-Id` et clé `localStorage` `sirh-device-id` cohérents entre Task 3 (lecture back), Task 5 (écriture front) et docs.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
# Contexte forensique dans le journal d'activité
|
||||
|
||||
Date : 2026-06-24
|
||||
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
|
||||
|
||||
## Problème
|
||||
|
||||
Le journal d'activité (`audit_logs`) ne stocke comme « qui » que le `username`. Or
|
||||
certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »). Sous
|
||||
un compte partagé, toutes les actions apparaissent sous le même nom → impossible de
|
||||
distinguer les intervenants en cas de litige. Les utilisateurs se connectent aussi
|
||||
depuis des **smartphones**.
|
||||
|
||||
## Objectif
|
||||
|
||||
Ajouter du **contexte forensique automatique** à chaque entrée du journal, sans rien
|
||||
demander à l'utilisateur. But : disposer d'assez d'indices techniques pour enquêter
|
||||
(IP, type d'appareil/OS/navigateur, identifiant d'appareil stable, User-Agent brut) et
|
||||
distinguer les **appareils** derrière un compte partagé.
|
||||
|
||||
Non-objectif (volontairement exclu) : identification explicite de la personne physique
|
||||
(liste de noms, PIN…). Écarté par l'utilisateur — on reste sur du signal automatique.
|
||||
|
||||
## Périmètre
|
||||
|
||||
**Inclus :**
|
||||
- Capture automatique de 4 signaux à chaque écriture d'audit.
|
||||
- 4 nouvelles colonnes nullable sur `audit_logs` + migration (avec `down()`).
|
||||
- Service `UserAgentParser` (libellé appareil lisible, sans dépendance externe).
|
||||
- Front : identifiant d'appareil persistant (`localStorage`) envoyé en header sur toutes
|
||||
les requêtes API.
|
||||
- Exposition des 4 champs dans l'API de lecture du journal (`AuditLogResource` / provider)
|
||||
pour que la future refonte d'écran les ait à disposition.
|
||||
- Config `framework.trusted_proxies` documentée (conservatrice, à activer selon l'infra).
|
||||
- Docs (`doc/audit-logging.md`, `documentation-content.ts`, `CLAUDE.md`) + tests unitaires.
|
||||
|
||||
**Exclu (étape suivante) :**
|
||||
- Refonte de l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes,
|
||||
filtre par appareil). L'utilisateur prévoit de revoir cet écran séparément. On se
|
||||
contente d'exposer les données via l'API ; aucune modif du composant Vue dans ce lot.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Capture — un seul point d'entrée
|
||||
|
||||
Toutes les écritures d'audit passent par `AuditLogger::log()` (`src/Service/AuditLogger.php`).
|
||||
On y injecte `RequestStack`. À chaque `log()`, on lit la requête courante et on renseigne
|
||||
les 4 champs sur l'entité `AuditLog` avant persistance. **Aucun processor à modifier.**
|
||||
|
||||
Extraction depuis la requête :
|
||||
- `ip_address` ← `Request::getClientIp()`
|
||||
- `user_agent` ← header `User-Agent` (brut)
|
||||
- `device_label` ← `UserAgentParser::parse(userAgent)`
|
||||
- `device_id` ← header `X-Device-Id`
|
||||
|
||||
Si aucune requête courante (ex. commande CLI / cron), les 4 champs restent `null`
|
||||
(comportement « system » déjà existant pour le username).
|
||||
|
||||
### Modèle de données — `audit_logs`
|
||||
|
||||
Ajout de 4 colonnes **nullable** (pas de backfill, l'existant reste valide) :
|
||||
|
||||
| Colonne | Type | Contenu |
|
||||
|---|---|---|
|
||||
| `ip_address` | VARCHAR(45) | IP source. 45 = longueur max IPv6 (avec mapping IPv4). |
|
||||
| `user_agent` | TEXT | User-Agent brut, stocké tel quel. |
|
||||
| `device_label` | VARCHAR(255) | Libellé lisible, ex. `Mobile · Android · Chrome`. |
|
||||
| `device_id` | VARCHAR(64) | UUID persistant fourni par le front. |
|
||||
|
||||
Migration Doctrine avec `down()` supprimant les 4 colonnes. Pas de nouvel index dans ce
|
||||
lot (le filtre par appareil étant reporté à la refonte d'écran).
|
||||
|
||||
### Service `UserAgentParser`
|
||||
|
||||
Nouveau service `src/Service/UserAgentParser.php`, maison, sans dépendance.
|
||||
`parse(?string $userAgent): ?string` → libellé court composé de :
|
||||
- **Type** : Mobile / Tablette / Ordinateur (détecté sur tokens `Mobile`, `Tablet`, `iPad`…).
|
||||
- **OS** : Android / iOS / Windows / macOS / Linux / autre.
|
||||
- **Navigateur** : Chrome / Safari / Firefox / Edge / autre (ordre de test important :
|
||||
Edge avant Chrome, Chrome avant Safari, car les UA s'imbriquent).
|
||||
|
||||
Format : `Type · OS · Navigateur` (ex. `Ordinateur · Windows · Firefox`). Retourne `null`
|
||||
si User-Agent vide. Heuristique volontairement simple et lisible ; suffisant pour
|
||||
distinguer mobile/poste et familles d'OS. (Alternative écartée : librairie
|
||||
`matomo/device-detector` — plus précise mais lourde et non nécessaire ici.)
|
||||
|
||||
### Front — identifiant d'appareil persistant
|
||||
|
||||
- Composable `frontend/composables/useDeviceId.ts` : côté client uniquement, lit
|
||||
`localStorage['sirh-device-id']` ; si absent, génère `crypto.randomUUID()` et le persiste.
|
||||
Retourne l'ID (ou `null` côté serveur en SSR).
|
||||
- `frontend/composables/useApi.ts`, fonction `request()` (point unique de construction des
|
||||
headers) : `headers.set('X-Device-Id', deviceId)` quand l'ID est disponible. Appliqué à
|
||||
toutes les méthodes (GET/POST/PUT/PATCH/DELETE).
|
||||
- Note : l'auth est par cookie JWT (`credentials: 'include'`), donc le device ID n'est pas
|
||||
lié à l'auth — `localStorage` est ici un usage non sensible, acceptable.
|
||||
- **Limite assumée** : l'ID est par navigateur/appareil, pas par personne. Sur un poste
|
||||
partagé (même navigateur), l'ID est identique pour tous → distingue les appareils, pas
|
||||
les humains. Cohérent avec l'objectif forensique.
|
||||
|
||||
### API de lecture
|
||||
|
||||
Exposer `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` dans la sortie de lecture du
|
||||
journal (`src/ApiResource/AuditLogResource.php` + sérialisation dans `AuditLogProvider`),
|
||||
ainsi que dans le DTO front `frontend/services/dto/audit-log.ts`. Aucune modif du
|
||||
composant `audit-logs.vue` (refonte ultérieure). Objectif : les données sont prêtes à
|
||||
être affichées par la future refonte.
|
||||
|
||||
### Trusted proxies (IP fiable)
|
||||
|
||||
`framework.trusted_proxies` n'est pas configuré aujourd'hui. Derrière un reverse proxy
|
||||
(nginx/traefik), `getClientIp()` renvoie l'IP du proxy. Architecture de déploiement non
|
||||
confirmée → on prévoit dans `config/packages/framework.yaml` une entrée **commentée et
|
||||
documentée** (avec exemple `trusted_proxies` réseau privé / loopback + `trusted_headers`),
|
||||
à activer selon l'infra. En attendant, l'IP est stockée telle que renvoyée par Symfony.
|
||||
|
||||
## Stratégie de test
|
||||
|
||||
- `tests/.../UserAgentParserTest` : table de User-Agents réels (Chrome desktop, Safari
|
||||
iPhone, Chrome Android, Firefox, Edge, UA vide/null) → libellés attendus.
|
||||
- `tests/.../AuditLoggerTest` : avec un `RequestStack` peuplé d'une `Request` factice
|
||||
(IP, headers User-Agent + X-Device-Id), vérifier que l'`AuditLog` persisté porte bien les
|
||||
4 champs ; et qu'avec une `RequestStack` vide (contexte CLI), les 4 champs sont `null`.
|
||||
|
||||
## Documentation à mettre à jour (règles obligatoires CLAUDE.md)
|
||||
|
||||
- `doc/audit-logging.md` : section « Données stockées par entrée » + nouveaux champs +
|
||||
note sur le device ID front et le caveat trusted proxies.
|
||||
- `frontend/data/documentation-content.ts` : doc in-app (niveau admin) du journal.
|
||||
- `CLAUDE.md` : section Audit Logging — mentionner les 4 nouveaux signaux et le point de
|
||||
capture unique (`AuditLogger` + `RequestStack`).
|
||||
|
||||
## Risques / limites
|
||||
|
||||
- Device ID = par appareil, pas par humain (cf. ci-dessus).
|
||||
- IP peu utile derrière proxy tant que `trusted_proxies` n'est pas activé.
|
||||
- Plusieurs personnes dans la même usine sortent souvent sur la même IP publique et
|
||||
peuvent avoir le même modèle de téléphone → les signaux se recoupent ; ce lot fournit
|
||||
des indices, pas une preuve d'identité.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Refonte de l'écran Journal d'activité (MalioDataTable + drawer de filtre)
|
||||
|
||||
Date : 2026-06-24
|
||||
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
|
||||
|
||||
## Problème / objectif
|
||||
|
||||
L'écran `frontend/pages/audit-logs.vue` (journal d'activité, `ROLE_SUPER_ADMIN`) est aujourd'hui
|
||||
fait main : `<select>`/`<input>` natifs, tableau en grille CSS, lignes dépliables affichant le diff
|
||||
JSON brut, pagination « précédent/suivant » figée à 50/page. Il faut le **moderniser** :
|
||||
|
||||
1. Passer le tableau en **`MalioDataTable`** (1er usage dans SIRH).
|
||||
2. Mettre les filtres dans un **drawer**, sur le **même principe que STARSEED** (les écrans de liste
|
||||
`modules/.../pages/.../index.vue` : `MalioDrawer` + `MalioAccordion`, état brouillon/appliqué,
|
||||
footer Réinitialiser/Appliquer, badge de compteur de filtres actifs).
|
||||
3. Passer **tous** les composants de l'écran en composants **Malio** quand l'équivalent existe.
|
||||
4. Exploiter les nouvelles données forensiques (IP, appareil, User-Agent, device id) déjà captées
|
||||
par le backend.
|
||||
|
||||
## Référence de pattern
|
||||
|
||||
- STARSEED, écran canonique : `/home/m-tristan/workspace/Starseed/frontend/modules/commercial/pages/clients/index.vue`
|
||||
(drawer de filtre, `MalioAccordion`, brouillon→appliqué, `MalioDataTable`, badge compteur).
|
||||
- Adaptations SIRH : **libellés en français en dur** (convention des drawers SIRH existants —
|
||||
`employees/index.vue`, `sites.vue` — pas d'i18n comme STARSEED) ; **filtres non persistés en URL**
|
||||
(comme STARSEED et l'écran actuel).
|
||||
- Malio `@malio/layer-ui` 1.7.15 (doc `node_modules/@malio/layer-ui/COMPONENTS.md`).
|
||||
|
||||
## Périmètre
|
||||
|
||||
**Inclus :** refonte complète de `audit-logs.vue` (tableau, filtres, détail) + évolutions backend
|
||||
nécessaires (perPage + nouveaux filtres) + DTO TS + docs.
|
||||
|
||||
**Exclus :** toute autre page ; l'audit reste `ROLE_SUPER_ADMIN` ; pas de doc in-app (outil caché,
|
||||
aucun article existant — décision déjà prise au lot précédent).
|
||||
|
||||
---
|
||||
|
||||
## A. Tableau — `MalioDataTable`
|
||||
|
||||
API (1.7.15) : `:columns` (`{key,label}[]`), `:items`, `:total-items`, `v-model:page`,
|
||||
`v-model:per-page`, `:per-page-options`, `row-clickable`, événements `row-click` /
|
||||
`update:page` / `update:per-page`, slots `#cell-{key}` et `#empty`.
|
||||
|
||||
Colonnes :
|
||||
|
||||
| key | label | rendu |
|
||||
|---|---|---|
|
||||
| `createdAt` | Date action | `JJ/MM/AAAA HH:MM` (déjà formaté par le provider) |
|
||||
| `username` | Utilisateur | texte brut |
|
||||
| `action` | Action | badge couleur via `#cell-action` (create=vert, update=bleu, delete=rouge, validate=violet, site_validate=indigo, défaut=neutre) |
|
||||
| `entityType` | Type | libellé FR via `#cell-entityType` (work_hour→Heures, absence→Absence, employee→Employé, contract_suspension→Suspension, rtt_payment→RTT, fractioned_days→Fract., paid_leave_days→Congés payés, week_comment→Commentaire) |
|
||||
| `employeeName` | Employé | nom ou `—` |
|
||||
| `deviceLabel` | Appareil | `deviceLabel` ou `—` |
|
||||
| `description` | Description | tronqué (`truncate` + `title`) via `#cell-description` |
|
||||
|
||||
- `:per-page-options="[25, 50, 100]"`, `perPage` par défaut 50.
|
||||
- `@row-click` → ouvre le drawer de détail avec la ligne cliquée.
|
||||
- `:items` = directement les `AuditLog` de la page courante (le DTO porte déjà toutes les clés ;
|
||||
les `key` de colonnes correspondent aux champs).
|
||||
|
||||
## B. Drawer de détail (clic ligne)
|
||||
|
||||
`MalioDrawer` (droite, `drawer-class="max-w-xl"`), titre `#header` = « Détail de l'action ».
|
||||
Contenu (lecture seule, sections) :
|
||||
|
||||
- **Méta** : Utilisateur, Employé, Date action, Date affectée, Action (badge), Type (libellé).
|
||||
- **Contexte technique** : IP (`ipAddress`), Appareil (`deviceLabel`), User-Agent brut
|
||||
(`userAgent`, en `break-all`/petite police), Device id (`deviceId`). Champs nuls → `—`.
|
||||
- **Changements** : si `changes` non nul, rendu lisible — pour chaque clé présente dans
|
||||
`old`/`new`, une ligne `clé : ancienne → nouvelle` (au lieu du double bloc JSON brut actuel).
|
||||
Helper front `formatChanges(changes)` qui fusionne les clés de `old` et `new`. Si `changes` nul →
|
||||
« Aucun détail de modification ».
|
||||
|
||||
État : `selectedLog: AuditLog | null` + `detailOpen: boolean`. Fermeture standard MalioDrawer.
|
||||
|
||||
## C. Drawer de filtre (principe STARSEED)
|
||||
|
||||
Bouton **« Filtrer »** (`MalioButton variant="tertiary" icon-name="mdi:tune"`) dans la barre de titre ;
|
||||
son label porte le **compteur de filtres actifs** (`Filtrer (N)` si N>0).
|
||||
|
||||
`MalioDrawer` (`drawer-class="max-w-[450px]"`, `body-class="p-0"`,
|
||||
`footer-class="justify-between border-t border-black p-6"`), titre `#header` = « Filtres ».
|
||||
Corps en `MalioAccordion` (un `MalioAccordionItem` par section) :
|
||||
|
||||
| Section | Composant | Champ filtre |
|
||||
|---|---|---|
|
||||
| Période | `MalioDateRange` (`v-model` = `{start,end}` ISO) | `from`/`to` sur `affectedDate` (sémantique actuelle conservée) |
|
||||
| Employé | `MalioSelect` (options = employés chargés au mount) | `employeeId` (valeur unique) |
|
||||
| Type d'entité | liste de `MalioCheckbox` (multi) | `entityType[]` |
|
||||
| Action | liste de `MalioCheckbox` (multi) | `action[]` |
|
||||
| Utilisateur / compte | `MalioInputText` (`icon mdi:magnify`) | `username` (ILIKE partiel) |
|
||||
| IP | `MalioInputText` | `ip` (ILIKE partiel) |
|
||||
| Appareil | `MalioInputText` | `device` (ILIKE partiel sur `device_label` OU `device_id`) |
|
||||
|
||||
Footer : `MalioButton variant="tertiary"` **Réinitialiser** (gauche) + `MalioButton variant="primary"`
|
||||
**Appliquer** (droite).
|
||||
|
||||
**État brouillon → appliqué** (pattern STARSEED) :
|
||||
- `draft*` refs (éditées dans le drawer) et `applied*` refs (pilotent le fetch).
|
||||
- `openFilters()` : copie `applied*` → `draft*` puis ouvre.
|
||||
- `applyFilters()` : copie `draft*` → `applied*`, remet `page=1`, refetch, ferme le drawer.
|
||||
- `resetFilters()` : vide `draft*` **et** `applied*`, remet `page=1`, refetch, **laisse le drawer ouvert**.
|
||||
- `activeFilterCount` (computed sur `applied*`) → badge bouton.
|
||||
- Helpers `toggle(arrayRef, value, selected)` pour les multi-select.
|
||||
- Options Type d'entité / Action = listes statiques (mêmes codes que le provider) ; options Employé
|
||||
chargées une fois au `onMounted` (réutiliser le chargement employés déjà fait par l'écran actuel).
|
||||
|
||||
## D. Composable `useAuditLogsList`
|
||||
|
||||
Composable **spécifique à l'écran** (`frontend/composables/useAuditLogsList.ts`) — pas de
|
||||
`usePaginatedList` générique (un seul consommateur → YAGNI). Expose :
|
||||
|
||||
- état : `items`, `total`, `page`, `perPage`, `loading`, les `draft*`/`applied*`, `activeFilterCount`,
|
||||
`employeeOptions`.
|
||||
- actions : `load()` (fetch avec filtres appliqués + page/perPage), `goToPage(n)`, `setPerPage(n)`,
|
||||
`openFilters()`, `applyFilters()`, `resetFilters()`, `loadEmployeeOptions()`.
|
||||
- `load()` doit ignorer les réponses périmées (garde anti-race : compteur de requête, on jette
|
||||
les réponses dont l'index n'est pas le dernier émis).
|
||||
|
||||
La page `audit-logs.vue` se réduit à : barre de titre (titre + bouton Filtrer), `MalioDataTable`,
|
||||
drawer filtre, drawer détail — toute la logique vit dans le composable.
|
||||
|
||||
## E. Backend
|
||||
|
||||
### `frontend/services/dto/audit-log.ts` (`AuditLogFilters`)
|
||||
Étendre :
|
||||
```ts
|
||||
export type AuditLogFilters = {
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string[]
|
||||
action?: string[]
|
||||
username?: string
|
||||
ip?: string
|
||||
device?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
```
|
||||
`fetchAuditLogs` sérialise les tableaux en `entityType[]`/`action[]` (syntaxe PHP) et n'inclut que
|
||||
les filtres non vides.
|
||||
|
||||
### `src/ApiResource/AuditLogResource.php`
|
||||
Ajouter les `QueryParameter` : `perPage`, `username`, `ip`, `device`, `action` (`entityType` existe
|
||||
déjà). (Les `QueryParameter` sont surtout documentaires : le provider lit `$request->query`.)
|
||||
|
||||
### `src/State/AuditLogProvider.php`
|
||||
- Lire `perPage` (défaut 50, clampé à un ensemble autorisé `[25,50,100]`, fallback 50 ; borne dure).
|
||||
- Lire `username`, `ip`, `device` (chaînes, `null` si vide).
|
||||
- Lire `entityType` et `action` en **tableaux** (`$request->query->all('entityType')` /
|
||||
`->all('action')`), `null`/`[]` si absent. Conserver la rétro-compat : si `entityType` arrive en
|
||||
scalaire, le normaliser en tableau à un élément.
|
||||
- Passer le tout au repository ; `perPage` remplace la constante `PER_PAGE`. La réponse renvoie
|
||||
`perPage` réel.
|
||||
|
||||
### `src/Repository/Contract/AuditLogReadRepositoryInterface.php` + `AuditLogRepository.php`
|
||||
Faire évoluer `findByFilters` / `countByFilters` :
|
||||
```php
|
||||
findByFilters(
|
||||
?int $employeeId,
|
||||
?DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
?array $entityTypes, // list<string>|null
|
||||
?array $actions, // list<string>|null
|
||||
?string $username,
|
||||
?string $ip,
|
||||
?string $device,
|
||||
int $limit,
|
||||
int $offset,
|
||||
): array
|
||||
countByFilters(... mêmes filtres ...): int
|
||||
```
|
||||
Clauses : `employeeId` =, dates BETWEEN sur `affectedDate` (inchangé), `entityTypes`/`actions`
|
||||
`IN (:...)` si non vides, `username`/`ip` `ILIKE %v%` (paramètre échappé), `device` →
|
||||
`(device_label ILIKE :d OR device_id ILIKE :d)`. Tri inchangé (`createdAt DESC`).
|
||||
Mutualiser la construction des critères entre les deux méthodes (méthode privée
|
||||
`applyFilters(QueryBuilder, ...)`) pour rester DRY.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend : `AuditLogProviderTest` étendu — vérifier que `perPage`, `username`, `ip`, `device`,
|
||||
`entityType[]`, `action[]` sont lus et transmis au repository (repo stubbé, on asserte les
|
||||
arguments via un spy), et que `perPage` hors liste retombe sur 50.
|
||||
- Backend : test repository des nouvelles clauses si un test repository existe ; sinon couvrir via le
|
||||
provider (le repo réel n'est pas unit-testé aujourd'hui — ne pas introduire d'intégration DB).
|
||||
- Front : pas de test auto (convention SIRH, pas de build) — revue de diff. Le composable
|
||||
`useAuditLogsList` reste pur/réactif et testable manuellement.
|
||||
|
||||
## Documentation
|
||||
|
||||
- `doc/audit-logging.md` : section « Filtres disponibles » mise à jour (employé, période, type[],
|
||||
action[], utilisateur, IP, appareil ; pagination perPage) + mention du drawer et du drawer de
|
||||
détail.
|
||||
- `CLAUDE.md` : compléter la puce « Contexte forensique » / journal pour noter l'écran refondu
|
||||
(`MalioDataTable`, drawer de filtre façon STARSEED, drawer de détail, filtres back
|
||||
username/ip/device/action[]/entityType[]/perPage).
|
||||
|
||||
## Risques / notes
|
||||
|
||||
- 1er `MalioDataTable` de SIRH : valider le rendu (le composant gère sa propre pagination/markup ;
|
||||
ne pas réappliquer le gabarit grille maison du CLAUDE.md à ce tableau).
|
||||
- `MalioDateRange` filtre `affectedDate` (cohérent avec l'existant) ; ne pas confondre avec
|
||||
`createdAt` (date d'action affichée en colonne).
|
||||
- Évolution de signature de `AuditLogReadRepositoryInterface` : mettre à jour l'implémentation et le
|
||||
provider dans le même lot (ils sont les seuls consommateurs).
|
||||
@@ -80,6 +80,14 @@ export const useApi = (): ApiClient => {
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onRequest({ options }) {
|
||||
const deviceId = useDeviceId()
|
||||
if (deviceId) {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
headers.set('X-Device-Id', deviceId)
|
||||
options.headers = headers
|
||||
}
|
||||
},
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
|
||||
|
||||
type Range = { start: string, end: string } | null
|
||||
|
||||
export const useAuditLogsList = () => {
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const loading = ref(false)
|
||||
const filterOpen = ref(false)
|
||||
|
||||
// Applied filters (drive the fetch)
|
||||
const appliedEmployee = ref('')
|
||||
const appliedRange = ref<Range>(null)
|
||||
const appliedEntityTypes = ref<string[]>([])
|
||||
const appliedActions = ref<string[]>([])
|
||||
const appliedUsername = ref('')
|
||||
const appliedIp = ref('')
|
||||
const appliedDevice = ref('')
|
||||
|
||||
// Draft filters (edited inside the drawer)
|
||||
const draftEmployee = ref('')
|
||||
const draftRange = ref<Range>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftActions = ref<string[]>([])
|
||||
const draftUsername = ref('')
|
||||
const draftIp = ref('')
|
||||
const draftDevice = ref('')
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let n = 0
|
||||
if (appliedEmployee.value.trim() !== '') n++
|
||||
if (appliedRange.value?.start || appliedRange.value?.end) n++
|
||||
if (appliedEntityTypes.value.length > 0) n++
|
||||
if (appliedActions.value.length > 0) n++
|
||||
if (appliedUsername.value.trim() !== '') n++
|
||||
if (appliedIp.value.trim() !== '') n++
|
||||
if (appliedDevice.value.trim() !== '') n++
|
||||
return n
|
||||
})
|
||||
|
||||
const buildFilters = (): AuditLogFilters => ({
|
||||
employee: appliedEmployee.value.trim() || undefined,
|
||||
from: appliedRange.value?.start || undefined,
|
||||
to: appliedRange.value?.end || undefined,
|
||||
entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
|
||||
action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
|
||||
username: appliedUsername.value.trim() || undefined,
|
||||
ip: appliedIp.value.trim() || undefined,
|
||||
device: appliedDevice.value.trim() || undefined,
|
||||
page: page.value,
|
||||
perPage: perPage.value,
|
||||
})
|
||||
|
||||
// Race guard: only the latest request may commit its result.
|
||||
let requestSeq = 0
|
||||
const load = async () => {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs(buildFilters())
|
||||
if (seq !== requestSeq) return
|
||||
items.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
perPage.value = result.perPage
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
page.value = n
|
||||
load()
|
||||
}
|
||||
|
||||
const setPerPage = (n: number) => {
|
||||
perPage.value = n
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const openFilters = () => {
|
||||
draftEmployee.value = appliedEmployee.value
|
||||
draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
|
||||
draftEntityTypes.value = [...appliedEntityTypes.value]
|
||||
draftActions.value = [...appliedActions.value]
|
||||
draftUsername.value = appliedUsername.value
|
||||
draftIp.value = appliedIp.value
|
||||
draftDevice.value = appliedDevice.value
|
||||
filterOpen.value = true
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
appliedEmployee.value = draftEmployee.value
|
||||
appliedRange.value = draftRange.value ? { ...draftRange.value } : null
|
||||
appliedEntityTypes.value = [...draftEntityTypes.value]
|
||||
appliedActions.value = [...draftActions.value]
|
||||
appliedUsername.value = draftUsername.value
|
||||
appliedIp.value = draftIp.value
|
||||
appliedDevice.value = draftDevice.value
|
||||
page.value = 1
|
||||
filterOpen.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
draftEmployee.value = ''
|
||||
draftRange.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftActions.value = []
|
||||
draftUsername.value = ''
|
||||
draftIp.value = ''
|
||||
draftDevice.value = ''
|
||||
appliedEmployee.value = ''
|
||||
appliedRange.value = null
|
||||
appliedEntityTypes.value = []
|
||||
appliedActions.value = []
|
||||
appliedUsername.value = ''
|
||||
appliedIp.value = ''
|
||||
appliedDevice.value = ''
|
||||
page.value = 1
|
||||
load() // drawer stays open
|
||||
}
|
||||
|
||||
const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
|
||||
arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
|
||||
}
|
||||
const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
|
||||
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
|
||||
|
||||
return {
|
||||
items, total, page, perPage, loading, filterOpen, activeFilterCount,
|
||||
draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
|
||||
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Stable per-device identifier used to add forensic context to audit logs.
|
||||
// Persisted in localStorage so the same browser/device reuses it across sessions.
|
||||
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
|
||||
// user of the same browser shares one id (intended: it distinguishes devices).
|
||||
|
||||
const STORAGE_KEY = 'sirh-device-id'
|
||||
let cached: string | null = null
|
||||
|
||||
export const useDeviceId = (): string | null => {
|
||||
if (!import.meta.client) {
|
||||
return null
|
||||
}
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
let id = localStorage.getItem(STORAGE_KEY)
|
||||
if (!id) {
|
||||
id = crypto.randomUUID()
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
cached = id
|
||||
return id
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, disabled) — degrade gracefully.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ export const useDriverHoursPage = () => {
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
const rows = ref<Record<number, DriverHourRow>>({})
|
||||
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
|
||||
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées, afin de ne jamais
|
||||
// écraser/supprimer une ligne saisie entre-temps par un autre utilisateur (enregistrement
|
||||
// « à l'aveugle » d'une grille périmée).
|
||||
const loadedRows = ref<Record<number, DriverHourRow>>({})
|
||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
@@ -458,6 +463,10 @@ export const useDriverHoursPage = () => {
|
||||
}
|
||||
|
||||
rows.value = nextRows
|
||||
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
|
||||
loadedRows.value = Object.fromEntries(
|
||||
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
|
||||
)
|
||||
}
|
||||
|
||||
const loadAbsenceTypes = async () => {
|
||||
@@ -924,6 +933,32 @@ export const useDriverHoursPage = () => {
|
||||
await refreshByDate()
|
||||
})
|
||||
|
||||
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||
const buildEntry = (employeeId: number, row: DriverHourRow) => {
|
||||
const dayMin = toMinutes(row.dayHours)
|
||||
const nightMin = toMinutes(row.nightHours)
|
||||
const workshopMin = toMinutes(row.workshopHours)
|
||||
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: null,
|
||||
morningTo: null,
|
||||
afternoonFrom: null,
|
||||
afternoonTo: null,
|
||||
eveningFrom: null,
|
||||
eveningTo: null,
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false,
|
||||
dayHoursMinutes: dayMin || null,
|
||||
nightHoursMinutes: nightMin || null,
|
||||
workshopHoursMinutes: workshopMin || null,
|
||||
hasBreakfast: row.hasBreakfast,
|
||||
hasLunch: row.hasLunch,
|
||||
hasDinner: row.hasDinner,
|
||||
hasOvernight: row.hasOvernight
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -933,32 +968,16 @@ export const useDriverHoursPage = () => {
|
||||
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
||||
)
|
||||
|
||||
const entries = driverEmployees.map((employee) => {
|
||||
const employeeId = employee.id
|
||||
const row = rows.value[employeeId] ?? emptyRow()
|
||||
const dayMin = toMinutes(row.dayHours)
|
||||
const nightMin = toMinutes(row.nightHours)
|
||||
const workshopMin = toMinutes(row.workshopHours)
|
||||
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: null,
|
||||
morningTo: null,
|
||||
afternoonFrom: null,
|
||||
afternoonTo: null,
|
||||
eveningFrom: null,
|
||||
eveningTo: null,
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false,
|
||||
dayHoursMinutes: dayMin || null,
|
||||
nightHoursMinutes: nightMin || null,
|
||||
workshopHoursMinutes: workshopMin || null,
|
||||
hasBreakfast: row.hasBreakfast,
|
||||
hasLunch: row.hasLunch,
|
||||
hasDinner: row.hasDinner,
|
||||
hasOvernight: row.hasOvernight
|
||||
}
|
||||
})
|
||||
const entries = driverEmployees
|
||||
.map((employee) => {
|
||||
const current = buildEntry(employee.id, rows.value[employee.id] ?? emptyRow())
|
||||
const original = buildEntry(employee.id, loadedRows.value[employee.id] ?? emptyRow())
|
||||
return { current, original }
|
||||
})
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
|
||||
if (entries.length === 0) return
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ export const useHoursPage = () => {
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
const rows = ref<Record<number, HourRow>>({})
|
||||
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
|
||||
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées par l'utilisateur,
|
||||
// afin de ne jamais écraser/supprimer une ligne saisie entre-temps par un autre utilisateur
|
||||
// (perte de données par enregistrement « à l'aveugle » d'une grille périmée).
|
||||
const loadedRows = ref<Record<number, HourRow>>({})
|
||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
@@ -600,6 +605,10 @@ export const useHoursPage = () => {
|
||||
}
|
||||
|
||||
rows.value = nextRows
|
||||
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
|
||||
loadedRows.value = Object.fromEntries(
|
||||
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
|
||||
)
|
||||
}
|
||||
|
||||
const loadAbsenceTypes = async () => {
|
||||
@@ -1136,6 +1145,36 @@ export const useHoursPage = () => {
|
||||
await refreshByDate()
|
||||
})
|
||||
|
||||
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||
const buildEntry = (employee: Employee, row: HourRow) => {
|
||||
const employeeId = employee.id
|
||||
if (isPresenceTracking(employee)) {
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: null,
|
||||
morningTo: null,
|
||||
afternoonFrom: null,
|
||||
afternoonTo: null,
|
||||
eveningFrom: null,
|
||||
eveningTo: null,
|
||||
isPresentMorning: row.isPresentMorning,
|
||||
isPresentAfternoon: row.isPresentAfternoon
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: normalizeTime(row.morningFrom),
|
||||
morningTo: normalizeTime(row.morningTo),
|
||||
afternoonFrom: normalizeTime(row.afternoonFrom),
|
||||
afternoonTo: normalizeTime(row.afternoonTo),
|
||||
eveningFrom: normalizeTime(row.eveningFrom),
|
||||
eveningTo: normalizeTime(row.eveningTo),
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -1144,34 +1183,14 @@ export const useHoursPage = () => {
|
||||
const entries = employees.value
|
||||
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
||||
.map((employee) => {
|
||||
const employeeId = employee.id
|
||||
const row = rows.value[employeeId] ?? emptyRow()
|
||||
if (isPresenceTracking(employee)) {
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: null,
|
||||
morningTo: null,
|
||||
afternoonFrom: null,
|
||||
afternoonTo: null,
|
||||
eveningFrom: null,
|
||||
eveningTo: null,
|
||||
isPresentMorning: row.isPresentMorning,
|
||||
isPresentAfternoon: row.isPresentAfternoon
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
employeeId,
|
||||
morningFrom: normalizeTime(row.morningFrom),
|
||||
morningTo: normalizeTime(row.morningTo),
|
||||
afternoonFrom: normalizeTime(row.afternoonFrom),
|
||||
afternoonTo: normalizeTime(row.afternoonTo),
|
||||
eveningFrom: normalizeTime(row.eveningFrom),
|
||||
eveningTo: normalizeTime(row.eveningTo),
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false
|
||||
}
|
||||
const current = buildEntry(employee, rows.value[employee.id] ?? emptyRow())
|
||||
const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow())
|
||||
return { current, original }
|
||||
})
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.11",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
@@ -2247,9 +2247,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.11/layer-ui-1.7.11.tgz",
|
||||
"integrity": "sha512-uTISSe0L2T0TcpJShdK8VOEr0GpYzyDFDkLNFRa5APbpnfb8GPchx0xlFA1pgEF7DbnYB/zxYTWZCrGOhmaWOQ==",
|
||||
"version": "1.7.15",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
|
||||
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.11",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
|
||||
+208
-220
@@ -1,254 +1,242 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
|
||||
<div class="flex items-center justify-between pb-6">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
@click="list.openFilters()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
||||
<select
|
||||
v-model="filters.employeeId"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
||||
{{ emp.lastName }} {{ emp.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Du</label>
|
||||
<input
|
||||
v-model="filters.from"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Au</label>
|
||||
<input
|
||||
v-model="filters.to"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Type</label>
|
||||
<select
|
||||
v-model="filters.entityType"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option value="work_hour">Heures</option>
|
||||
<option value="absence">Absences</option>
|
||||
<option value="employee">Employé</option>
|
||||
<option value="contract_suspension">Suspension</option>
|
||||
<option value="rtt_payment">Paiement RTT</option>
|
||||
<option value="fractioned_days">Jours fractionnés</option>
|
||||
<option value="paid_leave_days">Congés N-1 payés</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="search"
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="list.items.value"
|
||||
:total-items="list.total.value"
|
||||
:page="list.page.value"
|
||||
:per-page="list.perPage.value"
|
||||
:per-page-options="[10, 25, 50, 100]"
|
||||
empty-message="Aucune entrée trouvée."
|
||||
@row-click="openDetail"
|
||||
@update:page="list.goToPage($event)"
|
||||
@update:per-page="list.setPerPage($event)"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
<template #cell-createdAt="{ item }">
|
||||
{{ formatDateTime((item as AuditLog).createdAt) }}
|
||||
</template>
|
||||
<template #cell-action="{ item }">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
|
||||
{{ actionLabel((item as AuditLog).action) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-entityType="{ item }">
|
||||
{{ entityTypeLabel((item as AuditLog).entityType) }}
|
||||
</template>
|
||||
<template #cell-employeeName="{ item }">
|
||||
{{ (item as AuditLog).employeeName ?? '—' }}
|
||||
</template>
|
||||
<template #cell-deviceLabel="{ item }">
|
||||
{{ (item as AuditLog).deviceLabel ?? '—' }}
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
<span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
<!-- Filter drawer -->
|
||||
<MalioDrawer
|
||||
v-model="list.filterOpen.value"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
|
||||
</template>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Aucune entrée trouvée.
|
||||
</div>
|
||||
<MalioAccordion>
|
||||
<MalioAccordionItem title="Période" value="period">
|
||||
<MalioDateRange v-model="list.draftRange.value" clearable />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<template v-else>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
||||
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span>Date action</span>
|
||||
<span>Utilisateur</span>
|
||||
<span>Action</span>
|
||||
<span>Type</span>
|
||||
<span>Employé</span>
|
||||
<span>Description</span>
|
||||
<span>Date affectée</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<template v-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="toggleExpand(log.id)"
|
||||
>
|
||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
||||
<span>{{ log.username }}</span>
|
||||
<span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
||||
{{ actionLabel(log.action) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
||||
<span>{{ log.employeeName ?? '-' }}</span>
|
||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
||||
<MalioAccordionItem title="Employé" value="employee">
|
||||
<MalioInputText v-model="list.draftEmployee.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Type d'entité" value="entityType">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-type-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="list.draftEntityTypes.value.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Action" value="action">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in actionOptions"
|
||||
:id="`filter-action-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="list.draftActions.value.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Utilisateur / compte" value="username">
|
||||
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="IP" value="ip">
|
||||
<MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Appareil" value="device">
|
||||
<MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
|
||||
<MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Detail drawer -->
|
||||
<MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
|
||||
<template #header>
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
|
||||
</template>
|
||||
|
||||
<div v-if="selected" class="space-y-6 text-md text-primary-500">
|
||||
<section class="space-y-1">
|
||||
<p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
|
||||
<p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
|
||||
<p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
|
||||
<p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
|
||||
<p>
|
||||
<span class="font-semibold">Action :</span>
|
||||
<span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
|
||||
</p>
|
||||
<p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1">
|
||||
<h3 class="font-bold">Contexte technique</h3>
|
||||
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
|
||||
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
|
||||
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
|
||||
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1">
|
||||
<h3 class="font-bold">Changements</h3>
|
||||
<div v-if="changeRows.length > 0" class="space-y-1">
|
||||
<div v-for="row in changeRows" :key="row.key" class="text-sm">
|
||||
<span class="font-semibold">{{ row.key }} :</span>
|
||||
<span class="text-red-600">{{ row.old }}</span>
|
||||
<span class="px-1">→</span>
|
||||
<span class="text-green-600">{{ row.new }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expandedIds.has(log.id)"
|
||||
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
|
||||
>
|
||||
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div v-if="log.changes.old">
|
||||
<p class="font-bold text-red-600 mb-2">Ancien</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="log.changes.new">
|
||||
<p class="font-bold text-green-600 mb-2">Nouveau</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<p class="text-md text-neutral-500">
|
||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage <= 1"
|
||||
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { fetchAuditLogs } from '~/services/audit-logs'
|
||||
import { listEmployees } from '~/services/employees'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
import { useAuditLogsList } from '~/composables/useAuditLogsList'
|
||||
|
||||
definePageMeta({ middleware: 'super-admin' })
|
||||
useHead({ title: 'Journal des actions' })
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isLoading = ref(false)
|
||||
const expandedIds = ref(new Set<number>())
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(50)
|
||||
const list = useAuditLogsList()
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
||||
const columns = [
|
||||
{ key: 'createdAt', label: 'Date action' },
|
||||
{ key: 'username', label: 'Utilisateur' },
|
||||
{ key: 'action', label: 'Action' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'employeeName', label: 'Employé' },
|
||||
{ key: 'deviceLabel', label: 'Appareil' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
]
|
||||
|
||||
const filters = reactive<{
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
}>({})
|
||||
const entityTypeOptions = [
|
||||
{ value: 'work_hour', label: 'Heures' },
|
||||
{ value: 'absence', label: 'Absence' },
|
||||
{ value: 'employee', label: 'Employé' },
|
||||
{ value: 'contract_suspension', label: 'Suspension' },
|
||||
{ value: 'rtt_payment', label: 'RTT' },
|
||||
{ value: 'fractioned_days', label: 'Fract.' },
|
||||
{ value: 'paid_leave_days', label: 'Congés payés' },
|
||||
{ value: 'week_comment', label: 'Commentaire' },
|
||||
]
|
||||
|
||||
const loadLogs = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs({ ...filters, page })
|
||||
logs.value = result.items
|
||||
total.value = result.total
|
||||
currentPage.value = result.page
|
||||
perPage.value = result.perPage
|
||||
expandedIds.value.clear()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
const actionOptions = [
|
||||
{ value: 'create', label: 'Créer' },
|
||||
{ value: 'update', label: 'Modifier' },
|
||||
{ value: 'delete', label: 'Supprimer' },
|
||||
{ value: 'validate', label: 'Valider' },
|
||||
{ value: 'site_validate', label: 'Valider (site)' },
|
||||
]
|
||||
|
||||
const filterButtonLabel = computed(() =>
|
||||
list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
|
||||
)
|
||||
|
||||
// Detail drawer
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref<AuditLog | null>(null)
|
||||
|
||||
const openDetail = (item: Record<string, unknown>) => {
|
||||
selected.value = item as unknown as AuditLog
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
loadLogs(1)
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
loadLogs(page)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
const changeRows = computed(() => {
|
||||
const c = selected.value?.changes
|
||||
if (!c) return []
|
||||
const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
|
||||
return [...keys].map(key => ({
|
||||
key,
|
||||
old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
|
||||
new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
|
||||
}))
|
||||
})
|
||||
|
||||
const formatDateTime = (dt: string) => {
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
return d.split('-').reverse().join('/')
|
||||
}
|
||||
const formatDate = (d: string) => d.split('-').reverse().join('/')
|
||||
|
||||
const actionLabel = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'Créer',
|
||||
update: 'Modifier',
|
||||
delete: 'Suppr.',
|
||||
validate: 'Valid.',
|
||||
site_validate: 'Valid. site',
|
||||
}
|
||||
return map[action] ?? action
|
||||
}
|
||||
const actionLabel = (action: string): string => ({
|
||||
create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
|
||||
}[action] ?? action)
|
||||
|
||||
const actionClass = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'bg-green-500',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-500',
|
||||
validate: 'bg-purple-500',
|
||||
site_validate: 'bg-indigo-500',
|
||||
}
|
||||
return map[action] ?? 'bg-neutral-500'
|
||||
}
|
||||
const actionClass = (action: string): string => ({
|
||||
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
|
||||
}[action] ?? 'bg-neutral-500')
|
||||
|
||||
const entityTypeLabel = (type: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
work_hour: 'Heures',
|
||||
absence: 'Absence',
|
||||
employee: 'Employé',
|
||||
contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT',
|
||||
fractioned_days: 'Fract.',
|
||||
paid_leave_days: 'Congés payés',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
const entityTypeLabel = (type: string): string => ({
|
||||
work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
|
||||
}[type] ?? type)
|
||||
|
||||
onMounted(async () => {
|
||||
employees.value = await listEmployees()
|
||||
await loadLogs()
|
||||
})
|
||||
onMounted(() => { list.init() })
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { AuditLog } from './dto/audit-log'
|
||||
|
||||
export type AuditLogFilters = {
|
||||
employeeId?: number
|
||||
employee?: string
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
entityType?: string[]
|
||||
action?: string[]
|
||||
username?: string
|
||||
ip?: string
|
||||
device?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
export type AuditLogPage = {
|
||||
@@ -17,17 +22,18 @@ export type AuditLogPage = {
|
||||
|
||||
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
|
||||
const api = useApi()
|
||||
const params: Record<string, string> = {}
|
||||
const params: Record<string, string | string[]> = {}
|
||||
|
||||
if (filters.employeeId) params.employeeId = String(filters.employeeId)
|
||||
if (filters.employee && filters.employee.trim() !== '') params.employee = filters.employee.trim()
|
||||
if (filters.from) params.from = filters.from
|
||||
if (filters.to) params.to = filters.to
|
||||
if (filters.entityType) params.entityType = filters.entityType
|
||||
if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType
|
||||
if (filters.action && filters.action.length > 0) params['action[]'] = filters.action
|
||||
if (filters.username && filters.username.trim() !== '') params.username = filters.username.trim()
|
||||
if (filters.ip && filters.ip.trim() !== '') params.ip = filters.ip.trim()
|
||||
if (filters.device && filters.device.trim() !== '') params.device = filters.device.trim()
|
||||
if (filters.page) params.page = String(filters.page)
|
||||
if (filters.perPage) params.perPage = String(filters.perPage)
|
||||
|
||||
return api.get<AuditLogPage>(
|
||||
'/audit-logs',
|
||||
params,
|
||||
{ toast: false }
|
||||
)
|
||||
return api.get<AuditLogPage>('/audit-logs', params, { toast: false })
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@ export type AuditLog = {
|
||||
description: string
|
||||
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||
affectedDate: string | null
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
deviceLabel: string | null
|
||||
deviceId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260624120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,16 @@ use App\State\AuditLogProvider;
|
||||
provider: AuditLogProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'employeeId'),
|
||||
new QueryParameter(key: 'employee'),
|
||||
new QueryParameter(key: 'from'),
|
||||
new QueryParameter(key: 'to'),
|
||||
new QueryParameter(key: 'entityType'),
|
||||
new QueryParameter(key: 'action'),
|
||||
new QueryParameter(key: 'username'),
|
||||
new QueryParameter(key: 'ip'),
|
||||
new QueryParameter(key: 'device'),
|
||||
new QueryParameter(key: 'page'),
|
||||
new QueryParameter(key: 'perPage'),
|
||||
],
|
||||
security: "is_granted('ROLE_SUPER_ADMIN')"
|
||||
),
|
||||
|
||||
@@ -46,6 +46,18 @@ class AuditLog
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $affectedDate = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 45, nullable: true)]
|
||||
private ?string $ipAddress = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $userAgent = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $deviceLabel = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $deviceId = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -155,6 +167,54 @@ class AuditLog
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function setIpAddress(?string $ipAddress): self
|
||||
{
|
||||
$this->ipAddress = $ipAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(?string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceLabel(): ?string
|
||||
{
|
||||
return $this->deviceLabel;
|
||||
}
|
||||
|
||||
public function setDeviceLabel(?string $deviceLabel): self
|
||||
{
|
||||
$this->deviceLabel = $deviceLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceId(): ?string
|
||||
{
|
||||
return $this->deviceId;
|
||||
}
|
||||
|
||||
public function setDeviceId(?string $deviceId): self
|
||||
{
|
||||
$this->deviceId = $deviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
@@ -5,28 +5,32 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AuditLog>
|
||||
*/
|
||||
final class AuditLogRepository extends ServiceEntityRepository
|
||||
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AuditLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
@@ -35,30 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
@@ -67,36 +48,66 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
): int {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
$qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
|
||||
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*/
|
||||
private function applyFilters(
|
||||
QueryBuilder $qb,
|
||||
?int $employeeId,
|
||||
?DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
?array $entityTypes,
|
||||
?array $actions,
|
||||
?string $username,
|
||||
?string $ip,
|
||||
?string $device,
|
||||
?string $employeeName = null,
|
||||
): void {
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
|
||||
}
|
||||
if (null !== $employeeName && '' !== $employeeName) {
|
||||
$qb->join('a.employee', 'e')
|
||||
->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName')
|
||||
->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%')
|
||||
;
|
||||
}
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
|
||||
}
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
|
||||
}
|
||||
if (null !== $entityTypes && [] !== $entityTypes) {
|
||||
$qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
|
||||
}
|
||||
if (null !== $actions && [] !== $actions) {
|
||||
$qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
|
||||
}
|
||||
if (null !== $username && '' !== $username) {
|
||||
$qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
|
||||
}
|
||||
if (null !== $ip && '' !== $ip) {
|
||||
$qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
|
||||
}
|
||||
if (null !== $device && '' !== $device) {
|
||||
$qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
|
||||
->setParameter('device', '%'.mb_strtolower($device).'%')
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface AuditLogReadRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*/
|
||||
public function countByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
): int;
|
||||
}
|
||||
@@ -10,12 +10,15 @@ use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
readonly class AuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private UserAgentParser $userAgentParser,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
@@ -30,6 +33,25 @@ readonly class AuditLogger
|
||||
$user = $this->security->getUser();
|
||||
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$ipAddress = null;
|
||||
$userAgent = null;
|
||||
$deviceId = null;
|
||||
|
||||
if (null !== $request) {
|
||||
$ipAddress = $request->getClientIp();
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
$deviceId = $request->headers->get('X-Device-Id');
|
||||
// The device id comes from an untrusted client header; cap it to the column width.
|
||||
if (null !== $deviceId) {
|
||||
$deviceId = mb_substr($deviceId, 0, 64);
|
||||
}
|
||||
// The user agent comes from an untrusted client header; cap it to prevent storage bloat.
|
||||
if (null !== $userAgent) {
|
||||
$userAgent = mb_substr($userAgent, 0, 1024);
|
||||
}
|
||||
}
|
||||
|
||||
$auditLog = new AuditLog();
|
||||
$auditLog
|
||||
->setEmployee($employee)
|
||||
@@ -40,6 +62,10 @@ readonly class AuditLogger
|
||||
->setDescription($description)
|
||||
->setChanges($changes)
|
||||
->setAffectedDate($affectedDate)
|
||||
->setIpAddress($ipAddress)
|
||||
->setUserAgent($userAgent)
|
||||
->setDeviceLabel($this->userAgentParser->parse($userAgent))
|
||||
->setDeviceId($deviceId)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($auditLog);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
|
||||
* User-Agent string, used to add forensic context to audit log entries.
|
||||
* Heuristic on purpose — enough to tell a phone from a desktop and identify
|
||||
* OS/browser families on shared accounts.
|
||||
*/
|
||||
class UserAgentParser
|
||||
{
|
||||
public function parse(?string $userAgent): ?string
|
||||
{
|
||||
if (null === $userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ua = trim($userAgent);
|
||||
if ('' === $ua) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' · ', [
|
||||
$this->detectType($ua),
|
||||
$this->detectOs($ua),
|
||||
$this->detectBrowser($ua),
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectType(string $ua): string
|
||||
{
|
||||
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
|
||||
return 'Tablette';
|
||||
}
|
||||
|
||||
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
|
||||
return 'Mobile';
|
||||
}
|
||||
|
||||
return 'Ordinateur';
|
||||
}
|
||||
|
||||
private function detectOs(string $ua): string
|
||||
{
|
||||
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
|
||||
// Android before Linux (Android UAs contain "Linux").
|
||||
return match (true) {
|
||||
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
|
||||
1 === preg_match('/Android/i', $ua) => 'Android',
|
||||
1 === preg_match('/Windows/i', $ua) => 'Windows',
|
||||
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
|
||||
1 === preg_match('/Linux/i', $ua) => 'Linux',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
|
||||
private function detectBrowser(string $ua): string
|
||||
{
|
||||
// Order matters: Edge/Opera contain "Chrome" and "Safari";
|
||||
// Chrome contains "Safari". Match the most specific first.
|
||||
return match (true) {
|
||||
1 === preg_match('/Edg/i', $ua) => 'Edge',
|
||||
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
|
||||
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
|
||||
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
|
||||
1 === preg_match('/Safari/i', $ua) => 'Safari',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -14,11 +14,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
class AuditLogProvider implements ProviderInterface
|
||||
{
|
||||
private const PER_PAGE = 50;
|
||||
private const DEFAULT_PER_PAGE = 10;
|
||||
private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly AuditLogRepository $auditLogRepository,
|
||||
private readonly AuditLogReadRepositoryInterface $auditLogRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
||||
@@ -28,20 +29,33 @@ class AuditLogProvider implements ProviderInterface
|
||||
return new JsonResponse(['items' => [], 'total' => 0]);
|
||||
}
|
||||
|
||||
$employeeId = $request->query->get('employeeId');
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
$entityType = $request->query->get('entityType');
|
||||
$page = max(1, (int) $request->query->get('page', '1'));
|
||||
$query = $request->query;
|
||||
$all = $query->all();
|
||||
|
||||
$employeeId = $query->get('employeeId');
|
||||
$from = $query->get('from');
|
||||
$to = $query->get('to');
|
||||
$page = max(1, (int) $query->get('page', '1'));
|
||||
|
||||
$perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
|
||||
if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
|
||||
$perPage = self::DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
$entityTypes = $this->normalizeList($all['entityType'] ?? null);
|
||||
$actions = $this->normalizeList($all['action'] ?? null);
|
||||
$username = $this->normalizeString($query->get('username'));
|
||||
$ip = $this->normalizeString($query->get('ip'));
|
||||
$device = $this->normalizeString($query->get('device'));
|
||||
$employee = $this->normalizeString($query->get('employee'));
|
||||
|
||||
$empId = $employeeId ? (int) $employeeId : null;
|
||||
$fromDt = $from ? new DateTimeImmutable($from) : null;
|
||||
$toDt = $to ? new DateTimeImmutable($to) : null;
|
||||
$type = $entityType ?: null;
|
||||
$offset = ($page - 1) * self::PER_PAGE;
|
||||
$fromDt = $from ? new DateTimeImmutable((string) $from) : null;
|
||||
$toDt = $to ? new DateTimeImmutable((string) $to) : null;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
|
||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
|
||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee);
|
||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset);
|
||||
|
||||
$items = [];
|
||||
foreach ($logs as $log) {
|
||||
@@ -60,6 +74,10 @@ class AuditLogProvider implements ProviderInterface
|
||||
'description' => $log->getDescription(),
|
||||
'changes' => $log->getChanges(),
|
||||
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||
'ipAddress' => $log->getIpAddress(),
|
||||
'userAgent' => $log->getUserAgent(),
|
||||
'deviceLabel' => $log->getDeviceLabel(),
|
||||
'deviceId' => $log->getDeviceId(),
|
||||
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
@@ -68,7 +86,27 @@ class AuditLogProvider implements ProviderInterface
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'perPage' => self::PER_PAGE,
|
||||
'perPage' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|list<string>
|
||||
*/
|
||||
private function normalizeList(mixed $value): ?array
|
||||
{
|
||||
$list = array_values(array_filter(
|
||||
(array) ($value ?? []),
|
||||
static fn ($v): bool => is_string($v) && '' !== trim($v),
|
||||
));
|
||||
|
||||
return [] === $list ? null : $list;
|
||||
}
|
||||
|
||||
private function normalizeString(mixed $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) ($value ?? ''));
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\UserAgentParser;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLoggerTest extends TestCase
|
||||
{
|
||||
public function testCapturesRequestContext(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn(null); // -> username "system"
|
||||
|
||||
$request = Request::create('/api/work_hours', 'POST');
|
||||
$request->server->set('REMOTE_ADDR', '203.0.113.7');
|
||||
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
$request->headers->set('X-Device-Id', 'device-abc');
|
||||
|
||||
$stack = new RequestStack();
|
||||
$stack->push($request);
|
||||
|
||||
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertInstanceOf(AuditLog::class, $persisted);
|
||||
self::assertSame('203.0.113.7', $persisted->getIpAddress());
|
||||
self::assertSame('device-abc', $persisted->getDeviceId());
|
||||
self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel());
|
||||
self::assertNotNull($persisted->getUserAgent());
|
||||
}
|
||||
|
||||
public function testTruncatesOverlongDeviceId(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$request = Request::create('/api/work_hours', 'POST');
|
||||
$request->headers->set('X-Device-Id', str_repeat('x', 200));
|
||||
$stack = new RequestStack();
|
||||
$stack->push($request);
|
||||
|
||||
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertSame(64, mb_strlen((string) $persisted->getDeviceId()));
|
||||
}
|
||||
|
||||
public function testNoRequestLeavesContextNull(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted = $entity;
|
||||
});
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser());
|
||||
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||
|
||||
self::assertNull($persisted->getIpAddress());
|
||||
self::assertNull($persisted->getUserAgent());
|
||||
self::assertNull($persisted->getDeviceLabel());
|
||||
self::assertNull($persisted->getDeviceId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\UserAgentParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class UserAgentParserTest extends TestCase
|
||||
{
|
||||
private UserAgentParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new UserAgentParser();
|
||||
}
|
||||
|
||||
public function testNullAndEmptyReturnNull(): void
|
||||
{
|
||||
self::assertNull($this->parser->parse(null));
|
||||
self::assertNull($this->parser->parse(''));
|
||||
self::assertNull($this->parser->parse(' '));
|
||||
}
|
||||
|
||||
public function testChromeOnWindows(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testEdgeBeatsChrome(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
|
||||
self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testSafariOnIphoneIsMobileIos(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testChromeOnAndroid(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testFirefoxOnLinux(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0';
|
||||
self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testSafariOnMac(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||
self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testIpadIsTablet(): void
|
||||
{
|
||||
$ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua));
|
||||
}
|
||||
|
||||
public function testUnknownUaFallsBack(): void
|
||||
{
|
||||
self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||
use App\State\AuditLogProvider;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLogProviderTest extends TestCase
|
||||
{
|
||||
public function testExposesForensicFields(): void
|
||||
{
|
||||
$log = new AuditLog()
|
||||
->setUsername('usine')
|
||||
->setAction('create')
|
||||
->setEntityType('work_hour')
|
||||
->setDescription('desc')
|
||||
->setIpAddress('203.0.113.7')
|
||||
->setUserAgent('UA-string')
|
||||
->setDeviceLabel('Mobile · Android · Chrome')
|
||||
->setDeviceId('device-abc')
|
||||
;
|
||||
|
||||
$response = $this->provideWith($this->spyRepository([$log], 1), []);
|
||||
$item = json_decode((string) $response->getContent(), true)['items'][0];
|
||||
|
||||
self::assertSame('203.0.113.7', $item['ipAddress']);
|
||||
self::assertSame('UA-string', $item['userAgent']);
|
||||
self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
|
||||
self::assertSame('device-abc', $item['deviceId']);
|
||||
}
|
||||
|
||||
public function testPassesNewFiltersToRepository(): void
|
||||
{
|
||||
$repo = $this->spyRepository();
|
||||
$this->provideWith($repo, [
|
||||
'employeeId' => '5',
|
||||
'employee' => 'dupont',
|
||||
'username' => 'usine',
|
||||
'ip' => '10.0.',
|
||||
'device' => 'android',
|
||||
'entityType' => ['work_hour', 'absence'],
|
||||
'action' => ['create'],
|
||||
'perPage' => '25',
|
||||
'page' => '2',
|
||||
]);
|
||||
|
||||
self::assertSame(5, $repo->findArgs['employeeId']);
|
||||
self::assertSame('dupont', $repo->findArgs['employeeName']);
|
||||
self::assertSame('usine', $repo->findArgs['username']);
|
||||
self::assertSame('10.0.', $repo->findArgs['ip']);
|
||||
self::assertSame('android', $repo->findArgs['device']);
|
||||
self::assertSame(['work_hour', 'absence'], $repo->findArgs['entityTypes']);
|
||||
self::assertSame(['create'], $repo->findArgs['actions']);
|
||||
self::assertSame(25, $repo->findArgs['limit']);
|
||||
self::assertSame(25, $repo->findArgs['offset']); // page 2, perPage 25 -> offset 25
|
||||
}
|
||||
|
||||
public function testBlankFiltersBecomeNull(): void
|
||||
{
|
||||
$repo = $this->spyRepository();
|
||||
$this->provideWith($repo, ['username' => ' ', 'ip' => '', 'device' => '']);
|
||||
|
||||
self::assertNull($repo->findArgs['username']);
|
||||
self::assertNull($repo->findArgs['ip']);
|
||||
self::assertNull($repo->findArgs['device']);
|
||||
self::assertNull($repo->findArgs['entityTypes']);
|
||||
self::assertNull($repo->findArgs['actions']);
|
||||
}
|
||||
|
||||
public function testPerPageOutOfRangeFallsBackToDefault(): void
|
||||
{
|
||||
$repo = $this->spyRepository();
|
||||
$response = $this->provideWith($repo, ['perPage' => '999']);
|
||||
|
||||
self::assertSame(10, $repo->findArgs['limit']);
|
||||
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
|
||||
}
|
||||
|
||||
public function testDefaultPerPageIs10(): void
|
||||
{
|
||||
$repo = $this->spyRepository();
|
||||
$response = $this->provideWith($repo, []);
|
||||
|
||||
self::assertSame(10, $repo->findArgs['limit']);
|
||||
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
|
||||
}
|
||||
|
||||
private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface
|
||||
{
|
||||
return new class($items, $count) implements AuditLogReadRepositoryInterface {
|
||||
public array $findArgs = [];
|
||||
public array $countArgs = [];
|
||||
|
||||
public function __construct(private array $items, private int $count) {}
|
||||
|
||||
public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null, int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
$this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName', 'limit', 'offset');
|
||||
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null): int
|
||||
{
|
||||
$this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName');
|
||||
|
||||
return $this->count;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function provideWith(AuditLogReadRepositoryInterface $repo, array $query): JsonResponse
|
||||
{
|
||||
$stack = new RequestStack();
|
||||
$stack->push(Request::create('/api/audit-logs', 'GET', $query));
|
||||
$provider = new AuditLogProvider($stack, $repo);
|
||||
|
||||
return $provider->provide($this->createStub(Operation::class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user