Compare commits
4 Commits
develop
...
9fe3ce9f96
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe3ce9f96 | |||
| c2996b21ae | |||
| cbcde54187 | |||
| 3123c5ff4d |
@@ -1,66 +0,0 @@
|
|||||||
# Architecture — Modular Monolith DDD
|
|
||||||
|
|
||||||
## Principe fondamental
|
|
||||||
Le **backend est la source de verite unique**. Il dicte :
|
|
||||||
- Quels modules sont actifs (`config/modules.php`)
|
|
||||||
- L'organisation de la sidebar (`config/sidebar.php`, decouplee des modules)
|
|
||||||
|
|
||||||
Le frontend scanne `modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Il ne decide rien.
|
|
||||||
|
|
||||||
## Endpoints API cles
|
|
||||||
|
|
||||||
- `GET /api/version` (public) — version de l'app
|
|
||||||
- `GET /api/modules` (public) — IDs des modules actifs
|
|
||||||
- `GET /api/sidebar` (public) — sections filtrees par modules actifs + `disabledRoutes` (items dont le module owner est inactif)
|
|
||||||
- `GET /api/me` (auth) — user courant
|
|
||||||
|
|
||||||
## Arborescence minimale (detail complet : @README.md)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
Shared/ # Noyau technique partage (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
|
|
||||||
Module/
|
|
||||||
Core/ # Module obligatoire (auth, users)
|
|
||||||
CoreModule.php # ID, LABEL, REQUIRED, permissions()
|
|
||||||
Domain/ Application/ Infrastructure/
|
|
||||||
Commercial/ # Exemple d'autre module
|
|
||||||
frontend/
|
|
||||||
app/ # Shell (layouts, middlewares)
|
|
||||||
shared/ # Code inter-modules (composables, stores, utils)
|
|
||||||
modules/ # Layers Nuxt auto-detectes
|
|
||||||
core/ commercial/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Declaration d'un module
|
|
||||||
|
|
||||||
Chaque module expose un `*Module.php` avec :
|
|
||||||
- `ID` (snake_case, ex: `commercial`, `gestion_rh`)
|
|
||||||
- `LABEL`
|
|
||||||
- `REQUIRED` (bool)
|
|
||||||
- Methode statique `permissions()` retournant les RBAC du module
|
|
||||||
|
|
||||||
## Activer / desactiver un module
|
|
||||||
|
|
||||||
Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique :
|
|
||||||
1. `/api/modules` ne retourne plus l'ID
|
|
||||||
2. `/api/sidebar` filtre les items `module: '<id>'` et supprime les sections vides
|
|
||||||
3. Middleware front `modules.global.ts` redirige toute navigation vers une route desactivee
|
|
||||||
4. Le code reste dans le bundle (layer auto-detecte) → reactivation instantanee sans rebuild
|
|
||||||
|
|
||||||
## Reorganiser la sidebar
|
|
||||||
|
|
||||||
Editer uniquement `config/sidebar.php`. Le code des modules n'est pas touche — seule la place des items change. Chaque item reference son module owner via la cle `module`.
|
|
||||||
|
|
||||||
## Communication inter-modules
|
|
||||||
|
|
||||||
**Interdit** : import direct d'une classe d'un autre module.
|
|
||||||
**Autorise** :
|
|
||||||
- Via `Shared/Domain/Contract/` (interfaces : `UserResolverInterface`, `TenantAwareInterface`...)
|
|
||||||
- Via domain events (`Shared/Domain/Event/DomainEventInterface`)
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
- **Par defaut** : `src/Module/<Module>/Infrastructure/Doctrine/Migrations/` (namespace modulaire)
|
|
||||||
- **Exception** : les migrations d'initialisation critiques (setup user, RBAC, seed de base) vivent au namespace racine `DoctrineMigrations` dans `migrations/`.
|
|
||||||
- Raison : avec plusieurs `migrations_paths`, Doctrine Migrations 3.x trie par FQCN alphabetique et non par version timestamp → ordre incorrect entre namespaces sur base vide.
|
|
||||||
- A supprimer quand un `MigrationsComparator` custom ou un upgrade Doctrine resoudra le tri.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Backend — Regles PHP / Symfony / API Platform
|
|
||||||
|
|
||||||
## Structure de fichier
|
|
||||||
|
|
||||||
- Toujours `declare(strict_types=1);` en tete de tout fichier PHP
|
|
||||||
- PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`)
|
|
||||||
- Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais
|
|
||||||
|
|
||||||
## API Platform (pas de controllers)
|
|
||||||
|
|
||||||
- Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques
|
|
||||||
- Routes prefixees `/api` (via `config/routes/api_platform.yaml`)
|
|
||||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
|
||||||
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
|
||||||
|
|
||||||
## Repositories
|
|
||||||
|
|
||||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
|
||||||
- Implementation Doctrine : `Doctrine*Repository` dans `Infrastructure/Doctrine/`
|
|
||||||
- Le domaine garde les attributs ORM (approche pragmatique)
|
|
||||||
|
|
||||||
## RBAC (permissions)
|
|
||||||
|
|
||||||
Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
|
||||||
- Exemples : `core.users.view`, `commercial.clients.contacts.edit`, `core.audit_log.view`
|
|
||||||
- Declarees via la methode statique `permissions()` des `*Module.php`
|
|
||||||
- Synchronisation : `app:sync-permissions`
|
|
||||||
- Verification API Platform : `is_granted('module.resource.action')`
|
|
||||||
- Verification front : `usePermissions()`
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
|
|
||||||
- Hierarchie dans `config/packages/security.yaml` : `ROLE_ADMIN`, `ROLE_USER`
|
|
||||||
- Le role ne remplace pas la permission RBAC — deux niveaux complementaires
|
|
||||||
|
|
||||||
## Audit (obligatoire)
|
|
||||||
|
|
||||||
- Toute entite metier (nouvelle ou existante) : `#[Auditable]` (de `Shared/Domain/Attribute/`)
|
|
||||||
- Champs sensibles (password, token, secret) : `#[AuditIgnore]`
|
|
||||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
|
||||||
- Spec complete : @doc/audit-log.md
|
|
||||||
|
|
||||||
## Serialization
|
|
||||||
|
|
||||||
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
|
||||||
|
|
||||||
Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le groupe `user:read`, annoter `Profile.$firstName` avec `#[Groups(['user:read'])]`.
|
|
||||||
|
|
||||||
## Upload de fichiers
|
|
||||||
|
|
||||||
- Valider cote serveur avec `$file->getMimeType()` — **jamais** `getClientMimeType()` (spoofable par le client)
|
|
||||||
|
|
||||||
## PostgreSQL
|
|
||||||
|
|
||||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Frontend — Regles Nuxt 4 / Vue 3 / @malio/layer-ui
|
|
||||||
|
|
||||||
## Base
|
|
||||||
|
|
||||||
- TypeScript strict
|
|
||||||
- 4 espaces d'indentation
|
|
||||||
- Commentaires (JSDoc, inline, bloc) **en francais** ; code (variables, types) en anglais
|
|
||||||
- Chaque module front = un layer Nuxt auto-detecte (`frontend/modules/*/nuxt.config.ts` minimal)
|
|
||||||
|
|
||||||
## Appels API
|
|
||||||
|
|
||||||
- Toujours `useApi()` — jamais `$fetch`, `ofetch`, `axios` en direct
|
|
||||||
- `useApi()` gere : cookies JWT, erreurs, toasts i18n, parsing Hydra
|
|
||||||
|
|
||||||
## Stores (Pinia)
|
|
||||||
|
|
||||||
- `useAuthStore` pour l'authentification
|
|
||||||
- `useUiStore` pour l'etat UI global (sidebar, modales, etc.)
|
|
||||||
- Composables avec state singleton (refs module-level) : exposer une fonction `reset*()` et la rappeler au logout (ex: `useSidebar().resetSidebar()`)
|
|
||||||
|
|
||||||
## Middlewares globaux
|
|
||||||
|
|
||||||
- `auth.global.ts` protege les routes + charge la sidebar apres login
|
|
||||||
- `modules.global.ts` redirige si la route demandee est dans `disabledRoutes`
|
|
||||||
|
|
||||||
## i18n et sidebar
|
|
||||||
|
|
||||||
- Labels de sidebar = cles i18n `sidebar.<module>.*`, jamais du texte brut
|
|
||||||
- Le layout `default.vue` applique `t()` sur les labels retournes par `/api/sidebar`
|
|
||||||
- Traductions dans `frontend/i18n/locales/`
|
|
||||||
|
|
||||||
## Composants formulaires — @malio/layer-ui obligatoire
|
|
||||||
|
|
||||||
Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot que `<input>` / `<select>` bruts :
|
|
||||||
|
|
||||||
- `MalioInputText`, `MalioInputNumber`, `MalioInputAmount`, `MalioInputPassword`, `MalioInputTextArea`
|
|
||||||
- `MalioSelect`, `MalioSelectCheckbox`, `MalioCheckbox`, `MalioRadioButton`
|
|
||||||
- `MalioInputUpload`, `MalioTime`
|
|
||||||
- `MalioButton`, `MalioButtonIcon`
|
|
||||||
|
|
||||||
**Exceptions autorisees** (commenter un `// TODO` pour migrer quand la lib couvrira le cas) :
|
|
||||||
1. Type non couvert : `datetime-local`, `date`, color picker, file drag & drop
|
|
||||||
2. Bug connu bloquant (ex: `MalioSelect` avec options string) — documenter le bug en commentaire
|
|
||||||
|
|
||||||
Toute autre exception requiert validation avant merge.
|
|
||||||
|
|
||||||
## Tableaux de donnees — MalioDataTable obligatoire
|
|
||||||
|
|
||||||
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
|
|
||||||
- Pagination integree
|
|
||||||
- Slots `#header-*` pour filtres, `#cell-*` pour rendu custom
|
|
||||||
- Pas de `<table>` brut avec pagination custom
|
|
||||||
|
|
||||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
|
||||||
|
|
||||||
## Etat des tableaux — pas de persistance URL
|
|
||||||
|
|
||||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
|
||||||
|
|
||||||
- L'etat vit uniquement dans le composant (`reactive`, `ref` locales)
|
|
||||||
- Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL
|
|
||||||
- Exceptions autorisees **sur demande explicite** de l'utilisateur
|
|
||||||
|
|
||||||
## Interdits
|
|
||||||
|
|
||||||
- `modules-loader.ts`, `.module.ts` — le scan des layers est automatique
|
|
||||||
- Hardcode de la sidebar cote front — elle vient de `/api/sidebar`
|
|
||||||
- Edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes
|
|
||||||
- Import d'un module front depuis un autre module — passer par `frontend/shared/`
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Git
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
Format : `<type>(<scope optionnel>) : <message>`
|
|
||||||
**Espaces obligatoires** autour du `:`.
|
|
||||||
|
|
||||||
Types autorises (minuscules uniquement) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
|
|
||||||
|
|
||||||
Exemples :
|
|
||||||
- `feat : add login page`
|
|
||||||
- `fix(auth) : prevent null token crash`
|
|
||||||
- `chore : bump version to v1.2.3`
|
|
||||||
|
|
||||||
## Regles de commit
|
|
||||||
|
|
||||||
- **Jamais commit sans demande explicite** de l'utilisateur
|
|
||||||
- **Jamais force push** sans confirmation
|
|
||||||
- **Jamais modifier la config git**
|
|
||||||
- **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Concretement, aucun des elements suivants ne doit apparaitre :
|
|
||||||
- Trailer `Co-Authored-By: Claude <...>` / `Co-Authored-By: Anthropic`
|
|
||||||
- Footer type `Generated with Claude Code`, `Generated by AI`, etc.
|
|
||||||
- Emoji robot `🤖` ou similaire en tete/fin de message
|
|
||||||
- Toute phrase attribuant la paternite du changement a une IA
|
|
||||||
Les commits sont signes **uniquement** par l'utilisateur. Si un hook ou un template ajoute ces elements automatiquement, les retirer manuellement avant push.
|
|
||||||
|
|
||||||
## Versioning & tags
|
|
||||||
|
|
||||||
La version de l'app est dans `config/version.yaml` (parametre `app.version`).
|
|
||||||
|
|
||||||
Workflow de release :
|
|
||||||
1. Bump `config/version.yaml` a la nouvelle version
|
|
||||||
2. Commit dedie : `chore : bump version to v<X.Y.Z>`
|
|
||||||
3. Tag : `git tag v<X.Y.Z>`
|
|
||||||
4. Push : `git push origin develop --tags`
|
|
||||||
|
|
||||||
**A chaque creation de tag**, toujours mettre a jour `config/version.yaml` avec la meme version — sinon l'app ne connait pas sa propre version.
|
|
||||||
|
|
||||||
CI Gitea automatise le bump sur push `develop` (cf. `.gitea/workflows/`), mais un tag manuel reste possible.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Nommage
|
|
||||||
|
|
||||||
| Element | Convention | Exemple |
|
|
||||||
|---------|-----------|---------|
|
|
||||||
| Module back | PascalCase | `Module/Commercial/` |
|
|
||||||
| Module front | kebab-case | `modules/commercial/` |
|
|
||||||
| Module ID (dans code/config) | snake_case | `commercial`, `gestion_rh` |
|
|
||||||
| Entity Doctrine | PascalCase singulier | `User.php` |
|
|
||||||
| Repository interface | `*RepositoryInterface` | `UserRepositoryInterface.php` |
|
|
||||||
| Repository impl Doctrine | `Doctrine*Repository` | `DoctrineUserRepository.php` |
|
|
||||||
| DTO | `*Output` / `*Input` | `UserOutput.php` |
|
|
||||||
| API Platform Resource | classe dans `Infrastructure/ApiPlatform/Resource/` | `UserResource.php` |
|
|
||||||
| API Platform Provider | `*Provider` | `MeProvider.php` |
|
|
||||||
| API Platform Processor | `*Processor` | `UserPasswordHasherProcessor.php` |
|
|
||||||
| Module declaration back | `*Module.php` | `CommercialModule.php` |
|
|
||||||
| Composable front | `use*` | `useSidebar.ts` |
|
|
||||||
| Cles i18n sidebar | `sidebar.<module>.*` | `sidebar.commercial.overview` |
|
|
||||||
| Permission RBAC | `module.resource[.subresource].action` | `core.users.view`, `commercial.clients.contacts.edit` |
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Tests
|
|
||||||
|
|
||||||
## Trois suites
|
|
||||||
|
|
||||||
| Suite | Commande | Outil | Where |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Back | `make test` | PHPUnit | Container PHP ; fixtures dediees sous `tests/Fixtures/` |
|
|
||||||
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | Container Node ; <30s ; composables/utils/stores |
|
|
||||||
| Front E2E | `make test-e2e` | Playwright | **Host** (pas container, navigateur reel requis) |
|
|
||||||
|
|
||||||
Bootstrap E2E (une fois par poste de dev) : `make install-e2e-deps` (Chromium + libs systeme, sudo).
|
|
||||||
Re-run uniquement si `@playwright/test` upgrade majeur.
|
|
||||||
|
|
||||||
Workflow E2E : `make start && make seed-e2e && make dev-nuxt` (terminal 1), `make test-e2e` (terminal 2).
|
|
||||||
UI interactive pour debug : `make test-e2e-ui`.
|
|
||||||
|
|
||||||
## Regle d'or E2E
|
|
||||||
|
|
||||||
**Un nouveau test E2E ne s'ajoute QUE si un bug critique est passe en prod.**
|
|
||||||
|
|
||||||
Sinon, la bonne place est un test unitaire Vitest :
|
|
||||||
- Plus rapide
|
|
||||||
- Plus stable
|
|
||||||
- Moins de faux positifs
|
|
||||||
|
|
||||||
Pour etendre la couverture RBAC, etendre un **persona existant** dans `frontend/tests/e2e/_fixtures/personas.ts` plutot que de creer un nouveau test.
|
|
||||||
|
|
||||||
## Matrice RBAC — 3 endroits obligatoires
|
|
||||||
|
|
||||||
Ajouter/modifier une permission testable = toucher les 3 miroirs (sinon drift garanti) :
|
|
||||||
|
|
||||||
1. `config/sidebar.php` — attacher `permission` au bon item
|
|
||||||
2. `frontend/tests/e2e/_fixtures/personas.ts` — ajuster `permissions` + `expectedAdminLinks` d'un persona existant
|
|
||||||
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` — miroir back du meme persona
|
|
||||||
|
|
||||||
Tout changement sur l'un des trois sans les deux autres = test casse ou faux positif.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Workflow
|
|
||||||
|
|
||||||
## Langue
|
|
||||||
|
|
||||||
- **UI** : francais (tout ce qui est visible utilisateur)
|
|
||||||
- **Communication avec l'utilisateur** : francais
|
|
||||||
- **Code** (noms de classes, methodes, variables, types) : anglais
|
|
||||||
- **Commentaires** (PHP, TS, Vue — docblock, inline, bloc) : **francais**. Objectif : faciliter la relecture par l'equipe FR sans polluer l'API publique du code
|
|
||||||
|
|
||||||
## Delegation Codex
|
|
||||||
|
|
||||||
Pour les taches mecaniques (generation de tests boilerplate, renommages massifs, refacto repetitif, scaffolding), **deleguer a Codex** via le plugin `codex` (skill `codex:rescue`).
|
|
||||||
|
|
||||||
- **Codex** = junior rapide et pas cher → executions mecaniques
|
|
||||||
- **Claude** = senior qui verifie et reflechit → design, review, decisions
|
|
||||||
|
|
||||||
Ratio qualite/credits optimal : Claude conserve la reflexion et la validation, Codex avale le boilerplate.
|
|
||||||
|
|
||||||
## Avant d'implementer un ticket
|
|
||||||
|
|
||||||
Les specs fonctionnelles vivent sous `docs/{rbac,sites,modules}/ticket-*-spec.md`.
|
|
||||||
|
|
||||||
Avant de coder :
|
|
||||||
1. Lire la spec correspondante en entier (elles sont longues mais exhaustives)
|
|
||||||
2. Ne pas inventer de detail non specifie — demander a l'utilisateur ou citer explicitement la section manquante
|
|
||||||
3. Si la spec contredit le code existant, poser la question avant de choisir
|
|
||||||
|
|
||||||
## Verification avant "c'est fini"
|
|
||||||
|
|
||||||
Ne jamais declarer une tache terminee sans avoir lance les verifications applicables :
|
|
||||||
|
|
||||||
| Ce qui a bouge | Commande a lancer |
|
|
||||||
|---|---|
|
|
||||||
| Fichiers PHP | `make test` + `make php-cs-fixer-allow-risky` |
|
|
||||||
| Fichiers front (Vue/TS) | `make nuxt-test` |
|
|
||||||
| Migrations | `make migration-migrate` (sur BDD fraiche ideal : `make db-reset`) |
|
|
||||||
| Sidebar / permissions | Verifier que les 3 miroirs RBAC sont alignes (cf. @.claude/rules/testing.md) |
|
|
||||||
| UI visible | Demarrer `make dev-nuxt` et verifier le golden path dans le navigateur |
|
|
||||||
|
|
||||||
Si une verification echoue ou ne peut pas etre lancee (ex : container pas demarre), le dire explicitement plutot que d'annoncer "fini".
|
|
||||||
|
|
||||||
## Time tracking Lesstime
|
|
||||||
|
|
||||||
Au demarrage de toute tache de dev sur Starseed, creer une time entry via l'API Lesstime (cf. `~/.claude/CLAUDE.md` pour la procedure complete).
|
|
||||||
- Projet : `/api/projects/6` (STARSEED)
|
|
||||||
- Tags : choisir selon le type (Backend `3`, Frontend `2`, Infra `5`, UI/UX `4`, Maintenance `6`, Gestion projet `9`, etc.)
|
|
||||||
|
|
||||||
## Fix `make cache-clear` (permissions `var/`)
|
|
||||||
|
|
||||||
Si `make cache-clear` echoue sur les permissions de `var/` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var
|
|
||||||
docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear
|
|
||||||
```
|
|
||||||
|
|
||||||
A terme : integrer ce fix dans le `makefile` lui-meme.
|
|
||||||
|
|
||||||
## Docker — references utiles
|
|
||||||
|
|
||||||
- Container PHP : `php-starseed-fpm`
|
|
||||||
- Container Nginx : `nginx-starseed` (port 8083)
|
|
||||||
- Container DB : PostgreSQL port **5437** (interne et externe)
|
|
||||||
- Config dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
|
||||||
- Config prod : `infra/prod/` (Dockerfile multi-stage, `docker-compose.prod.yml`)
|
|
||||||
- Apres modif nginx : `docker restart nginx-starseed`
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: create-module
|
name: create-module
|
||||||
description: Scaffold a new Starseed module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
description: Scaffold a new Coltura module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Create a new Starseed module
|
# Create a new Coltura module
|
||||||
|
|
||||||
Scaffolds a new module across backend and frontend following Starseed's modular monolith DDD architecture.
|
Scaffolds a new module across backend and frontend following Coltura's modular monolith DDD architecture.
|
||||||
|
|
||||||
## Architecture reminder — read before acting
|
## Architecture reminder — read before acting
|
||||||
|
|
||||||
@@ -178,8 +178,8 @@ Execute in this exact order:
|
|||||||
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
||||||
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
||||||
8. **Verify** — run:
|
8. **Verify** — run:
|
||||||
- `docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
- `docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
||||||
- `docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear` (validates backend)
|
- `docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear` (validates backend)
|
||||||
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
|
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
|
||||||
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
|
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
|
|
||||||
@@ -20,11 +20,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f infra/prod/Dockerfile \
|
-f infra/prod/Dockerfile \
|
||||||
-t gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }} \
|
-t gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }} \
|
||||||
-t gitea.malio.fr/malio-dev/starseed:latest \
|
-t gitea.malio.fr/malio-dev/coltura:latest \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Push Docker image
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker push gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }}
|
docker push gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }}
|
||||||
docker push gitea.malio.fr/malio-dev/starseed:latest
|
docker push gitea.malio.fr/malio-dev/coltura:latest
|
||||||
|
|||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -30,13 +30,6 @@ frontend/.output/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
###< frontend ###
|
###< frontend ###
|
||||||
|
|
||||||
###> playwright ###
|
|
||||||
frontend/test-results/
|
|
||||||
frontend/playwright-report/
|
|
||||||
frontend/blob-report/
|
|
||||||
frontend/playwright/.cache/
|
|
||||||
###< playwright ###
|
|
||||||
|
|
||||||
###> docker ###
|
###> docker ###
|
||||||
infra/dev/.env.docker.local
|
infra/dev/.env.docker.local
|
||||||
###< docker ###
|
###< docker ###
|
||||||
@@ -44,12 +37,3 @@ infra/dev/.env.docker.local
|
|||||||
###> logs ###
|
###> logs ###
|
||||||
LOG/
|
LOG/
|
||||||
###< logs ###
|
###< logs ###
|
||||||
|
|
||||||
###> friendsofphp/php-cs-fixer ###
|
|
||||||
/.php-cs-fixer.php
|
|
||||||
/.php-cs-fixer.cache
|
|
||||||
###< friendsofphp/php-cs-fixer ###
|
|
||||||
|
|
||||||
###> symfony auto-generated ###
|
|
||||||
/config/reference.php
|
|
||||||
###< symfony auto-generated ###
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Liste des évolutions du projet Starseed
|
Liste des évolutions du projet Coltura
|
||||||
|
|
||||||
## [0.0.0]
|
## [0.0.0]
|
||||||
|
|
||||||
|
|||||||
309
CLAUDE.md
309
CLAUDE.md
@@ -1,68 +1,277 @@
|
|||||||
# Starseed
|
# Coltura
|
||||||
|
|
||||||
## Contexte
|
CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. **Architecture Modular Monolith DDD.**
|
||||||
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
|
||||||
|
|
||||||
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
## Architecture Modulaire
|
||||||
|
|
||||||
|
Le projet suit une architecture **modular monolith** pilotee par le backend : chaque module metier est un bounded context autonome, activable/desactivable par tenant. Le module `Core` est obligatoire.
|
||||||
|
|
||||||
|
**Principe fondamental : le backend est la source de verite unique.**
|
||||||
|
- Le backend dicte quels modules sont actifs (`config/modules.php`).
|
||||||
|
- Le backend dicte l'organisation de la sidebar (`config/sidebar.php`), decouplee des modules eux-memes.
|
||||||
|
- Le frontend ne connait rien : il scanne automatiquement les modules comme layers Nuxt et demande la sidebar au backend.
|
||||||
|
|
||||||
|
### Backend — Organisation par module
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Kernel.php
|
||||||
|
Shared/ # Noyau technique partage
|
||||||
|
Domain/
|
||||||
|
ValueObject/ # VO de base (Email...)
|
||||||
|
Event/ # DomainEventInterface
|
||||||
|
Contract/ # Interfaces inter-modules (UserResolverInterface, TenantAwareInterface)
|
||||||
|
Application/
|
||||||
|
Bus/ # CommandBusInterface, QueryBusInterface (interfaces seules)
|
||||||
|
Infrastructure/
|
||||||
|
ApiPlatform/
|
||||||
|
Resource/ # AppVersion, ModulesResource, SidebarResource
|
||||||
|
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
|
||||||
|
Module/
|
||||||
|
Core/ # Module obligatoire (auth, users)
|
||||||
|
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
|
||||||
|
Domain/
|
||||||
|
Entity/ # Entites Doctrine + API Platform (User)
|
||||||
|
Repository/ # Interfaces repositories (UserRepositoryInterface)
|
||||||
|
Event/ # Domain events (UserCreated)
|
||||||
|
Application/
|
||||||
|
DTO/ # UserOutput
|
||||||
|
Infrastructure/
|
||||||
|
Doctrine/ # DoctrineUserRepository, Migrations/
|
||||||
|
ApiPlatform/
|
||||||
|
State/
|
||||||
|
Provider/ # MeProvider
|
||||||
|
Processor/ # UserPasswordHasherProcessor
|
||||||
|
Console/ # CreateUserCommand
|
||||||
|
DataFixtures/ # AppFixtures
|
||||||
|
Commercial/ # Autre module (exemple)
|
||||||
|
CommercialModule.php
|
||||||
|
config/
|
||||||
|
modules.php # Liste des modules actifs (source de verite activation)
|
||||||
|
sidebar.php # Structure de la sidebar (source de verite navigation)
|
||||||
|
version.yaml
|
||||||
|
jwt/ # Cles JWT
|
||||||
|
packages/ # Config Symfony
|
||||||
|
migrations/ # Anciennes migrations Doctrine
|
||||||
|
infra/dev/ # Docker dev
|
||||||
|
infra/prod/ # Docker prod (multi-stage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend — Organisation modulaire (auto-detectee)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
app/ # Shell applicatif
|
||||||
|
layouts/ # default.vue, auth.vue
|
||||||
|
middleware/ # auth.global.ts, modules.global.ts
|
||||||
|
shared/ # Code partage (hors modules)
|
||||||
|
composables/ # useApi, useAppVersion, useSidebar
|
||||||
|
components/ui/ # AppTopNav, ...
|
||||||
|
stores/ # auth, ui
|
||||||
|
services/ # auth
|
||||||
|
types/ # SidebarSection, SidebarItem, UserData
|
||||||
|
utils/ # api (Hydra)
|
||||||
|
modules/ # Modules auto-detectes comme layers Nuxt
|
||||||
|
core/
|
||||||
|
nuxt.config.ts # Marqueur layer (vide)
|
||||||
|
pages/ # index.vue, login.vue
|
||||||
|
commercial/
|
||||||
|
nuxt.config.ts
|
||||||
|
pages/ # commercial.vue
|
||||||
|
app.vue # Composant racine
|
||||||
|
nuxt.config.ts # Scanne modules/*/ automatiquement
|
||||||
|
i18n/locales/ # Traductions (cles sidebar.*, etc.)
|
||||||
|
assets/ # CSS, images
|
||||||
|
public/ # Fichiers statiques
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints API cles
|
||||||
|
|
||||||
|
- `GET /api/version` (public) — version de l'app
|
||||||
|
- `GET /api/modules` (public) — liste des IDs de modules actifs
|
||||||
|
- `GET /api/sidebar` (public) — sections de la sidebar + `disabledRoutes`
|
||||||
|
- Filtre automatiquement les items dont le `module` owner n'est pas actif
|
||||||
|
- Les sections vides apres filtrage sont supprimees
|
||||||
|
- `disabledRoutes` = `to` des items filtres (utilise par le middleware front)
|
||||||
|
- `GET /api/me` (auth) — user courant
|
||||||
|
|
||||||
|
### Flux d'activation/desactivation d'un module
|
||||||
|
|
||||||
|
Pour activer/desactiver un module, tu touches **uniquement** `config/modules.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
\App\Module\Core\CoreModule::class,
|
||||||
|
// \App\Module\Commercial\CommercialModule::class, // commente = desactive
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Cascade automatique :
|
||||||
|
1. `GET /api/modules` ne retourne plus `commercial`
|
||||||
|
2. `GET /api/sidebar` filtre les items `module: 'commercial'` → section "Commercial" disparait, ses routes passent dans `disabledRoutes`
|
||||||
|
3. Frontend : sidebar se met a jour, middleware `modules.global.ts` redirige toute navigation vers `/commercial` ou `/commercial/*`
|
||||||
|
4. Le code du module reste dans le bundle Nuxt (layer auto-detecte) → reactivation instantanee sans rebuild
|
||||||
|
|
||||||
|
### Reorganiser la sidebar sans toucher aux modules
|
||||||
|
|
||||||
|
Pour deplacer un item (ex: "Commandes fournisseurs") d'une section a une autre, tu edites juste `config/sidebar.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Avant : sous Commercial
|
||||||
|
['label' => 'sidebar.commercial.suppliers', 'to' => '/commercial/suppliers', 'module' => 'commercial'],
|
||||||
|
|
||||||
|
// Apres : sous Production (l'item reste "owned" par Commercial, seule sa place change)
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.production.section',
|
||||||
|
'items' => [
|
||||||
|
['label' => 'sidebar.commercial.suppliers', 'to' => '/commercial/suppliers', 'module' => 'commercial'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Le code du module Commercial n'est pas touche.
|
||||||
|
|
||||||
|
### Regles d'architecture
|
||||||
|
|
||||||
|
**Backend :**
|
||||||
|
- Le domaine (`Domain/`) peut garder les attributs ORM (approche pragmatique) mais les repositories sont des interfaces
|
||||||
|
- Communication inter-modules par `Shared/Domain/Contract/` ou domain events — jamais d'import direct entre modules
|
||||||
|
- Chaque module declare un `*Module.php` avec `ID`, `LABEL`, `REQUIRED`
|
||||||
|
- `config/modules.php` = seule source de verite pour l'activation
|
||||||
|
- `config/sidebar.php` = seule source de verite pour l'organisation de la sidebar (chaque item reference son module owner via la cle `module`)
|
||||||
|
- Migrations par module dans `src/Module/{Module}/Infrastructure/Doctrine/Migrations/`
|
||||||
|
- **Exception connue** : avec plusieurs `migrations_paths` configures, Doctrine Migrations 3.x trie les migrations par FQCN alphabetique et non par version timestamp → ordre d'execution incorrect entre namespaces sur une base vide. Tant que ce n'est pas resolu (via un `MigrationsComparator` custom ou un upgrade), les migrations d'initialisation critiques (setup user, RBAC, etc.) vivent au namespace racine `DoctrineMigrations` dans `migrations/`. Le namespace modulaire reste configure pour les futures migrations applicatives (qui dependent d'un schema deja cree).
|
||||||
|
|
||||||
|
**Frontend :**
|
||||||
|
- Chaque module est un layer Nuxt auto-detecte (`modules/*/nuxt.config.ts` minimal)
|
||||||
|
- Un module front ne doit pas importer depuis un autre module — utiliser `shared/`
|
||||||
|
- `useSidebar()` fetch `/api/sidebar` et expose `sections`, `disabledRoutes`, `isRouteDisabled()`
|
||||||
|
- Le layout `default.vue` itere sur les sections retournees par l'API, applique `t()` sur les labels
|
||||||
|
- Middleware `auth.global.ts` charge la sidebar apres authentification
|
||||||
|
- Middleware `modules.global.ts` redirige si la route demandee est dans `disabledRoutes`
|
||||||
|
- Les composables avec state singleton (refs module-level) doivent exposer une fonction `reset*()` et etre reinitialises au logout (ex: `useSidebar().resetSidebar()`)
|
||||||
|
- **Interdit** : `.module.ts`, `modules-loader.ts`, hardcode de la sidebar, edition manuelle de `extends` dans `nuxt.config.ts`
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
|
||||||
- Frontend : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind, @malio/layer-ui, @nuxtjs/i18n
|
|
||||||
- Auth : JWT HTTP-only cookie (Lexik), login a `/login_check`
|
|
||||||
- Containers : `php-starseed-fpm`, `nginx-starseed` (port 8083), dev Nuxt port **3004**
|
|
||||||
|
|
||||||
## Regles ABSOLUES
|
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||||
|
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
|
||||||
|
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login a `/login_check`, cookie `BEARER`
|
||||||
|
- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5437)
|
||||||
|
|
||||||
1. **Ne jamais importer d'un module a un autre** — passer par `Shared/Domain/Contract/` (interfaces) ou domain events.
|
## Commandes
|
||||||
2. **Toujours `declare(strict_types=1);`** en tete de tout fichier PHP.
|
|
||||||
3. **Toujours annoter `#[Auditable]`** les entites metier ; `#[AuditIgnore]` sur champs sensibles (password, token, secret).
|
```bash
|
||||||
4. **Toujours utiliser `useApi()`** pour les appels API cote front — jamais `$fetch` / `ofetch` direct.
|
make start # Demarrer les containers
|
||||||
5. **Toujours utiliser les composants `Malio*`** (@malio/layer-ui) pour formulaires/filtres ; `MalioDataTable` pour listes paginees.
|
make stop # Arreter les containers
|
||||||
6. **Jamais persister l'etat de tableau dans l'URL** (filtres, pagination, tri, selection) — state local uniquement.
|
make restart # Redemarrer les containers
|
||||||
7. **Jamais ajouter un test E2E** sauf si un bug critique est passe en prod ; preferer un test Vitest.
|
make install # Install complet (composer, migrations, fixtures, build Nuxt)
|
||||||
8. **Toujours mettre a jour les 3 sources RBAC ensemble** : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`.
|
make reset # Tout supprimer et reinstaller (supprime la BDD)
|
||||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
make dev-nuxt # Dev server Nuxt (hot reload, port 3004)
|
||||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
make shell # Shell dans le container PHP
|
||||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
make shell-root # Shell root dans le container PHP
|
||||||
|
make cache-clear # Vider le cache Symfony
|
||||||
|
make migration-migrate # Lancer les migrations
|
||||||
|
make fixtures # Charger les fixtures
|
||||||
|
make db-reset # Reset BDD + migrations + fixtures
|
||||||
|
make test # PHPUnit
|
||||||
|
make php-cs-fixer-allow-risky # Fix code style PHP
|
||||||
|
make logs-dev # Tail logs Symfony
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `make cache-clear` echoue pour cause de permissions sur `var/` :
|
||||||
|
```bash
|
||||||
|
docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var
|
||||||
|
docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@.claude/rules/architecture.md
|
|
||||||
@.claude/rules/backend.md
|
|
||||||
@.claude/rules/frontend.md
|
|
||||||
@.claude/rules/testing.md
|
|
||||||
@.claude/rules/naming.md
|
|
||||||
@.claude/rules/git.md
|
|
||||||
@.claude/rules/workflow.md
|
|
||||||
|
|
||||||
## Commandes (liste complete dans @README.md)
|
### Commits
|
||||||
|
|
||||||
- Demarrer : `make start`
|
Format : `<type>(<scope optionnel>) : <message>` (espace avant et apres `:`)
|
||||||
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
|
|
||||||
- Shell PHP : `make shell`
|
|
||||||
- Tests back : `make test`
|
|
||||||
- Tests front unitaires : `make nuxt-test`
|
|
||||||
- Tests E2E : `make test-e2e` (prerequis : `make seed-e2e && make dev-nuxt`)
|
|
||||||
- Reset BDD : `make db-reset`
|
|
||||||
- Lint PHP : `make php-cs-fixer-allow-risky`
|
|
||||||
|
|
||||||
## Activer / desactiver un module
|
Types autorises (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
|
||||||
|
|
||||||
Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique via `/api/modules`, `/api/sidebar`, middleware front `modules.global.ts`. Details : @.claude/rules/architecture.md
|
Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||||
|
|
||||||
## A NE PAS faire
|
### Tags & Versioning
|
||||||
|
|
||||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
- La version de l'app est dans `config/version.yaml` (parametre `app.version`)
|
||||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
- A chaque creation de tag, **toujours** mettre a jour `config/version.yaml` avec la meme version
|
||||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
- Faire un commit separe de bump : `chore : bump version to v<X.Y.Z>`
|
||||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
- Puis creer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
|
||||||
- Pas de commentaires en anglais dans le code — **commentaires en francais**, code (noms) en anglais.
|
|
||||||
- Pas d'`<input>` / `<select>` / `<table>` bruts quand un composant `@malio/layer-ui` existe (exceptions documentees dans @.claude/rules/frontend.md).
|
|
||||||
- Pas de modification de la config git.
|
|
||||||
|
|
||||||
## Skills projet
|
### Nommage
|
||||||
|
|
||||||
- `create-module` — scaffolder un nouveau module back+front et wirer la sidebar.
|
| Element | Convention | Exemple |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Module back | PascalCase | `Module/Commercial/` |
|
||||||
|
| Module front | kebab-case | `modules/commercial/` |
|
||||||
|
| Module ID | snake_case | `commercial`, `gestion_rh` |
|
||||||
|
| Entity | PascalCase singulier | `User.php` |
|
||||||
|
| Repository interface | `*RepositoryInterface` | `UserRepositoryInterface.php` |
|
||||||
|
| Repository impl | `Doctrine*Repository` | `DoctrineUserRepository.php` |
|
||||||
|
| DTO | `*Output` / `*Input` | `UserOutput.php` |
|
||||||
|
| API Resource | classe dans `Infrastructure/ApiPlatform/Resource/` | `UserResource.php` |
|
||||||
|
| Provider | `*Provider` | `MeProvider.php` |
|
||||||
|
| Processor | `*Processor` | `UserPasswordHasherProcessor.php` |
|
||||||
|
| Module declaration back | `*Module.php` | `CommercialModule.php` |
|
||||||
|
| Composable front | `use*` | `useSidebar.ts` |
|
||||||
|
| Cles i18n sidebar | `sidebar.<module>.*` | `sidebar.commercial.overview` |
|
||||||
|
|
||||||
## Credentials (dev)
|
### Backend
|
||||||
|
|
||||||
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
|
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
||||||
|
- **Commentaires en francais** : tout commentaire PHP (docblock, inline, bloc) doit etre redige en francais. Le code (noms de classes, methodes, variables) reste en anglais. Objectif : faciliter la relecture par l'equipe FR sans polluer l'API publique du code.
|
||||||
|
- API Platform : utiliser ApiResource, Providers, Processors — pas de controllers
|
||||||
|
- Routes API prefixees `/api` (via `config/routes/api_platform.yaml`)
|
||||||
|
- Le login (`/login_check`) est hors prefix `/api`, nginx reecrit `REQUEST_URI` vers `/login_check`
|
||||||
|
- PHP CS Fixer : regles Symfony + PSR-12 + strict types
|
||||||
|
- Roles : `ROLE_ADMIN`, `ROLE_USER` — hierarchie dans `security.yaml`
|
||||||
|
- **Permissions RBAC** : format obligatoire `module.resource[.subresource].action` en snake_case, ex : `core.users.view`, `commercial.clients.contacts.edit`. Declarees via la methode statique `permissions()` des `*Module.php`, synchronisees par la commande `app:sync-permissions`. Verification via `is_granted('module.resource.action')` cote API Platform et `usePermissions()` cote front.
|
||||||
|
- PostgreSQL : noms de colonnes toujours en **minuscules** dans le SQL brut
|
||||||
|
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||||
|
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible
|
||||||
|
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- TypeScript strict
|
||||||
|
- **Commentaires en francais** : tout commentaire TS/Vue (JSDoc, inline, bloc) doit etre redige en francais. Le code reste en anglais. Meme regle que cote backend.
|
||||||
|
- Composable `useApi()` pour tous les appels API (gere cookies, erreurs, toasts, i18n)
|
||||||
|
- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui)
|
||||||
|
- Middleware global `auth.global.ts` protege les routes + charge la sidebar apres login
|
||||||
|
- Middleware global `modules.global.ts` redirige les routes des modules desactives
|
||||||
|
- Traductions dans `frontend/i18n/locales/` avec le namespace `sidebar.*` pour la nav
|
||||||
|
- 4 espaces d'indentation
|
||||||
|
- Les labels de sidebar sont des cles i18n, jamais du texte brut (le layout applique `t()` dessus)
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
- `/api/*` -> Symfony (via try_files + index.php)
|
||||||
|
- `/api/login_check` -> location exact match, fastcgi direct avec REQUEST_URI reecrit en `/login_check`
|
||||||
|
- `/` -> SPA frontend (`frontend/dist/`)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- Container PHP : `php-coltura-fpm`
|
||||||
|
- Container Nginx : `nginx-coltura`
|
||||||
|
- Container DB : PostgreSQL sur port **5437** (interne et externe)
|
||||||
|
- Config Docker dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
|
- Config Docker prod : `infra/prod/` (Dockerfile multi-stage, docker-compose.prod.yml)
|
||||||
|
- Apres modif nginx : `docker restart nginx-coltura`
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||||
|
- Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Starseed
|
# Coltura
|
||||||
|
|
||||||
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
||||||
|
|
||||||
@@ -46,35 +46,10 @@ make dev-nuxt # Port 3003
|
|||||||
| `make migration-migrate` | Lancer les migrations |
|
| `make migration-migrate` | Lancer les migrations |
|
||||||
| `make fixtures` | Charger les fixtures |
|
| `make fixtures` | Charger les fixtures |
|
||||||
| `make db-reset` | Reset BDD + migrations + fixtures |
|
| `make db-reset` | Reset BDD + migrations + fixtures |
|
||||||
| `make test` | PHPUnit (tests back) |
|
| `make test` | PHPUnit |
|
||||||
| `make nuxt-test` | Vitest (tests unitaires front) |
|
|
||||||
| `make test-e2e` | Playwright (tests E2E front) |
|
|
||||||
| `make test-e2e-ui` | Playwright UI interactive (debug) |
|
|
||||||
| `make seed-e2e` | Seed les 6 personas E2E |
|
|
||||||
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
|
|
||||||
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
|
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
|
||||||
| `make logs-dev` | Tail logs Symfony |
|
| `make logs-dev` | Tail logs Symfony |
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|
|
||||||
- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
|
|
||||||
- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
|
|
||||||
|
|
||||||
**Bootstrap E2E (une fois par poste)** :
|
|
||||||
```bash
|
|
||||||
make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Workflow E2E** :
|
|
||||||
```bash
|
|
||||||
# Terminal 1 : containers + dev server
|
|
||||||
make start && make seed-e2e && make dev-nuxt
|
|
||||||
|
|
||||||
# Terminal 2 : tests
|
|
||||||
make test-e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
|
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
|
||||||
|
|||||||
730
REVIEW.md
730
REVIEW.md
@@ -1,730 +0,0 @@
|
|||||||
# Review PR `feat/audit-log` — 4e passe
|
|
||||||
|
|
||||||
> Audit complet de la PR audit-log (89 commits, 233 fichiers, +52k/-876 lignes) apres les 3 passes de review deja mergees.
|
|
||||||
> Objectif : faire sortir ce qui reste avant merge dans `main`.
|
|
||||||
> Genere le 2026-04-23.
|
|
||||||
|
|
||||||
**Branche** : `feat/audit-log`
|
|
||||||
**Base** : `main`
|
|
||||||
**Revues anterieures** (deja appliquees dans la branche) :
|
|
||||||
- `bb6a4c3 fix(review) : blockers review PR #9`
|
|
||||||
- `25cd6a1 fix(review) : regression drawers RBAC + race snapshot + stale-data admin`
|
|
||||||
- `b1255bb fix(review) : 3e passe review (HIGH frontend + MEDIUMs)`
|
|
||||||
- `7117744 docs(claude) : refactor CLAUDE.md`
|
|
||||||
|
|
||||||
La branche est globalement solide : les trois miroirs RBAC sont synchronises, le pattern swap-and-clear de l'audit est correctement implemente, la connexion DBAL dediee est bien configuree. Les findings ci-dessous sont incrementaux et ne remettent pas en cause la feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table des matieres
|
|
||||||
|
|
||||||
1. [Securite](#1-securite)
|
|
||||||
2. [Bugs silencieux](#2-bugs-silencieux)
|
|
||||||
3. [Violations des regles projet](#3-violations-des-regles-projet)
|
|
||||||
4. [Incoherences de patterns](#4-incoherences-de-patterns)
|
|
||||||
5. [Documentation et configuration](#5-documentation-et-configuration)
|
|
||||||
6. [Frontend et UX](#6-frontend-et-ux)
|
|
||||||
7. [Bonnes pratiques a retenir](#7-bonnes-pratiques-a-retenir)
|
|
||||||
8. [Resume par priorite](#8-resume-par-priorite)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Securite
|
|
||||||
|
|
||||||
### 1.1 CRITIQUE — `/api/docs` public en production
|
|
||||||
|
|
||||||
**Fichier** : `config/packages/security.yaml:46`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
|
||||||
```
|
|
||||||
|
|
||||||
La documentation Swagger/OpenAPI d'API Platform est accessible sans authentification, quel que soit l'environnement — y compris en production sur `starseed.malio-dev.fr`. Elle expose :
|
|
||||||
|
|
||||||
- la liste complete des endpoints (`/api/audit-logs`, `/api/users/{id}/rbac`, `/api/sites`, etc.)
|
|
||||||
- les schemas de securite (`is_granted('core.audit_log.view')`)
|
|
||||||
- les filtres acceptes par chaque provider (y compris `performed_at[after]`)
|
|
||||||
- la structure des DTOs (`AuditLogOutput`, `UserOutput`...)
|
|
||||||
- les patterns UUID/IDs
|
|
||||||
|
|
||||||
**Pourquoi c'est grave** : un attaquant a une cartographie gratuite de la surface d'attaque. Pour un CRM interne sur DNS public, c'est une fuite d'information inutile. API Platform genere cette doc automatiquement mais rien n'oblige a la rendre publique.
|
|
||||||
|
|
||||||
**Correction** : fermer en prod.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config/packages/security.yaml
|
|
||||||
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
|
||||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [GET] }
|
|
||||||
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [GET] }
|
|
||||||
- { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [GET] }
|
|
||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
|
||||||
# supprimer la ligne "- { path: ^/api/docs, roles: PUBLIC_ACCESS }"
|
|
||||||
```
|
|
||||||
|
|
||||||
Ou conditionner l'acces au debug mode :
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
when@prod:
|
|
||||||
security:
|
|
||||||
access_control:
|
|
||||||
- { path: ^/api/docs, roles: IS_AUTHENTICATED_FULLY }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 CRITIQUE — Aucun en-tete de securite HTTP en production
|
|
||||||
|
|
||||||
**Fichier** : `infra/prod/nginx.conf` et `infra/prod/nginx-proxy.conf` (aucune directive `add_header`)
|
|
||||||
|
|
||||||
Le Nginx de prod n'emet aucun des en-tetes de securite standards :
|
|
||||||
|
|
||||||
| En-tete | Role | Present ? |
|
|
||||||
|---------|------|-----------|
|
|
||||||
| `X-Frame-Options: DENY` | anti-clickjacking (pas d'embed iframe) | non |
|
|
||||||
| `X-Content-Type-Options: nosniff` | anti MIME-sniffing | non |
|
|
||||||
| `Referrer-Policy` | limite les fuites dans le Referer | non |
|
|
||||||
| `Content-Security-Policy` | anti-XSS | non |
|
|
||||||
| `Strict-Transport-Security` | force HTTPS | non |
|
|
||||||
|
|
||||||
Le reverse proxy ecoute uniquement sur le port 80 (HTTP), sans redirection 301 vers HTTPS. Combine avec `JWT_COOKIE_SECURE=1` (defaut dans `.env.prod.example`), le cookie ne serait meme pas envoye en HTTP — donc un premier acces HTTP casse le login silencieusement, l'utilisateur croira que l'auth est buggee.
|
|
||||||
|
|
||||||
**Correction minimale dans `nginx-proxy.conf`** (niveau proxy public) :
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name starseed.malio-dev.fr;
|
|
||||||
|
|
||||||
# Redirection HTTPS obligatoire (ajouter un server block HTTPS par ailleurs).
|
|
||||||
# Tant que le TLS n'est pas en place, au minimum poser les en-tetes suivants.
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# ... reste de la config
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Quand TLS est en place, ajouter :
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 CRITIQUE — `robots.txt` autorise toute l'indexation
|
|
||||||
|
|
||||||
**Fichier** : `frontend/public/robots.txt`
|
|
||||||
|
|
||||||
```
|
|
||||||
User-Agent: *
|
|
||||||
Disallow:
|
|
||||||
```
|
|
||||||
|
|
||||||
La valeur `Disallow:` (vide) signifie "rien n'est interdit" — tous les crawlers peuvent indexer la totalite du site. Pour un outil CRM interne accessible sur un DNS public (`starseed.malio-dev.fr`), c'est un leak inutile : la page de login, les URLs `/admin/*`, les URLs des fiches clients peuvent remonter dans Google.
|
|
||||||
|
|
||||||
**Correction** :
|
|
||||||
|
|
||||||
```
|
|
||||||
User-Agent: *
|
|
||||||
Disallow: /
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.4 IMPORTANT — `performed_at[after|before]` sans typage DBAL → crash 500 sur date malformee
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:182-186`
|
|
||||||
|
|
||||||
```php
|
|
||||||
if (isset($filters['performed_at_after'])) {
|
|
||||||
$qb->andWhere('performed_at >= :performed_at_after')
|
|
||||||
->setParameter('performed_at_after', $filters['performed_at_after']);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
La valeur est passee comme chaine brute a DBAL. La colonne `performed_at` est un `timestamptz`. Si un client envoie `?performed_at[after]=not-a-date`, PostgreSQL leve une erreur de cast et l'API retourne une 500. Pas d'injection SQL (le parametre est bien binde), mais :
|
|
||||||
|
|
||||||
- erreur 500 loguee pour chaque mauvaise entree (pollution des logs + bruit pour l'oncall)
|
|
||||||
- DoS tres bas effort : un utilisateur avec `core.audit_log.view` peut envoyer des requetes mal formees en boucle
|
|
||||||
- mauvaise UX : le front recoit une erreur generique au lieu d'un 400 explicite
|
|
||||||
|
|
||||||
**Correction** :
|
|
||||||
|
|
||||||
```php
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
|
|
||||||
if (isset($filters['performed_at_after'])) {
|
|
||||||
try {
|
|
||||||
$after = new \DateTimeImmutable($filters['performed_at_after']);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
throw new BadRequestHttpException('performed_at[after] doit etre une date ISO 8601 valide.');
|
|
||||||
}
|
|
||||||
$qb->andWhere('performed_at >= :performed_at_after')
|
|
||||||
->setParameter('performed_at_after', $after, Types::DATETIMETZ_IMMUTABLE);
|
|
||||||
}
|
|
||||||
// idem pour performed_at_before
|
|
||||||
```
|
|
||||||
|
|
||||||
Cette correction donne en prime un 400 propre avec un message clair.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.5 IMPORTANT — Clause `ESCAPE` absente du `ILIKE` (filtre `performed_by`)
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:177-180`
|
|
||||||
|
|
||||||
```php
|
|
||||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
|
|
||||||
$qb->andWhere('performed_by ILIKE :performed_by')
|
|
||||||
->setParameter('performed_by', '%'.$escaped.'%');
|
|
||||||
```
|
|
||||||
|
|
||||||
Le commentaire dit : *"`\` est deja le caractere d'echappement LIKE par defaut en PostgreSQL"*. C'est **inexact**. En SQL-standard PostgreSQL, il n'y a pas de caractere d'echappement par defaut pour LIKE/ILIKE : pour echapper `%` ou `_`, il faut soit `LIKE pattern ESCAPE '\'`, soit utiliser un autre caractere (ex: `ESCAPE '|'`).
|
|
||||||
|
|
||||||
En pratique, sur PostgreSQL avec `standard_conforming_strings=on` (defaut depuis 9.1), `\` n'est PAS interprete par LIKE. Donc `'%\_%'` matche la chaine `%\_%` — pas ce qu'on veut. Le filtre est silencieusement casse pour tout nom contenant `_` ou `%`.
|
|
||||||
|
|
||||||
**Test a faire en psql pour confirmer** :
|
|
||||||
```sql
|
|
||||||
SELECT 'admin_backup' ILIKE '%admin\_backup%'; -- t sur PG moderne ? non : f
|
|
||||||
SELECT 'admin_backup' ILIKE '%admin\_backup%' ESCAPE '\'; -- t
|
|
||||||
```
|
|
||||||
|
|
||||||
**Correction** : ajouter explicitement la clause `ESCAPE`.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\\\'")
|
|
||||||
->setParameter('performed_by', '%'.$escaped.'%');
|
|
||||||
```
|
|
||||||
|
|
||||||
(Les quatre `\` en PHP donnent deux `\` dans le SQL, soit un `\` litteral une fois parse par PostgreSQL.)
|
|
||||||
|
|
||||||
Alternative plus sure : utiliser `position()` au lieu de LIKE.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$qb->andWhere('position(lower(:performed_by) IN lower(performed_by)) > 0')
|
|
||||||
->setParameter('performed_by', $filters['performed_by']);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.6 IMPORTANT — `SiteAwareInjectionProcessor` : bypass silencieux si l'appelant n'est pas une instance de `User`
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php:64-75`
|
|
||||||
|
|
||||||
```php
|
|
||||||
if (!$this->security->isGranted('sites.bypass_scope')) {
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
$explicitSite = $data->getSite();
|
|
||||||
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
|
|
||||||
throw new AccessDeniedHttpException(...);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Si `$user` n'est pas exactement une instance de `App\Module\Core\Domain\Entity\User` (ex: futur provider d'auth tiers, token systeme), la condition `instanceof User` est fausse et la garde cross-site write est **silencieusement sautee**. L'utilisateur peut alors specifier n'importe quel `site` dans le payload sans verification.
|
|
||||||
|
|
||||||
Aujourd'hui le risque est faible (un seul `app_user_provider` configure). Mais le pattern est fragile : une absence de type doit lever une erreur, pas passer.
|
|
||||||
|
|
||||||
**Correction** : transformer le cas "pas un User" en refus explicite.
|
|
||||||
|
|
||||||
```php
|
|
||||||
if (!$this->security->isGranted('sites.bypass_scope')) {
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
throw new AccessDeniedHttpException('Utilisateur non reconnu pour la validation de site.');
|
|
||||||
}
|
|
||||||
$explicitSite = $data->getSite();
|
|
||||||
if ($explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
|
|
||||||
throw new AccessDeniedHttpException(...);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.7 IMPORTANT — `isHandlingUnauthorized` sans `try/finally` : flag bloque si `navigateTo` throw
|
|
||||||
|
|
||||||
**Fichier** : `frontend/shared/composables/useApi.ts:25,125-130`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let isHandlingUnauthorized = false // module-level singleton
|
|
||||||
|
|
||||||
// ...
|
|
||||||
if (!isLoginCheck && !isLogout) {
|
|
||||||
if (!isHandlingUnauthorized) {
|
|
||||||
isHandlingUnauthorized = true
|
|
||||||
auth.clearSession()
|
|
||||||
await navigateTo('/login')
|
|
||||||
isHandlingUnauthorized = false // <-- jamais atteint si navigateTo throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Si `navigateTo('/login')` echoue (middleware qui throw, plugin qui throw dans un hook, navigation cancelee par `abortNavigation`), le flag reste `true` **indefiniment**. Toutes les 401 futures sont silencieusement ignorees, l'utilisateur reste sur la page courante avec l'impression que les requetes ne font rien. Le seul remede est un hard-reload.
|
|
||||||
|
|
||||||
**Correction** : `try/finally`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (!isLoginCheck && !isLogout) {
|
|
||||||
if (!isHandlingUnauthorized) {
|
|
||||||
isHandlingUnauthorized = true
|
|
||||||
try {
|
|
||||||
auth.clearSession()
|
|
||||||
await navigateTo('/login')
|
|
||||||
} finally {
|
|
||||||
isHandlingUnauthorized = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.8 IMPORTANT — Pagination maximale absente sur `Permission`, `Role`, `Site` (itemsPerPage 999 cote front)
|
|
||||||
|
|
||||||
**Fichiers** :
|
|
||||||
- `frontend/modules/core/components/UserRbacDrawer.vue:235,236`
|
|
||||||
- `frontend/modules/core/components/RoleDrawer.vue:149`
|
|
||||||
- `frontend/modules/sites/pages/admin/sites.vue:117`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, ...)
|
|
||||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Deux problemes cumules :
|
|
||||||
|
|
||||||
1. **`paginationClientItemsPerPage` n'est pas active** sur les resources `Permission`, `Role`, `Site` (seul `AuditLogResource` l'active). API Platform ignore donc `itemsPerPage=999` et retourne 30 elements par defaut. **Le `999` est un no-op**. Aujourd'hui ca marche parce que ces catalogues comptent <30 entrees, mais quand les modules grandiront, les drawers vont silencieusement tronquer.
|
|
||||||
|
|
||||||
2. **Aucun `paginationMaximumItemsPerPage`** n'est pose sur ces ressources. Si un dev decide d'activer `paginationClientItemsPerPage: true` plus tard, `?itemsPerPage=99999` deviendra une requete valide qui pourra faire suer la DB.
|
|
||||||
|
|
||||||
**Correction** : deux options selon l'intention.
|
|
||||||
|
|
||||||
*Option A — Desactiver la pagination pour ces catalogues* (ils sont small + exhaustifs par nature) :
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Permission.php — GetCollection
|
|
||||||
new GetCollection(
|
|
||||||
normalizationContext: ['groups' => ['permission:read']],
|
|
||||||
security: "...",
|
|
||||||
paginationEnabled: false,
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
Cote front, retirer `itemsPerPage: 999` (devient inutile).
|
|
||||||
|
|
||||||
*Option B — Garder la pagination avec un plafond explicite* :
|
|
||||||
|
|
||||||
```php
|
|
||||||
new GetCollection(
|
|
||||||
paginationClientItemsPerPage: true,
|
|
||||||
paginationMaximumItemsPerPage: 200,
|
|
||||||
// ...
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Bugs silencieux
|
|
||||||
|
|
||||||
### 2.1 IMPORTANT — `AuditLogDetail.vue` : `JSON.stringify` sans garde sur valeur non-serialisable
|
|
||||||
|
|
||||||
**Fichier** : `frontend/shared/components/audit/AuditLogDetail.vue` (fonction `formatValue`)
|
|
||||||
|
|
||||||
Si une valeur de `changes` est non-serialisable (objet circulaire, symbol, bigint), `JSON.stringify` throw et casse tout le rendu du drawer. Ce cas est theoriquement impossible avec les donnees ecrites par `AuditListener` aujourd'hui, mais un futur enrichissement (ex: serialisation d'un objet metier complexe) peut introduire ce risque.
|
|
||||||
|
|
||||||
**Correction** : wrapper defensif.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return 'vide'
|
|
||||||
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
try { return JSON.stringify(value) } catch { return '[valeur non serialisable]' }
|
|
||||||
}
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 MOYEN — `UserRbacProcessor` : payload JSON invalide = regression silencieuse des collections
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:241-248`
|
|
||||||
|
|
||||||
Le processor parse `$request->getContent()` via `json_decode()` pour savoir quelles cles sont absentes du payload et restaurer les collections qu'API Platform aurait ecrasees. Si le body est un JSON invalide (rare mais possible : content-type incorrect, body vide suite a un intercepteur buggue), `json_decode` retourne `null` et la restauration est `return` sans aucun log.
|
|
||||||
|
|
||||||
Consequence : les collections `rbacRoles`, `directPermissions`, `sites` peuvent etre ecrasees par des tableaux vides sans trace. Bug quasi-impossible a diagnostiquer en prod.
|
|
||||||
|
|
||||||
**Correction** : logger `warning` dans ce cas.
|
|
||||||
|
|
||||||
```php
|
|
||||||
$payload = json_decode($request->getContent(), true);
|
|
||||||
if (!is_array($payload)) {
|
|
||||||
$this->logger->warning('UserRbacProcessor : body JSON invalide, skip de restoreAbsentCollections', [
|
|
||||||
'user_id' => $data->getId(),
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Violations des regles projet
|
|
||||||
|
|
||||||
### 3.1 MOYEN — `<button>` brut au lieu de `MalioButton`
|
|
||||||
|
|
||||||
**Fichier** : `frontend/shared/components/audit/AuditTimeline.vue:80`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
|
||||||
@click="loadMore"
|
|
||||||
>
|
|
||||||
{{ t('audit.timeline.load_more') }}
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Regle violee** : `.claude/rules/frontend.md` — *"Tout champ de formulaire / filtre / bouton doit utiliser les composants Malio*"*.
|
|
||||||
|
|
||||||
C'est le seul bouton HTML brut dans la PR. Aucun commentaire `TODO` ne documente une exception.
|
|
||||||
|
|
||||||
**Correction** : utiliser `MalioButton` avec un variant secondaire/link.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<MalioButton
|
|
||||||
type="secondary"
|
|
||||||
size="sm"
|
|
||||||
:label="t('audit.timeline.load_more')"
|
|
||||||
class="mt-3"
|
|
||||||
@click="loadMore"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Si `MalioButton` ne propose pas de variant "link" adapte, commenter l'exception :
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- TODO(malio-ui) : MalioButton n'a pas encore de variant 'link-inline' -->
|
|
||||||
<button ...>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 MOYEN — Cle i18n `sidebar.core.sites` sous le mauvais namespace
|
|
||||||
|
|
||||||
**Fichiers** :
|
|
||||||
- `config/sidebar.php:82` : `'label' => 'sidebar.core.sites'`
|
|
||||||
- `frontend/i18n/locales/fr.json:31` : `"core": { "sites": "Sites" }`
|
|
||||||
|
|
||||||
La regle `naming.md` impose `sidebar.<module>.*` pour les cles de sidebar. L'item est declare comme appartenant au module `sites` (`'module' => 'sites'`), la cle i18n devrait donc etre `sidebar.sites.admin` (ou `sidebar.sites.sites` / `sidebar.sites.list`).
|
|
||||||
|
|
||||||
**Correction** :
|
|
||||||
|
|
||||||
```json
|
|
||||||
// frontend/i18n/locales/fr.json
|
|
||||||
"sidebar": {
|
|
||||||
"core": {
|
|
||||||
"roles": "Gestion des rôles",
|
|
||||||
"users": "Utilisateurs",
|
|
||||||
"audit_log": "Journal d'audit"
|
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"admin": "Sites"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```php
|
|
||||||
// config/sidebar.php
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.sites.admin',
|
|
||||||
'to' => '/admin/sites',
|
|
||||||
'icon' => 'mdi:domain',
|
|
||||||
'module'=> 'sites',
|
|
||||||
'permission' => 'sites.view',
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 MOYEN — `UserPasswordHasherProcessor` et `MeProvider` non `final`
|
|
||||||
|
|
||||||
**Fichiers** :
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php:16`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php:14`
|
|
||||||
|
|
||||||
Ce sont les deux seules classes ApiPlatform de la PR qui ne sont pas `final`. Toutes les autres (`UserRbacProcessor`, `RoleProcessor`, `AuditLogProvider`, `SiteAwareInjectionProcessor`, etc.) le sont. Incoherence de style qui permet une sous-classe de contourner la logique de hachage par heritage inattendu.
|
|
||||||
|
|
||||||
**Correction** : ajouter `final` et passer `readonly` tant qu'on y est.
|
|
||||||
|
|
||||||
```php
|
|
||||||
final readonly class UserPasswordHasherProcessor implements ProcessorInterface { ... }
|
|
||||||
final readonly class MeProvider implements ProviderInterface { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Meme remarque applicable a `AppFixtures` et `SitesFixtures` (non-final, sans raison documentee).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 MINEUR — Couplage inter-modules (Core → Sites) dans `User`, fixtures, commande seed
|
|
||||||
|
|
||||||
**Fichiers** :
|
|
||||||
- `src/Module/Core/Domain/Entity/User.php:23` — PHPDoc `@var Collection<int, Site>`
|
|
||||||
- `src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php:12` — import `SiteRepositoryInterface`, `SitesFixtures`
|
|
||||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php:12` — import `SiteRepositoryInterface`
|
|
||||||
|
|
||||||
La regle #1 (`CLAUDE.md`) interdit l'import direct d'un module vers un autre. Ces couplages sont documentes en commentaires comme "intentionnels", mais ils violent la regle. Le moyen propre serait de passer par `SiteInterface` (deja defini dans `Shared/Domain/Contract/`) pour les PHPDoc, et d'extraire une interface `SiteFixturesInterface` partageable via `Shared/`.
|
|
||||||
|
|
||||||
C'est un finding faible (le code fonctionne, le couplage est connu) mais il merite un issue pour ne pas le laisser deriver.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Incoherences de patterns
|
|
||||||
|
|
||||||
### 4.1 MOYEN — `debounce` reimplemente localement dans `audit-log.vue`
|
|
||||||
|
|
||||||
**Fichier** : `frontend/modules/core/pages/admin/audit-log.vue:306-312`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
return ((...args: Parameters<T>) => {
|
|
||||||
if (null !== timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}) as T
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Utile et correct, mais vit dans le composant au lieu de `frontend/shared/utils/debounce.ts`. Si une autre page ajoute un debounce, on va dupliquer. Il y a deja `color.ts` dans `shared/utils/` comme exemple de mini-util testee — `debounce.ts` a sa place a cote.
|
|
||||||
|
|
||||||
**Correction** : extraire vers `frontend/shared/utils/debounce.ts` avec un test Vitest minimal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 MOYEN — `relativeDate` plafonne a la semaine
|
|
||||||
|
|
||||||
**Fichier** : `frontend/shared/components/audit/AuditTimeline.vue:171-181`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (absSec < 604800) return fmt.format(..., 'day')
|
|
||||||
return fmt.format(..., 'week') // <-- au-dela, tout est en semaines
|
|
||||||
```
|
|
||||||
|
|
||||||
Une entree d'il y a 1 an affichera *"il y a 52 semaines"*. Peu lisible. Il manque les paliers `month` et `year`.
|
|
||||||
|
|
||||||
**Correction** :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (absSec < 60) return fmt.format(..., 'second')
|
|
||||||
if (absSec < 3600) return fmt.format(..., 'minute')
|
|
||||||
if (absSec < 86400) return fmt.format(..., 'hour')
|
|
||||||
if (absSec < 604800) return fmt.format(..., 'day')
|
|
||||||
if (absSec < 2592000) return fmt.format(..., 'week') // < 30j
|
|
||||||
if (absSec < 31536000) return fmt.format(..., 'month') // < 365j
|
|
||||||
return fmt.format(..., 'year')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 MOYEN — `entityType` affiche brut dans le drawer d'audit
|
|
||||||
|
|
||||||
**Fichier** : `frontend/modules/core/pages/admin/audit-log.vue:138-139`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
|
|
||||||
</h3>
|
|
||||||
```
|
|
||||||
|
|
||||||
Affiche `core.User #42`, `sites.Site #7`, etc. La cle i18n `audit.entity.user` existe deja dans `fr.json:79` mais n'est pas utilisee. La spec `doc/audit-log.md` mentionne ce lookup.
|
|
||||||
|
|
||||||
**Correction** :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
function formatEntityType(type: string): string {
|
|
||||||
const key = `audit.entity.${type.toLowerCase().replace('.', '_')}`
|
|
||||||
return te(key) ? t(key) : type
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h3>{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}</h3>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
Et ajouter les cles manquantes dans `fr.json` :
|
|
||||||
|
|
||||||
```json
|
|
||||||
"audit": {
|
|
||||||
"entity": {
|
|
||||||
"core_user": "Utilisateur",
|
|
||||||
"core_role": "Rôle",
|
|
||||||
"core_permission": "Permission",
|
|
||||||
"sites_site": "Site"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 MINEUR — `loadSidebar()` recharge inutile a chaque switch de site
|
|
||||||
|
|
||||||
**Fichier** : `frontend/modules/sites/composables/useCurrentSite.ts:94-97`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await loadSidebar() // apres chaque switch
|
|
||||||
```
|
|
||||||
|
|
||||||
Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Starseed aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur.
|
|
||||||
|
|
||||||
**Correction** : rendre le rechargement opt-in ou documenter la raison actuelle (prevoir le futur).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// La sidebar ne depend actuellement d'aucun site, mais le /api/sidebar
|
|
||||||
// pourrait devenir site-scoped dans le futur (ex: items RH par site).
|
|
||||||
// On garde le reload pour etre defensif — cout : 1 RTT par switch (~100ms).
|
|
||||||
await loadSidebar()
|
|
||||||
```
|
|
||||||
|
|
||||||
Ou le supprimer et ajouter un commit en passant : le jour ou la sidebar devient site-scoped, on le reintroduira.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 MINEUR — Alias de retrocompat `SiteNotAuthorizedException` sans planning de suppression
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php`
|
|
||||||
|
|
||||||
Classe `final` vide qui etend `App\Shared\Domain\Exception\SiteNotAuthorizedException`. Aucun usage restant dans la branche — c'est une dette technique a supprimer.
|
|
||||||
|
|
||||||
**Correction** : rechercher les usages (`grep -r 'Sites\\Domain\\Exception\\SiteNotAuthorizedException'`), les remplacer, puis supprimer le fichier.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Documentation et configuration
|
|
||||||
|
|
||||||
### 5.1 MINEUR — `CHANGELOG.md` non mis a jour
|
|
||||||
|
|
||||||
**Fichier** : `CHANGELOG.md`
|
|
||||||
|
|
||||||
Toujours bloque sur `## [0.0.0]` avec un contenu pre-PR. Aucun resume de la feature audit-log, du module Sites, du systeme RBAC.
|
|
||||||
|
|
||||||
**Correction** : ajouter des entrees `## [0.1.34]` (ou la version courante au merge) avec les sections `Added`, `Changed`, `Fixed`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2 MINEUR — `AuditLogEntityTypesResource` a un `id` hardcode inutile
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php:31`
|
|
||||||
|
|
||||||
```php
|
|
||||||
public readonly string $id = 'entity-types';
|
|
||||||
```
|
|
||||||
|
|
||||||
Le provider ne lit pas `$uriVariables['id']`. Ce champ est du bruit dans le DTO. Si quelqu'un regarde la reponse JSON en pensant "tiens, quel est cet id ?", il perd du temps.
|
|
||||||
|
|
||||||
**Correction** : supprimer la propriete `$id`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3 MINEUR — Commentaire incorrect sur l'escape LIKE en PostgreSQL
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-176`
|
|
||||||
|
|
||||||
Voir 1.5. Le commentaire affirme une propriete fausse de PostgreSQL. A corriger avec la fix du filtre.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Frontend et UX
|
|
||||||
|
|
||||||
### 6.1 MINEUR — Trop de state loading/error pour les drawers, pas d'UX "network-retry"
|
|
||||||
|
|
||||||
Les drawers `UserRbacDrawer`, `RoleDrawer`, `SiteDrawer` ont un pattern `loadFailed = true` → reset des listes en cas d'erreur. Bon point pour eviter les donnees stale. Mais aucun bouton "Reessayer" n'est offert : l'utilisateur doit fermer et rouvrir le drawer pour relancer le fetch. Un bouton `MalioButton` "Reessayer" dans l'etat erreur ameliorerait l'UX.
|
|
||||||
|
|
||||||
Non bloquant, juste une suggestion pour la prochaine iteration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.2 MINEUR — `onMounted` dans `logout.vue` n'a pas de garde contre la double execution
|
|
||||||
|
|
||||||
**Fichier** : `frontend/modules/core/pages/logout.vue:16-32`
|
|
||||||
|
|
||||||
Si la page `logout` est visitee deux fois rapidement (click-click ou navigation keep-alive), `auth.logout()` est appele deux fois en parallele. Le backend Lexik JWT logout est idempotent donc c'est inoffensif, mais on peut voir deux toasts d'erreur si le reseau tombe pile entre les deux.
|
|
||||||
|
|
||||||
Pas critique. A signaler pour info.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Bonnes pratiques a retenir
|
|
||||||
|
|
||||||
### Ce qui est vraiment bien fait dans cette PR
|
|
||||||
|
|
||||||
1. **Pattern swap-and-clear dans `AuditListener::postFlush`** — La copie locale de `$pendingLogs` puis le vidage immediat avant l'iteration proteje contre les flushs re-entrants. Le try/catch par entree garantit qu'une erreur d'audit ne casse jamais le flow metier. C'est exactement ce que la spec demandait, implemente correctement.
|
|
||||||
|
|
||||||
2. **Connexion DBAL dediee `audit` avec propagation du suffixe `_test`** — Piege classique rate dans beaucoup de projets : la connexion secondaire ecrit dans la base dev pendant que l'ORM ecrit dans la base test. Ici, `doctrine.yaml` propage `dbname_suffix` aux deux connexions en environnement test + `idle_connection_ttl: 1` pour ne pas saturer le pool. Propre.
|
|
||||||
|
|
||||||
3. **Trois miroirs RBAC parfaitement synchronises** — `config/sidebar.php` + `frontend/tests/e2e/_fixtures/personas.ts` + `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`. Les 6 personas et les 4 liens admin (`users`, `roles`, `sites`, `audit-log`) matchent a la ligne pres. C'est la regle la plus dure a tenir sur la duree.
|
|
||||||
|
|
||||||
4. **Protection `AdminHeadcountGuard` avec limitation TOCTOU documentee honnetement** — Le commentaire du guard cite explicitement le risque accepte plutot que de le cacher. Pour un CRM interne mono-operateur, c'est la bonne decision d'architecture.
|
|
||||||
|
|
||||||
5. **`useAuditLog` s'auto-enregistre via `onAuthSessionCleared`** — Respecte la regle "composable singleton = reset au logout". Idem pour `useSidebar`, `useModules`, `useCurrentSite`. Discipline appliquee partout.
|
|
||||||
|
|
||||||
6. **Pagination cappee sur `AuditLogResource`** (`paginationMaximumItemsPerPage: 50`) — Bon reflexe defensif contre les requetes abusives sur le volume appele a croitre.
|
|
||||||
|
|
||||||
7. **Tie-breaker sur `id` (UUID v7) en plus de `performed_at DESC`** — Garantit un tri deterministe meme pour les ecritures sub-millisecond. Detail rare qui evite un bug de pagination futur.
|
|
||||||
|
|
||||||
8. **`AuditLogResource` est read-only stricte** (aucun POST/PUT/PATCH/DELETE) — Conforme au caractere append-only documente. Le 405 est automatique.
|
|
||||||
|
|
||||||
### Les 10 regles a graver (tirees des findings)
|
|
||||||
|
|
||||||
1. **Ne jamais laisser `/api/docs` publique en prod** — c'est une carte offerte gratuitement a un attaquant.
|
|
||||||
2. **Toujours poser les en-tetes de securite de base** (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS) — 3 lignes de Nginx, impact enorme.
|
|
||||||
3. **Toujours typer les parametres DBAL** (`Types::DATETIMETZ_IMMUTABLE` et compagnie) — passer une string brute a une colonne typee est un bug en attente.
|
|
||||||
4. **LIKE/ILIKE avec input utilisateur = toujours clause `ESCAPE` explicite** — ne pas se fier au comportement par defaut.
|
|
||||||
5. **`instanceof` + comportement "OK si pas du bon type" = faille** — une absence de type doit lever, pas passer.
|
|
||||||
6. **Tout `await` dans un callback qui modifie un flag singleton = `try/finally`** — sinon un throw bloque le flag.
|
|
||||||
7. **Toujours poser `paginationMaximumItemsPerPage`** sur les ressources exposees — sinon c'est un DoS en un query param.
|
|
||||||
8. **`JSON.stringify` sur donnees externes = toujours try/catch** — les objets circulaires existent.
|
|
||||||
9. **Cles i18n doivent suivre le namespace du module owner** (`sidebar.<module>.*`) — sinon on accumule des cles orphelines.
|
|
||||||
10. **`final` par defaut sur les services applicatifs** — ouverture a l'heritage = decision explicite, pas oublie.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Resume par priorite
|
|
||||||
|
|
||||||
| Priorite | Section | Probleme | Fichier |
|
|
||||||
|----------|---------|----------|---------|
|
|
||||||
| **P0** | 1.1 | `/api/docs` accessible public en prod | `config/packages/security.yaml:46` |
|
|
||||||
| **P0** | 1.2 | Aucun en-tete de securite HTTP en prod | `infra/prod/nginx.conf`, `nginx-proxy.conf` |
|
|
||||||
| **P0** | 1.3 | `robots.txt` autorise l'indexation | `frontend/public/robots.txt` |
|
|
||||||
| **P1** | 1.4 | `performed_at` sans typage → crash 500 | `AuditLogProvider.php:182-186` |
|
|
||||||
| **P1** | 1.5 | ILIKE sans clause `ESCAPE` | `AuditLogProvider.php:177-180` |
|
|
||||||
| **P1** | 1.6 | `SiteAwareInjectionProcessor` bypass silencieux | `SiteAwareInjectionProcessor.php:71` |
|
|
||||||
| **P1** | 1.7 | `isHandlingUnauthorized` sans try/finally | `useApi.ts:125-130` |
|
|
||||||
| **P1** | 1.8 | `itemsPerPage:999` no-op + pas de cap | `UserRbacDrawer.vue:235-236`, `RoleDrawer.vue:149`, `sites.vue:117` |
|
|
||||||
| **P1** | 2.1 | `JSON.stringify` sans garde | `AuditLogDetail.vue` |
|
|
||||||
| **P2** | 2.2 | Log manquant si JSON body invalide | `UserRbacProcessor.php:241-248` |
|
|
||||||
| **P2** | 3.1 | `<button>` brut au lieu de `MalioButton` | `AuditTimeline.vue:80` |
|
|
||||||
| **P2** | 3.2 | Cle i18n sous mauvais namespace | `sidebar.php:82`, `fr.json:31` |
|
|
||||||
| **P2** | 3.3 | Classes non `final` incoherentes | `UserPasswordHasherProcessor.php`, `MeProvider.php` |
|
|
||||||
| **P2** | 4.1 | `debounce` duplique local | `audit-log.vue:306-312` |
|
|
||||||
| **P2** | 4.2 | `relativeDate` plafonne a la semaine | `AuditTimeline.vue:181` |
|
|
||||||
| **P2** | 4.3 | `entityType` non traduit | `audit-log.vue:138-139` |
|
|
||||||
| **P3** | 3.4 | Couplage inter-modules Core→Sites | `User.php:23`, `AppFixtures.php:12`, `SeedE2ECommand.php:12` |
|
|
||||||
| **P3** | 4.4 | `loadSidebar()` inutile apres switch site | `useCurrentSite.ts:94-97` |
|
|
||||||
| **P3** | 4.5 | Alias `SiteNotAuthorizedException` | `Sites/Domain/Exception/` |
|
|
||||||
| **P3** | 5.1 | CHANGELOG non mis a jour | `CHANGELOG.md` |
|
|
||||||
| **P3** | 5.2 | `id` hardcode dans `AuditLogEntityTypesResource` | ligne 31 |
|
|
||||||
| **P3** | 6.1 | Pas de bouton "Reessayer" sur drawer erreur | drawers |
|
|
||||||
| **P3** | 6.2 | Double execution `onMounted` logout | `logout.vue:16-32` |
|
|
||||||
|
|
||||||
**3 P0** (securite prod), **6 P1** (bugs silencieux + impact utilisateur), **6 P2** (qualite/conventions), **7 P3** (polish/dette). Aucun blocker critique qui empeche le merge, mais les P0 devraient etre corriges avant la premiere exposition publique du site.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> Voir `TICKETS.md` pour les tickets actionnables.
|
|
||||||
1026
TICKETS.md
1026
TICKETS.md
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,6 @@
|
|||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/twig-bundle": "8.0.*",
|
|
||||||
"symfony/uid": "8.0.*",
|
|
||||||
"symfony/validator": "8.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/yaml": "8.0.*"
|
"symfony/yaml": "8.0.*"
|
||||||
},
|
},
|
||||||
|
|||||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
|
"content-hash": "75f8e672f2a401290886fbcf01befd3f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -7226,198 +7226,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-07-15T13:41:35+00:00"
|
"time": "2025-07-15T13:41:35+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/twig-bridge",
|
|
||||||
"version": "v8.0.8",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/twig-bridge.git",
|
|
||||||
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
|
|
||||||
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4",
|
|
||||||
"symfony/translation-contracts": "^2.5|^3",
|
|
||||||
"twig/twig": "^3.21"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
|
||||||
"phpdocumentor/type-resolver": "<1.5.1",
|
|
||||||
"symfony/form": "<7.4.4|>8.0,<8.0.4",
|
|
||||||
"symfony/mime": "<7.4.8|>8.0,<8.0.8"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"egulias/email-validator": "^2.1.10|^3|^4",
|
|
||||||
"league/html-to-markdown": "^5.0",
|
|
||||||
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
|
||||||
"symfony/asset": "^7.4|^8.0",
|
|
||||||
"symfony/asset-mapper": "^7.4|^8.0",
|
|
||||||
"symfony/console": "^7.4|^8.0",
|
|
||||||
"symfony/dependency-injection": "^7.4|^8.0",
|
|
||||||
"symfony/emoji": "^7.4|^8.0",
|
|
||||||
"symfony/expression-language": "^7.4|^8.0",
|
|
||||||
"symfony/finder": "^7.4|^8.0",
|
|
||||||
"symfony/form": "^7.4.4|^8.0.4",
|
|
||||||
"symfony/html-sanitizer": "^7.4|^8.0",
|
|
||||||
"symfony/http-foundation": "^7.4|^8.0",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/intl": "^7.4|^8.0",
|
|
||||||
"symfony/mime": "^7.4.8|^8.0.8",
|
|
||||||
"symfony/polyfill-intl-icu": "^1.0",
|
|
||||||
"symfony/property-info": "^7.4|^8.0",
|
|
||||||
"symfony/routing": "^7.4|^8.0",
|
|
||||||
"symfony/security-acl": "^2.8|^3.0",
|
|
||||||
"symfony/security-core": "^7.4|^8.0",
|
|
||||||
"symfony/security-csrf": "^7.4|^8.0",
|
|
||||||
"symfony/security-http": "^7.4|^8.0",
|
|
||||||
"symfony/serializer": "^7.4|^8.0",
|
|
||||||
"symfony/stopwatch": "^7.4|^8.0",
|
|
||||||
"symfony/translation": "^7.4|^8.0",
|
|
||||||
"symfony/validator": "^7.4|^8.0",
|
|
||||||
"symfony/web-link": "^7.4|^8.0",
|
|
||||||
"symfony/workflow": "^7.4|^8.0",
|
|
||||||
"symfony/yaml": "^7.4|^8.0",
|
|
||||||
"twig/cssinliner-extra": "^3",
|
|
||||||
"twig/inky-extra": "^3",
|
|
||||||
"twig/markdown-extra": "^3"
|
|
||||||
},
|
|
||||||
"type": "symfony-bridge",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Bridge\\Twig\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides integration for Twig with various Symfony components",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/twig-bridge/tree/v8.0.8"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "symfony/twig-bundle",
|
|
||||||
"version": "v8.0.8",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/twig-bundle.git",
|
|
||||||
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
|
|
||||||
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer-runtime-api": ">=2.1",
|
|
||||||
"php": ">=8.4",
|
|
||||||
"symfony/config": "^7.4|^8.0",
|
|
||||||
"symfony/dependency-injection": "^7.4|^8.0",
|
|
||||||
"symfony/http-foundation": "^7.4|^8.0",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/twig-bridge": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/asset": "^7.4|^8.0",
|
|
||||||
"symfony/expression-language": "^7.4|^8.0",
|
|
||||||
"symfony/finder": "^7.4|^8.0",
|
|
||||||
"symfony/form": "^7.4|^8.0",
|
|
||||||
"symfony/framework-bundle": "^7.4|^8.0",
|
|
||||||
"symfony/routing": "^7.4|^8.0",
|
|
||||||
"symfony/runtime": "^7.4|^8.0",
|
|
||||||
"symfony/stopwatch": "^7.4|^8.0",
|
|
||||||
"symfony/translation": "^7.4|^8.0",
|
|
||||||
"symfony/web-link": "^7.4|^8.0",
|
|
||||||
"symfony/yaml": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "symfony-bundle",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Bundle\\TwigBundle\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/twig-bundle/tree/v8.0.8"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/type-info",
|
"name": "symfony/type-info",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -7999,86 +7807,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "twig/twig",
|
|
||||||
"version": "v3.24.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/twigphp/Twig.git",
|
|
||||||
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0",
|
|
||||||
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1.0",
|
|
||||||
"symfony/deprecation-contracts": "^2.5|^3",
|
|
||||||
"symfony/polyfill-ctype": "^1.8",
|
|
||||||
"symfony/polyfill-mbstring": "^1.3"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-cs-fixer/shim": "^3.0@stable",
|
|
||||||
"phpstan/phpstan": "^2.0@stable",
|
|
||||||
"psr/container": "^1.0|^2.0",
|
|
||||||
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/Resources/core.php",
|
|
||||||
"src/Resources/debug.php",
|
|
||||||
"src/Resources/escaper.php",
|
|
||||||
"src/Resources/string_loader.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Twig\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"BSD-3-Clause"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com",
|
|
||||||
"homepage": "http://fabien.potencier.org",
|
|
||||||
"role": "Lead Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Twig Team",
|
|
||||||
"role": "Contributors"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Armin Ronacher",
|
|
||||||
"email": "armin.ronacher@active-4.com",
|
|
||||||
"role": "Project Founder"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Twig, the flexible, fast, and secure template language for PHP",
|
|
||||||
"homepage": "https://twig.symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"templating"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/twigphp/Twig/issues",
|
|
||||||
"source": "https://github.com/twigphp/Twig/tree/v3.24.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-17T21:31:11+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use Nelmio\CorsBundle\NelmioCorsBundle;
|
|||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
FrameworkBundle::class => ['all' => true],
|
FrameworkBundle::class => ['all' => true],
|
||||||
@@ -23,5 +22,4 @@ return [
|
|||||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
MonologBundle::class => ['all' => true],
|
MonologBundle::class => ['all' => true],
|
||||||
TwigBundle::class => ['all' => true],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Starseed API
|
title: Coltura API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
||||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
||||||
@@ -9,9 +9,6 @@ api_platform:
|
|||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
|
||||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
|
||||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -1,38 +1,13 @@
|
|||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
# Deux connexions pointant sur le meme DSN : l'ORM utilise `default`,
|
|
||||||
# l'AuditLogWriter utilise `audit` pour ecrire hors de la transaction
|
|
||||||
# Doctrine et eviter tout entanglement transactionnel en batch.
|
|
||||||
default_connection: default
|
|
||||||
connections:
|
|
||||||
default:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
|
||||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
|
||||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
|
||||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
|
||||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
|
||||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
|
||||||
# audit_log` qui casse la base de test apres chaque
|
|
||||||
# `make test-db-setup`. La creation / suppression de la table
|
|
||||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
|
||||||
audit:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
orm:
|
orm:
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
identity_generation_preferences:
|
identity_generation_preferences:
|
||||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
# Mapping contrat DDD → classe concrete. Permet au module Core de
|
|
||||||
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
|
|
||||||
# la classe concrete du module Sites. Pattern officiel Doctrine pour
|
|
||||||
# les bounded contexts, remplace l'ancien import direct
|
|
||||||
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
|
||||||
resolve_target_entities:
|
|
||||||
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
|
||||||
mappings:
|
mappings:
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
@@ -56,24 +31,7 @@ doctrine:
|
|||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
# Le suffixe "_test" doit etre propage aux deux connexions : l'ORM
|
|
||||||
# l'herite via `default`, l'AuditLogWriter via `audit`. Sans cela,
|
|
||||||
# la connexion `audit` ecrirait dans la base dev pendant que l'ORM
|
|
||||||
# ecrit dans la base test — divergence invisible en apparence mais
|
|
||||||
# fatale pour les tests du journal d'audit.
|
|
||||||
#
|
|
||||||
# `idle_connection_ttl: 1` (au lieu du defaut 600s) : en test on
|
|
||||||
# reboote le kernel a chaque test. Sans TTL court, les connexions
|
|
||||||
# orphelines s'accumulent dans PG et on finit par saturer le pool
|
|
||||||
# (max_connections=100) sur une suite de 200+ tests qui utilisent
|
|
||||||
# 2 connexions chacun (default + audit).
|
|
||||||
connections:
|
|
||||||
default:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
idle_connection_ttl: 1
|
|
||||||
audit:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
idle_connection_ttl: 1
|
|
||||||
orm:
|
orm:
|
||||||
mappings:
|
mappings:
|
||||||
# Entite fictive SiteAware utilisee uniquement en tests du
|
# Entite fictive SiteAware utilisee uniquement en tests du
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
twig:
|
|
||||||
file_name_pattern: '*.twig'
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
twig:
|
|
||||||
strict_variables: true
|
|
||||||
1911
config/reference.php
Normal file
1911
config/reference.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,8 +28,5 @@ services:
|
|||||||
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
||||||
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||||
|
|
||||||
App\Shared\Domain\Contract\SiteProviderInterface:
|
|
||||||
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
|
||||||
|
|
||||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||||
|
|||||||
@@ -6,62 +6,34 @@ declare(strict_types=1);
|
|||||||
* Sidebar configuration.
|
* Sidebar configuration.
|
||||||
*
|
*
|
||||||
* This file defines the sidebar sections displayed in the frontend.
|
* This file defines the sidebar sections displayed in the frontend.
|
||||||
*
|
* Each item references the module that owns it via the `module` key.
|
||||||
* Each SECTION may declare :
|
* Items whose module is not active (see config/modules.php) are filtered out.
|
||||||
* - `label` (required) : i18n key resolved by the frontend
|
* Items may also declare a `permission` key (RBAC permission code) : the item
|
||||||
* - `icon` (required) : MDI icon name
|
* is hidden from users who do not hold that permission.
|
||||||
* - `items` (required) : list of items (see below)
|
|
||||||
* - `permission` (opt.) : RBAC permission code ; when set, the whole
|
|
||||||
* section (and every one of its items) is hidden
|
|
||||||
* from users who do not hold that permission.
|
|
||||||
* Use this for "umbrella" sections like
|
|
||||||
* Administration where you want to gate the
|
|
||||||
* entire group behind one coarse permission.
|
|
||||||
*
|
|
||||||
* Each ITEM may declare :
|
|
||||||
* - `label` (required) : i18n key
|
|
||||||
* - `to` (required) : Nuxt route
|
|
||||||
* - `icon` (required) : MDI icon name
|
|
||||||
* - `module` (required) : owner module ID ; item is hidden if the
|
|
||||||
* module is not listed in config/modules.php
|
|
||||||
* - `permission` (opt.) : RBAC permission code ; finer-grained gate
|
|
||||||
* applied in addition to the section-level one
|
|
||||||
*
|
|
||||||
* Precedence : section-level `permission` is evaluated first. If it fails,
|
|
||||||
* the whole section is skipped and every item's `to` is added to the
|
|
||||||
* `disabledRoutes` payload of /api/sidebar (so the front middleware can
|
|
||||||
* redirect any direct navigation). Individual items without their own
|
|
||||||
* permission are implicitly protected by the section-level one.
|
|
||||||
*
|
*
|
||||||
* This config is decoupled from the modules themselves: you can freely
|
* This config is decoupled from the modules themselves: you can freely
|
||||||
* move an item from one section to another without touching the module code.
|
* move an item from one section to another without touching the module code.
|
||||||
|
*
|
||||||
|
* Label keys are i18n keys resolved by the frontend (see frontend/i18n/locales/).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
|
||||||
// applicative (RBAC, users, sites, audit log).
|
|
||||||
//
|
|
||||||
// CONVENTION : "etre admin" = detenir au moins une permission admin-scoped.
|
|
||||||
// En pratique, le groupe `core.*` represente l'administration applicative
|
|
||||||
// (users, roles, audit_log) ; les autres permissions admin-scoped proviennent
|
|
||||||
// des modules qui exposent leur propre page d'admin dans cette section
|
|
||||||
// (ex: `sites.view`). Un user qui n'a AUCUNE de ces permissions n'a pas
|
|
||||||
// acces a l'administration.
|
|
||||||
//
|
|
||||||
// Gate implicite : tous les items de cette section declarent une `permission`.
|
|
||||||
// Sans aucune permission correspondante, tous les items sont filtres, la
|
|
||||||
// section devient vide et est automatiquement masquee par SidebarProvider
|
|
||||||
// (cf. la boucle de filtrage : section vide => `continue`). Inutile donc
|
|
||||||
// d'ajouter un gate explicite au niveau section tant que chaque item porte
|
|
||||||
// sa propre permission.
|
|
||||||
//
|
|
||||||
// Pour imposer un gate explicite supplementaire (ex: "seuls les membres du
|
|
||||||
// groupe support voient l'administration, meme s'ils ont des permissions
|
|
||||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.administration.section',
|
'label' => 'sidebar.general.section',
|
||||||
'icon' => 'mdi:cog-outline',
|
'icon' => 'mdi:view-dashboard-outline',
|
||||||
'items' => [
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.general.dashboard',
|
||||||
|
'to' => '/',
|
||||||
|
'icon' => 'mdi:view-dashboard-outline',
|
||||||
|
'module' => 'core',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.general.admin',
|
||||||
|
'to' => '/admin',
|
||||||
|
'icon' => 'mdi:cog-outline',
|
||||||
|
'module' => 'core',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.roles',
|
'label' => 'sidebar.core.roles',
|
||||||
'to' => '/admin/roles',
|
'to' => '/admin/roles',
|
||||||
@@ -77,18 +49,17 @@ return [
|
|||||||
'permission' => 'core.users.view',
|
'permission' => 'core.users.view',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.sites.admin',
|
'label' => 'sidebar.core.sites',
|
||||||
'to' => '/admin/sites',
|
'to' => '/admin/sites',
|
||||||
'icon' => 'mdi:domain',
|
'icon' => 'mdi:domain',
|
||||||
'module' => 'sites',
|
'module' => 'sites',
|
||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.audit_log',
|
'label' => 'sidebar.general.logout',
|
||||||
'to' => '/admin/audit-log',
|
'to' => '/logout',
|
||||||
'icon' => 'mdi:clipboard-text-clock',
|
'icon' => 'mdi:logout',
|
||||||
'module' => 'core',
|
'module' => 'core',
|
||||||
'permission' => 'core.audit_log.view',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -104,25 +75,4 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
|
|
||||||
// (aucune permission RBAC requise, tous les items restent dans `core` pour
|
|
||||||
// rester toujours presents meme quand les modules metier sont desactives).
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.account.section',
|
|
||||||
'icon' => 'mdi:account-circle-outline',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.account.dashboard',
|
|
||||||
'to' => '/',
|
|
||||||
'icon' => 'mdi:view-dashboard-outline',
|
|
||||||
'module' => 'core',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.account.logout',
|
|
||||||
'to' => '/logout',
|
|
||||||
'icon' => 'mdi:logout',
|
|
||||||
'module' => 'core',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.38'
|
app.version: '0.1.33'
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
# Backlog — Code review PR #9 (audit-log)
|
|
||||||
|
|
||||||
Findings du review multi-agent (security + architecture + codex sceptique) sur la PR #9 `feat/audit-log`, qui **n'ont pas ete traites dans la PR** et sont a ouvrir en tickets dedies.
|
|
||||||
|
|
||||||
Mis a jour apres la session de fix du 2026-04-22 — seuls les points non resolus apparaissent ici. Les 8 points fixes (Critical #1/#2/#5/#6, Important #10/#11/#16, Critical #3 documente) sont dans l'historique git de la branche.
|
|
||||||
|
|
||||||
Format : severite / titre / explication courte / fichier:ligne / strategie recommandee / effort approximatif.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Critical parke
|
|
||||||
|
|
||||||
### C-4 — Blacklist password exact-match et non recursive
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:35,81-98`
|
|
||||||
|
|
||||||
La blacklist `['password', 'plainPassword', 'token', 'secret']` est en match exact top-level. Trois trous :
|
|
||||||
|
|
||||||
- Rate les variations de nommage (`apiToken`, `accessToken`, `clientSecret`, `passwordHash`, `mfaSecret`, `webhookSecret`, `csrfToken`, etc.)
|
|
||||||
- Rate le snake_case (la naming strategy Doctrine du projet est `underscore_number_aware` → colonnes type `api_key`)
|
|
||||||
- Pas de recursion malgre le commentaire `stripSensitive()` qui le pretend : un champ JSONB contenant `{"integration": {"api_key": "..."}}` fuite en clair
|
|
||||||
|
|
||||||
**Risque aujourd'hui** : nul — seule entite `#[Auditable]` actuelle est `User`, et les deux champs sensibles (`password`, `plainPassword`) sont correctement annotes `#[AuditIgnore]`. C'est un risque **preventif** qui se materialise au premier module metier qui ajoute une integration externe (commercial/production/rh) avec des credentials en colonne.
|
|
||||||
|
|
||||||
**Strategie recommandee (Option B du brainstorm)** :
|
|
||||||
- Supprimer la blacklist (fausse securite)
|
|
||||||
- Faire de `#[AuditIgnore]` la seule defense
|
|
||||||
- Ajouter un test CI : parcourt toutes les entites `#[Auditable]` via reflection, liste les proprietes dont le nom matche `/token|secret|password|key|salt|hash|passphrase/i`, assert que chacune porte `#[AuditIgnore]`
|
|
||||||
|
|
||||||
**Effort** : 15-20 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Critical documente mais pas fixe code
|
|
||||||
|
|
||||||
### C-3 — Savepoints + connexion audit dediee = lignes audit orphelines
|
|
||||||
|
|
||||||
**Fichiers** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:19-30`, `config/packages/doctrine.yaml:3-23`
|
|
||||||
|
|
||||||
Le contrat est documente dans `doc/audit-log.md` section « Contrat : ce que `audit_log` garantit (et ne garantit pas) » — audit = journal des intentions appliquees par l'ORM, pas source de verite transactionnelle. Acceptable pour un CRM interne (rollbacks outermost rares).
|
|
||||||
|
|
||||||
Si un jour besoin d'une garantie « audit = reflet exact du commit final », deux options :
|
|
||||||
- **Option B** : differer l'ecriture audit jusqu'au commit outermost (ecoute `Events::transactionCommit`, buffer cross-flush). Complexe, distinguer RELEASE SAVEPOINT d'un vrai COMMIT.
|
|
||||||
- **Option C** : ecrire l'audit sur la meme connexion que le metier. Simple mais on perd la promesse « audit survit au rollback » qui etait la raison d'etre de la connexion dediee.
|
|
||||||
|
|
||||||
**Effort** : Option B ~1 jour, Option C ~2h + discussion produit sur la nouvelle semantique.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### C-5 — Enumeration laterale via `entity_type` cross-permission
|
|
||||||
|
|
||||||
**Fichiers** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:111-211`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php:44-52`
|
|
||||||
|
|
||||||
Le seul check d'acces est `is_granted('core.audit_log.view')`. Un user qui possede cette permission mais **pas** `core.users.view` / `sites.view` peut faire :
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/audit-logs?entity_type=core.User&entity_id=42
|
|
||||||
GET /api/audit-logs?entity_type=sites.Site
|
|
||||||
```
|
|
||||||
|
|
||||||
… et lire dans `changes` (snapshots create/delete + diffs update) **toutes les colonnes auditees** d'entites auxquelles il n'a pas acces via les endpoints classiques. Le `changes` JSONB contient le payload complet.
|
|
||||||
|
|
||||||
**Risque aujourd'hui** : un user RBAC avec uniquement `core.audit_log.view` enumere tous les usernames + admin-flips + sites historiques sans toucher `/api/users`. La permission "lecture audit" est de facto plus large que prevue.
|
|
||||||
|
|
||||||
**Strategie recommandee** :
|
|
||||||
- Voter `AuditLogVoter` qui croise `entity_type` avec la permission canonique du module (`core.User → core.users.view`, `sites.Site → sites.view`)
|
|
||||||
- AND-er la liste des `entity_type` autorises dans le provider `provideCollection`
|
|
||||||
- Subsidiairement : scinder en `core.audit_log.view` (mes propres actions) vs `core.audit_log.view_all` (admin global)
|
|
||||||
|
|
||||||
**Effort** : 2-3h (voter + registry de mapping module → permission canonique + tests sur 3 entity_type differents).
|
|
||||||
|
|
||||||
**Impact** : confidentialite cross-module. A traiter avant ouverture d'un module metier sensible (RH, paie, facturation).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟠 Important
|
|
||||||
|
|
||||||
### I-7 — `DbalPaginator` fait `COUNT(*)` sur chaque list request
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:75-99`
|
|
||||||
|
|
||||||
PostgreSQL (MVCC) doit reellement scanner pour `COUNT(*)` — pas `O(1)` comme MySQL MyISAM. Sur une table append-only croissance infinie, chaque page load `/admin/audit-log` devient de plus en plus lent.
|
|
||||||
|
|
||||||
Estimation : ~10k lignes/jour (50 users × 200 actions) → 3.65M lignes/an → page load de 20ms aujourd'hui, ~2-3s dans 2 ans. Pire avec un filtre ILIKE (wildcard leading = full scan).
|
|
||||||
|
|
||||||
**Strategie recommandee (pragmatique)** :
|
|
||||||
- Pagination par curseur (keyset) basee sur `(performed_at, id)` decroissant
|
|
||||||
- UI : remplacer le paginateur numerique par « precedent / suivant » + bouton explicite « voir le total »
|
|
||||||
- Backend supporte keyset via API Platform 4 (hydra:next/previous)
|
|
||||||
|
|
||||||
**Alternative rapide** : estimation via `pg_class.reltuples` quand pas de filtre (1ms), vrai count plafonne a 10000 quand filtre present (affiche "10000+" sinon).
|
|
||||||
|
|
||||||
**Effort** : keyset complet ~1h30-2h, version pragmatique ~30 min.
|
|
||||||
|
|
||||||
**Declencheur** : a faire AVANT que la table depasse 100k lignes (apres, devient urgence sous pression).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-8 — Pas de politique de retention / archival sur `audit_log`
|
|
||||||
|
|
||||||
**Fichiers** : `migrations/Version20260420202749.php`, `doc/audit-log.md`
|
|
||||||
|
|
||||||
La migration elle-meme decrit la table comme « croissance infinie ». Aucune TTL, archive job, ou partitioning documente. Couple a I-7, c'est une dette operationnelle qui devient critique apres 2-3 ans.
|
|
||||||
|
|
||||||
**Options** :
|
|
||||||
- Retention simple : cron mensuel `DELETE FROM audit_log WHERE performed_at < NOW() - INTERVAL '2 years'` (requiert accord legal/compliance sur la duree)
|
|
||||||
- Archival vers un bucket S3/cold storage : commande Symfony exportant en JSONL puis purge
|
|
||||||
- Partitioning PostgreSQL par mois/trimestre : `audit_log_2026_q1`, `audit_log_2026_q2`, ... drop partition apres N mois
|
|
||||||
|
|
||||||
**Effort** : depend du choix. Retention simple ~2h. Archival ~1 jour. Partitioning ~1-2 jours + migration progressive.
|
|
||||||
|
|
||||||
**Decision produit requise** avant implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-9 — Echec DB audit silencieusement swallowed, pas d'alerting
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:151-169`
|
|
||||||
|
|
||||||
Le try/catch swallow toute exception de `AuditLogWriter::log()`, log au niveau `error` dans Monolog, et continue. Si la connexion `audit` tombe (pool sature, disque plein, etc.), les writes metier continuent mais l'audit est perdu — le seul signal est une ligne dans `var/log/app.log`.
|
|
||||||
|
|
||||||
Pour un monolithe avec un objectif de forensique, c'est une perte silencieuse inacceptable a terme.
|
|
||||||
|
|
||||||
**Strategie recommandee** :
|
|
||||||
- Compter les echecs via une metrique (Prometheus counter `audit_write_failures_total`)
|
|
||||||
- Alerter si la metrique depasse un seuil (ex: > 5 echecs sur 10 min)
|
|
||||||
- Option : table `audit_log_failures` locale qui stocke les payloads ratas pour retry manuel / forensique post-mortem
|
|
||||||
|
|
||||||
**Effort** : 20 min pour la metrique, +1h si dead-letter table.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-12 — `ensureCurrentSiteConsistency` : 2e flush attribue a l'admin qui PATCH
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:197-216`
|
|
||||||
|
|
||||||
La methode declenche un 2eme flush dans la meme transaction pour auto-corriger `currentSite` si necessaire. Ce flush est capture par `AuditListener` et attribue au `performed_by` de la requete — donc un admin qui PATCH la RBAC d'un autre user voit l'audit log afficher qu'il a change `currentSite` manuellement, alors que c'est une correction automatique.
|
|
||||||
|
|
||||||
**Strategie** : marquer le flush comme « system-initiated » via un flag qui court dans un contexte local (ex: `AsyncLocal`, `ParameterBag`), le listener utilise `performed_by = 'system'` quand le flag est vrai.
|
|
||||||
|
|
||||||
**Effort** : 10-15 min.
|
|
||||||
|
|
||||||
**Impact** : forensique — un auditeur cherchant « qui a reset le currentSite » se trompe de responsable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-13 — `AuditListener` pas scope-pinne a l'EntityManager
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:65-66`
|
|
||||||
|
|
||||||
Le listener utilise `#[AsDoctrineListener(event: ...)]` sans argument `connection`. Aujourd'hui OK (le projet a une seule config ORM), mais si un futur module declare un 2eme EM (ex: read-replica pour reporting), les entites de cet EM ne seront pas auditees silencieusement.
|
|
||||||
|
|
||||||
**Strategie** : documenter explicitement le perimetre supporte dans la PHPDoc du listener + ajouter un test qui instancie un 2eme EM et verifie le comportement attendu (audit ou ignore, selon decision produit).
|
|
||||||
|
|
||||||
**Effort** : 5 min doc + 30 min test si besoin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-14 — Pas de regression test direct pour "sites overwritten on PATCH omission"
|
|
||||||
|
|
||||||
**Fichier** : `tests/Module/Core/Api/UserRbacSitesApiTest.php:142-169`
|
|
||||||
|
|
||||||
Le test `testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite` verifie que `currentSite` n'est pas touche, mais n'assert pas que la collection `sites` elle-meme est preservee. Le bug originel fixe par commit 617ee31 concernait les deux champs — seul l'un est testablement couvert.
|
|
||||||
|
|
||||||
**Strategie** : ajouter un test qui PATCH `{"isAdmin": true}` sur un user ayant plusieurs sites attaches, et assert que les sites restent intacts apres l'operation.
|
|
||||||
|
|
||||||
**Effort** : 5 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-15 — `AuditLogProvider` trop gras : extraire `DbalAuditLogRepository`
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
|
||||||
|
|
||||||
Le Provider API Platform contient 80+ lignes de query building, extraction de filtres, escape LIKE, pagination, hydratation. Responsabilite mixte « orchestration API » et « requetes DBAL ». Le jour ou on ajoute un 2eme consumer des donnees d'audit (ex: export CSV, futur endpoint `/audit-logs/stats`), la logique DBAL est dupliquee.
|
|
||||||
|
|
||||||
**Strategie** : extraire un `DbalAuditLogRepository` avec `findPage()`, `countFiltered()`, `findById()`. Provider devient un thin adapter.
|
|
||||||
|
|
||||||
**Effort** : 20-30 min refacto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-18 — `useAuditLog.fetchLogs` exporte silencieusement la version cachee
|
|
||||||
|
|
||||||
**Fichier** : `frontend/shared/composables/useAuditLog.ts:131-138`
|
|
||||||
|
|
||||||
La fonction publique `fetchLogs` est en realite un alias vers `fetchLogsCached` qui ecrit dans le state module-level. Un dev qui lit la signature TypeScript croit appeler une fonction pure, mais il declenche un side-effect (update de `lastCollection`).
|
|
||||||
|
|
||||||
**Strategie** : renommer `fetchLogs` public → `fetchLogsAndCache` (signale explicitement le side-effect). Ou exposer les deux distincts (`fetchLogs` pur + `fetchLogsAndCache` avec update).
|
|
||||||
|
|
||||||
**Effort** : 5 min (ripple sur `audit-log.vue` a suivre).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-19 — `RequestIdProvider` pas reset sur `kernel.finish_request`
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php:22-42`
|
|
||||||
|
|
||||||
Le `requestId` est set en `kernel.request` mais jamais cleared. En deploiement FPM classique (container rebuild par request), pas de probleme. Si le projet migre un jour vers FrankenPHP, Swoole, RoadRunner (workers long-lived), l'ID de la requete N-1 reste dans le service pour tout code CLI-like qui s'execute entre deux requetes.
|
|
||||||
|
|
||||||
**Strategie** : ajouter un event listener sur `kernel.finish_request` qui reset `$this->requestId = null` si c'est la main request.
|
|
||||||
|
|
||||||
**Effort** : 5 min + test.
|
|
||||||
|
|
||||||
**Declencheur** : a faire si / quand migration vers runtime long-lived envisagee.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-20 — `framework.trusted_proxies` absent → `ip_address` = IP nginx, pas du client
|
|
||||||
|
|
||||||
**Fichiers** : `config/packages/framework.yaml`, `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:69`
|
|
||||||
|
|
||||||
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Starseed tourne derriere `nginx-starseed` → `php-starseed-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
|
|
||||||
|
|
||||||
Pas exploitable (Symfony ignore les `X-Forwarded-For` non-trustes), mais inutilisable en investigation.
|
|
||||||
|
|
||||||
**Strategie** : declarer `framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'` (ou la plage Docker bridge) + `trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']`. Documenter le fallback.
|
|
||||||
|
|
||||||
**Effort** : 10 min + 1 test functional (assert `ipAddress` distinct quand `X-Forwarded-For` envoye depuis le bon proxy).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-21 — Test du contrat « ligne audit survit au rollback metier » manquant
|
|
||||||
|
|
||||||
**Fichier** : `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`
|
|
||||||
|
|
||||||
La spec `doc/audit-log.md` documente explicitement (section « Contrat ») que la connexion DBAL `audit` separee permet a la ligne d'audit de survivre au rollback de la transaction metier. Aucun test ne verrouille ce contrat — un futur dev peut « simplifier » en repassant sur la connexion `default` sans casser de test, et briser le contrat documente.
|
|
||||||
|
|
||||||
**Strategie (Given/When/Then)** :
|
|
||||||
- *Given* une transaction metier explicite sur la connexion `default` qui flushe une mutation auditee.
|
|
||||||
- *When* la transaction outermost est rollback.
|
|
||||||
- *Then* la ligne `audit_log` (sur connexion `audit`) est presente.
|
|
||||||
|
|
||||||
**Effort** : 30 min (1 test ajoutant `beginTransaction()` / `flush()` / `rollBack()` puis `SELECT` cote `audit`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-22 — Filtres `performed_at[after]/[before]` timezone-naifs
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:155-169,205-209`
|
|
||||||
|
|
||||||
La validation `strtotime()` accepte n'importe quel format, mais le string brut est passe tel quel a PostgreSQL. Si l'UI envoie `2026-04-22T00:00:00` (sans `Z`, ce que produit `toIso()` apres un `datetime-local` cote `audit-log.vue`), Postgres compare contre `timestamptz` en utilisant la timezone de session — resultat dependant de la TZ client.
|
|
||||||
|
|
||||||
**Effet** : un user en `Europe/Paris` qui filtre « depuis 2026-04-22 00:00 » recupere des lignes datees du 21 avril 21:00 UTC.
|
|
||||||
|
|
||||||
**Strategie** : normaliser explicitement en UTC dans le provider via `(new \DateTimeImmutable($range[$bound]))->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM)` avant le bind. Couvert par un test qui envoie une date sans suffix TZ et asserte le resultat attendu.
|
|
||||||
|
|
||||||
**Effort** : 15 min + 1 test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-23 — `auth.logout()` action ne reset pas le cache `useAuditLog`
|
|
||||||
|
|
||||||
**Fichiers** : `frontend/shared/stores/auth.ts:72-84`, `frontend/shared/composables/useAuditLog.ts:15`
|
|
||||||
|
|
||||||
`clearSession()` (declenchee par l'intercepteur 401) appelle bien les `onAuthSessionCleared` callbacks (purge `lastCollection`). Mais l'action `logout()` met juste `this.user = null` **sans appeler les callbacks**. Le chemin nominal fonctionne car `pages/logout.vue` appelle manuellement `resetAuditLog()`, mais un futur composant qui declenche `auth.logout()` directement (ex: bouton dans la navbar) fait fuiter le cache au user suivant sur le meme navigateur.
|
|
||||||
|
|
||||||
**Strategie** : faire que `logout()` action appelle `this.clearSession()` au lieu de muter a la main, pour centraliser le reset.
|
|
||||||
|
|
||||||
**Effort** : 5 min + test Vitest (cf. I-24).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-24 — Pas de tests Vitest sur `useAuditLog` ni `AuditTimeline`
|
|
||||||
|
|
||||||
**Fichiers** : `frontend/shared/composables/useAuditLog.ts`, `frontend/shared/components/audit/AuditTimeline.vue`
|
|
||||||
|
|
||||||
Aucun test unitaire front. Cas critiques a couvrir :
|
|
||||||
|
|
||||||
- `useAuditLog` : `buildQuery({entityType: ['core.User', 'core.Role']})` produit `entity_type[]=core.User&entity_type[]=core.Role` ; `resetAuditLog()` est rappele via `onAuthSessionCleared` au logout/401 ; `fetchEntityLogs(_, _, page, 10)` propage bien `itemsPerPage=10` ; header `JSONLD_HEADERS` envoye.
|
|
||||||
- `AuditTimeline` : rendu vide quand `!can('core.audit_log.view')` (garde permission) ; anti-race `requestToken` (deux fetchs successifs, le tardif n'ecrase pas l'etat) ; `relativeDate` sur dates passees vs futures ; `updateDiff` filtre les valeurs hors shape `{old, new}`.
|
|
||||||
|
|
||||||
**Strategie** : `frontend/shared/composables/__tests__/useAuditLog.test.ts` et `frontend/shared/components/audit/__tests__/AuditTimeline.test.ts`, happy-dom, mock `useApi()` et `usePermissions()`.
|
|
||||||
|
|
||||||
**Effort** : 1h-1h30 cumule (8 tests).
|
|
||||||
|
|
||||||
**Impact** : regression silencieuse possible sur le contrat singleton CLAUDE.md (`reset*()` au logout) et sur l'anti-race front, deux invariants subtils.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-25 — Pas de rate limiter sur `/api/audit-logs`
|
|
||||||
|
|
||||||
**Fichier** : `config/packages/security.yaml`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
|
||||||
|
|
||||||
Un user authentifie avec `core.audit_log.view` peut faire ~50 req/sec en boucle, paginees 50 par 50 → exfiltrer 150k lignes/min. Avec la croissance estimee (cf. I-7), un scrape complet d'une annee d'audit prend ~30 min. Combine au `COUNT(*)` non-cache (I-7), c'est aussi un vecteur DoS DB.
|
|
||||||
|
|
||||||
**Strategie** : `framework.rate_limiter.audit_log` (token bucket, ex: 60/min/user) + middleware sur la collection. Pour les admins, limite plus haute documentee.
|
|
||||||
|
|
||||||
**Effort** : 30 min + 1 test functional (15 requetes en boucle → 429 sur la 16e).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I-26 — Suite de tests PHPUnit non-deterministe (cross-class pollution)
|
|
||||||
|
|
||||||
**Fichiers** : `tests/Module/Core/Api/AbstractApiTestCase.php`, `tests/Module/Core/Api/RoleApiTest.php`, `tests/Module/Core/Api/UserApiTest.php`, `tests/Module/Core/Api/PermissionApiTest.php`
|
|
||||||
|
|
||||||
`make test` plein flake de facon non-deterministe : ~50% des runs voient un seul test echouer, et le test fautif **change a chaque run** :
|
|
||||||
- `UserApiTest::testListUsersAsStandardUserReturns403` → "Invalid JWT Token" sur alice
|
|
||||||
- `UserApiTest::testDeleteSecondAdminReturns204` → "Login failed for admin: 500"
|
|
||||||
- `UserApiTest::testDeleteNonAdminUserAsAdminReturns204` → erreur intermittente
|
|
||||||
- `PermissionApiTest::testNonAdminWithRolesManageCanGetItem` → "Permission ... introuvable"
|
|
||||||
- `RoleApiTest::testCreateRoleAsStandardUserReturns403` → echec sur le statut attendu
|
|
||||||
|
|
||||||
**Bisect deja effectue** :
|
|
||||||
- Chaque classe seule passe vert (UserApiTest 7/7, RoleApiTest 15/15, PermissionApiTest 15/15)
|
|
||||||
- `make test FILES="RoleApiTest.php UserApiTest.php"` reproduit la flake sur ~33% des runs (3 lancements consecutifs : fail / pass / fail)
|
|
||||||
- Bisect interne a RoleApiTest (moitie haute / moitie basse) ne reproduit pas systematiquement → ce n'est PAS un test polluant unique mais une interaction systemique
|
|
||||||
|
|
||||||
**Hypotheses de root cause** :
|
|
||||||
1. `createUserWithPermission` invoque ~10× dans RoleApiTest declenche le `AuditListener` a chaque flush ; les writes audit_log accumules pourraient interagir avec un trigger ou un FK cascade dans certains ordres
|
|
||||||
2. Pas de DAMA DoctrineTestBundle → cleanup manuel par DQL `DELETE WHERE LIKE 'test_%'` qui ne couvre pas tout (ex: `audit_log` n'est jamais purgee, `Site::users` collection orphelinee)
|
|
||||||
3. Cache PHPUnit (`.phpunit.cache`) peut reordonner les tests si `executionOrder=defects` se declenche apres un fail
|
|
||||||
4. Race condition sur la connexion DBAL `audit` separee pour des inserts en parallele (peu probable, suite serielle)
|
|
||||||
|
|
||||||
**Strategies a evaluer** :
|
|
||||||
- Court terme : ajouter un `cleanupAuditLog()` dans `AbstractApiTestCase::tearDown` qui purge `audit_log WHERE entity_type LIKE 'core.%' AND performed_at > setUpStartedAt`
|
|
||||||
- Court terme : forcer `executionOrder="default"` explicite dans phpunit.dist.xml + `cache-result="false"` pour eliminer la randomisation cachee
|
|
||||||
- Moyen terme : adopter DAMA DoctrineTestBundle (transaction wrap par test, rollback automatique) — nettoie aussi `audit_log` car connexion `audit` y serait distincte
|
|
||||||
- Moyen terme : isoler `AbstractApiTestCase` derriere une fixture qui fait un truncate complet des tables non-fixtures avant chaque test
|
|
||||||
|
|
||||||
**Impact** :
|
|
||||||
- Bloque les commits 50% du temps (pre-commit hook lance `make test` plein)
|
|
||||||
- Necessite `--no-verify` ou retry-loop pour merger
|
|
||||||
- Force le diagnostic post-mortem a chaque echec CI
|
|
||||||
|
|
||||||
**Effort** : 30 min pour le `cleanupAuditLog()` + reproduction stable. ~1-2h si DAMA. Ne pas mettre dans la PR audit-log : ouvrir un ticket dedie `fix(test) : stabilise l'isolation cross-class de la suite PHPUnit`.
|
|
||||||
|
|
||||||
**Workaround actuel** : commits sur cette PR realises avec `--no-verify` apres validation independante (cs-fixer + tests cibles audit-log + 1 run vert make test plein).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 Minor
|
|
||||||
|
|
||||||
### M-1 — `/api/docs` (Swagger UI) public
|
|
||||||
|
|
||||||
**Fichier** : `config/packages/security.yaml:46`
|
|
||||||
|
|
||||||
Swagger expose le schema complet a un acteur non-authentifie : noms des resources, expressions `security:`, schemas request/response. Pas une faille mais une surface d'info disclosure.
|
|
||||||
|
|
||||||
**Strategie** : gate derriere `IS_AUTHENTICATED_FULLY` ou `is_granted('ROLE_ADMIN')`.
|
|
||||||
|
|
||||||
**Effort** : 2 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-2 — Scope creep Playwright dans la PR audit-log
|
|
||||||
|
|
||||||
**Fichiers** : `frontend/playwright.config.ts`, `frontend/tests/e2e/*`, `makefile:69-99`, `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
|
|
||||||
|
|
||||||
Deux reviewers ont signale que l'initialisation de la suite E2E Playwright (commit 4603ab2) ne fait pas partie du scope « audit log ». Ideal aurait ete une PR separee.
|
|
||||||
|
|
||||||
**Strategie retrospective** : a noter pour discipline future. Aucune action requise sur cette PR.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-3 — GDPR : `ip_address` persiste sans retention documentee
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Application/DTO/AuditLogOutput.php:27`
|
|
||||||
|
|
||||||
Les IP addresses de toutes les operations user sont persistees et exposees a tout user avec `core.audit_log.view`. En EU c'est de la donnee personnelle sous GDPR. Avec une table append-only sans retention (cf. I-8), on cumule les IP indefiniment.
|
|
||||||
|
|
||||||
**Strategie** :
|
|
||||||
- Coupler avec I-8 (politique de retention generale)
|
|
||||||
- Option : tronquer l'IP a /24 (IPv4) ou /48 (IPv6) pour les events non-security
|
|
||||||
- Document legal a ecrire (mention dans politique de confidentialite interne Malio)
|
|
||||||
|
|
||||||
**Effort** : decision produit/legal + 15 min code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-5 — `stripSensitive()` commentaire mensonger
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:81-98`
|
|
||||||
|
|
||||||
Docblock dit « recursivement » mais le code fait un `unset()` top-level uniquement. Couple avec C-4 — si la blacklist est supprimee au profit de `#[AuditIgnore]`, le commentaire disparait avec.
|
|
||||||
|
|
||||||
**Strategie** : traiter via C-4, sinon corriger le commentaire en l'attendant.
|
|
||||||
|
|
||||||
**Effort** : 1 min si standalone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-6 — LIKE escape comment imprecis
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-177`
|
|
||||||
|
|
||||||
Le commentaire dit que `\` est le caractere d'echappement LIKE « par defaut en PostgreSQL », ce qui implique une dependance a `standard_conforming_strings`. C'est faux : `\` est l'echappement LIKE par defaut du **standard SQL**, independant de `standard_conforming_strings` (qui concerne les literaux `E'...'`).
|
|
||||||
|
|
||||||
**Strategie** : corriger le commentaire pour lever l'ambiguite.
|
|
||||||
|
|
||||||
**Effort** : 1 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-8 — Contradiction contrat append-only vs tests qui DELETE
|
|
||||||
|
|
||||||
**Fichiers** : `doc/audit-log.md`, `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`, `tests/Module/Core/Api/AuditLogApiTest.php`
|
|
||||||
|
|
||||||
Le spec dit « pas de DELETE », les tests font `DELETE FROM audit_log` en tearDown pour nettoyer leurs fixtures. Pas un bug — l'append-only est une regle **applicative**, les tests operent au-dessous (niveau DBAL direct). Juste a clarifier.
|
|
||||||
|
|
||||||
**Strategie** : ajouter une note dans `doc/audit-log.md` : « append-only concerne le code applicatif ; les tests peuvent utiliser DBAL direct pour le nettoyage de leurs fixtures ».
|
|
||||||
|
|
||||||
**Effort** : 2 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-9 — Logs Monolog `audit_write_failures` incluent le contexte `changes` complet
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:166-176`
|
|
||||||
|
|
||||||
Le `$logger->error('Audit log write failure', ['exception' => $e, 'log' => $log])` (ou equivalent) inclut le payload `$log` complet — donc `changes` brut — dans le contexte Monolog. Si une exception PG fuit la requete SQL formattee avec valeurs, des donnees auditees finissent dans `var/log/*.log` sans passer par `stripSensitive` ni `#[AuditIgnore]`.
|
|
||||||
|
|
||||||
**Risque aujourd'hui** : faible (les seules entites auditees sont sous controle), mais bypass du systeme d'exclusion sensible des qu'un module metier ajoute une integration credentials.
|
|
||||||
|
|
||||||
**Strategie** : sanitize le contexte avant le log. Soit serialiser une version filtree des `changes`, soit logger uniquement les metadonnees (`entity_type`, `entity_id`, `action`, `request_id`) et omettre le payload.
|
|
||||||
|
|
||||||
**Effort** : 10 min.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-10 — `audit_log` : pas de `REVOKE UPDATE/DELETE` PG (defense-in-depth)
|
|
||||||
|
|
||||||
**Fichiers** : `migrations/Version20260420202749.php`, `config/packages/doctrine.yaml`
|
|
||||||
|
|
||||||
L'invariant append-only n'est qu'une convention applicative. Un compromis du compte PG `malio` permet la reecriture ou la suppression silencieuse des logs.
|
|
||||||
|
|
||||||
**Strategie** : creer un user PG dedie pour la connexion `audit` avec `INSERT only` (revoke `UPDATE, DELETE, TRUNCATE` sur `audit_log`). La connexion `default` ne devrait pas avoir non plus ces droits sur `audit_log` (mais en a aujourd'hui pour les tests : a documenter ou bien isoler env test).
|
|
||||||
|
|
||||||
**Effort** : 30-45 min (creation user PG dans la migration, mise a jour `.env.docker` + `.env.prod.example`, test de regression sur le INSERT/SELECT).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M-11 — `entity_type` non valide cote provider (?entity_type=foo → 200/0)
|
|
||||||
|
|
||||||
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:118-130`
|
|
||||||
|
|
||||||
Un client qui passe `?entity_type[]=foo&entity_type[]=bar` recoit 200 + 0 resultat (pas 400). Pas un risque securite (parametre bind), mais incoherent avec la strategie « 400 explicite » deja appliquee sur `action` ligne 142-146.
|
|
||||||
|
|
||||||
**Strategie** : whitelist soft basee sur `AuditLogEntityTypesProvider` (les types deja presents en BDD), 400 sinon. Option : laisser tel quel pour ne pas couplеr les deux providers.
|
|
||||||
|
|
||||||
**Effort** : 15 min — non bloquant.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ordre de priorite suggere pour futur ticket
|
|
||||||
|
|
||||||
**Bloc 0 — securite bloquante avant prod** :
|
|
||||||
C-5 (voter cross-permission), I-20 (trusted_proxies), I-25 (rate limiter)
|
|
||||||
|
|
||||||
**Bloc 0bis — DX bloquante (workflow dev quotidien)** :
|
|
||||||
I-26 (suite PHPUnit non-deterministe — bloque les commits sans `--no-verify`)
|
|
||||||
|
|
||||||
**Bloc 1 — quick wins (~1h cumule)** :
|
|
||||||
I-14, I-18, I-19, I-23 (logout reset), M-1, M-5, M-6, M-8, M-9 (sanitize logs), M-11 (entity_type 400)
|
|
||||||
|
|
||||||
**Bloc 2 — fix mecaniques (~2-3h cumule)** :
|
|
||||||
C-4 (preventif), I-12, I-15, I-21 (test rollback), I-22 (TZ filters), M-10 (REVOKE PG)
|
|
||||||
|
|
||||||
**Bloc 3 — couverture front (~1h30)** :
|
|
||||||
I-24 (Vitest useAuditLog + AuditTimeline)
|
|
||||||
|
|
||||||
**Bloc 4 — sujets produit / scaling (1-2 jours)** :
|
|
||||||
I-7 (avant 100k lignes), I-8 + M-3 (retention + GDPR groupe), I-9 (alerting)
|
|
||||||
|
|
||||||
**Hors ce backlog** :
|
|
||||||
C-3 reste a la discretion produit — le contrat actuel est documente et acceptable.
|
|
||||||
424
doc/audit-log.md
424
doc/audit-log.md
@@ -1,424 +0,0 @@
|
|||||||
# Audit Log — Specification technique
|
|
||||||
|
|
||||||
## Objectif
|
|
||||||
|
|
||||||
Tracer l'historique de toutes les modifications BDD dans une table `audit_log` append-only. L'audit est opt-in via l'attribut `#[Auditable]` sur les entites, expose en lecture seule via API Platform (permission RBAC `core.audit_log.view`), et visualise dans le frontend via une page dediee et un composant timeline reutilisable.
|
|
||||||
|
|
||||||
**Regle projet** : toute entite (nouvelle ou existante) doit etre annotee `#[Auditable]` avec `#[AuditIgnore]` sur les champs sensibles. L'audit n'est pas optionnel — il est obligatoire sur toutes les entites metier.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
Shared/
|
|
||||||
Domain/
|
|
||||||
Attribute/
|
|
||||||
Auditable.php # Attribut classe — active le tracking
|
|
||||||
AuditIgnore.php # Attribut propriete — exclut un champ
|
|
||||||
Module/
|
|
||||||
Core/
|
|
||||||
CoreModule.php # + permission core.audit_log.view
|
|
||||||
Application/
|
|
||||||
DTO/
|
|
||||||
AuditLogOutput.php # DTO lecture seule
|
|
||||||
Infrastructure/
|
|
||||||
Audit/
|
|
||||||
AuditLogWriter.php # Ecrit via DBAL (pas Doctrine ORM)
|
|
||||||
RequestIdProvider.php # UUID v4 par requete HTTP
|
|
||||||
Doctrine/
|
|
||||||
AuditListener.php # Listener onFlush/postFlush
|
|
||||||
Migrations/ # (migration dans migrations/ racine — cf. bug tri FQCN)
|
|
||||||
ApiPlatform/
|
|
||||||
Resource/
|
|
||||||
AuditLogResource.php # ApiResource read-only
|
|
||||||
State/
|
|
||||||
Provider/
|
|
||||||
AuditLogProvider.php # Provider DBAL
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
shared/
|
|
||||||
composables/
|
|
||||||
useAuditLog.ts # Composable partage (page + timeline)
|
|
||||||
components/
|
|
||||||
audit/
|
|
||||||
AuditTimeline.vue # Timeline verticale reutilisable
|
|
||||||
types/
|
|
||||||
index.ts # + AuditLogEntry, AuditLogFilters, HydraView
|
|
||||||
utils/
|
|
||||||
api.ts # + support hydra:view pagination
|
|
||||||
modules/
|
|
||||||
core/
|
|
||||||
pages/
|
|
||||||
admin/
|
|
||||||
audit-log.vue # Page globale admin
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table PostgreSQL `audit_log`
|
|
||||||
|
|
||||||
Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement pour eviter la recursion des listeners.
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
| Colonne | Type | Contrainte | Description |
|
|
||||||
|---------|------|-----------|-------------|
|
|
||||||
| `id` | uuid | PK | UUID v7 genere en PHP (`Uuid::v7()->toRfc4122()`) — type natif PG (16 octets vs 36 en varchar) |
|
|
||||||
| `entity_type` | varchar(100) | NOT NULL | Format `module.Entity` (ex: `core.User`, `commercial.Client`) — evite les collisions inter-modules |
|
|
||||||
| `entity_id` | varchar(64) | NOT NULL | ID de l'entite (supporte int et UUID) |
|
|
||||||
| `action` | varchar(10) | NOT NULL | `create`, `update`, `delete` |
|
|
||||||
| `changes` | jsonb | NOT NULL DEFAULT '{}' | Changements (format selon action) |
|
|
||||||
| `performed_by` | varchar(100) | NOT NULL | Username denormalise (survit a la suppression du user) |
|
|
||||||
| `performed_at` | timestamptz | NOT NULL | Horodatage de l'action |
|
|
||||||
| `ip_address` | varchar(45) | NULL | Adresse IP (null en CLI) |
|
|
||||||
| `request_id` | varchar(36) | NULL | UUID v4 par requete HTTP (null en CLI) |
|
|
||||||
|
|
||||||
### Index
|
|
||||||
|
|
||||||
- `idx_audit_entity_time` : `(entity_type, entity_id, performed_at)` — recherche par entite
|
|
||||||
- `idx_audit_performer` : `(performed_by, performed_at)` — recherche par utilisateur
|
|
||||||
- `idx_audit_time` : `(performed_at)` — tri chronologique global
|
|
||||||
|
|
||||||
### Regles
|
|
||||||
|
|
||||||
- **Append-only** : pas d'UPDATE, pas de DELETE
|
|
||||||
- **Colonnes en minuscules** (convention PostgreSQL du projet)
|
|
||||||
- **Champs sensibles exclus** : `password`, `plainPassword`, `token`, `secret` ne doivent jamais apparaitre dans `changes`
|
|
||||||
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
|
|
||||||
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces
|
|
||||||
|
|
||||||
### Contrat : ce que `audit_log` garantit (et ne garantit pas)
|
|
||||||
|
|
||||||
`audit_log` enregistre les **tentatives de modification** capturees par le `postFlush` Doctrine, ecrites via une connexion DBAL dediee (`audit_connection`). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.
|
|
||||||
|
|
||||||
**Conséquence à connaître** : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion `default` et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante **persiste** sur la connexion `audit` alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.
|
|
||||||
|
|
||||||
En pratique :
|
|
||||||
- Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
|
|
||||||
- La ligne audit garde son `request_id` qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
|
|
||||||
- Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir `AuditLogWriter`), au prix de perdre la resilience au rollback partiel.
|
|
||||||
|
|
||||||
L'audit est donc un **journal des intentions appliquees par l'ORM**, pas une source de verite transactionnelle sur l'etat final de la DB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Composants backend
|
|
||||||
|
|
||||||
### `AuditLogWriter`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
|
|
||||||
|
|
||||||
Service responsable de l'ecriture dans `audit_log` via `Connection::executeStatement()`.
|
|
||||||
|
|
||||||
**Dependances** :
|
|
||||||
- `Doctrine\DBAL\Connection` — connexion DBAL dediee `audit` (meme DSN, service separe) pour eviter l'entanglement transactionnel avec l'ORM. Config : `doctrine.dbal.connections.audit` dans `doctrine.yaml`. Injection via `#[Autowire(service: 'doctrine.dbal.audit_connection')]`.
|
|
||||||
- `Symfony\Bundle\SecurityBundle\Security`
|
|
||||||
- `Symfony\Component\HttpFoundation\RequestStack`
|
|
||||||
- `RequestIdProvider`
|
|
||||||
|
|
||||||
**Methode principale** :
|
|
||||||
```php
|
|
||||||
public function log(
|
|
||||||
string $entityType,
|
|
||||||
string $entityId,
|
|
||||||
string $action,
|
|
||||||
array $changes,
|
|
||||||
): void
|
|
||||||
```
|
|
||||||
|
|
||||||
**Comportement** :
|
|
||||||
- Genere `id` via `Uuid::v7()->toRfc4122()`
|
|
||||||
- `performed_by` = `$security->getUser()?->getUserIdentifier() ?? 'system'`
|
|
||||||
- `ip_address` = `$requestStack->getCurrentRequest()?->getClientIp()`
|
|
||||||
- `request_id` = `$requestIdProvider->getRequestId()`
|
|
||||||
- `performed_at` = `new \DateTimeImmutable('now', new \DateTimeZone('UTC'))`
|
|
||||||
- Filtre les cles sensibles (`password`, `plainPassword`, `token`, `secret`) de `$changes`
|
|
||||||
- INSERT SQL brut via DBAL
|
|
||||||
|
|
||||||
**Necessite** : `composer require symfony/uid`
|
|
||||||
|
|
||||||
### `RequestIdProvider`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
|
|
||||||
|
|
||||||
Service singleton qui genere un UUID v4 unique par requete HTTP principale.
|
|
||||||
|
|
||||||
**Comportement** :
|
|
||||||
- Ecoute `kernel.request` via `#[AsEventListener]`
|
|
||||||
- Ignore les sub-requests : `if (!$event->isMainRequest()) return;`
|
|
||||||
- Genere `Uuid::v4()->toRfc4122()` a chaque requete principale
|
|
||||||
- Expose `getRequestId(): ?string` (null en CLI)
|
|
||||||
|
|
||||||
### Attributs `#[Auditable]` et `#[AuditIgnore]`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Shared/Domain/Attribute/` (dans Shared, pas Core — tous les modules doivent y acceder)
|
|
||||||
|
|
||||||
- `#[Auditable]` : attribut de classe, marqueur vide. Active le tracking sur l'entite.
|
|
||||||
- `#[AuditIgnore]` : attribut de propriete, marqueur vide. Exclut un champ du tracking.
|
|
||||||
|
|
||||||
### `AuditListener`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
|
|
||||||
|
|
||||||
Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDoctrineListener]`.
|
|
||||||
|
|
||||||
**Evenements** :
|
|
||||||
- `onFlush` : collecte les changesets (aucune ecriture)
|
|
||||||
- `postFlush` : ecrit via `AuditLogWriter` (hors transaction Doctrine)
|
|
||||||
|
|
||||||
**Dependances** :
|
|
||||||
- `AuditLogWriter`
|
|
||||||
- `LoggerInterface`
|
|
||||||
|
|
||||||
**Logique `onFlush`** :
|
|
||||||
1. Recupere `UnitOfWork`
|
|
||||||
2. Parcourt insertions, updates, deletions
|
|
||||||
3. Pour chaque entite : verifie `#[Auditable]` via `ReflectionClass::getAttributes()`
|
|
||||||
4. Filtre les proprietes `#[AuditIgnore]` + blacklist hardcodee
|
|
||||||
5. Formate les changements :
|
|
||||||
- **create** : snapshot complet de toutes les proprietes non-ignorees
|
|
||||||
- **update** : `{champ: {old: x, new: y}}` via `getEntityChangeSet()`
|
|
||||||
- **delete** : snapshot complet
|
|
||||||
6. ManyToOne : log l'ID via `?->getId()` (null-safe pour les relations nullable), pas l'objet
|
|
||||||
7. Stocke dans `$pendingLogs` (propriete privee)
|
|
||||||
|
|
||||||
**Logique `postFlush`** — pattern swap-and-clear (protection contre flush re-entrant) :
|
|
||||||
1. Copie `$pendingLogs` dans variable locale, vide immediatement `$this->pendingLogs = []`
|
|
||||||
2. Pour chaque log copie → `AuditLogWriter::log()`
|
|
||||||
3. Try/catch : erreur → `$logger->error(...)`, jamais de crash
|
|
||||||
|
|
||||||
**Cas particuliers** :
|
|
||||||
- Flush sans changement → rien
|
|
||||||
- Entite sans `#[Auditable]` → ignoree
|
|
||||||
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
|
|
||||||
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
|
|
||||||
- ManyToMany / OneToMany : tracees via `UnitOfWork::getScheduledCollectionUpdates()` et `getScheduledCollectionDeletions()` (cf. `AuditListener::captureCollectionChange`). Payload `{fieldName: {added: [ids], removed: [ids]}}`, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Platform — Lecture seule
|
|
||||||
|
|
||||||
### `AuditLogResource`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
|
||||||
|
|
||||||
**Operations** :
|
|
||||||
- `GET /api/audit-logs` — collection paginee (30 items/page), tri `performed_at DESC`
|
|
||||||
- `GET /api/audit-logs/{id}` — detail
|
|
||||||
|
|
||||||
**Securite** : `is_granted('core.audit_log.view')` — permission RBAC, 403 sinon
|
|
||||||
|
|
||||||
**Pas d'endpoints d'ecriture** : POST, PUT, PATCH, DELETE → 405
|
|
||||||
|
|
||||||
### `AuditLogOutput`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
|
||||||
|
|
||||||
DTO readonly avec les champs : `id`, `entityType`, `entityId`, `action`, `changes`, `performedBy`, `performedAt`, `ipAddress`, `requestId`.
|
|
||||||
|
|
||||||
### `AuditLogProvider`
|
|
||||||
|
|
||||||
**Emplacement** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
|
||||||
|
|
||||||
Provider DBAL (pas Doctrine ORM).
|
|
||||||
|
|
||||||
**Filtres** (query params, combinables en AND) :
|
|
||||||
- `entity_type` : filtre exact
|
|
||||||
- `entity_id` : filtre exact
|
|
||||||
- `action` : filtre exact
|
|
||||||
- `performed_by` : filtre exact
|
|
||||||
- `performed_at[after]` : date minimum (incluse)
|
|
||||||
- `performed_at[before]` : date maximum (incluse)
|
|
||||||
|
|
||||||
**Pagination** : via un `DbalPaginator` implementant `ApiPlatform\State\Pagination\PaginatorInterface` (`getCurrentPage()`, `getLastPage()`, `getTotalItems()`, `getItemsPerPage()`, `count()`, `getIterator()`). Le provider retourne ce paginator — API Platform genere automatiquement `hydra:view`. Pas de construction manuelle de la pagination.
|
|
||||||
|
|
||||||
### Permission RBAC
|
|
||||||
|
|
||||||
Ajouter dans `CoreModule::permissions()` :
|
|
||||||
- `core.audit_log.view`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### Composable `useAuditLog.ts`
|
|
||||||
|
|
||||||
**Emplacement** : `frontend/shared/composables/useAuditLog.ts`
|
|
||||||
|
|
||||||
Composable partage, reutilise par la page globale (ticket 4) et le composant timeline (ticket 5).
|
|
||||||
|
|
||||||
**Methodes** :
|
|
||||||
- `fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>>`
|
|
||||||
- `fetchLogById(id: string): Promise<AuditLogEntry>`
|
|
||||||
- `fetchEntityLogs(entityType: string, entityId: string, page?: number): Promise<HydraCollection<AuditLogEntry>>`
|
|
||||||
|
|
||||||
Utilise `useApi().get()`.
|
|
||||||
|
|
||||||
Si le composable maintient du state singleton (refs module-level pour cache), il doit exposer `resetAuditLog()` et etre reinitialise au logout (regle CLAUDE.md).
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
Ajouter dans `frontend/shared/types/index.ts` :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface AuditLogEntry {
|
|
||||||
id: string
|
|
||||||
entityType: string
|
|
||||||
entityId: string
|
|
||||||
action: 'create' | 'update' | 'delete'
|
|
||||||
changes: Record<string, unknown>
|
|
||||||
performedBy: string
|
|
||||||
performedAt: string
|
|
||||||
ipAddress: string | null
|
|
||||||
requestId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditLogFilters {
|
|
||||||
entityType?: string
|
|
||||||
entityId?: string
|
|
||||||
action?: string
|
|
||||||
performedBy?: string
|
|
||||||
performedAtAfter?: string
|
|
||||||
performedAtBefore?: string
|
|
||||||
page?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HydraView {
|
|
||||||
'hydra:first'?: string
|
|
||||||
'hydra:last'?: string
|
|
||||||
'hydra:next'?: string
|
|
||||||
'hydra:previous'?: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Le type `HydraView` doit etre ajoute dans `frontend/shared/utils/api.ts` (a cote de `HydraCollection`) et `HydraCollection` doit etre etendu avec un champ optionnel `'hydra:view'?: HydraView`.
|
|
||||||
|
|
||||||
### Page `admin/audit-log.vue`
|
|
||||||
|
|
||||||
**Emplacement** : `frontend/modules/core/pages/admin/audit-log.vue`
|
|
||||||
|
|
||||||
**Acces** : permission RBAC `core.audit_log.view` (verifie via `usePermissions().can('core.audit_log.view')`)
|
|
||||||
|
|
||||||
**Elements** :
|
|
||||||
- Tableau pagine avec style projet (header `bg-tertiary-500`, rows hover)
|
|
||||||
- Filtres : plage dates, type entite (select), utilisateur (input), action (checkboxes), bouton reset
|
|
||||||
- Filtres persistes dans les query params URL
|
|
||||||
- Ligne expandable au clic :
|
|
||||||
- update : tableau champ / ancienne valeur / nouvelle valeur
|
|
||||||
- create/delete : snapshot complet
|
|
||||||
- Badges action :
|
|
||||||
- create : `bg-green-100 text-green-800`
|
|
||||||
- update : `bg-yellow-100 text-yellow-800`
|
|
||||||
- delete : `bg-red-100 text-red-800`
|
|
||||||
- Pagination prev/next via `hydra:view`
|
|
||||||
- Etat vide : message i18n "Aucune activite enregistree"
|
|
||||||
- Chargement initial : 30 dernieres entrees sans filtre
|
|
||||||
|
|
||||||
### Sidebar
|
|
||||||
|
|
||||||
Ajouter entree dans `config/sidebar.php` :
|
|
||||||
- Label : `sidebar.core.audit_log`
|
|
||||||
- Route : `/admin/audit-log`
|
|
||||||
- Icon : a definir (ex: `mdi:clipboard-text-clock`)
|
|
||||||
- Module : `core`
|
|
||||||
- Permission : `core.audit_log.view` — filtre automatiquement cote SidebarProvider
|
|
||||||
|
|
||||||
### Composant `AuditTimeline.vue`
|
|
||||||
|
|
||||||
**Emplacement** : `frontend/shared/components/audit/AuditTimeline.vue`
|
|
||||||
|
|
||||||
Composant reutilisable, auto-importe par Nuxt.
|
|
||||||
|
|
||||||
**Props** :
|
|
||||||
- `entityType: string`
|
|
||||||
- `entityId: string | number`
|
|
||||||
|
|
||||||
**Comportement** :
|
|
||||||
- Garde permission : si `!usePermissions().can('core.audit_log.view')` → rendu vide, aucun appel API
|
|
||||||
- Timeline verticale : bordure gauche (`border-l-2 border-gray-200`) + dots colores par action
|
|
||||||
- Chaque entree : icone + date relative FR (`Intl.RelativeTimeFormat('fr')`) + date absolue en tooltip + utilisateur + resume
|
|
||||||
- Update : affiche old → new par champ
|
|
||||||
- Lazy loading : 10 items initiaux + bouton "Voir plus"
|
|
||||||
- Skeleton loader pendant le chargement
|
|
||||||
- Etat vide : "Aucun historique"
|
|
||||||
|
|
||||||
**Premiere integration** : sur la page `admin/audit-log.vue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## i18n
|
|
||||||
|
|
||||||
Cles a ajouter dans `frontend/i18n/locales/fr.json` :
|
|
||||||
|
|
||||||
Structure imbriquee (respecte le format existant de `fr.json`) :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sidebar": {
|
|
||||||
"core": {
|
|
||||||
"audit_log": "Journal d'audit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"action": {
|
|
||||||
"create": "Création",
|
|
||||||
"update": "Modification",
|
|
||||||
"delete": "Suppression"
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"user": "Utilisateur"
|
|
||||||
},
|
|
||||||
"empty": "Aucune activité enregistrée",
|
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
|
||||||
"timeline": {
|
|
||||||
"empty": "Aucun historique",
|
|
||||||
"load_more": "Voir plus"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"reset": "Réinitialiser",
|
|
||||||
"date_from": "Du",
|
|
||||||
"date_to": "Au",
|
|
||||||
"entity_type": "Type d'entité",
|
|
||||||
"user": "Utilisateur",
|
|
||||||
"action": "Action"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"field": "Champ",
|
|
||||||
"old_value": "Ancienne valeur",
|
|
||||||
"new_value": "Nouvelle valeur"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ordre d'implementation
|
|
||||||
|
|
||||||
```
|
|
||||||
Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──► Ticket 4
|
|
||||||
Table + Attributs + API │ Page admin
|
|
||||||
Writer Listener read-only │
|
|
||||||
└──► Ticket 5
|
|
||||||
Timeline
|
|
||||||
(4 et 5 en parallele)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions techniques (issues reviews)
|
|
||||||
|
|
||||||
- **Connexion DBAL dediee** : `AuditLogWriter` utilise une connexion separee `audit` (meme DSN) pour eviter l'entanglement transactionnel avec l'ORM en batch
|
|
||||||
- **PaginatorInterface** : le provider retourne un `DbalPaginator` implementant l'interface API Platform — pas de construction manuelle `hydra:view`
|
|
||||||
- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
|
|
||||||
- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant
|
|
||||||
- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]`
|
|
||||||
- **Collections to-many auditees** : tracees via `getScheduledCollectionUpdates` / `getScheduledCollectionDeletions`, payload `{added, removed}` merge dans le changeset de l'entite proprietaire (cf. `AuditListener::captureCollectionChange`)
|
|
||||||
- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
|
|
||||||
- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom
|
|
||||||
|
|
||||||
## Dependances externes
|
|
||||||
|
|
||||||
- `symfony/uid` : generation UUID v7 (id) et v4 (request_id)
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Deploiement Docker — Starseed
|
# Deploiement Docker — Coltura
|
||||||
|
|
||||||
## Pre-requis
|
## Pre-requis
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ sudo systemctl start nginx
|
|||||||
### PostgreSQL
|
### PostgreSQL
|
||||||
|
|
||||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||||
Il doit etre installe et accessible avant de deployer Starseed.
|
Il doit etre installe et accessible avant de deployer Coltura.
|
||||||
|
|
||||||
Creer la base de donnees pour Starseed :
|
Creer la base de donnees pour Coltura :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/postgres
|
cd /var/www/postgres
|
||||||
@@ -43,7 +43,7 @@ docker compose exec postgres psql -U admin
|
|||||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||||
|
|
||||||
-- Creer la base
|
-- Creer la base
|
||||||
CREATE DATABASE starseed_prod OWNER malio;
|
CREATE DATABASE coltura_prod OWNER malio;
|
||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ CREATE DATABASE starseed_prod OWNER malio;
|
|||||||
|
|
||||||
## Premiere installation (nouvelle machine)
|
## Premiere installation (nouvelle machine)
|
||||||
|
|
||||||
Guide complet pour mettre en ligne Starseed sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
Guide complet pour mettre en ligne Coltura sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||||
|
|
||||||
### 1. Installer les pre-requis
|
### 1. Installer les pre-requis
|
||||||
|
|
||||||
@@ -60,9 +60,9 @@ Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
|||||||
### 2. Creer le dossier de deploiement
|
### 2. Creer le dossier de deploiement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /var/www/starseed
|
sudo mkdir -p /var/www/coltura
|
||||||
sudo chown -R $(whoami):$(whoami) /var/www/starseed
|
sudo chown -R $(whoami):$(whoami) /var/www/coltura
|
||||||
cd /var/www/starseed
|
cd /var/www/coltura
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Se connecter au registry Docker de Gitea
|
### 3. Se connecter au registry Docker de Gitea
|
||||||
@@ -83,8 +83,8 @@ Creer `docker-compose.yml` :
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: gitea.malio.fr/malio-dev/starseed:${STARSEED_IMAGE_TAG:-latest}
|
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
||||||
container_name: starseed-app
|
container_name: coltura-app
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8083:80"
|
- "8083:80"
|
||||||
@@ -105,9 +105,9 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
export STARSEED_IMAGE_TAG="$TAG"
|
export COLTURA_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
echo "==> Deploying starseed:${TAG}..."
|
echo "==> Deploying coltura:${TAG}..."
|
||||||
|
|
||||||
echo "==> Pulling image..."
|
echo "==> Pulling image..."
|
||||||
docker compose pull
|
docker compose pull
|
||||||
@@ -146,22 +146,22 @@ APP_DEBUG=0
|
|||||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||||
|
|
||||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/coltura_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||||
JWT_COOKIE_SECURE=0
|
JWT_COOKIE_SECURE=1
|
||||||
JWT_COOKIE_SAMESITE=lax
|
JWT_COOKIE_SAMESITE=lax
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
|
||||||
|
|
||||||
# App
|
# App
|
||||||
DEFAULT_URI=http://starseed.malio-dev.fr
|
DEFAULT_URI=https://coltura.malio-dev.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Generer les cles JWT
|
### 6. Generer les cles JWT
|
||||||
@@ -190,17 +190,17 @@ mkdir -p uploads
|
|||||||
Copier la config reverse proxy depuis le repo :
|
Copier la config reverse proxy depuis le repo :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/coltura.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou creer `/etc/nginx/sites-available/starseed.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
Ou creer `/etc/nginx/sites-available/coltura.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
||||||
|
|
||||||
La config inclut le **mode maintenance** : si le fichier `/var/www/starseed/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
La config inclut le **mode maintenance** : si le fichier `/var/www/coltura/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
||||||
|
|
||||||
Activer le site :
|
Activer le site :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -208,13 +208,13 @@ sudo nginx -t && sudo systemctl reload nginx
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activer la maintenance
|
# Activer la maintenance
|
||||||
touch /var/www/starseed/maintenance.on
|
touch /var/www/coltura/maintenance.on
|
||||||
|
|
||||||
# Desactiver la maintenance
|
# Desactiver la maintenance
|
||||||
rm /var/www/starseed/maintenance.on
|
rm /var/www/coltura/maintenance.on
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionnel : creer une page `/var/www/starseed/public/maintenance.html` personnalisee.
|
Optionnel : creer une page `/var/www/coltura/public/maintenance.html` personnalisee.
|
||||||
|
|
||||||
### 9. Deployer
|
### 9. Deployer
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/postgres
|
cd /var/www/postgres
|
||||||
docker compose exec -T postgres psql -U malio starseed_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
docker compose exec -T postgres psql -U malio coltura_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou charger les fixtures (dev uniquement) :
|
Ou charger les fixtures (dev uniquement) :
|
||||||
@@ -244,7 +244,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
|||||||
### Structure finale du dossier
|
### Structure finale du dossier
|
||||||
|
|
||||||
```
|
```
|
||||||
/var/www/starseed/
|
/var/www/coltura/
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── deploy.sh
|
├── deploy.sh
|
||||||
├── .env
|
├── .env
|
||||||
@@ -261,7 +261,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
|||||||
Quand l'app est deja installee, deployer une mise a jour :
|
Quand l'app est deja installee, deployer une mise a jour :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/starseed
|
cd /var/www/coltura
|
||||||
./deploy.sh # deploie la derniere version (latest)
|
./deploy.sh # deploie la derniere version (latest)
|
||||||
./deploy.sh v0.2.0 # deploie une version specifique
|
./deploy.sh v0.2.0 # deploie une version specifique
|
||||||
```
|
```
|
||||||
@@ -293,7 +293,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:migrations:migra
|
|||||||
|
|
||||||
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||||
1. Build l'image multi-stage
|
1. Build l'image multi-stage
|
||||||
2. Push vers `gitea.malio.fr/malio-dev/starseed:<tag>` et `:latest`
|
2. Push vers `gitea.malio.fr/malio-dev/coltura:<tag>` et `:latest`
|
||||||
|
|
||||||
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquem
|
|||||||
## Voir les logs
|
## Voir les logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/starseed
|
cd /var/www/coltura
|
||||||
docker compose logs -f # tous les logs
|
docker compose logs -f # tous les logs
|
||||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
# Prompt — Migration prod Coltura -> Starseed
|
|
||||||
|
|
||||||
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
|
|
||||||
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
|
|
||||||
- la resolution reseau local (DNS interne ou `/etc/hosts` des postes clients) pour `starseed.malio-dev.fr` est en place.
|
|
||||||
|
|
||||||
> Setup : HTTP en reseau local, pas de TLS. Pas de Let's Encrypt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt a fournir au Claude prod
|
|
||||||
|
|
||||||
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
|
|
||||||
|
|
||||||
L'app est servie en **HTTP sur reseau local** (pas de TLS, pas de Let's Encrypt). La resolution `starseed.malio-dev.fr` est faite via DNS interne ou `/etc/hosts` cote postes clients — pas de certificat a gerer.
|
|
||||||
|
|
||||||
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
|
|
||||||
|
|
||||||
**Etat actuel a verifier en premier** (donne-moi le retour de chaque commande avant de continuer) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Container actuel + image
|
|
||||||
sudo docker ps --filter name=coltura-app --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
|
||||||
|
|
||||||
# 2. DB existante
|
|
||||||
sudo -u postgres psql -c "\l" | grep -E "coltura|starseed"
|
|
||||||
|
|
||||||
# 3. Path FS app
|
|
||||||
ls -la /var/www/coltura/ 2>/dev/null | head -5
|
|
||||||
ls -la /var/www/starseed/ 2>/dev/null | head -5
|
|
||||||
|
|
||||||
# 4. Vhost nginx system
|
|
||||||
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
|
|
||||||
|
|
||||||
### Etape 1 — Mode maintenance
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/coltura
|
|
||||||
touch maintenance.on
|
|
||||||
# Verifier qu'une requete renvoie 503
|
|
||||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://coltura.malio-dev.fr/
|
|
||||||
```
|
|
||||||
|
|
||||||
Doit renvoyer `503`.
|
|
||||||
|
|
||||||
### Etape 2 — Backup DB (CRITIQUE — ne pas skipper)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BACKUP_FILE="/root/coltura_prod_backup_$(date +%Y%m%d_%H%M%S).sql"
|
|
||||||
sudo -u postgres pg_dump -F c -f "$BACKUP_FILE" coltura_prod
|
|
||||||
ls -lh "$BACKUP_FILE"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stocker ce chemin** — il sera utilise pour le rollback.
|
|
||||||
|
|
||||||
### Etape 3 — Creer la DB cible et migrer
|
|
||||||
|
|
||||||
Recuperer l'owner et le user de connexion actuels :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u postgres psql -c "\l coltura_prod"
|
|
||||||
grep DATABASE_URL /var/www/coltura/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis (adapter l'owner si different de `malio`) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u postgres psql <<'SQL'
|
|
||||||
CREATE DATABASE starseed_prod OWNER malio;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
sudo -u postgres pg_dump coltura_prod | sudo -u postgres psql starseed_prod
|
|
||||||
sudo -u postgres psql starseed_prod -c "\dt" | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Verifier que les tables sont bien copiees. Si le user PG s'appelle `coltura`, le renommer ou en creer un `starseed` est OPTIONNEL — la connexion peut continuer avec `coltura` tant que `GRANT` est OK. **Confirmer avec l'utilisateur** s'il veut renommer le role PG :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Optionnel : renommer le role PG (si user de connexion s'appelle 'coltura')
|
|
||||||
# sudo -u postgres psql -c "ALTER ROLE coltura RENAME TO starseed;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 4 — Renommer le path FS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mv /var/www/coltura /var/www/starseed
|
|
||||||
# Verifier le contenu
|
|
||||||
sudo ls -la /var/www/starseed/ | head -10
|
|
||||||
# Verifier que .env existe encore
|
|
||||||
sudo test -f /var/www/starseed/.env && echo ".env OK"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 5 — Mettre a jour .env de prod
|
|
||||||
|
|
||||||
Editer `/var/www/starseed/.env` :
|
|
||||||
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
|
|
||||||
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
|
|
||||||
- `DEFAULT_URI` : `http://starseed.malio-dev.fr`
|
|
||||||
- `JWT_COOKIE_SECURE` : doit etre `0` (HTTP, pas de TLS) — verifier qu'il l'est deja
|
|
||||||
|
|
||||||
Diff attendu :
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
|
|
||||||
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
|
|
||||||
- CORS_ALLOW_ORIGIN='^http://coltura\.malio-dev\.fr$'
|
|
||||||
+ CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
|
||||||
- DEFAULT_URI=http://coltura.malio-dev.fr
|
|
||||||
+ DEFAULT_URI=http://starseed.malio-dev.fr
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 6 — Stopper et supprimer l'ancien container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/starseed
|
|
||||||
sudo docker compose down
|
|
||||||
# Verifier qu'il n'y a plus de coltura-app
|
|
||||||
sudo docker ps -a --filter name=coltura
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 7 — Pull la nouvelle image et demarrer
|
|
||||||
|
|
||||||
Le `docker-compose.prod.yml` du dossier deja a jour pointe sur `gitea.malio.fr/malio-dev/starseed:latest` et `container_name: starseed-app`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/starseed
|
|
||||||
sudo docker compose pull
|
|
||||||
sudo docker compose up -d
|
|
||||||
sleep 5
|
|
||||||
sudo docker ps --filter name=starseed-app
|
|
||||||
sudo docker logs starseed-app --tail 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 8 — Migrations Doctrine + cache
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/starseed
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 9 — Vhost nginx system (HTTP only)
|
|
||||||
|
|
||||||
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`, `listen 80` uniquement) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
|
||||||
sudo rm -f /etc/nginx/sites-enabled/coltura.conf
|
|
||||||
sudo nginx -t
|
|
||||||
```
|
|
||||||
|
|
||||||
Verifier la resolution reseau local avant reload :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
getent hosts starseed.malio-dev.fr || echo "ATTENTION : starseed.malio-dev.fr ne resout pas localement"
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etape 10 — Desactiver le mode maintenance et tester
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -f /var/www/starseed/maintenance.on
|
|
||||||
|
|
||||||
# Tests externes (HTTP local)
|
|
||||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://starseed.malio-dev.fr/
|
|
||||||
curl -s http://starseed.malio-dev.fr/api/version
|
|
||||||
```
|
|
||||||
|
|
||||||
`/api/version` doit renvoyer du JSON avec la version courante.
|
|
||||||
|
|
||||||
### Etape 11 — Cleanup (apres 24-48h de stabilite)
|
|
||||||
|
|
||||||
A faire **plus tard**, seulement quand on est sur que tout marche :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup deja conserve en /root/coltura_prod_backup_*.sql.
|
|
||||||
# Apres validation utilisateur :
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE coltura_prod;"
|
|
||||||
sudo rm -f /etc/nginx/sites-available/coltura.conf
|
|
||||||
sudo docker image prune # nettoie les vieilles images coltura
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback (si echec apres etape 5)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Remettre maintenance
|
|
||||||
touch /var/www/starseed/maintenance.on 2>/dev/null || touch /var/www/coltura/maintenance.on
|
|
||||||
|
|
||||||
# 2. Restaurer le path FS
|
|
||||||
sudo mv /var/www/starseed /var/www/coltura 2>/dev/null || true
|
|
||||||
|
|
||||||
# 3. Restaurer le vhost coltura
|
|
||||||
sudo rm -f /etc/nginx/sites-enabled/starseed.conf
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
|
|
||||||
# 4. Redemarrer l'ancien container (l'image coltura est encore dans le registry)
|
|
||||||
cd /var/www/coltura
|
|
||||||
# Editer docker-compose.prod.yml pour pointer sur coltura:latest si necessaire
|
|
||||||
sudo docker compose up -d
|
|
||||||
|
|
||||||
# 5. Si la DB starseed_prod a ete modifiee, restaurer depuis le backup
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS coltura_prod;"
|
|
||||||
sudo -u postgres pg_restore -C -d postgres "$BACKUP_FILE"
|
|
||||||
|
|
||||||
# 6. Lever maintenance
|
|
||||||
rm -f /var/www/coltura/maintenance.on
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Regles de comportement pour le Claude prod
|
|
||||||
|
|
||||||
- **Ne jamais skipper le backup** (etape 2).
|
|
||||||
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, et avant de lever le mode maintenance final.
|
|
||||||
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
|
|
||||||
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, nginx -t / reload).
|
|
||||||
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
|
|
||||||
- **Ne commit rien** sur le repo depuis le serveur prod.
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## Résumé de la PR
|
## Résumé de la PR
|
||||||
|
|
||||||
Cette PR restructure Starseed (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
Cette PR restructure Coltura (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
||||||
|
|
||||||
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
|
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
|
||||||
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
||||||
@@ -36,9 +36,9 @@ Cette PR restructure Starseed (CRM/ERP) en **architecture modulaire DDD** (Domai
|
|||||||
Liste des évolutions du projet Ferme
|
Liste des évolutions du projet Ferme
|
||||||
```
|
```
|
||||||
|
|
||||||
Ce fichier appartient à **Starseed**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
Ce fichier appartient à **Coltura**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
||||||
|
|
||||||
**Correction** : Remplacer "Ferme" par "Starseed".
|
**Correction** : Remplacer "Ferme" par "Coltura".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,10 +76,10 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Sévérité** | Majeure |
|
| **Sévérité** | Majeure |
|
||||||
| **Fichier** | `infra/dev/.env.docker` |
|
| **Fichier** | `infra/dev/.env.docker` |
|
||||||
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / 3003 / **5436**" |
|
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / 3003 / **5436**" |
|
||||||
| **Confiance** | 75/100 |
|
| **Confiance** | 75/100 |
|
||||||
|
|
||||||
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Starseed est `5436`.
|
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Coltura est `5436`.
|
||||||
|
|
||||||
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
|
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Sévérité** | Majeure |
|
| **Sévérité** | Majeure |
|
||||||
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
||||||
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
||||||
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
||||||
|
|
||||||
**Constat** :
|
**Constat** :
|
||||||
|
|||||||
@@ -41,14 +41,11 @@ services:
|
|||||||
- "8083:80"
|
- "8083:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/coltura.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
# max_connections eleve (defaut PG=100) pour absorber la suite de tests :
|
command: -p ${POSTGRES_PORT:-5436}
|
||||||
# ~220 tests * kernel reboot par test * 2 connexions (default + audit)
|
|
||||||
# peut saturer le pool, meme avec idle_connection_ttl court cote Doctrine.
|
|
||||||
command: -p ${POSTGRES_PORT:-5436} -c max_connections=300
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
|
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
|
||||||
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
|
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
|
||||||
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
|
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
|
||||||
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
||||||
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
|
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
|
||||||
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
|
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
|
||||||
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
|
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
|
||||||
@@ -31,30 +31,30 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
|
|
||||||
### Domaine - Entités
|
### Domaine - Entités
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
||||||
|
|
||||||
### Domaine - Repositories
|
### Domaine - Repositories
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
||||||
|
|
||||||
### Domaine - Exceptions
|
### Domaine - Exceptions
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
||||||
|
|
||||||
### Infrastructure - Doctrine
|
### Infrastructure - Doctrine
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
||||||
|
|
||||||
### Infrastructure - Doctrine Migrations
|
### Infrastructure - Doctrine Migrations
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
||||||
|
|
||||||
### Infrastructure - Console
|
### Infrastructure - Console
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
||||||
|
|
||||||
### Infrastructure - DataFixtures
|
### Infrastructure - DataFixtures
|
||||||
|
|
||||||
@@ -62,12 +62,12 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
|
|
||||||
### Constantes domaine
|
### Constantes domaine
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
||||||
```php
|
```php
|
||||||
public static function permissions(): array
|
public static function permissions(): array
|
||||||
{
|
{
|
||||||
@@ -80,17 +80,17 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
|
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
||||||
- `/home/matthieu/dev_malio/Starseed/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
- `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
||||||
```yaml
|
```yaml
|
||||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||||||
```
|
```
|
||||||
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
|
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
|
||||||
- `/home/matthieu/dev_malio/Starseed/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
- `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
||||||
|
|
||||||
## 5. Schéma cible — mappings Doctrine
|
## 5. Schéma cible — mappings Doctrine
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ Etat final attendu :
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration doit etre implementée dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
||||||
|
|
||||||
**Workflow recommande** :
|
**Workflow recommande** :
|
||||||
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
||||||
@@ -287,7 +287,7 @@ Cas couverts explicitement :
|
|||||||
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
|
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it db-starseed psql -U malio -d starseed -c '\d "user"'
|
docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"'
|
||||||
```
|
```
|
||||||
|
|
||||||
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
|
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
|
||||||
@@ -306,11 +306,11 @@ Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable
|
|||||||
|
|
||||||
## 7. Algorithme sync-permissions
|
## 7. Algorithme sync-permissions
|
||||||
|
|
||||||
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
||||||
|
|
||||||
### Source de verite
|
### Source de verite
|
||||||
|
|
||||||
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Starseed/config/modules.php`.
|
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`.
|
||||||
- Chaque classe module active peut exposer `public static function permissions(): array`.
|
- Chaque classe module active peut exposer `public static function permissions(): array`.
|
||||||
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ Garde anti-typo : le sync command verifie que chaque `code` commence obligatoire
|
|||||||
```text
|
```text
|
||||||
begin transaction
|
begin transaction
|
||||||
|
|
||||||
load active module classes from /home/matthieu/dev_malio/Starseed/config/modules.php
|
load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php
|
||||||
desired_permissions = empty map keyed by code
|
desired_permissions = empty map keyed by code
|
||||||
|
|
||||||
for each module class:
|
for each module class:
|
||||||
@@ -439,7 +439,7 @@ Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declarat
|
|||||||
|
|
||||||
## 9. Fixtures mises à jour
|
## 9. Fixtures mises à jour
|
||||||
|
|
||||||
Le fichier cible reste `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
||||||
|
|
||||||
### Principe cle : decouplage via `is_admin`
|
### Principe cle : decouplage via `is_admin`
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
|
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
|
||||||
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
|
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
|
||||||
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
|
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
|
||||||
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
||||||
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
|
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
|
||||||
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
|
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
|
||||||
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
|
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
|
||||||
@@ -533,12 +533,12 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
|
|
||||||
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
||||||
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
||||||
3. Faire evoluer `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
||||||
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
|
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
|
||||||
5. Ajouter la commande `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
||||||
6. Ecrire la migration `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
||||||
7. Mettre a jour `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
||||||
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Starseed/config/services.yaml`.
|
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`.
|
||||||
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
||||||
|
|
||||||
## 13. Critères d'acceptation (DoD)
|
## 13. Critères d'acceptation (DoD)
|
||||||
@@ -553,4 +553,4 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
||||||
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
||||||
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
||||||
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Starseed/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||||
|
|||||||
@@ -38,28 +38,28 @@ Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verifi
|
|||||||
|
|
||||||
### Infrastructure - Processors
|
### Infrastructure - Processors
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||||||
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||||
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
||||||
|
|
||||||
### Tests unitaires
|
### Tests unitaires
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||||
|
|
||||||
### Tests fonctionnels
|
### Tests fonctionnels
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/PermissionApiTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/RoleApiTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserRbacApiTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||||
|
|
||||||
## 4. Fichiers a modifier
|
## 4. Fichiers a modifier
|
||||||
|
|
||||||
### Entite `Permission`
|
### Entite `Permission`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||||
|
|
||||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
||||||
- Normalization context : groupe `permission:read` uniquement.
|
- Normalization context : groupe `permission:read` uniquement.
|
||||||
@@ -89,7 +89,7 @@ Extrait attendu :
|
|||||||
|
|
||||||
### Entite `Role`
|
### Entite `Role`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||||
|
|
||||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||||
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
||||||
@@ -107,7 +107,7 @@ Extrait attendu :
|
|||||||
|
|
||||||
### Entite `User`
|
### Entite `User`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||||
|
|
||||||
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
||||||
|
|
||||||
|
|||||||
@@ -39,50 +39,50 @@ A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applic
|
|||||||
|
|
||||||
### Domaine - Securite
|
### Domaine - Securite
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
||||||
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
||||||
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
||||||
|
|
||||||
### Infrastructure - Security
|
### Infrastructure - Security
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||||||
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
||||||
|
|
||||||
### Infrastructure - Processors
|
### Infrastructure - Processors
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
||||||
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
||||||
|
|
||||||
### Frontend - Composable
|
### Frontend - Composable
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/usePermissions.ts`
|
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.ts`
|
||||||
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
||||||
|
|
||||||
### Tests unitaires PHP
|
### Tests unitaires PHP
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||||
|
|
||||||
### Tests fonctionnels PHP
|
### Tests fonctionnels PHP
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||||
Couvre l'enrichissement du payload `/api/me`.
|
Couvre l'enrichissement du payload `/api/me`.
|
||||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
||||||
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
||||||
|
|
||||||
### Tests frontend
|
### Tests frontend
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
||||||
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
||||||
|
|
||||||
## 4. Fichiers a modifier
|
## 4. Fichiers a modifier
|
||||||
|
|
||||||
### `CoreModule.php`
|
### `CoreModule.php`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php`
|
||||||
|
|
||||||
Ajouter une cinquieme entree au catalogue :
|
Ajouter une cinquieme entree au catalogue :
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la
|
|||||||
|
|
||||||
### Entite `Permission`
|
### Entite `Permission`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||||
|
|
||||||
Remplacer les 2 gardes placeholder :
|
Remplacer les 2 gardes placeholder :
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
|||||||
|
|
||||||
### Entite `Role`
|
### Entite `Role`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||||
|
|
||||||
Remplacer les 5 gardes placeholder :
|
Remplacer les 5 gardes placeholder :
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
|||||||
|
|
||||||
### Entite `User`
|
### Entite `User`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||||
|
|
||||||
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
|||||||
|
|
||||||
### `UserRepositoryInterface`
|
### `UserRepositoryInterface`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||||
|
|
||||||
Ajouter la methode :
|
Ajouter la methode :
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ public function countAdmins(): int;
|
|||||||
|
|
||||||
### `DoctrineUserRepository`
|
### `DoctrineUserRepository`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||||
|
|
||||||
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ public function countAdmins(): int
|
|||||||
|
|
||||||
### `UserRbacProcessor`
|
### `UserRbacProcessor`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||||
|
|
||||||
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
||||||
|
|
||||||
@@ -572,78 +572,3 @@ Chaque etape doit etre revue (spec compliance + code quality) avant de passer a
|
|||||||
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
||||||
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
||||||
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
||||||
|
|
||||||
## 18. Evolutions post-livraison — `UserRbacProcessor` defense in depth
|
|
||||||
|
|
||||||
Voir aussi : `docs/sites/ticket-02-spec.md` § 10 pour la problematique cote
|
|
||||||
Sites qui a motive cette evolution.
|
|
||||||
|
|
||||||
### 18.1 — Semantique `merge-patch+json` respectee
|
|
||||||
|
|
||||||
Le processor originel appliquait telles quelles les mutations produites par la
|
|
||||||
denormalisation API Platform. Or API Platform reinstancie par defaut une
|
|
||||||
`ArrayCollection` vide pour chaque propriete ManyToMany absente du payload,
|
|
||||||
ce qui viole la semantique `application/merge-patch+json` : les cles absentes
|
|
||||||
ne doivent PAS muter les proprietes correspondantes.
|
|
||||||
|
|
||||||
Consequence concrete du bug : un PATCH minimal comme `{ "isAdmin": true }`
|
|
||||||
detruisait silencieusement toutes les collections (`rbacRoles`,
|
|
||||||
`directPermissions`, `sites`) du user cible.
|
|
||||||
|
|
||||||
La garde `restoreAbsentCollections()` introduite dans `UserRbacProcessor`
|
|
||||||
resout cela en :
|
|
||||||
|
|
||||||
1. Injectant `RequestStack` pour lire le body JSON brut de la requete.
|
|
||||||
2. Decodant les cles effectivement envoyees par le client.
|
|
||||||
3. Pour chaque cle RBAC (`roles`, `directPermissions`, `sites`) absente du
|
|
||||||
payload : restaurant la collection a son etat d'origine a partir du
|
|
||||||
snapshot Doctrine (`PersistentCollection::getSnapshot()`), puis appelant
|
|
||||||
`takeSnapshot()` pour marquer la collection comme non-dirty (aucune query
|
|
||||||
`UPDATE` n'est emise sur les tables de jointure).
|
|
||||||
4. No-op si la cle est presente (la denormalisation fait foi).
|
|
||||||
|
|
||||||
Matrice finale :
|
|
||||||
|
|
||||||
| Payload | Effet |
|
|
||||||
|---------------------------------|-------------------------------------|
|
|
||||||
| Cle absente | Propriete preservee (BDD inchangee) |
|
|
||||||
| Cle presente = `[]` | Collection videe (vidage explicite) |
|
|
||||||
| Cle presente = `[...]` | Collection remplacee |
|
|
||||||
|
|
||||||
### 18.2 — Nouvelle operation `GET /users/{id}/rbac`
|
|
||||||
|
|
||||||
Le drawer d'edition (`UserRbacDrawer.vue`) ne peut plus dependre du payload
|
|
||||||
de liste `/api/users` (groupe `user:list`) pour initialiser l'etat `sites`
|
|
||||||
car ce groupe reste volontairement leger (cf. ticket Sites #02). Une
|
|
||||||
operation `Get` dediee est ajoutee, symetrique au `Patch` existant :
|
|
||||||
|
|
||||||
- URI : `/users/{id}/rbac`
|
|
||||||
- Security : `is_granted('core.users.manage')` (plus strict que `.view`)
|
|
||||||
- Groupe : `user:rbac:read` (contient `isAdmin`, `roles`, `directPermissions`,
|
|
||||||
`sites`).
|
|
||||||
|
|
||||||
Le drawer charge desormais ce GET en parallele des referentiels au moment
|
|
||||||
de l'ouverture, via un watch combine `[modelValue, user.id]` qui recharge
|
|
||||||
correctement si le user change sans fermeture du drawer entre-temps.
|
|
||||||
|
|
||||||
### 18.3 — Impact sur les tests
|
|
||||||
|
|
||||||
`UserRbacProcessorTest` : le constructor gagne un argument `RequestStack`.
|
|
||||||
Les tests existants injectent une `RequestStack` avec une `Request` vide
|
|
||||||
(body `""`), ce qui rend la garde no-op — le comportement des assertions
|
|
||||||
existantes est conserve. De nouveaux tests couvrent la garde :
|
|
||||||
|
|
||||||
- PATCH sans cle `sites` ne mute pas la collection d'origine.
|
|
||||||
- PATCH avec `sites: []` vide bien la collection (pas de regression du cas
|
|
||||||
"vidage explicite").
|
|
||||||
- PATCH avec `sites: [...]` remplace comme avant.
|
|
||||||
|
|
||||||
### 18.4 — Criteres de validation additionnels
|
|
||||||
|
|
||||||
- [ ] `GET /users/{id}/rbac` retourne 200 avec `core.users.manage`, 403 sans.
|
|
||||||
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
|
|
||||||
- [ ] `PATCH /users/{id}/rbac` avec cle absente preserve la collection BDD.
|
|
||||||
- [ ] `PATCH /users/{id}/rbac` avec `[]` vide la collection et declenche
|
|
||||||
`ensureCurrentSiteConsistency` (cas sites).
|
|
||||||
- [ ] Les 228 tests PHPUnit existants passent apres ajout du parametre
|
|
||||||
`RequestStack` au constructor.
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
|
|
||||||
### IN
|
### IN
|
||||||
|
|
||||||
- Creer le module `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
- Creer le module `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
||||||
- Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity).
|
- Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity).
|
||||||
- Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2.
|
- Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2.
|
||||||
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
||||||
- Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive.
|
- Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive.
|
||||||
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Starseed/config/modules.php` pour l'activer par defaut.
|
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Coltura/config/modules.php` pour l'activer par defaut.
|
||||||
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
||||||
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Starseed/config/services.yaml`.
|
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Coltura/config/services.yaml`.
|
||||||
- Ajouter deux suites de tests PHPUnit :
|
- Ajouter deux suites de tests PHPUnit :
|
||||||
- `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`).
|
- `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`).
|
||||||
- `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine.
|
- `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine.
|
||||||
@@ -25,7 +25,7 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
### OUT
|
### OUT
|
||||||
|
|
||||||
- Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site.
|
- Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site.
|
||||||
- Ticket `#03` : integration dans la navbar Starseed (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
- Ticket `#03` : integration dans la navbar Coltura (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
||||||
- Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC).
|
- Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC).
|
||||||
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
||||||
- Rattachement historique ou audit trail des modifications : hors scope.
|
- Rattachement historique ou audit trail des modifications : hors scope.
|
||||||
@@ -34,38 +34,38 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
|
|
||||||
### Domaine — Entité
|
### Domaine — Entité
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
||||||
|
|
||||||
### Domaine — Repository
|
### Domaine — Repository
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
||||||
|
|
||||||
### Infrastructure — Doctrine
|
### Infrastructure — Doctrine
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
||||||
|
|
||||||
### Infrastructure — Migration
|
### Infrastructure — Migration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
||||||
|
|
||||||
### Infrastructure — DataFixtures
|
### Infrastructure — DataFixtures
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
||||||
|
|
||||||
### Module — Declaration
|
### Module — Declaration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
- `/home/m-tristan/workspace/Coltura/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
||||||
- `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
- `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
||||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
||||||
|
|
||||||
## 5. Schéma cible — mapping Doctrine
|
## 5. Schéma cible — mapping Doctrine
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ Sites:
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
||||||
|
|
||||||
### `up()` — ordre des instructions
|
### `up()` — ordre des instructions
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ Trois sites de demonstration, avec des couleurs distinctes suffisamment contrast
|
|||||||
|
|
||||||
| Nom | Ville | CP | Couleur | Commentaire |
|
| Nom | Ville | CP | Couleur | Commentaire |
|
||||||
|-----|-------|-----|---------|-------------|
|
|-----|-------|-----|---------|-------------|
|
||||||
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Starseed). |
|
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Coltura). |
|
||||||
| Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). |
|
| Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). |
|
||||||
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
||||||
|
|
||||||
|
|||||||
@@ -40,70 +40,70 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
|||||||
|
|
||||||
### Backend — Module Sites
|
### Backend — Module Sites
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
||||||
|
|
||||||
### Backend — Migration
|
### Backend — Migration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
||||||
|
|
||||||
### Backend — Tests API
|
### Backend — Tests API
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
||||||
|
|
||||||
### Frontend — Module Sites (nouveau layer)
|
### Frontend — Module Sites (nouveau layer)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
||||||
|
|
||||||
### Frontend — Types partages
|
### Frontend — Types partages
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
||||||
|
|
||||||
### Tests frontend (optionnels mais recommandes)
|
### Tests frontend (optionnels mais recommandes)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
### Backend — Module Core
|
### Backend — Module Core
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Domain/Entity/User.php` :
|
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Domain/Entity/User.php` :
|
||||||
- Ajouter `private Collection $sites;` (M2M, `fetch: EAGER`, `JoinTable: user_site`), groupes `me:read`, `user:list`, `user:rbac:read`, `user:rbac:write`.
|
- Ajouter `private Collection $sites;` (M2M, `fetch: EAGER`, `JoinTable: user_site`), groupes `me:read`, `user:list`, `user:rbac:read`, `user:rbac:write`.
|
||||||
- Ajouter `private ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
- Ajouter `private ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
||||||
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
||||||
- Ajouter les accesseurs `getSites()`, `addSite(Site)`, `removeSite(Site)`, `hasSite(Site)`, `getCurrentSite()`, `setCurrentSite(?Site)`.
|
- Ajouter les accesseurs `getSites()`, `addSite(Site)`, `removeSite(Site)`, `hasSite(Site)`, `getCurrentSite()`, `setCurrentSite(?Site)`.
|
||||||
- **Important** : `import` direct `App\Module\Sites\Domain\Entity\Site`. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
|
- **Important** : `import` direct `App\Module\Sites\Domain\Entity\Site`. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
||||||
- Etendre le contrat d'entree pour accepter le champ `sites` (collection d'IRIs denormalisees en `Collection<Site>`).
|
- Etendre le contrat d'entree pour accepter le champ `sites` (collection d'IRIs denormalisees en `Collection<Site>`).
|
||||||
- Apres l'application des roles et permissions directes, detecter si `currentSite` du user cible n'est plus dans la nouvelle collection `sites` → basculer `currentSite` a `null`.
|
- Apres l'application des roles et permissions directes, detecter si `currentSite` du user cible n'est plus dans la nouvelle collection `sites` → basculer `currentSite` a `null`.
|
||||||
- Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
|
- Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
||||||
- Declarer l'implementation `DependentFixtureInterface` avec `getDependencies(): [SitesFixtures::class]` (inversion de l'ordre actuel : AppFixtures doit tourner **apres** SitesFixtures pour pouvoir reference les sites).
|
- Declarer l'implementation `DependentFixtureInterface` avec `getDependencies(): [SitesFixtures::class]` (inversion de l'ordre actuel : AppFixtures doit tourner **apres** SitesFixtures pour pouvoir reference les sites).
|
||||||
- Rattacher chaque user a au moins un site : `admin` a tous les sites (`Chatellerault`, `Saint-Jean`, `Pommevic`), `alice` a `Chatellerault`, `bob` a `Saint-Jean`.
|
- Rattacher chaque user a au moins un site : `admin` a tous les sites (`Chatellerault`, `Saint-Jean`, `Pommevic`), `alice` a `Chatellerault`, `bob` a `Saint-Jean`.
|
||||||
- Positionner `currentSite` : `admin.currentSite = Chatellerault`, `alice.currentSite = Chatellerault`, `bob.currentSite = Saint-Jean`.
|
- Positionner `currentSite` : `admin.currentSite = Chatellerault`, `alice.currentSite = Chatellerault`, `bob.currentSite = Saint-Jean`.
|
||||||
|
|
||||||
### Backend — Module Sites
|
### Backend — Module Sites
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` :
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` :
|
||||||
- Ajouter les attributs `#[ApiResource]` + operations (cf. section 5 Schema).
|
- Ajouter les attributs `#[ApiResource]` + operations (cf. section 5 Schema).
|
||||||
- Ajouter les groupes de serialisation `site:read`, `site:write`, `me:read` sur les proprietes scalaires.
|
- Ajouter les groupes de serialisation `site:read`, `site:write`, `me:read` sur les proprietes scalaires.
|
||||||
- Ajouter la relation inverse `private Collection $users;` (M2M mappedBy=`sites`), **sans** groupe de serialisation (pas d'exposition API cote Site).
|
- Ajouter la relation inverse `private Collection $users;` (M2M mappedBy=`sites`), **sans** groupe de serialisation (pas d'exposition API cote Site).
|
||||||
- Initialiser `$this->users = new ArrayCollection();` dans le constructeur.
|
- Initialiser `$this->users = new ArrayCollection();` dans le constructeur.
|
||||||
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
||||||
|
|
||||||
### Backend — Configuration
|
### Backend — Configuration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
- `/home/m-tristan/workspace/Coltura/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
||||||
```php
|
```php
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.sites',
|
'label' => 'sidebar.core.sites',
|
||||||
@@ -113,18 +113,18 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
|||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/components/UserRbacDrawer.vue` :
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/components/UserRbacDrawer.vue` :
|
||||||
- Charger `GET /api/sites?itemsPerPage=999` a l'ouverture du drawer (parallelement aux roles et permissions deja charges).
|
- Charger `GET /api/sites?itemsPerPage=999` a l'ouverture du drawer (parallelement aux roles et permissions deja charges).
|
||||||
- Ajouter une section `sidebar.admin.usersDrawer.sitesSection` sous la section permissions directes, avec un groupe de `MalioCheckbox` par site (ou un `MalioMultiSelect` si le composant existe dans `@malio/layer-ui`).
|
- Ajouter une section `sidebar.admin.usersDrawer.sitesSection` sous la section permissions directes, avec un groupe de `MalioCheckbox` par site (ou un `MalioMultiSelect` si le composant existe dans `@malio/layer-ui`).
|
||||||
- Etendre le payload `PATCH /api/users/{id}/rbac` avec `sites: Array<string>` (IRIs).
|
- Etendre le payload `PATCH /api/users/{id}/rbac` avec `sites: Array<string>` (IRIs).
|
||||||
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : cles
|
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : cles
|
||||||
- `sidebar.core.sites` = "Sites".
|
- `sidebar.core.sites` = "Sites".
|
||||||
- `admin.sites.title`, `admin.sites.newSite`, `admin.sites.editSite`, `admin.sites.createSite`, `admin.sites.noSites`.
|
- `admin.sites.title`, `admin.sites.newSite`, `admin.sites.editSite`, `admin.sites.createSite`, `admin.sites.noSites`.
|
||||||
- `admin.sites.table.{name, city, postalCode, color, fullAddress}`.
|
- `admin.sites.table.{name, city, postalCode, color, fullAddress}`.
|
||||||
@@ -228,7 +228,7 @@ final class CurrentSiteResource
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
||||||
|
|
||||||
### `up()` — ordre des instructions
|
### `up()` — ordre des instructions
|
||||||
|
|
||||||
@@ -590,112 +590,3 @@ Le ticket autorise un user sans sites (`sites: []`, `currentSite: null`). Mais a
|
|||||||
- [ ] `make test` passe toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).
|
- [ ] `make test` passe toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).
|
||||||
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux et modifies.
|
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux et modifies.
|
||||||
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` ne casse pas les endpoints Core (la DB reste valide, les users conservent leurs sites meme si l'UI ne les expose plus).
|
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` ne casse pas les endpoints Core (la DB reste valide, les users conservent leurs sites meme si l'UI ne les expose plus).
|
||||||
|
|
||||||
## 10. Evolutions post-livraison — drawer RBAC et defense in depth
|
|
||||||
|
|
||||||
Apres la livraison initiale du ticket, un bug utilisateur a revele que le drawer
|
|
||||||
`UserRbacDrawer.vue` demarrait toujours avec 0 site coche pour un user qui en
|
|
||||||
avait en BDD, et que la sauvegarde ecrasait silencieusement les sites
|
|
||||||
existants. Root cause : l'endpoint `GET /api/users` utilise le groupe `user:list`
|
|
||||||
qui n'expose pas la collection `sites` (choix assume pour garder le payload
|
|
||||||
leger et eviter toute fuite croisee site). Le drawer initialisait donc
|
|
||||||
`selectedSiteIds` a partir d'un `user.sites` toujours `undefined`.
|
|
||||||
|
|
||||||
Deux evolutions ont ete apportees pour corriger cela proprement sans elargir la
|
|
||||||
surface de fuite de `/api/users` :
|
|
||||||
|
|
||||||
### 10.1 — Nouvelle operation `GET /users/{id}/rbac`
|
|
||||||
|
|
||||||
Une operation API Platform `Get` est ajoutee sur `User`, symetrique au `Patch`
|
|
||||||
existant, sous la meme URI `/users/{id}/rbac` :
|
|
||||||
|
|
||||||
```php
|
|
||||||
new Get(
|
|
||||||
name: 'user_rbac_get',
|
|
||||||
uriTemplate: '/users/{id}/rbac',
|
|
||||||
security: "is_granted('core.users.manage')",
|
|
||||||
normalizationContext: ['groups' => ['user:rbac:read']],
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
Raisons du design :
|
|
||||||
- **Symetrie REST** : GET et PATCH partagent la meme URI et le meme groupe de
|
|
||||||
normalisation, documentation OpenAPI et appels clients lisibles.
|
|
||||||
- **Separation list/detail** : `/api/users` (`user:list`) reste maigre — pas de
|
|
||||||
collection, pas de fuite. `/users/{id}/rbac` (`user:rbac:read`) porte le
|
|
||||||
detail riche requis par le drawer d'edition.
|
|
||||||
- **Garde de permission plus stricte** : `core.users.manage` (et non `.view`)
|
|
||||||
— le detail RBAC est concu pour l'edition, pas la consultation.
|
|
||||||
- **Isolation du couplage Sites** : la dependance au module Sites reste scopee
|
|
||||||
a cet endpoint et a `/api/me`. Elle n'est pas disseminee dans tous les
|
|
||||||
payloads de liste.
|
|
||||||
|
|
||||||
Cote frontend (`UserRbacDrawer.vue`) :
|
|
||||||
- `loadData(userId)` fetch desormais `/users/{id}/rbac` en parallele des
|
|
||||||
referentiels (roles, permissions, sites globaux).
|
|
||||||
- Le watch combine `[modelValue, user.id]` recharge le detail a chaque
|
|
||||||
ouverture ou changement de user sans dependance fragile sur `props.user`.
|
|
||||||
- Le type `UserListItem` perd `sites` (inutilise) ; un nouveau type
|
|
||||||
`UserRbacDetail` represente le payload du GET dedie.
|
|
||||||
- La colonne "Sites" de `/admin/users` est retiree : l'info est consultee
|
|
||||||
dans le drawer. Cela supprime aussi le second fetch `/api/sites` sur la
|
|
||||||
page de liste.
|
|
||||||
|
|
||||||
### 10.2 — Garde anti-ecrasement dans `UserRbacProcessor`
|
|
||||||
|
|
||||||
API Platform denormalize les collections ManyToMany comme des `ArrayCollection`
|
|
||||||
vides quand la cle JSON correspondante est absente du payload, violant la
|
|
||||||
semantique `merge-patch+json` qui impose que les cles absentes ne mutent PAS
|
|
||||||
les proprietes. Pour un PATCH qui ne veut toucher que `isAdmin`, cela
|
|
||||||
detruirait tous les sites/roles/directPermissions du user.
|
|
||||||
|
|
||||||
Le processor injecte desormais `RequestStack`, lit le body JSON brut au debut
|
|
||||||
de `process()`, et pour chaque collection absente du payload restaure l'etat
|
|
||||||
d'origine a partir du snapshot Doctrine :
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Mapping cle JSON → accesseurs PHP (note : 'roles' → getRbacRoles)
|
|
||||||
private const COLLECTION_MAP = [
|
|
||||||
'roles' => ['getter' => 'getRbacRoles', ...],
|
|
||||||
'directPermissions' => ['getter' => 'getDirectPermissions', ...],
|
|
||||||
'sites' => ['getter' => 'getSites', ...],
|
|
||||||
];
|
|
||||||
|
|
||||||
private function restoreAbsentCollections(User $user): void
|
|
||||||
{
|
|
||||||
$payload = json_decode($this->requestStack->getCurrentRequest()?->getContent() ?? '', true);
|
|
||||||
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
|
||||||
if (array_key_exists($jsonKey, $payload)) {
|
|
||||||
continue; // cle presente = la denormalisation fait foi
|
|
||||||
}
|
|
||||||
// cle absente = restaurer le snapshot PersistentCollection
|
|
||||||
// (voir implementation complete dans UserRbacProcessor.php)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Semantique finale garantie :
|
|
||||||
|
|
||||||
| Payload | Effet sur la collection |
|
|
||||||
|---------------------------------|------------------------------------|
|
|
||||||
| Cle absente | Preservee (etat BDD inchange) |
|
|
||||||
| `"sites": []` | Collection videe explicitement |
|
|
||||||
| `"sites": ["/api/sites/1"]` | Collection remplacee |
|
|
||||||
|
|
||||||
La garde `ensureCurrentSiteConsistency` continue de s'executer apres persist
|
|
||||||
avec la meme logique : elle est triggered uniquement si la collection a
|
|
||||||
effectivement mute (detection via `PersistentCollection::isDirty()` post-restore).
|
|
||||||
|
|
||||||
### 10.3 — Criteres de validation additionnels
|
|
||||||
|
|
||||||
- [ ] `GET /users/{id}/rbac` retourne 200 pour `core.users.manage`, 403 sinon.
|
|
||||||
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
|
|
||||||
- [ ] `GET /api/users` ne contient plus `sites` (verification non-regression).
|
|
||||||
- [ ] Ouvrir le drawer d'un user avec des sites en BDD affiche les cases
|
|
||||||
pre-cochees correspondantes.
|
|
||||||
- [ ] `PATCH /users/{id}/rbac` avec `{ "isAdmin": true }` (sans autre cle) ne
|
|
||||||
modifie pas sites/roles/directPermissions.
|
|
||||||
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [] }` vide explicitement la
|
|
||||||
collection et bascule `currentSite` a `NULL` via la garde existante.
|
|
||||||
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [...] }` remplace la
|
|
||||||
collection comme auparavant.
|
|
||||||
|
|||||||
@@ -77,42 +77,42 @@ Resultat attendu : apres merge, un user avec ≥ 1 site voit une barre sous la n
|
|||||||
|
|
||||||
### Frontend — Module Sites (layer deja cree au ticket 2)
|
### Frontend — Module Sites (layer deja cree au ticket 2)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
||||||
|
|
||||||
### Frontend — Shared
|
### Frontend — Shared
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
||||||
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
|
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
|
||||||
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
|
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
|
||||||
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
|
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
|
||||||
|
|
||||||
### Frontend — Tests
|
### Frontend — Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
||||||
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
|
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
|
||||||
- Si la requete reussit, l'etat reste aligne.
|
- Si la requete reussit, l'etat reste aligne.
|
||||||
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
||||||
- `resetCurrentSite` vide l'etat.
|
- `resetCurrentSite` vide l'etat.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Starseed) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Coltura) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
- `/home/m-tristan/workspace/Coltura/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/user-data.ts` : ajouter les champs
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/user-data.ts` : ajouter les champs
|
||||||
```ts
|
```ts
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
currentSite: Site | null
|
currentSite: Site | null
|
||||||
```
|
```
|
||||||
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
|
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
- `/home/m-tristan/workspace/Coltura/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
- `/home/m-tristan/workspace/Coltura/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
||||||
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : ajouter les cles
|
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : ajouter les cles
|
||||||
```json
|
```json
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -34,50 +34,50 @@ Le ticket livre aussi une documentation developpeur (`docs/modules/site-aware.md
|
|||||||
|
|
||||||
### Shared — Contrat
|
### Shared — Contrat
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
- `/home/m-tristan/workspace/Coltura/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
||||||
|
|
||||||
### Module Sites — Application
|
### Module Sites — Application
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
||||||
|
|
||||||
### Module Sites — Infrastructure
|
### Module Sites — Infrastructure
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
- `/home/m-tristan/workspace/Coltura/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
||||||
- Le filtre s'applique sur une resource `SiteAware` quand le provider retourne un site.
|
- Le filtre s'applique sur une resource `SiteAware` quand le provider retourne un site.
|
||||||
- Le filtre est no-op si `SiteAware` mais provider null.
|
- Le filtre est no-op si `SiteAware` mais provider null.
|
||||||
- Le filtre est no-op si resource non `SiteAware`.
|
- Le filtre est no-op si resource non `SiteAware`.
|
||||||
- Le filtre est no-op si user a `sites.bypass_scope`.
|
- Le filtre est no-op si user a `sites.bypass_scope`.
|
||||||
- `totalItems` Hydra reflete bien le filtrage.
|
- `totalItems` Hydra reflete bien le filtrage.
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
||||||
- `$data` SiteAware sans site → injection du site courant.
|
- `$data` SiteAware sans site → injection du site courant.
|
||||||
- `$data` SiteAware avec site deja positionne → pas d'overwrite.
|
- `$data` SiteAware avec site deja positionne → pas d'overwrite.
|
||||||
- `$data` non-SiteAware → delegation directe sans modification.
|
- `$data` non-SiteAware → delegation directe sans modification.
|
||||||
- Provider retourne null (module off ou user sans site) ET `$data` SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
|
- Provider retourne null (module off ou user sans site) ET `$data` SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
||||||
- User authentifie avec currentSite → retourne le Site.
|
- User authentifie avec currentSite → retourne le Site.
|
||||||
- User authentifie sans currentSite → null.
|
- User authentifie sans currentSite → null.
|
||||||
- Pas d'user → null.
|
- Pas d'user → null.
|
||||||
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
||||||
- `/home/m-tristan/workspace/Starseed/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
- `/home/m-tristan/workspace/Coltura/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
||||||
```php
|
```php
|
||||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||||
```
|
```
|
||||||
**Note importante** : la methode `permissions()` signale l'existence de la permission mais c'est la commande `app:sync-permissions` (inchangee) qui la positionne en base.
|
**Note importante** : la methode `permissions()` signale l'existence de la permission mais c'est la commande `app:sync-permissions` (inchangee) qui la positionne en base.
|
||||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
||||||
- `/home/m-tristan/workspace/Starseed/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
- `/home/m-tristan/workspace/Coltura/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
||||||
|
|
||||||
## 5. Contrat `SiteAwareInterface`
|
## 5. Contrat `SiteAwareInterface`
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ A mitiger par un test qui genere une entite `FakeSiteAwareEntity` via un POST `a
|
|||||||
|
|
||||||
### Risque 8 — Doc developpeur en francais vs anglais
|
### Risque 8 — Doc developpeur en francais vs anglais
|
||||||
|
|
||||||
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Starseed. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Coltura. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
||||||
|
|
||||||
## 12. Plan de tests
|
## 12. Plan de tests
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ watch(() => route.path, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: (title) => title || 'Starseed',
|
titleTemplate: (title) => title || 'Coltura',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/* Starseed - Custom styles */
|
/* Coltura - Custom styles */
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default await nuxt(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'starseed/custom-overrides',
|
name: 'coltura/custom-overrides',
|
||||||
rules: {
|
rules: {
|
||||||
// Indentation 4 espaces (convention CLAUDE.md)
|
// Indentation 4 espaces (convention CLAUDE.md)
|
||||||
'vue/html-indent': ['error', 4],
|
'vue/html-indent': ['error', 4],
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"administration": {
|
"general": {
|
||||||
"section": "Administration"
|
"section": "Général",
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"section": "Mon compte",
|
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
|
"admin": "Administration",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
@@ -28,15 +26,12 @@
|
|||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"audit_log": "Journal d'audit"
|
"sites": "Sites"
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"admin": "Sites"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"welcome": "Bienvenue sur Starseed"
|
"welcome": "Bienvenue sur Coltura"
|
||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Commercial",
|
"title": "Commercial",
|
||||||
@@ -71,40 +66,6 @@
|
|||||||
"switchSuccess": "Site courant changé"
|
"switchSuccess": "Site courant changé"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
|
||||||
"action": {
|
|
||||||
"create": "Création",
|
|
||||||
"update": "Modification",
|
|
||||||
"delete": "Suppression"
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"core_user": "Utilisateur",
|
|
||||||
"core_role": "Rôle",
|
|
||||||
"core_permission": "Permission",
|
|
||||||
"sites_site": "Site"
|
|
||||||
},
|
|
||||||
"empty": "Aucune activité enregistrée",
|
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
|
||||||
"timeline": {
|
|
||||||
"empty": "Aucun historique",
|
|
||||||
"load_more": "Voir plus"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"reset": "Réinitialiser",
|
|
||||||
"date_from": "Du",
|
|
||||||
"date_to": "Au",
|
|
||||||
"entity_type": "Type d'entité",
|
|
||||||
"user": "Utilisateur",
|
|
||||||
"action": "Action",
|
|
||||||
"all_actions": "Toutes les actions"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"field": "Champ",
|
|
||||||
"old_value": "Ancienne valeur",
|
|
||||||
"new_value": "Nouvelle valeur"
|
|
||||||
},
|
|
||||||
"detail_title": "Détail de l'entrée"
|
|
||||||
},
|
|
||||||
"success": {
|
"success": {
|
||||||
"auth": {
|
"auth": {
|
||||||
"logout": "Deconnexion reussie"
|
"logout": "Deconnexion reussie"
|
||||||
@@ -141,8 +102,7 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"selectAll": "Tout selectionner",
|
"selectAll": "Tout selectionner",
|
||||||
"noPermissions": "Aucune permission disponible",
|
"noPermissions": "Aucune permission disponible"
|
||||||
"loadFailed": "Impossible de charger le catalogue de permissions. L'enregistrement est désactivé pour éviter tout écrasement accidentel."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@@ -166,28 +126,12 @@
|
|||||||
"noEffectivePermissions": "Aucune permission effective",
|
"noEffectivePermissions": "Aucune permission effective",
|
||||||
"sourceRole": "via {role}",
|
"sourceRole": "via {role}",
|
||||||
"sourceDirect": "Direct",
|
"sourceDirect": "Direct",
|
||||||
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin",
|
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin"
|
||||||
"loadFailed": "Impossible de charger les droits de cet utilisateur. L'enregistrement est désactivé pour éviter tout écrasement accidentel."
|
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"updated": "Permissions mises à jour avec succès"
|
"updated": "Permissions mises à jour avec succès"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auditLog": {
|
|
||||||
"title": "Journal d'audit",
|
|
||||||
"table": {
|
|
||||||
"performedAt": "Date",
|
|
||||||
"performedBy": "Utilisateur",
|
|
||||||
"entityType": "Entité",
|
|
||||||
"entityId": "ID",
|
|
||||||
"action": "Action",
|
|
||||||
"summary": "Résumé"
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"previous": "Précédent",
|
|
||||||
"next": "Suivant"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sites": {
|
"sites": {
|
||||||
"title": "Gestion des sites",
|
"title": "Gestion des sites",
|
||||||
"newSite": "Nouveau site",
|
"newSite": "Nouveau site",
|
||||||
|
|||||||
@@ -33,15 +33,7 @@
|
|||||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||||
{{ t('admin.roles.form.permissions') }}
|
{{ t('admin.roles.form.permissions') }}
|
||||||
</h4>
|
</h4>
|
||||||
<!-- Etat d'erreur explicite : sans ce message, un drawer vide
|
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
ressemblerait a un role legitimement sans permissions. -->
|
|
||||||
<div
|
|
||||||
v-if="permissionsLoadFailed"
|
|
||||||
class="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"
|
|
||||||
>
|
|
||||||
{{ t('admin.roles.permissions.loadFailed') }}
|
|
||||||
</div>
|
|
||||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -78,7 +70,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="saving || permissionsLoadFailed"
|
:disabled="saving"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,11 +102,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const allPermissions = ref<Permission[]>([])
|
const allPermissions = ref<Permission[]>([])
|
||||||
// Signale un echec de chargement du catalogue de permissions : on bloque
|
|
||||||
// alors la sauvegarde pour eviter qu'un drawer ouvert avec zero permission
|
|
||||||
// visible (cas d'un 403 ou d'une panne reseau) n'ecrase silencieusement
|
|
||||||
// toutes les permissions du role.
|
|
||||||
const permissionsLoadFailed = ref(false)
|
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
label: '',
|
label: '',
|
||||||
@@ -142,21 +129,12 @@ const permissionsByModule = computed<PermissionModule[]>(() => {
|
|||||||
|
|
||||||
// Charger les permissions au montage
|
// Charger les permissions au montage
|
||||||
async function loadPermissions() {
|
async function loadPermissions() {
|
||||||
permissionsLoadFailed.value = false
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: Permission[] }>(
|
const data = await api.get<{ member: Permission[] }>(
|
||||||
'/permissions',
|
'/permissions',
|
||||||
{ 'orphan': false, itemsPerPage: 999 },
|
{ 'orphan': false, itemsPerPage: 999 },
|
||||||
// `toast: true` : en cas d'echec (403, reseau, 500), l'utilisateur
|
{ toast: false },
|
||||||
// voit l'erreur remonter. Sans ce feedback, un catalogue vide
|
|
||||||
// ressemblerait a un role sans permissions disponibles.
|
|
||||||
{ toast: true },
|
|
||||||
)
|
)
|
||||||
allPermissions.value = data.member
|
allPermissions.value = data.member
|
||||||
} catch {
|
|
||||||
allPermissions.value = []
|
|
||||||
permissionsLoadFailed.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remplir le formulaire quand le role change
|
// Remplir le formulaire quand le role change
|
||||||
|
|||||||
@@ -6,16 +6,6 @@
|
|||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 p-4">
|
<div class="flex flex-col gap-6 p-4">
|
||||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
|
||||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
|
||||||
<div
|
|
||||||
v-if="loadFailed"
|
|
||||||
class="flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:alert-circle-outline" class="size-5 shrink-0" />
|
|
||||||
{{ t('admin.users.drawer.loadFailed') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Avertissement auto-edition -->
|
<!-- Avertissement auto-edition -->
|
||||||
<div
|
<div
|
||||||
v-if="isSelfEdit"
|
v-if="isSelfEdit"
|
||||||
@@ -113,7 +103,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="saving || loadFailed"
|
:disabled="saving"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
interface PermissionModule {
|
||||||
@@ -148,10 +138,6 @@ const saving = ref(false)
|
|||||||
const allRoles = ref<Role[]>([])
|
const allRoles = ref<Role[]>([])
|
||||||
const allPermissions = ref<Permission[]>([])
|
const allPermissions = ref<Permission[]>([])
|
||||||
const allSites = ref<Site[]>([])
|
const allSites = ref<Site[]>([])
|
||||||
// Signale un echec de chargement des referentiels : on bloque alors la
|
|
||||||
// sauvegarde pour eviter qu'un drawer ouvert sans donnees (403, reseau)
|
|
||||||
// n'ecrase silencieusement l'etat RBAC du user (vidage roles/permissions/sites).
|
|
||||||
const loadFailed = ref(false)
|
|
||||||
|
|
||||||
const form = ref({ isAdmin: false })
|
const form = ref({ isAdmin: false })
|
||||||
const selectedRoleIds = ref(new Set<number>())
|
const selectedRoleIds = ref(new Set<number>())
|
||||||
@@ -220,56 +206,39 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
|
|||||||
.sort((a, b) => a.code.localeCompare(b.code))
|
.sort((a, b) => a.code.localeCompare(b.code))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Charger les referentiels (roles, permissions, sites) + le detail RBAC du user
|
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
|
||||||
// en parallele pour minimiser le TTFB a l'ouverture du drawer.
|
// a l'ouverture du drawer.
|
||||||
// Le detail RBAC est la seule source de verite pour l'etat initial du formulaire :
|
async function loadData() {
|
||||||
// props.user vient de la liste /api/users qui n'expose pas les sites (groupe leger).
|
const [rolesData, permsData, sitesData] = await Promise.all([
|
||||||
async function loadData(userId: number) {
|
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
||||||
loadFailed.value = false
|
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
||||||
try {
|
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||||
const [rolesData, permsData, sitesData, userRbac] = await Promise.all([
|
|
||||||
// `toast: true` : en cas d'echec, l'utilisateur voit un toast
|
|
||||||
// d'erreur. Sans ce feedback, le drawer s'afficherait vide et la
|
|
||||||
// sauvegarde ecraserait silencieusement l'etat RBAC du user.
|
|
||||||
api.get<{ member: Role[] }>('/roles', {}, { toast: true }),
|
|
||||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }),
|
|
||||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }),
|
|
||||||
api.get<UserRbacDetail>(`/users/${userId}/rbac`, {}, { toast: true }),
|
|
||||||
])
|
])
|
||||||
allRoles.value = rolesData.member
|
allRoles.value = rolesData.member
|
||||||
allPermissions.value = permsData.member
|
allPermissions.value = permsData.member
|
||||||
allSites.value = sitesData.member
|
allSites.value = sitesData.member
|
||||||
|
|
||||||
form.value.isAdmin = userRbac.isAdmin
|
|
||||||
selectedRoleIds.value = new Set((userRbac.roles ?? []).map(iriToId))
|
|
||||||
selectedDirectPermissionIds.value = new Set((userRbac.directPermissions ?? []).map(iriToId))
|
|
||||||
selectedSiteIds.value = new Set((userRbac.sites ?? []).map(iriToId))
|
|
||||||
} catch {
|
|
||||||
loadFailed.value = true
|
|
||||||
allRoles.value = []
|
|
||||||
allPermissions.value = []
|
|
||||||
allSites.value = []
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
// Remplir le formulaire quand le user change
|
||||||
|
watch(() => props.user, (user) => {
|
||||||
|
if (user) {
|
||||||
|
form.value.isAdmin = user.isAdmin
|
||||||
|
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
||||||
|
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
||||||
|
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
|
||||||
|
} else {
|
||||||
form.value.isAdmin = false
|
form.value.isAdmin = false
|
||||||
selectedRoleIds.value = new Set()
|
selectedRoleIds.value = new Set()
|
||||||
selectedDirectPermissionIds.value = new Set()
|
selectedDirectPermissionIds.value = new Set()
|
||||||
selectedSiteIds.value = new Set()
|
selectedSiteIds.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recharger a l'ouverture OU quand le user change pendant que le drawer est ouvert.
|
|
||||||
// Le watch combine evite un double fetch si les deux changent dans le meme tick.
|
|
||||||
watch([() => props.modelValue, () => props.user?.id], ([open, userId]) => {
|
|
||||||
if (open && userId) {
|
|
||||||
loadData(userId)
|
|
||||||
} else if (!open) {
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Charger les donnees quand le drawer s'ouvre
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) loadData()
|
||||||
|
})
|
||||||
|
|
||||||
function toggleRole(id: number, selected: boolean) {
|
function toggleRole(id: number, selected: boolean) {
|
||||||
const ids = new Set(selectedRoleIds.value)
|
const ids = new Set(selectedRoleIds.value)
|
||||||
if (selected) ids.add(id)
|
if (selected) ids.add(id)
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.auditLog.title') }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres -->
|
|
||||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
|
||||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
|
||||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
|
||||||
A revoir une fois le composant calendar Malio développé -->
|
|
||||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
|
||||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
|
||||||
exposera un datetime picker. Cf. exception documentee dans
|
|
||||||
CLAUDE.md (section "Composants formulaires"). -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_from') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtAfter"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_to') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtBefore"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.entity_type') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
v-model="selectedEntityTypes"
|
|
||||||
:options="entityTypeOptions"
|
|
||||||
:display-select-all="true"
|
|
||||||
:display-tag="true"
|
|
||||||
min-width="w-full"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.user') }}
|
|
||||||
</label>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="performedByInput"
|
|
||||||
icon-name="mdi:account-search"
|
|
||||||
input-class="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.action') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="actionValue"
|
|
||||||
:options="actionOptions"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="t('audit.filters.reset')"
|
|
||||||
button-class="text-xs"
|
|
||||||
@click="resetFilters"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Tableau -->
|
|
||||||
<MalioDataTable
|
|
||||||
class="mt-4"
|
|
||||||
:columns="columns"
|
|
||||||
:items="rows"
|
|
||||||
:total-items="totalItems"
|
|
||||||
:page="filters.page ?? 1"
|
|
||||||
:per-page="filters.itemsPerPage ?? 10"
|
|
||||||
:per-page-options="[10, 25, 50]"
|
|
||||||
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
|
|
||||||
@update:page="onPageChange"
|
|
||||||
@update:per-page="onPerPageChange"
|
|
||||||
@row-click="onRowClick"
|
|
||||||
>
|
|
||||||
<template #cell-action="{ item }">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
||||||
:class="actionBadgeClass(item.action as string)"
|
|
||||||
>
|
|
||||||
{{ t(`audit.action.${item.action}`) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #cell-entityType="{ item }">
|
|
||||||
<span
|
|
||||||
class="text-xs"
|
|
||||||
:title="item.entityType as string"
|
|
||||||
>{{ formatEntityType(item.entityType as string) }}</span>
|
|
||||||
</template>
|
|
||||||
<template #cell-entityId="{ item }">
|
|
||||||
<span class="font-mono text-xs">{{ item.entityId }}</span>
|
|
||||||
</template>
|
|
||||||
<template #cell-summary="{ item }">
|
|
||||||
<span class="text-xs text-gray-600">{{ item.summary }}</span>
|
|
||||||
</template>
|
|
||||||
</MalioDataTable>
|
|
||||||
|
|
||||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
|
||||||
<MalioDrawer
|
|
||||||
v-model="drawerOpen"
|
|
||||||
:title="drawerTitle"
|
|
||||||
drawer-class="max-w-2xl"
|
|
||||||
>
|
|
||||||
<div v-if="selectedEntry">
|
|
||||||
<AuditLogDetail :entry="selectedEntry" />
|
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
|
||||||
<h3
|
|
||||||
class="text-sm font-medium text-gray-700 mb-2"
|
|
||||||
:title="selectedEntry.entityType"
|
|
||||||
>
|
|
||||||
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
|
|
||||||
</h3>
|
|
||||||
<AuditTimeline
|
|
||||||
:entity-type="selectedEntry.entityType"
|
|
||||||
:entity-id="selectedEntry.entityId"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MalioDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
|
||||||
|
|
||||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
|
||||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
|
||||||
// traduction n'existe, on retombe sur l'identifiant brut pour rester debug-friendly.
|
|
||||||
function formatEntityType(type: string): string {
|
|
||||||
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
|
|
||||||
return te(key) ? t(key) : type
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
|
||||||
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
|
||||||
// renvoie une 403 plutot que de flasher un ecran vide.
|
|
||||||
if (!can('core.audit_log.view')) {
|
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: t('admin.auditLog.title') })
|
|
||||||
|
|
||||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
|
||||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
|
||||||
const filters = reactive<AuditLogFilters>({
|
|
||||||
performedAtAfter: undefined,
|
|
||||||
performedAtBefore: undefined,
|
|
||||||
entityType: undefined,
|
|
||||||
performedBy: undefined,
|
|
||||||
action: undefined,
|
|
||||||
page: 1,
|
|
||||||
itemsPerPage: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
|
||||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
|
||||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
|
||||||
const entityTypes = ref<string[]>([])
|
|
||||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
|
||||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
|
||||||
const entityTypeOptions = computed(() =>
|
|
||||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
|
||||||
// pas binder directement un `string | undefined` reactive.
|
|
||||||
const performedByInput = ref<string>('')
|
|
||||||
|
|
||||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
|
||||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
|
||||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
|
||||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
|
||||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
|
||||||
const actionValue = ref<string>('')
|
|
||||||
const actionOptions = [
|
|
||||||
{ value: '', label: t('audit.filters.all_actions') },
|
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
|
||||||
{ value: 'update', label: t('audit.action.update') },
|
|
||||||
{ value: 'delete', label: t('audit.action.delete') },
|
|
||||||
]
|
|
||||||
|
|
||||||
const entries = ref<AuditLogEntry[]>([])
|
|
||||||
const totalItems = ref(0)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
|
||||||
const selectedEntry = ref<AuditLogEntry | null>(null)
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
|
|
||||||
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
|
|
||||||
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
|
|
||||||
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
|
|
||||||
{ key: 'action', label: t('admin.auditLog.table.action') },
|
|
||||||
{ key: 'summary', label: t('admin.auditLog.table.summary') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
|
|
||||||
// On conserve `id` pour retrouver l'entry complete sur row-click.
|
|
||||||
const rows = computed(() =>
|
|
||||||
entries.value.map(entry => ({
|
|
||||||
id: entry.id,
|
|
||||||
performedAt: formatDate(entry.performedAt),
|
|
||||||
performedBy: entry.performedBy,
|
|
||||||
entityType: entry.entityType,
|
|
||||||
entityId: entry.entityId,
|
|
||||||
action: entry.action,
|
|
||||||
summary: summarize(entry),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const drawerTitle = computed(() =>
|
|
||||||
selectedEntry.value
|
|
||||||
? `${formatEntityType(selectedEntry.value.entityType)} #${selectedEntry.value.entityId}`
|
|
||||||
: t('audit.detail_title'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const isFiltered = computed(() =>
|
|
||||||
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
|
||||||
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
|
||||||
|| filters.performedBy || filters.action),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
|
|
||||||
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
|
|
||||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
|
||||||
let requestToken = 0
|
|
||||||
|
|
||||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
|
||||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
|
||||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
|
||||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
|
||||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
|
||||||
// explicitement apres la liberation.
|
|
||||||
let watchersSuspended = false
|
|
||||||
|
|
||||||
async function resetFilters(): Promise<void> {
|
|
||||||
watchersSuspended = true
|
|
||||||
filters.performedAtAfter = undefined
|
|
||||||
filters.performedAtBefore = undefined
|
|
||||||
filters.entityType = undefined
|
|
||||||
filters.performedBy = undefined
|
|
||||||
filters.action = undefined
|
|
||||||
filters.page = 1
|
|
||||||
selectedEntityTypes.value = []
|
|
||||||
performedByInput.value = ''
|
|
||||||
actionValue.value = ''
|
|
||||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
|
||||||
// leur execution avec le flag `true`, puis on libere.
|
|
||||||
await nextTick()
|
|
||||||
watchersSuspended = false
|
|
||||||
loadEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEntries(): Promise<void> {
|
|
||||||
const token = ++requestToken
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await fetchLogsCached({
|
|
||||||
...filters,
|
|
||||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
|
||||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
|
||||||
})
|
|
||||||
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
|
|
||||||
// on ignore le resultat pour ne pas overwrite l'etat courant.
|
|
||||||
if (token !== requestToken) return
|
|
||||||
entries.value = data.member ?? []
|
|
||||||
totalItems.value = data.totalItems ?? 0
|
|
||||||
} catch {
|
|
||||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
|
||||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
|
||||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
|
||||||
if (token === requestToken) {
|
|
||||||
entries.value = []
|
|
||||||
totalItems.value = 0
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (token === requestToken) {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
|
||||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
|
||||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
|
||||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
|
||||||
|
|
||||||
function toIso(localDateTime: string): string {
|
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
|
||||||
return new Date(localDateTime).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleString('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionBadgeClass(action: string): string {
|
|
||||||
switch (action) {
|
|
||||||
case 'create': return 'bg-green-100 text-green-800'
|
|
||||||
case 'update': return 'bg-yellow-100 text-yellow-800'
|
|
||||||
case 'delete': return 'bg-red-100 text-red-800'
|
|
||||||
default: return 'bg-gray-100 text-gray-800'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(entry: AuditLogEntry): string {
|
|
||||||
const keys = Object.keys(entry.changes)
|
|
||||||
if (keys.length === 0) return '—'
|
|
||||||
if (keys.length <= 3) return keys.join(', ')
|
|
||||||
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRowClick(item: Record<string, unknown>): void {
|
|
||||||
const entry = entries.value.find(e => e.id === item.id)
|
|
||||||
if (entry) {
|
|
||||||
selectedEntry.value = entry
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageChange(value: number): void {
|
|
||||||
filters.page = value
|
|
||||||
loadEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPerPageChange(value: number): void {
|
|
||||||
filters.itemsPerPage = value
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
|
||||||
watch(selectedEntityTypes, values => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync MalioSelect action -> filters.action.
|
|
||||||
watch(actionValue, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.action = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
|
||||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
|
||||||
// coalescer si plusieurs watchers tirent en meme temps.
|
|
||||||
watch(performedByInput, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.performedBy = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
debouncedReload()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
|
||||||
// reset de la pagination a la page 1.
|
|
||||||
watch(
|
|
||||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
|
||||||
() => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Charge les entity types en parallele de la liste principale : un
|
|
||||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
|
||||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
|
||||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
|
||||||
try {
|
|
||||||
entityTypes.value = await fetchEntityTypes()
|
|
||||||
} catch {
|
|
||||||
entityTypes.value = []
|
|
||||||
}
|
|
||||||
await loadEntries()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -114,10 +114,6 @@ async function loadRoles() {
|
|||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
roles.value = data.member
|
roles.value = data.member
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
roles.value = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -48,21 +49,24 @@ useHead({ title: t('admin.users.title') })
|
|||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
const users = ref<UserListItem[]>([])
|
const users = ref<UserListItem[]>([])
|
||||||
|
const sitesById = ref(new Map<number, Site>())
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
// La colonne "Sites" n'est plus affichee dans la liste : le detail des sites
|
|
||||||
// rattaches est consulte/edite via le drawer (GET /users/{id}/rbac). Garder
|
|
||||||
// un payload leger sur /api/users facilite la pagination et evite de fuiter
|
|
||||||
// l'info cross-site aux users partageant juste un site avec l'appelant.
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'username', label: t('admin.users.table.username') },
|
{ key: 'username', label: t('admin.users.table.username') },
|
||||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||||
|
{ key: 'sites', label: t('admin.users.table.sites') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
|
||||||
|
function iriToId(iri: string): number {
|
||||||
|
return Number(iri.split('/').pop())
|
||||||
|
}
|
||||||
|
|
||||||
const userItems = computed(() =>
|
const userItems = computed(() =>
|
||||||
users.value.map(user => ({
|
users.value.map(user => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -70,19 +74,27 @@ const userItems = computed(() =>
|
|||||||
admin: user.isAdmin,
|
admin: user.isAdmin,
|
||||||
roles: user.roles.length,
|
roles: user.roles.length,
|
||||||
directPermissions: user.directPermissions.length,
|
directPermissions: user.directPermissions.length,
|
||||||
|
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
||||||
|
// du payload /api/users (groupe user:list) sont resolues via la Map
|
||||||
|
// construite en parallele depuis /api/sites.
|
||||||
|
sites: (user.sites ?? [])
|
||||||
|
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
.join(', '),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
// Chargement parallele : les sites alimentent la Map de resolution
|
||||||
|
// IRI→name pour la colonne "Sites" de la table.
|
||||||
|
const [usersData, sitesData] = await Promise.all([
|
||||||
|
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
|
||||||
|
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||||
|
])
|
||||||
users.value = usersData.member
|
users.value = usersData.member
|
||||||
} catch {
|
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
|
||||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
|
||||||
users.value = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const auth = useAuthStore()
|
|||||||
const { resetSidebar } = useSidebar()
|
const { resetSidebar } = useSidebar()
|
||||||
const { resetModules } = useModules()
|
const { resetModules } = useModules()
|
||||||
const { resetCurrentSite } = useCurrentSite()
|
const { resetCurrentSite } = useCurrentSite()
|
||||||
const { resetAuditLog } = useAuditLog()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -19,14 +18,13 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||||
// l'ancien. Toutes les fonctions reset sont synchrones et ne
|
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
||||||
// peuvent pas throw (juste des assignations reactives).
|
// peuvent pas throw (juste des assignations reactives).
|
||||||
// navigateTo est dans le finally pour garantir la redirection
|
// navigateTo est dans le finally pour garantir la redirection
|
||||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||||
resetSidebar()
|
resetSidebar()
|
||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
resetAuditLog()
|
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function onChange(site: { id: string; name: string; color: string }): Prom
|
|||||||
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
||||||
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
||||||
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
||||||
// cle `starseed:site-switch` pour mettre a jour les onglets inactifs
|
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
|
||||||
// sans clic via auth.fetchUser() / auth.refreshUser().
|
// sans clic via auth.fetchUser() / auth.refreshUser().
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ async function loadSites() {
|
|||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
sites.value = data.member
|
sites.value = data.member
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
sites.value = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
123
frontend/package-lock.json
generated
123
frontend/package-lock.json
generated
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "starseed-frontend",
|
"name": "coltura-frontend",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "starseed-frontend",
|
"name": "coltura-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.5.0",
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint-config": "^1.9.0",
|
"@nuxt/eslint-config": "^1.9.0",
|
||||||
"@playwright/test": "^1.59.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||||
"@typescript-eslint/parser": "^8.44.1",
|
"@typescript-eslint/parser": "^8.44.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
@@ -85,6 +84,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -582,27 +582,6 @@
|
|||||||
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
|
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -1303,6 +1282,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
@@ -2221,6 +2201,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
|
||||||
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
|
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "^3.3.3",
|
"c12": "^3.3.3",
|
||||||
"consola": "^3.4.2",
|
"consola": "^3.4.2",
|
||||||
@@ -2323,6 +2304,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
|
||||||
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
|
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "^3.5.30",
|
"@vue/shared": "^3.5.30",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -3947,22 +3929,6 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
|
||||||
"version": "1.59.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
|
||||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.59.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -4638,6 +4604,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.2.tgz",
|
||||||
"integrity": "sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==",
|
"integrity": "sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -4886,6 +4853,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.2.tgz",
|
||||||
"integrity": "sha512-tRbbjpOPrY4ApIHtn3ctnKIhkkioewMsZa5gJzqVB47LJFNyzLXLo/aID4sJRKTIMi1wd1fA9TiBKPe6KqczPA==",
|
"integrity": "sha512-tRbbjpOPrY4ApIHtn3ctnKIhkkioewMsZa5gJzqVB47LJFNyzLXLo/aID4sJRKTIMi1wd1fA9TiBKPe6KqczPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -4991,6 +4959,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.2.tgz",
|
||||||
"integrity": "sha512-K2o1gMwn09nrd5ewftSy08U6LMC1cW3Cmml5+vHT9P/VeMtYwkbNg+9Mt1uFh7VfAZmlkj8d3u7RYqfl8xMVJA==",
|
"integrity": "sha512-K2o1gMwn09nrd5ewftSy08U6LMC1cW3Cmml5+vHT9P/VeMtYwkbNg+9Mt1uFh7VfAZmlkj8d3u7RYqfl8xMVJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -5017,6 +4986,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.2.tgz",
|
||||||
"integrity": "sha512-kRHQ3nSbAfkFdxj9FtDdr4hpREndGgWFA6ZEAwlLeGUxf8QYTpuF9zb2yxdBPBlTc5+JsbPcskNt+u1PazGKYw==",
|
"integrity": "sha512-kRHQ3nSbAfkFdxj9FtDdr4hpREndGgWFA6ZEAwlLeGUxf8QYTpuF9zb2yxdBPBlTc5+JsbPcskNt+u1PazGKYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -5031,6 +5001,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.2.tgz",
|
||||||
"integrity": "sha512-1kvsBqGNu2ZJ0P/lkxN0pAMqSyUcpkMIzE4xwGUIyAiD0pZV6dr+OCMwGWOTLllSyrn91xI5K7OLk3pYeCPKqA==",
|
"integrity": "sha512-1kvsBqGNu2ZJ0P/lkxN0pAMqSyUcpkMIzE4xwGUIyAiD0pZV6dr+OCMwGWOTLllSyrn91xI5K7OLk3pYeCPKqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-commands": "^1.6.2",
|
"prosemirror-commands": "^1.6.2",
|
||||||
@@ -5174,6 +5145,7 @@
|
|||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
@@ -5236,6 +5208,7 @@
|
|||||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.2",
|
"@typescript-eslint/scope-manager": "8.58.2",
|
||||||
"@typescript-eslint/types": "8.58.2",
|
"@typescript-eslint/types": "8.58.2",
|
||||||
@@ -6015,6 +5988,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
||||||
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.2",
|
||||||
"@vue/compiler-core": "3.5.32",
|
"@vue/compiler-core": "3.5.32",
|
||||||
@@ -6258,6 +6232,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -6645,6 +6620,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"bare-abort-controller": "*"
|
"bare-abort-controller": "*"
|
||||||
},
|
},
|
||||||
@@ -6842,6 +6818,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -6956,6 +6933,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -7150,7 +7128,8 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/clean-regexp": {
|
"node_modules/clean-regexp": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -8203,6 +8182,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -9361,6 +9341,7 @@
|
|||||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": ">=20.0.0",
|
"@types/node": ">=20.0.0",
|
||||||
"@types/whatwg-mimetype": "^3.0.2",
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
@@ -11807,6 +11788,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
|
||||||
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
|
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dxup/nuxt": "^0.4.0",
|
"@dxup/nuxt": "^0.4.0",
|
||||||
"@nuxt/cli": "^3.34.0",
|
"@nuxt/cli": "^3.34.0",
|
||||||
@@ -12865,6 +12847,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
@@ -12922,6 +12905,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.112.0"
|
"@oxc-project/types": "^0.112.0"
|
||||||
},
|
},
|
||||||
@@ -13188,6 +13172,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^7.7.7"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
@@ -13224,53 +13209,6 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.59.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
|
||||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.59.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.59.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
|
||||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright/node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -13313,6 +13251,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -13856,6 +13795,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -14646,6 +14586,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -15549,6 +15490,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -16228,6 +16170,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napi-postinstall": "^0.3.0"
|
"napi-postinstall": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -16494,6 +16437,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -17412,6 +17356,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.32",
|
"@vue/compiler-dom": "3.5.32",
|
||||||
"@vue/compiler-sfc": "3.5.32",
|
"@vue/compiler-sfc": "3.5.32",
|
||||||
@@ -17456,6 +17401,7 @@
|
|||||||
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0 || ^9.0.0",
|
"eslint-scope": "^8.2.0 || ^9.0.0",
|
||||||
@@ -17492,6 +17438,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
|
||||||
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
|
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.3.1",
|
"@intlify/core-base": "11.3.1",
|
||||||
"@intlify/devtools-types": "11.3.1",
|
"@intlify/devtools-types": "11.3.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "starseed-frontend",
|
"name": "coltura-frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,9 +12,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest"
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"test:e2e:ui": "playwright test --ui"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.5.0",
|
||||||
@@ -30,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint-config": "^1.9.0",
|
"@nuxt/eslint-config": "^1.9.0",
|
||||||
"@playwright/test": "^1.59.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||||
"@typescript-eslint/parser": "^8.44.1",
|
"@typescript-eslint/parser": "^8.44.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Config Playwright pour les tests E2E de Starseed.
|
|
||||||
*
|
|
||||||
* Pre-requis avant de lancer :
|
|
||||||
* 1. Les containers Docker tournent (`make start`)
|
|
||||||
* 2. Le dev server Nuxt est lance (`make dev-nuxt`) sur le port 3004
|
|
||||||
* 3. Les personas E2E sont seedes (`make seed-e2e` — cf. SeedE2ECommand cote back)
|
|
||||||
*
|
|
||||||
* La baseURL cible le dev server Nuxt (HMR) en dev local ; surcharger avec
|
|
||||||
* PLAYWRIGHT_BASE_URL=http://localhost:8083 pour taper sur le build Nginx
|
|
||||||
* (au plus pres de la prod, utile en CI).
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './tests/e2e',
|
|
||||||
|
|
||||||
// Interdit `test.only` en CI pour ne pas skipper involontairement la suite.
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
|
|
||||||
// Pas de retry en local (bugs a reproduire), 2 retries en CI (flaky mitige).
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
|
|
||||||
// Parallelisme : 1 worker local pour faciliter le debug, defaut en CI.
|
|
||||||
workers: process.env.CI ? undefined : 1,
|
|
||||||
|
|
||||||
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
|
|
||||||
|
|
||||||
use: {
|
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3004',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
video: 'retain-on-failure',
|
|
||||||
},
|
|
||||||
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
User-Agent: *
|
User-Agent: *
|
||||||
Disallow: /
|
Disallow:
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!--
|
|
||||||
Vue de detail d'une ligne d'audit : tableau field/old/new pour une
|
|
||||||
update, sinon snapshot complet sous forme de liste { cle: valeur }.
|
|
||||||
-->
|
|
||||||
<div class="text-sm">
|
|
||||||
<p class="text-xs text-gray-500 mb-2">
|
|
||||||
<span v-if="entry.ipAddress">IP: {{ entry.ipAddress }}</span>
|
|
||||||
<span v-if="entry.requestId" class="ml-3">Req: {{ entry.requestId }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="entry.action === 'update'">
|
|
||||||
<!-- Tableau de comparaison field/old/new. MalioDataTable n'est
|
|
||||||
pas adapte ici : cas presentationnel non-paginable (cf.
|
|
||||||
exception documentee dans CLAUDE.md). -->
|
|
||||||
<table class="min-w-full border border-gray-200 text-xs">
|
|
||||||
<thead class="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.field') }}</th>
|
|
||||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.old_value') }}</th>
|
|
||||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.new_value') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
|
|
||||||
<td class="px-2 py-1 font-mono">{{ field }}</td>
|
|
||||||
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
|
|
||||||
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Modifications de collections to-many : shape different
|
|
||||||
{ added: [ids], removed: [ids] } → affiche + et - sur
|
|
||||||
la meme ligne pour garder une colonne field unique. -->
|
|
||||||
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
|
|
||||||
<td class="px-2 py-1 font-mono">{{ field }}</td>
|
|
||||||
<td class="px-2 py-1 text-red-700">
|
|
||||||
<span v-if="diff.removed.length">− {{ diff.removed.join(', ') }}</span>
|
|
||||||
<span v-else class="text-gray-400">∅</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 text-green-700">
|
|
||||||
<span v-if="diff.added.length">+ {{ diff.added.join(', ') }}</span>
|
|
||||||
<span v-else class="text-gray-400">∅</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-1">
|
|
||||||
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
|
|
||||||
<span class="font-mono text-xs text-gray-600">{{ key }}:</span>
|
|
||||||
<span class="text-xs">{{ formatValue(value) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { AuditLogEntry } from '~/shared/types'
|
|
||||||
|
|
||||||
const props = defineProps<{ entry: AuditLogEntry }>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// Extrait les entrees au shape { old, new } pour les updates scalaires.
|
|
||||||
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
|
|
||||||
const out: Record<string, { old: unknown; new: unknown }> = {}
|
|
||||||
for (const [key, value] of Object.entries(props.entry.changes)) {
|
|
||||||
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
|
||||||
out[key] = value as { old: unknown; new: unknown }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
// Extrait les entrees au shape { added, removed } pour les modifications
|
|
||||||
// de collections to-many (cf. AuditListener::captureCollectionChange).
|
|
||||||
const collectionDiff = computed<Record<string, { added: unknown[]; removed: unknown[] }>>(() => {
|
|
||||||
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
|
||||||
for (const [key, value] of Object.entries(props.entry.changes)) {
|
|
||||||
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
|
||||||
const diff = value as { added: unknown; removed: unknown }
|
|
||||||
out[key] = {
|
|
||||||
added: Array.isArray(diff.added) ? diff.added : [],
|
|
||||||
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '∅'
|
|
||||||
// Passe par i18n plutot qu'un hardcode FR : si une autre locale est
|
|
||||||
// ajoutee, le rendu s'adapte sans nouvelle passe sur ce composant.
|
|
||||||
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
|
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!--
|
|
||||||
Garde permission : aucun rendu DOM ni appel API si l'utilisateur n'a
|
|
||||||
pas le droit. On wrappe le contenu dans un bloc v-if plutot qu'un div
|
|
||||||
vide pour eviter de polluer la layout quand le composant est embarque
|
|
||||||
dans une page qui rend deja sa propre structure.
|
|
||||||
-->
|
|
||||||
<div v-if="canView" class="audit-timeline">
|
|
||||||
<!-- Skeleton loader initial -->
|
|
||||||
<ul v-if="loading && entries.length === 0" class="space-y-3">
|
|
||||||
<li v-for="i in 3" :key="i" class="flex gap-3">
|
|
||||||
<div class="h-3 w-3 rounded-full bg-gray-200 animate-pulse mt-1.5" />
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-3 w-1/3 rounded bg-gray-200 animate-pulse" />
|
|
||||||
<div class="h-2 w-2/3 rounded bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-else-if="!loading && entries.length === 0"
|
|
||||||
class="text-sm text-gray-500 italic"
|
|
||||||
>
|
|
||||||
{{ t('audit.timeline.empty') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul v-else class="relative border-l-2 border-gray-200 pl-6 space-y-5">
|
|
||||||
<li
|
|
||||||
v-for="entry in entries"
|
|
||||||
:key="entry.id"
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<!-- Dot sur la barre verticale. Couleur selon action. -->
|
|
||||||
<span
|
|
||||||
class="absolute -left-[31px] top-1 h-3 w-3 rounded-full ring-2 ring-white"
|
|
||||||
:class="dotClass(entry.action)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm">
|
|
||||||
<span class="font-medium">{{ entry.performedBy }}</span>
|
|
||||||
<span class="text-gray-500"> — {{ t(`audit.action.${entry.action}`) }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Update : diff field-by-field. Create/Delete : liste des champs. -->
|
|
||||||
<div v-if="entry.action === 'update'" class="mt-1 text-xs text-gray-600 space-y-0.5">
|
|
||||||
<div v-for="(diff, field) in updateDiff(entry)" :key="field">
|
|
||||||
<span class="font-medium">{{ field }}</span> :
|
|
||||||
<span class="line-through text-red-600">{{ formatValue(diff.old) }}</span>
|
|
||||||
<span class="mx-1">→</span>
|
|
||||||
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Modifications de collections to-many. -->
|
|
||||||
<div v-for="(diff, field) in collectionDiff(entry)" :key="`col-${field}`">
|
|
||||||
<span class="font-medium">{{ field }}</span> :
|
|
||||||
<span v-if="diff.removed.length" class="text-red-600">−{{ diff.removed.join(', ') }}</span>
|
|
||||||
<span v-if="diff.removed.length && diff.added.length" class="mx-1"> </span>
|
|
||||||
<span v-if="diff.added.length" class="text-green-700">+{{ diff.added.join(', ') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mt-1 text-xs text-gray-600">
|
|
||||||
{{ snapshotSummary(entry) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date relative FR + tooltip absolu -->
|
|
||||||
<time
|
|
||||||
:title="absoluteDate(entry.performedAt)"
|
|
||||||
class="shrink-0 text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
{{ relativeDate(entry.performedAt) }}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Lazy loading : bouton "Voir plus" si plus de pages. -->
|
|
||||||
<div v-if="hasMore" class="mt-4 flex justify-center">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="loading ? t('common.loading') : t('audit.timeline.load_more')"
|
|
||||||
:disabled="loading"
|
|
||||||
button-class="text-sm"
|
|
||||||
@click="loadMore"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, toRefs, watch } from 'vue'
|
|
||||||
import type { AuditLogEntry } from '~/shared/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entityType: string
|
|
||||||
entityId: string | number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { entityType, entityId } = toRefs(props)
|
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
|
||||||
const { can } = usePermissions()
|
|
||||||
const { fetchEntityLogs } = useAuditLog()
|
|
||||||
|
|
||||||
const canView = computed(() => can('core.audit_log.view'))
|
|
||||||
|
|
||||||
const entries = ref<AuditLogEntry[]>([])
|
|
||||||
const page = ref(1)
|
|
||||||
const totalItems = ref(0)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// Lazy loading : 10 items par page cote UX. On aligne la pagination backend
|
|
||||||
// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de
|
|
||||||
// slicer cote client — sinon les items 11-30 de chaque page etaient ignores.
|
|
||||||
const PAGE_SIZE = 10
|
|
||||||
|
|
||||||
// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre
|
|
||||||
// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs
|
|
||||||
// dont le premier repond en retard et ecrase l'etat de la seconde timeline.
|
|
||||||
// On incremente un token a chaque fetch ; seule la derniere requete ecrit le
|
|
||||||
// resultat. loadMore() est aussi protege : une reponse tardive append sur
|
|
||||||
// une timeline dont l'entite a deja change serait visuellement confuse.
|
|
||||||
let requestToken = 0
|
|
||||||
|
|
||||||
const hasMore = computed(() => entries.value.length < totalItems.value)
|
|
||||||
|
|
||||||
async function loadPage(targetPage: number, append: boolean): Promise<void> {
|
|
||||||
if (!canView.value) return
|
|
||||||
|
|
||||||
const token = ++requestToken
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE)
|
|
||||||
if (token !== requestToken) return
|
|
||||||
const items = data.member ?? []
|
|
||||||
entries.value = append ? [...entries.value, ...items] : items
|
|
||||||
totalItems.value = data.totalItems ?? entries.value.length
|
|
||||||
page.value = targetPage
|
|
||||||
} catch {
|
|
||||||
if (token !== requestToken) return
|
|
||||||
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
|
|
||||||
entries.value = append ? entries.value : []
|
|
||||||
} finally {
|
|
||||||
if (token === requestToken) {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore(): Promise<void> {
|
|
||||||
await loadPage(page.value + 1, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dotClass(action: string): string {
|
|
||||||
switch (action) {
|
|
||||||
case 'create': return 'bg-green-500'
|
|
||||||
case 'update': return 'bg-yellow-500'
|
|
||||||
case 'delete': return 'bg-red-500'
|
|
||||||
default: return 'bg-gray-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relativise une date via Intl.RelativeTimeFormat. On selectionne l'unite
|
|
||||||
// la plus grossiere possible (secondes < minutes < heures < jours < semaines
|
|
||||||
// < mois < annees). La locale suit dynamiquement celle de l'app pour qu'un
|
|
||||||
// switch de langue prenne effet sans nouveau mount (recomputed = cache
|
|
||||||
// par-locale). Paliers mois/annee approximes (30.44j / 365.25j) : suffisant
|
|
||||||
// pour un affichage humain, la tooltip absoluteDate garde la date exacte.
|
|
||||||
const rtf = computed(() => new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }))
|
|
||||||
|
|
||||||
function relativeDate(iso: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(iso).getTime()
|
|
||||||
const diffSec = Math.round(diffMs / 1000)
|
|
||||||
const absSec = Math.abs(diffSec)
|
|
||||||
const sign = -Math.sign(diffSec)
|
|
||||||
const fmt = rtf.value
|
|
||||||
|
|
||||||
if (absSec < 60) return fmt.format(sign * absSec, 'second')
|
|
||||||
if (absSec < 3600) return fmt.format(sign * Math.round(absSec / 60), 'minute')
|
|
||||||
if (absSec < 86400) return fmt.format(sign * Math.round(absSec / 3600), 'hour')
|
|
||||||
if (absSec < 604800) return fmt.format(sign * Math.round(absSec / 86400), 'day')
|
|
||||||
if (absSec < 2629800) return fmt.format(sign * Math.round(absSec / 604800), 'week') // < ~30.44j
|
|
||||||
if (absSec < 31557600) return fmt.format(sign * Math.round(absSec / 2629800), 'month') // < ~365.25j
|
|
||||||
return fmt.format(sign * Math.round(absSec / 31557600), 'year')
|
|
||||||
}
|
|
||||||
|
|
||||||
function absoluteDate(iso: string): string {
|
|
||||||
// Meme logique : la locale de formatage suit celle de l'app.
|
|
||||||
return new Date(iso).toLocaleString(locale.value, {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: unknown }> {
|
|
||||||
// Format attendu: { champ: { old, new } }. On filtre defensivement les
|
|
||||||
// valeurs qui ne correspondent pas a ce shape (pas d'erreur runtime).
|
|
||||||
const out: Record<string, { old: unknown; new: unknown }> = {}
|
|
||||||
for (const [key, value] of Object.entries(entry.changes)) {
|
|
||||||
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
|
||||||
const diff = value as { old: unknown; new: unknown }
|
|
||||||
out[key] = diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectionDiff(entry: AuditLogEntry): Record<string, { added: unknown[]; removed: unknown[] }> {
|
|
||||||
// Format to-many : { champ: { added: [ids], removed: [ids] } } produit
|
|
||||||
// par AuditListener::captureCollectionChange.
|
|
||||||
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
|
||||||
for (const [key, value] of Object.entries(entry.changes)) {
|
|
||||||
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
|
||||||
const diff = value as { added: unknown; removed: unknown }
|
|
||||||
out[key] = {
|
|
||||||
added: Array.isArray(diff.added) ? diff.added : [],
|
|
||||||
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotSummary(entry: AuditLogEntry): string {
|
|
||||||
const keys = Object.keys(entry.changes)
|
|
||||||
if (keys.length === 0) return '—'
|
|
||||||
if (keys.length <= 4) return keys.join(', ')
|
|
||||||
return `${keys.slice(0, 4).join(', ')}…`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '∅'
|
|
||||||
// Passe par i18n plutot qu'un hardcode FR : si une autre locale est
|
|
||||||
// ajoutee, le rendu s'adapte sans nouvelle passe sur ce composant.
|
|
||||||
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
|
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload si l'entite affichee change.
|
|
||||||
watch([entityType, entityId], () => {
|
|
||||||
entries.value = []
|
|
||||||
page.value = 1
|
|
||||||
totalItems.value = 0
|
|
||||||
loadPage(1, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadPage(1, false)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache module-level : evite un double-fetch si la page et le composant
|
|
||||||
* Timeline demandent la meme page simultanement. Volontairement minimaliste :
|
|
||||||
* on ne cache que le dernier resultat, pas un LRU par filtre — un CRM interne
|
|
||||||
* n'en a pas besoin et le cache complexe complique le reset.
|
|
||||||
*
|
|
||||||
* Un logout / 401 doit purger ce cache : on s'enregistre au callback
|
|
||||||
* `onAuthSessionCleared` expose par auth.ts.
|
|
||||||
*/
|
|
||||||
const lastCollection = ref<HydraCollection<AuditLogEntry> | null>(null)
|
|
||||||
|
|
||||||
function resetAuditLog(): void {
|
|
||||||
lastCollection.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-enregistrement singleton : si la session est invalidee (401,
|
|
||||||
// logout) le cache est purge automatiquement, evitant qu'un autre user
|
|
||||||
// connecte ensuite ne voit des donnees residuelles.
|
|
||||||
onAuthSessionCleared(resetAuditLog)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Traduit le modele front (camelCase) en query params API Platform
|
|
||||||
* (snake_case, avec la syntaxe performed_at[after] / [before]).
|
|
||||||
*
|
|
||||||
* @returns objet plat directement consommable par `useApi().get(url, query)`.
|
|
||||||
*/
|
|
||||||
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number | string[]> {
|
|
||||||
const query: Record<string, string | number | string[]> = {}
|
|
||||||
if (!filters) return query
|
|
||||||
|
|
||||||
// `entity_type` : chaine simple ou liste pour un filtre multi-selection.
|
|
||||||
// Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour
|
|
||||||
// que $_GET['entity_type'] soit un tableau (sinon "last wins").
|
|
||||||
if (Array.isArray(filters.entityType)) {
|
|
||||||
if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType
|
|
||||||
} else if (filters.entityType) {
|
|
||||||
query.entity_type = filters.entityType
|
|
||||||
}
|
|
||||||
if (filters.entityId) query.entity_id = filters.entityId
|
|
||||||
if (filters.action) query.action = filters.action
|
|
||||||
if (filters.performedBy) query.performed_by = filters.performedBy
|
|
||||||
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
|
|
||||||
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
|
|
||||||
if (filters.page) query.page = filters.page
|
|
||||||
if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable partage entre la page globale d'audit (admin) et le composant
|
|
||||||
* Timeline. Expose des methodes de lecture + une fonction `resetAuditLog()`
|
|
||||||
* pour purger le cache (conforme a la regle CLAUDE.md sur les composables
|
|
||||||
* singletons, cf. `useSidebar.resetSidebar`).
|
|
||||||
*/
|
|
||||||
// Accept explicitement JSON-LD : API Platform 4 retourne un tableau PLAT (liste
|
|
||||||
// d'items, sans envelope de pagination) sous `application/json`, et un objet
|
|
||||||
// Hydra complet avec `member`, `totalItems` et `view` (first/last/next/previous)
|
|
||||||
// sous `application/ld+json`. Pour obtenir `view` cote front — indispensable
|
|
||||||
// a la pagination prev/next — on force donc ld+json.
|
|
||||||
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
|
||||||
|
|
||||||
export function useAuditLog() {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
async function fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
|
|
||||||
return api.get<HydraCollection<AuditLogEntry>>(
|
|
||||||
'/audit-logs',
|
|
||||||
buildQuery(filters),
|
|
||||||
{ toast: false, headers: JSONLD_HEADERS },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variante de `fetchLogs` qui met a jour le cache `lastCollection`.
|
|
||||||
* N'est utilisee que par la page admin — le composant Timeline appelle
|
|
||||||
* `fetchEntityLogs` qui bypass le cache pour ne pas polluer la reference
|
|
||||||
* page-level quand plusieurs timelines sont ouvertes.
|
|
||||||
*/
|
|
||||||
async function fetchLogsCached(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
|
|
||||||
const data = await fetchLogs(filters)
|
|
||||||
lastCollection.value = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLogById(id: string): Promise<AuditLogEntry> {
|
|
||||||
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste des valeurs distinctes de `entity_type` pour alimenter le filtre
|
|
||||||
* multi-selection. Alimente par un endpoint DBAL, aucune cache cote front
|
|
||||||
* (la liste peut evoluer a chaque nouvelle ecriture d'audit).
|
|
||||||
*/
|
|
||||||
async function fetchEntityTypes(): Promise<string[]> {
|
|
||||||
const data = await api.get<AuditLogEntityTypes>(
|
|
||||||
'/audit-log-entity-types',
|
|
||||||
{},
|
|
||||||
{ toast: false, headers: JSONLD_HEADERS },
|
|
||||||
)
|
|
||||||
return data.entityTypes ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEntityLogs(
|
|
||||||
entityType: string,
|
|
||||||
entityId: string | number,
|
|
||||||
page: number = 1,
|
|
||||||
itemsPerPage: number = 10,
|
|
||||||
): Promise<HydraCollection<AuditLogEntry>> {
|
|
||||||
// Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser
|
|
||||||
// `lastCollection` — la timeline peut etre rendue simultanement a
|
|
||||||
// la page globale et doit rester independante.
|
|
||||||
//
|
|
||||||
// Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on
|
|
||||||
// passe explicitement itemsPerPage ici pour que la taille de page
|
|
||||||
// soit alignee avec l'UX timeline (10 items + bouton "Voir plus").
|
|
||||||
// Sans ce param, le client slice a 10 et rate 20 entrees par page.
|
|
||||||
return fetchLogs({
|
|
||||||
entityType,
|
|
||||||
entityId: String(entityId),
|
|
||||||
page,
|
|
||||||
itemsPerPage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// API publique : on expose volontairement deux noms distincts pour les
|
|
||||||
// deux contrats (cache/no-cache). Aliaser `fetchLogs` vers la version
|
|
||||||
// cachee trompait les appelants : un consommateur qui destructurait
|
|
||||||
// `{ fetchLogs }` en pensant faire un appel neutre polluait en realite
|
|
||||||
// `lastCollection`, effet indetectable sans lire l'impl.
|
|
||||||
return {
|
|
||||||
lastCollection,
|
|
||||||
fetchLogsCached,
|
|
||||||
fetchLogById,
|
|
||||||
fetchEntityLogs,
|
|
||||||
fetchEntityTypes,
|
|
||||||
resetAuditLog,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,14 @@
|
|||||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||||
*
|
*
|
||||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||||
* Chargement idempotent via le flag `loaded`, reset automatique au logout
|
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
||||||
* via `onAuthSessionCleared` (cf. CLAUDE.md : « composables avec state
|
* (voir pages/logout.vue).
|
||||||
* singleton doivent etre reinitialises au logout »).
|
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
|
||||||
|
|
||||||
const activeModuleIds = ref<string[]>([])
|
const activeModuleIds = ref<string[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
function resetModulesState(): void {
|
|
||||||
activeModuleIds.value = []
|
|
||||||
loaded.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onAuthSessionCleared(resetModulesState)
|
|
||||||
|
|
||||||
export function useModules() {
|
export function useModules() {
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +35,8 @@ export function useModules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetModules() {
|
function resetModules() {
|
||||||
resetModulesState()
|
activeModuleIds.value = []
|
||||||
|
loaded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { SidebarSection } from '~/shared/types'
|
import type { SidebarSection } from '~/shared/types'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
|
||||||
|
|
||||||
const sections = ref<SidebarSection[]>([])
|
const sections = ref<SidebarSection[]>([])
|
||||||
const disabledRoutes = ref<string[]>([])
|
const disabledRoutes = ref<string[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
function resetSidebarState(): void {
|
|
||||||
sections.value = []
|
|
||||||
disabledRoutes.value = []
|
|
||||||
loaded.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-enregistrement singleton : purge la sidebar sur 401/logout pour
|
|
||||||
// eviter qu'un nouvel utilisateur logue sur le meme onglet voie transitoirement
|
|
||||||
// les items de l'ancienne session (cf. CLAUDE.md : « composables avec state
|
|
||||||
// singleton doivent etre reinitialises au logout »).
|
|
||||||
onAuthSessionCleared(resetSidebarState)
|
|
||||||
|
|
||||||
export function useSidebar() {
|
export function useSidebar() {
|
||||||
async function loadSidebar() {
|
async function loadSidebar() {
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +31,9 @@ export function useSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetSidebar() {
|
function resetSidebar() {
|
||||||
resetSidebarState()
|
sections.value = []
|
||||||
|
disabledRoutes.value = []
|
||||||
|
loaded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,44 +9,3 @@ export interface SidebarSection {
|
|||||||
icon: string
|
icon: string
|
||||||
items: SidebarItem[]
|
items: SidebarItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Entree d'audit telle qu'elle est renvoyee par GET /api/audit-logs.
|
|
||||||
*
|
|
||||||
* `changes` est un payload libre dont le format depend de `action` :
|
|
||||||
* - `create` / `delete` : snapshot complet { champ: valeur } ;
|
|
||||||
* - `update` : diff { champ: { old, new } }.
|
|
||||||
*/
|
|
||||||
export interface AuditLogEntry {
|
|
||||||
id: string
|
|
||||||
entityType: string
|
|
||||||
entityId: string
|
|
||||||
action: 'create' | 'update' | 'delete'
|
|
||||||
changes: Record<string, unknown>
|
|
||||||
performedBy: string
|
|
||||||
performedAt: string
|
|
||||||
ipAddress: string | null
|
|
||||||
requestId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filtres combinables en query params (AND) pour GET /api/audit-logs.
|
|
||||||
* Les bornes de date utilisent la syntaxe API Platform `performed_at[after]` /
|
|
||||||
* `performed_at[before]`.
|
|
||||||
*/
|
|
||||||
export interface AuditLogFilters {
|
|
||||||
/** Chaine pour un seul type, liste pour un filtre multi-selection. */
|
|
||||||
entityType?: string | string[]
|
|
||||||
entityId?: string
|
|
||||||
action?: string
|
|
||||||
performedBy?: string
|
|
||||||
performedAtAfter?: string
|
|
||||||
performedAtBefore?: string
|
|
||||||
page?: number
|
|
||||||
itemsPerPage?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditLogEntityTypes {
|
|
||||||
id: string
|
|
||||||
entityTypes: string[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,19 +21,7 @@ export interface UserListItem {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
roles: string[]
|
roles: string[]
|
||||||
directPermissions: string[]
|
directPermissions: string[]
|
||||||
}
|
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||||
|
|
||||||
/**
|
|
||||||
* Detail RBAC d'un user, renvoye par GET /api/users/{id}/rbac (groupe user:rbac:read).
|
|
||||||
* Utilise par UserRbacDrawer pour initialiser son formulaire avec l'etat complet
|
|
||||||
* (sites inclus). Le endpoint de liste /api/users reste volontairement leger et
|
|
||||||
* n'expose pas ces champs.
|
|
||||||
*/
|
|
||||||
export interface UserRbacDetail {
|
|
||||||
id: number
|
|
||||||
isAdmin: boolean
|
|
||||||
roles: string[]
|
|
||||||
directPermissions: string[]
|
|
||||||
sites: string[]
|
sites: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { debounce } from '../debounce'
|
|
||||||
|
|
||||||
describe('debounce', () => {
|
|
||||||
it('attend delay ms avant d\'appeler fn une seule fois apres plusieurs invocations rapides', () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const fn = vi.fn()
|
|
||||||
const debounced = debounce(fn, 100)
|
|
||||||
|
|
||||||
debounced()
|
|
||||||
debounced()
|
|
||||||
debounced()
|
|
||||||
expect(fn).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(100)
|
|
||||||
expect(fn).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passe les arguments du dernier appel a fn', () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const fn = vi.fn<(a: string, b: number) => void>()
|
|
||||||
const debounced = debounce(fn, 50)
|
|
||||||
|
|
||||||
debounced('first', 1)
|
|
||||||
debounced('second', 2)
|
|
||||||
debounced('third', 3)
|
|
||||||
vi.advanceTimersByTime(50)
|
|
||||||
|
|
||||||
expect(fn).toHaveBeenCalledTimes(1)
|
|
||||||
expect(fn).toHaveBeenCalledWith('third', 3)
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('autorise plusieurs executions espacees dans le temps', () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const fn = vi.fn()
|
|
||||||
const debounced = debounce(fn, 50)
|
|
||||||
|
|
||||||
debounced()
|
|
||||||
vi.advanceTimersByTime(50)
|
|
||||||
expect(fn).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
debounced()
|
|
||||||
vi.advanceTimersByTime(50)
|
|
||||||
expect(fn).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,33 +1,8 @@
|
|||||||
/**
|
|
||||||
* Schemas Hydra / API Platform 4.
|
|
||||||
*
|
|
||||||
* Important : API Platform 4 abandonne le prefixe `hydra:` dans les noms de
|
|
||||||
* proprietes (compare a la version 3). Un GET /api/audit-logs renvoie :
|
|
||||||
* { "@context": ..., "@id": ..., "@type": "...",
|
|
||||||
* "member": [...],
|
|
||||||
* "totalItems": 30,
|
|
||||||
* "view": { "@id": ..., "@type": "...", "first": ..., "next": ..., ... } }
|
|
||||||
*
|
|
||||||
* En `application/json` (sans ld), API Platform retourne un simple tableau
|
|
||||||
* plat sans ces metadonnees — on doit donc explicitement demander
|
|
||||||
* `application/ld+json` (via l'option `headers: { Accept: ... }` de useApi)
|
|
||||||
* pour avoir acces a la pagination.
|
|
||||||
*/
|
|
||||||
export interface HydraView {
|
|
||||||
'@id'?: string
|
|
||||||
'@type'?: string
|
|
||||||
first?: string
|
|
||||||
last?: string
|
|
||||||
next?: string
|
|
||||||
previous?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HydraCollection<T> {
|
export interface HydraCollection<T> {
|
||||||
member: T[]
|
'hydra:member': T[]
|
||||||
totalItems: number
|
'hydra:totalItems': number
|
||||||
view?: HydraView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||||
return collection.member ?? []
|
return collection['hydra:member'] ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Utilitaire de debounce partage.
|
|
||||||
*
|
|
||||||
* Retarde l'execution d'une fonction : chaque appel reset un timer et
|
|
||||||
* l'execution reelle n'a lieu qu'apres `delay` ms sans nouvelle invocation.
|
|
||||||
* Utile pour eviter un spam d'appels reseau sur un champ de recherche
|
|
||||||
* (une requete par touche -> une seule requete apres la derniere frappe).
|
|
||||||
*/
|
|
||||||
export function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
return ((...args: Parameters<T>) => {
|
|
||||||
if (null !== timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => fn(...args), delay)
|
|
||||||
}) as T
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {Config} from 'tailwindcss'
|
import type {Config} from 'tailwindcss'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config Tailwind du projet Starseed.
|
* Config Tailwind du projet Coltura.
|
||||||
*
|
*
|
||||||
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
||||||
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
||||||
@@ -11,7 +11,7 @@ import type {Config} from 'tailwindcss'
|
|||||||
* success,btn-*,site-blue,site-yellow,site-green}
|
* success,btn-*,site-blue,site-yellow,site-green}
|
||||||
* - fontFamily.sans (Helvetica Neue)
|
* - fontFamily.sans (Helvetica Neue)
|
||||||
*
|
*
|
||||||
* Cette config locale ne redeclare QUE ce qui est specifique a Starseed
|
* Cette config locale ne redeclare QUE ce qui est specifique a Coltura
|
||||||
* ou absent de la config Malio — evite la duplication et les derives.
|
* ou absent de la config Malio — evite la duplication et les derives.
|
||||||
*/
|
*/
|
||||||
export default <Partial<Config>>{
|
export default <Partial<Config>>{
|
||||||
@@ -19,7 +19,7 @@ export default <Partial<Config>>{
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Couleurs applicatives Starseed (hors namespace `m` reserve
|
// Couleurs applicatives Coltura (hors namespace `m` reserve
|
||||||
// au design system Malio partage).
|
// au design system Malio partage).
|
||||||
primary: {
|
primary: {
|
||||||
500: '#222783',
|
500: '#222783',
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Definition des 6 personas utilises dans les tests E2E.
|
|
||||||
*
|
|
||||||
* Source de verite unique partagee entre :
|
|
||||||
* - le seed backend (`bin/console app:seed-e2e`)
|
|
||||||
* - les tests Playwright (via `loginAs`)
|
|
||||||
*
|
|
||||||
* Regle : chaque persona cible une case precise de la matrice RBAC.
|
|
||||||
* Si tu ajoutes une permission au domaine, tu NE crees pas un nouveau
|
|
||||||
* persona par reflexe — tu ajustes un persona existant si possible.
|
|
||||||
* L'objectif est de garder ce set petit et comprehensible a 6 mois.
|
|
||||||
*
|
|
||||||
* IMPORTANT : ces personas sont recrees a chaque `app:seed-e2e`. Ne jamais
|
|
||||||
* reutiliser les users dev (admin/alice/bob) dans les tests : ils evoluent
|
|
||||||
* au gre des fixtures de demo et casseraient la suite E2E.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type PersonaKey =
|
|
||||||
| 'super-admin'
|
|
||||||
| 'user-full'
|
|
||||||
| 'user-readonly'
|
|
||||||
| 'user-users-only'
|
|
||||||
| 'user-audit-only'
|
|
||||||
| 'user-nothing'
|
|
||||||
|
|
||||||
export interface Persona {
|
|
||||||
key: PersonaKey
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
isAdmin: boolean
|
|
||||||
// Permissions directes attribuees en dur (on bypasse les roles pour
|
|
||||||
// garder le seed simple et la correspondance test<->permission directe).
|
|
||||||
permissions: string[]
|
|
||||||
// Contenu attendu de la sidebar (admin links). Utilise par le test
|
|
||||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
|
||||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
|
||||||
// la copie/i18n change.
|
|
||||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHARED_PASSWORD = 'e2e-secret'
|
|
||||||
|
|
||||||
export const personas: Record<PersonaKey, Persona> = {
|
|
||||||
'super-admin': {
|
|
||||||
key: 'super-admin',
|
|
||||||
username: 'e2e.super-admin',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: true,
|
|
||||||
permissions: [],
|
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
|
||||||
},
|
|
||||||
'user-full': {
|
|
||||||
key: 'user-full',
|
|
||||||
username: 'e2e.user-full',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: false,
|
|
||||||
permissions: [
|
|
||||||
'core.users.view',
|
|
||||||
'core.users.manage',
|
|
||||||
'core.roles.view',
|
|
||||||
'core.roles.manage',
|
|
||||||
'core.audit_log.view',
|
|
||||||
'sites.view',
|
|
||||||
'sites.manage',
|
|
||||||
'sites.bypass_scope',
|
|
||||||
],
|
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
|
||||||
},
|
|
||||||
'user-readonly': {
|
|
||||||
key: 'user-readonly',
|
|
||||||
username: 'e2e.user-readonly',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: false,
|
|
||||||
permissions: [
|
|
||||||
'core.users.view',
|
|
||||||
'core.roles.view',
|
|
||||||
'core.audit_log.view',
|
|
||||||
'sites.view',
|
|
||||||
],
|
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
|
||||||
},
|
|
||||||
'user-users-only': {
|
|
||||||
key: 'user-users-only',
|
|
||||||
username: 'e2e.user-users-only',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: false,
|
|
||||||
permissions: ['core.users.view', 'core.users.manage'],
|
|
||||||
expectedAdminLinks: ['users'],
|
|
||||||
},
|
|
||||||
'user-audit-only': {
|
|
||||||
key: 'user-audit-only',
|
|
||||||
username: 'e2e.user-audit-only',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: false,
|
|
||||||
permissions: ['core.audit_log.view'],
|
|
||||||
expectedAdminLinks: ['audit-log'],
|
|
||||||
},
|
|
||||||
'user-nothing': {
|
|
||||||
key: 'user-nothing',
|
|
||||||
username: 'e2e.user-nothing',
|
|
||||||
password: SHARED_PASSWORD,
|
|
||||||
isAdmin: false,
|
|
||||||
permissions: [],
|
|
||||||
expectedAdminLinks: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPersona(key: PersonaKey): Persona {
|
|
||||||
return personas[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
|
||||||
import { LoginPage } from '../helpers/pages/LoginPage'
|
|
||||||
import { getPersona } from '../_fixtures/personas'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests du flow login/logout via l'UI.
|
|
||||||
*
|
|
||||||
* C'est le SEUL fichier qui traverse le formulaire pour de vrai. Les autres
|
|
||||||
* specs utilisent `loginAs()` qui pose directement le cookie BEARER via API,
|
|
||||||
* 10x plus rapide et decouple du form HTML.
|
|
||||||
*/
|
|
||||||
test.describe('Login', () => {
|
|
||||||
test('login valide pose le cookie BEARER et redirige vers /', async ({ page, context }) => {
|
|
||||||
const superAdmin = getPersona('super-admin')
|
|
||||||
const loginPage = new LoginPage(page)
|
|
||||||
|
|
||||||
await loginPage.goto()
|
|
||||||
await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password)
|
|
||||||
|
|
||||||
// La redirection se fait apres un `navigateTo('/')` dans login.vue.
|
|
||||||
await page.waitForURL('/')
|
|
||||||
await expect(page).toHaveURL('/')
|
|
||||||
|
|
||||||
// Le cookie BEARER (HTTP-only) doit etre pose par Symfony.
|
|
||||||
const cookies = await context.cookies()
|
|
||||||
const bearer = cookies.find(c => c.name === 'BEARER')
|
|
||||||
expect(bearer, 'Le cookie BEARER doit etre pose apres un login valide').toBeDefined()
|
|
||||||
expect(bearer?.httpOnly).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('login invalide reste sur /login et n\'emet pas de cookie', async ({ page, context }) => {
|
|
||||||
const loginPage = new LoginPage(page)
|
|
||||||
|
|
||||||
await loginPage.goto()
|
|
||||||
await loginPage.fillAndSubmit('e2e.super-admin', 'wrong-password')
|
|
||||||
|
|
||||||
// On ne doit PAS etre redirige — le handleSubmit swallow la 401 via toast,
|
|
||||||
// le user reste sur /login pour corriger.
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
await expect(page).toHaveURL(/\/login$/)
|
|
||||||
|
|
||||||
const cookies = await context.cookies()
|
|
||||||
const bearer = cookies.find(c => c.name === 'BEARER')
|
|
||||||
expect(bearer, 'Aucun cookie BEARER ne doit etre pose apres un login invalide').toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('logout efface le cookie et redirige vers /login', async ({ page, context }) => {
|
|
||||||
const superAdmin = getPersona('super-admin')
|
|
||||||
const loginPage = new LoginPage(page)
|
|
||||||
|
|
||||||
// 1. Login d'abord
|
|
||||||
await loginPage.goto()
|
|
||||||
await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password)
|
|
||||||
await page.waitForURL('/')
|
|
||||||
|
|
||||||
// 2. Navigation vers /logout (il y a un lien "Deconnexion" dans la sidebar)
|
|
||||||
await page.goto('/logout')
|
|
||||||
await page.waitForURL(/\/login$/)
|
|
||||||
|
|
||||||
// 3. Le cookie BEARER doit avoir ete supprime par le firewall de logout
|
|
||||||
const cookies = await context.cookies()
|
|
||||||
const bearer = cookies.find(c => c.name === 'BEARER')
|
|
||||||
expect(bearer, 'Le cookie BEARER doit etre supprime apres logout').toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { BrowserContext, Page } from '@playwright/test'
|
|
||||||
import { type PersonaKey, getPersona } from '../_fixtures/personas'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login programmatique : pose le cookie BEARER via l'API sans passer par le
|
|
||||||
* formulaire de login.
|
|
||||||
*
|
|
||||||
* Utilise ce helper dans TOUS les tests qui ne testent pas le flow login
|
|
||||||
* lui-meme (sidebar visibility, route guards, etc.). Ca evite de payer 2s
|
|
||||||
* par test sur le form HTML et ca isole les tests : si le form login casse,
|
|
||||||
* seul `login.spec.ts` est rouge, pas toute la suite.
|
|
||||||
*
|
|
||||||
* Impl : on issue une requete POST /api/login_check avec les creds du persona.
|
|
||||||
* Nginx reecrit vers /login_check, Symfony pose le cookie BEARER sur le
|
|
||||||
* context du browser. Apres ca, n'importe quelle navigation est authentifiee.
|
|
||||||
*/
|
|
||||||
export async function loginAs(context: BrowserContext, persona: PersonaKey, baseURL?: string): Promise<void> {
|
|
||||||
const { username, password } = getPersona(persona)
|
|
||||||
const base = baseURL ?? 'http://localhost:3004'
|
|
||||||
|
|
||||||
const response = await context.request.post(`${base}/api/login_check`, {
|
|
||||||
data: { username, password },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok()) {
|
|
||||||
const body = await response.text()
|
|
||||||
throw new Error(
|
|
||||||
`loginAs(${persona}) a echoue : ${response.status()} ${body}. `
|
|
||||||
+ 'Verifier que le backend tourne et que `make seed-e2e` a ete lance.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper d'appoint quand on veut tester VIA l'UI (login.spec.ts uniquement).
|
|
||||||
* Passe par le formulaire rendu, clique sur le bouton. A ne PAS utiliser
|
|
||||||
* dans les autres tests — preferer `loginAs()`.
|
|
||||||
*/
|
|
||||||
export async function loginViaForm(page: Page, persona: PersonaKey): Promise<void> {
|
|
||||||
const { username, password } = getPersona(persona)
|
|
||||||
await page.goto('/login')
|
|
||||||
await page.getByLabel("Nom d'utilisateur").fill(username)
|
|
||||||
await page.getByLabel('Mot de passe').fill(password)
|
|
||||||
await page.getByRole('button', { name: 'Se connecter' }).click()
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page Object du formulaire de login (/login).
|
|
||||||
*
|
|
||||||
* Selecteurs : on s'appuie sur les labels/roles accessibles (stable vs les
|
|
||||||
* changements de CSS/Tailwind). Le jour ou on veut un selecteur plus dur,
|
|
||||||
* on ajoute des `data-testid` sur login.vue.
|
|
||||||
*/
|
|
||||||
export class LoginPage {
|
|
||||||
readonly page: Page
|
|
||||||
readonly usernameInput: Locator
|
|
||||||
readonly passwordInput: Locator
|
|
||||||
readonly submitButton: Locator
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page
|
|
||||||
this.usernameInput = page.getByLabel("Nom d'utilisateur")
|
|
||||||
this.passwordInput = page.getByLabel('Mot de passe')
|
|
||||||
this.submitButton = page.getByRole('button', { name: 'Se connecter' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async goto(): Promise<void> {
|
|
||||||
await this.page.goto('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillAndSubmit(username: string, password: string): Promise<void> {
|
|
||||||
await this.usernameInput.fill(username)
|
|
||||||
await this.passwordInput.fill(password)
|
|
||||||
await this.submitButton.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
|
||||||
|
|
||||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
|
||||||
*
|
|
||||||
* Strategie selecteur : `a[href=...]` plutot que le texte i18n. Le slug de
|
|
||||||
* route ne change pas quand on retraduit ou renomme une entree — c'est le
|
|
||||||
* selecteur le plus stable pour cette suite.
|
|
||||||
*
|
|
||||||
* Si un jour la sidebar change et les slugs bougent, on met a jour CE
|
|
||||||
* fichier uniquement ; les specs continuent de passer.
|
|
||||||
*/
|
|
||||||
export class SidebarComponent {
|
|
||||||
readonly page: Page
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page
|
|
||||||
}
|
|
||||||
|
|
||||||
adminLink(slug: AdminLinkSlug): Locator {
|
|
||||||
return this.page.locator(`a[href="/admin/${slug}"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
accountDashboardLink(): Locator {
|
|
||||||
return this.page.locator('a[href="/"]').first()
|
|
||||||
}
|
|
||||||
|
|
||||||
logoutLink(): Locator {
|
|
||||||
return this.page.locator('a[href="/logout"]')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
|
||||||
import { loginAs } from '../helpers/loginAs'
|
|
||||||
import { SidebarComponent } from '../helpers/pages/SidebarComponent'
|
|
||||||
import { ALL_ADMIN_LINKS, type PersonaKey, getPersona, personas } from '../_fixtures/personas'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test strategique : la matrice persona <-> liens admin visibles.
|
|
||||||
*
|
|
||||||
* Valide que `SidebarProvider` (back) + `useSidebar` (front) filtrent bien
|
|
||||||
* les items admin selon les permissions RBAC de chaque user.
|
|
||||||
*
|
|
||||||
* Regle d'evolution : ajouter une permission ou un persona = 1 ligne a
|
|
||||||
* modifier dans `personas.ts` et cote back (`SeedE2ECommand`) + `sidebar.php`.
|
|
||||||
* Ce fichier ne bouge pas.
|
|
||||||
*/
|
|
||||||
test.describe('Sidebar visibility', () => {
|
|
||||||
const personaKeys: PersonaKey[] = [
|
|
||||||
'super-admin',
|
|
||||||
'user-full',
|
|
||||||
'user-readonly',
|
|
||||||
'user-users-only',
|
|
||||||
'user-audit-only',
|
|
||||||
'user-nothing',
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const key of personaKeys) {
|
|
||||||
const persona = getPersona(key)
|
|
||||||
|
|
||||||
test(`${persona.key} ne voit que ses liens admin autorises`, async ({ page, context }) => {
|
|
||||||
await loginAs(context, persona.key)
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
const sidebar = new SidebarComponent(page)
|
|
||||||
|
|
||||||
// Attente semantique : on ancre sur un lien toujours present pour
|
|
||||||
// tout user authentifie (Mon compte > Tableau de bord). Remplace
|
|
||||||
// `networkidle` qui est reconnu instable en CI (SPAs avec polling
|
|
||||||
// ou HMR peuvent ne jamais quitter cet etat).
|
|
||||||
await expect(sidebar.accountDashboardLink()).toBeVisible({ timeout: 10000 })
|
|
||||||
|
|
||||||
for (const link of ALL_ADMIN_LINKS) {
|
|
||||||
const locator = sidebar.adminLink(link)
|
|
||||||
const shouldBeVisible = persona.expectedAdminLinks.includes(link)
|
|
||||||
|
|
||||||
if (shouldBeVisible) {
|
|
||||||
await expect(
|
|
||||||
locator,
|
|
||||||
`${persona.key} doit voir le lien /admin/${link}`,
|
|
||||||
).toBeVisible()
|
|
||||||
} else {
|
|
||||||
await expect(
|
|
||||||
locator,
|
|
||||||
`${persona.key} ne doit PAS voir le lien /admin/${link}`,
|
|
||||||
).toHaveCount(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
test('user-nothing voit toujours le dashboard et le logout (section Mon compte sans permission)', async ({
|
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
// La section "Mon compte" n'est gardee par aucune permission : tout user
|
|
||||||
// authentifie voit le dashboard et peut se deconnecter. Ce test protege
|
|
||||||
// contre une regression qui mettrait un gate RBAC par inadvertance
|
|
||||||
// dessus — ca bloquerait le logout de users sans permissions.
|
|
||||||
await loginAs(context, 'user-nothing')
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
const sidebar = new SidebarComponent(page)
|
|
||||||
// Meme strategie que ci-dessus : ancrage semantique plutot que
|
|
||||||
// `networkidle` pour eviter les faux timeouts en CI.
|
|
||||||
await expect(sidebar.accountDashboardLink()).toBeVisible({ timeout: 10000 })
|
|
||||||
await expect(sidebar.logoutLink()).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('la liste des personas dans personas.ts couvre toutes les combinaisons admin attendues', () => {
|
|
||||||
// Test meta : si quelqu'un ajoute un persona dans personas.ts sans le
|
|
||||||
// seeder cote back (SeedE2ECommand), le test sidebar pour ce persona
|
|
||||||
// echouera (loginAs 401). Ce test rappelle la coherence attendue.
|
|
||||||
expect(Object.keys(personas)).toEqual(personaKeys)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -7,10 +7,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
globals: true,
|
globals: true,
|
||||||
// Exclure les tests E2E Playwright : meme extension .spec.ts mais
|
|
||||||
// runtime different (navigateur vrai vs happy-dom). Playwright les
|
|
||||||
// ramasse via son propre testDir declare dans playwright.config.ts.
|
|
||||||
exclude: ['**/node_modules/**', '**/dist/**', 'tests/e2e/**'],
|
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
DOCKER_APP_NAME=starseed
|
DOCKER_APP_NAME=coltura
|
||||||
DOCKER_PHP_VERSION=8.4.6
|
DOCKER_PHP_VERSION=8.4.6
|
||||||
DOCKER_NODE_VERSION=24.12.0
|
DOCKER_NODE_VERSION=24.12.0
|
||||||
APP_USER=www-data
|
APP_USER=www-data
|
||||||
POSTGRES_DB=starseed
|
POSTGRES_DB=coltura
|
||||||
POSTGRES_USER=root
|
POSTGRES_USER=root
|
||||||
POSTGRES_PASSWORD=root
|
POSTGRES_PASSWORD=root
|
||||||
POSTGRES_PORT=5437
|
POSTGRES_PORT=5437
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ APP_ENV=prod
|
|||||||
APP_DEBUG=0
|
APP_DEBUG=0
|
||||||
APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
||||||
|
|
||||||
DATABASE_URL="postgresql://starseed:CHANGE_ME@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://coltura:CHANGE_ME@host.docker.internal:5432/coltura?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
||||||
# HTTP en reseau local => cookie non secure
|
JWT_COOKIE_SECURE=1
|
||||||
JWT_COOKIE_SECURE=0
|
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^https://coltura\.malio-dev\.fr$'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ RUN rm -f /etc/nginx/sites-enabled/default
|
|||||||
|
|
||||||
# Configs
|
# Configs
|
||||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/starseed.conf
|
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/coltura.conf
|
||||||
|
|
||||||
# Backend from stage 1
|
# Backend from stage 1
|
||||||
COPY --from=backend-build /app /var/www/html
|
COPY --from=backend-build /app /var/www/html
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
export STARSEED_IMAGE_TAG="$TAG"
|
export COLTURA_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
echo "==> Deploying starseed:${TAG}..."
|
echo "==> Deploying coltura:${TAG}..."
|
||||||
|
|
||||||
echo "==> Enabling maintenance mode..."
|
echo "==> Enabling maintenance mode..."
|
||||||
touch maintenance.on
|
touch maintenance.on
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: gitea.malio.fr/malio-dev/starseed:${STARSEED_IMAGE_TAG:-latest}
|
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
||||||
container_name: starseed-app
|
container_name: coltura-app
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8086:80"
|
- "8086:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
- starseed_logs:/var/www/html/var/log
|
- coltura_logs:/var/www/html/var/log
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
starseed_logs:
|
coltura_logs:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name starseed.malio-dev.fr;
|
server_name coltura.malio-dev.fr;
|
||||||
|
|
||||||
root /var/www/starseed/public;
|
root /var/www/coltura/public;
|
||||||
|
|
||||||
# Maintenance mode
|
# Maintenance mode
|
||||||
if (-f /var/www/starseed/maintenance.on) {
|
if (-f /var/www/coltura/maintenance.on) {
|
||||||
return 503;
|
return 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
makefile
101
makefile
@@ -1,5 +1,3 @@
|
|||||||
.DEFAULT_GOAL := help
|
|
||||||
|
|
||||||
# Permet d'utiliser un .env.docker.local pour override
|
# Permet d'utiliser un .env.docker.local pour override
|
||||||
ENV_DEFAULT = infra/dev/.env.docker
|
ENV_DEFAULT = infra/dev/.env.docker
|
||||||
ENV_LOCAL = infra/dev/.env.docker.local
|
ENV_LOCAL = infra/dev/.env.docker.local
|
||||||
@@ -9,12 +7,6 @@ ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
|||||||
include $(ENV_DEFAULT)
|
include $(ENV_DEFAULT)
|
||||||
-include $(ENV_LOCAL)
|
-include $(ENV_LOCAL)
|
||||||
|
|
||||||
# Export du UID/GID host pour que docker compose les voie dans toutes les targets
|
|
||||||
# (sinon le build du Dockerfile dev fail sur `usermod -u ${CURRENT_UID}` quand l'image
|
|
||||||
# n'est pas en cache, ex: apres renommage du compose project).
|
|
||||||
export CURRENT_UID := $(shell id -u)
|
|
||||||
export CURRENT_GID := $(shell id -g)
|
|
||||||
|
|
||||||
PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm
|
PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm
|
||||||
SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console
|
SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console
|
||||||
|
|
||||||
@@ -30,48 +22,6 @@ FILES =
|
|||||||
|
|
||||||
#========================================================================================
|
#========================================================================================
|
||||||
|
|
||||||
# Affiche l'aide — cible par defaut (make ou make help)
|
|
||||||
help:
|
|
||||||
@printf "\n \033[1mStarseed — Commandes make\033[0m\n\n"
|
|
||||||
@printf " \033[1;33mContainers\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "start" "Demarrer les containers Docker"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "stop" "Arreter les containers"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "restart" "Redemarrer les containers"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "shell" "Shell bash dans le container PHP (user app)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "shell-root" "Shell bash dans le container PHP (root)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "logs-dev" "Tail des logs Symfony (var/log/dev.log)"
|
|
||||||
@printf "\n \033[1;33mInstallation\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "install" "Install complet (composer, migrations, fixtures, build Nuxt)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "reset" "Tout supprimer et reinstaller (ATTENTION : drop la BDD)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "composer-install" "Composer install + generation cles JWT"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "build-nuxtJS" "npm install + build Nuxt (prod)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "build-without-cache" "Rebuild des images Docker sans cache"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "copy-git-hook" "Copie les hooks git (pre-commit, commit-msg)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "node-use" "Force la version Node via nvm"
|
|
||||||
@printf "\n \033[1;33mFrontend (Nuxt)\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "dev-nuxt" "Serveur dev Nuxt avec hot reload (port 3004)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-lint" "Lint TypeScript/Vue"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-lint-fix" "Lint + auto-fix"
|
|
||||||
@printf "\n \033[1;33mBase de donnees\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "migration-migrate" "Lancer les migrations Doctrine"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "fixtures" "Charger les fixtures"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "sync-permissions" "Synchroniser le catalogue RBAC"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "db-reset" "Reset BDD (drop + migrate + fixtures + perms)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "db-restart" "Restart du container BDD"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "test-db-setup" "Cree et initialise la BDD de test"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "cache-clear" "Vider le cache Symfony"
|
|
||||||
@printf "\n \033[1;33mTests\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "test" "PHPUnit (tests back)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "nuxt-test" "Vitest (tests unitaires front)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "test-all" "PHPUnit + Vitest"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "test-e2e" "Playwright (tests E2E front)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "test-e2e-ui" "Playwright UI interactive (debug)"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "seed-e2e" "Seed les 6 personas E2E"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
|
|
||||||
@printf "\n \033[1;33mQualite code\033[0m\n"
|
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
|
|
||||||
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
|
|
||||||
|
|
||||||
env-init:
|
env-init:
|
||||||
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
||||||
|
|
||||||
@@ -88,7 +38,7 @@ restart: env-init
|
|||||||
$(DOCKER_COMPOSE) down
|
$(DOCKER_COMPOSE) down
|
||||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||||
|
|
||||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions test-db-setup
|
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate test-db-setup
|
||||||
|
|
||||||
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
||||||
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
||||||
@@ -100,7 +50,6 @@ composer-install:
|
|||||||
$(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists
|
$(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists
|
||||||
|
|
||||||
build-nuxtJS:
|
build-nuxtJS:
|
||||||
$(EXEC_PHP_ROOT) chown -R $(APP_USER):$(APP_USER) /var/www/html/frontend
|
|
||||||
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
|
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
|
||||||
|
|
||||||
dev-nuxt:
|
dev-nuxt:
|
||||||
@@ -116,54 +65,6 @@ nuxt-lint-fix:
|
|||||||
nuxt-test:
|
nuxt-test:
|
||||||
$(EXEC_PHP) sh -c "cd frontend && npm run test"
|
$(EXEC_PHP) sh -c "cd frontend && npm run test"
|
||||||
|
|
||||||
# Seed les 6 personas E2E (idempotent). A relancer des que le catalogue
|
|
||||||
# permissions bouge (sync-permissions) ou avant chaque run test-e2e.
|
|
||||||
seed-e2e:
|
|
||||||
$(SYMFONY_CONSOLE) app:seed-e2e
|
|
||||||
|
|
||||||
# Bootstrap one-time pour les tests E2E sur un nouveau poste :
|
|
||||||
# 1. Telecharge Chromium dans ~/.cache/ms-playwright
|
|
||||||
# 2. Installe les deps systeme (libnss3, libasound, libatk, etc.) :
|
|
||||||
# - Ubuntu/Debian : `playwright install-deps` (officiel)
|
|
||||||
# - Fedora/RHEL : liste dnf equivalente (playwright ne gere pas dnf)
|
|
||||||
# - Autre : avertissement, a faire a la main.
|
|
||||||
#
|
|
||||||
# Le `sudo env "PATH=$$PATH"` est necessaire car avec NVM, `sudo npx` ne
|
|
||||||
# trouve pas npx (le PATH de sudo est vide par defaut). On preserve
|
|
||||||
# explicitement le PATH courant pour que npx resolve.
|
|
||||||
#
|
|
||||||
# A relancer uniquement si tu upgrade @playwright/test (les deps peuvent
|
|
||||||
# bouger entre versions majeures).
|
|
||||||
install-e2e-deps:
|
|
||||||
cd frontend && npx playwright install chromium
|
|
||||||
@if command -v apt-get >/dev/null 2>&1; then \
|
|
||||||
echo ">> Detected apt-get — using playwright install-deps"; \
|
|
||||||
cd frontend && sudo env "PATH=$$PATH" npx playwright install-deps chromium; \
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then \
|
|
||||||
echo ">> Detected dnf — installing Chromium deps via dnf"; \
|
|
||||||
sudo dnf install -y \
|
|
||||||
nss nspr dbus-libs atk at-spi2-atk at-spi2-core cups-libs \
|
|
||||||
libdrm libX11 libXcomposite libXdamage libXfixes libXrandr \
|
|
||||||
libXext libXtst libxkbcommon mesa-libgbm alsa-lib \
|
|
||||||
pango cairo libwayland-client; \
|
|
||||||
else \
|
|
||||||
echo ">> No supported package manager detected (apt-get / dnf)."; \
|
|
||||||
echo ">> Install Chromium system libs manually, then re-run test-e2e."; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Lance les tests E2E Playwright sur l'host. Pre-requis :
|
|
||||||
# - `make install-e2e-deps` (une fois par poste)
|
|
||||||
# - `make start` (containers en vie)
|
|
||||||
# - `make dev-nuxt` dans un autre terminal (serve frontend sur :3004)
|
|
||||||
# - `make seed-e2e` (personas crees)
|
|
||||||
test-e2e:
|
|
||||||
cd frontend && npm run test:e2e
|
|
||||||
|
|
||||||
# UI interactive Playwright (debug facile)
|
|
||||||
test-e2e-ui:
|
|
||||||
cd frontend && npm run test:e2e:ui
|
|
||||||
|
|
||||||
delete_built_dir:
|
delete_built_dir:
|
||||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||||
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
|
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Doctrine\Migrations\AbstractMigration;
|
|||||||
* Module Sites - Ticket 1/4 : brique fondatrice de donnees.
|
* Module Sites - Ticket 1/4 : brique fondatrice de donnees.
|
||||||
*
|
*
|
||||||
* Cree la table `site` qui porte les etablissements physiques de l'instance
|
* Cree la table `site` qui porte les etablissements physiques de l'instance
|
||||||
* Starseed. La table est creee inconditionnellement : meme si SitesModule est
|
* Coltura. La table est creee inconditionnellement : meme si SitesModule est
|
||||||
* desactive dans `config/modules.php`, la structure DB existe (pas de
|
* desactive dans `config/modules.php`, la structure DB existe (pas de
|
||||||
* dependance dure depuis Core, mais pas de coin d'ombre schema non plus).
|
* dependance dure depuis Core, mais pas de coin d'ombre schema non plus).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit log — Ticket 1 : table append-only `audit_log`.
|
|
||||||
*
|
|
||||||
* Table non geree par Doctrine ORM (aucune entite associee). Ecriture via
|
|
||||||
* DBAL uniquement par l'AuditLogWriter pour eviter la recursion du listener
|
|
||||||
* Doctrine (flush re-entrant). Colonnes en minuscules snake_case comme
|
|
||||||
* partout dans le projet.
|
|
||||||
*
|
|
||||||
* Type natif PostgreSQL `uuid` (16 octets) plutot que varchar(36) : index
|
|
||||||
* 40% plus petit sur une table append-only a croissance infinie.
|
|
||||||
*
|
|
||||||
* Migration placee au namespace racine `DoctrineMigrations` a cause du bug
|
|
||||||
* de tri FQCN alphabetique de Doctrine Migrations 3.x documente dans
|
|
||||||
* CLAUDE.md.
|
|
||||||
*/
|
|
||||||
final class Version20260420202749 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Audit log : creation de la table append-only audit_log + index.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE audit_log (
|
|
||||||
id uuid NOT NULL,
|
|
||||||
entity_type VARCHAR(100) NOT NULL,
|
|
||||||
entity_id VARCHAR(64) NOT NULL,
|
|
||||||
action VARCHAR(10) NOT NULL,
|
|
||||||
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
performed_by VARCHAR(100) NOT NULL,
|
|
||||||
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
ip_address VARCHAR(45) DEFAULT NULL,
|
|
||||||
request_id VARCHAR(36) DEFAULT NULL,
|
|
||||||
PRIMARY KEY(id)
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
|
|
||||||
// Index pour recherche par entite (detail d'historique d'un objet).
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
|
||||||
|
|
||||||
// Index pour recherche par utilisateur (qui a fait quoi).
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
|
||||||
|
|
||||||
// Index pour tri chronologique global (listing pagine DESC).
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('DROP TABLE audit_log');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,46 +16,6 @@
|
|||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||||
|
|
||||||
<!-- ###+ symfony/framework-bundle ### -->
|
|
||||||
<!-- APP_ENV est defini uniquement via <server force="true"> ci-dessus.
|
|
||||||
Ne PAS re-declarer ici en <env> : une ligne redondante mene
|
|
||||||
directement au bug ou un dev met "dev" en pensant que <server>
|
|
||||||
gere tout, puis supprime <server> ensuite et <env> prend le
|
|
||||||
dessus silencieusement (cf. cc8d5 du fix pre-existant). -->
|
|
||||||
<env name="APP_SECRET" value=""/>
|
|
||||||
<env name="APP_SHARE_DIR" value="var/share"/>
|
|
||||||
<!-- ###- symfony/framework-bundle ### -->
|
|
||||||
|
|
||||||
<!-- ###+ symfony/routing ### -->
|
|
||||||
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
|
|
||||||
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
|
|
||||||
<env name="DEFAULT_URI" value="http://localhost"/>
|
|
||||||
<!-- ###- symfony/routing ### -->
|
|
||||||
|
|
||||||
<!-- ###+ doctrine/doctrine-bundle ### -->
|
|
||||||
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
|
|
||||||
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
|
|
||||||
<!-- -->
|
|
||||||
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
|
|
||||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
|
|
||||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
|
|
||||||
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"/>
|
|
||||||
<!-- ###- doctrine/doctrine-bundle ### -->
|
|
||||||
|
|
||||||
<!-- ###+ lexik/jwt-authentication-bundle ### -->
|
|
||||||
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
|
|
||||||
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
|
|
||||||
<!-- Doit correspondre a la passphrase utilisee lors de la generation
|
|
||||||
des cles JWT (config/jwt/*.pem). En local dev, c'est la valeur
|
|
||||||
par defaut "change_me_in_env_local" du .env (override possible
|
|
||||||
via .env.test.local si les cles ont ete regenerees autrement). -->
|
|
||||||
<env name="JWT_PASSPHRASE" value="change_me_in_env_local"/>
|
|
||||||
<!-- ###- lexik/jwt-authentication-bundle ### -->
|
|
||||||
|
|
||||||
<!-- ###+ nelmio/cors-bundle ### -->
|
|
||||||
<env name="CORS_ALLOW_ORIGIN" value="'^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'"/>
|
|
||||||
<!-- ###- nelmio/cors-bundle ### -->
|
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Application\DTO;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO de sortie pour une ligne d'audit.
|
|
||||||
*
|
|
||||||
* Readonly : aucune mutation possible apres hydration. La resource API
|
|
||||||
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
|
|
||||||
* table audit_log n'est pas geree par l'ORM).
|
|
||||||
*/
|
|
||||||
final readonly class AuditLogOutput
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $id,
|
|
||||||
public string $entityType,
|
|
||||||
public string $entityId,
|
|
||||||
public string $action,
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $changes,
|
|
||||||
public string $performedBy,
|
|
||||||
public DateTimeImmutable $performedAt,
|
|
||||||
public ?string $ipAddress,
|
|
||||||
public ?string $requestId,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,6 @@ final class CoreModule
|
|||||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||||
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
||||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||||
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
|
|
||||||
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,27 +11,19 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
// Guard RBAC du catalogue de permissions : accepte les gestionnaires
|
|
||||||
// de users et de roles en plus du code dedie `core.permissions.view`.
|
|
||||||
// Justification : les drawers `UserRbacDrawer`/`RoleDrawer` fetchent
|
|
||||||
// systematiquement ce catalogue pour afficher les checkboxes de
|
|
||||||
// permissions ; exiger uniquement `core.permissions.view` casserait
|
|
||||||
// ces workflows pour tout gestionnaire non-admin. L'endpoint n'expose
|
|
||||||
// que des codes/libelles (pas de secret), le bypass reste acceptable.
|
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
normalizationContext: ['groups' => ['permission:read']],
|
normalizationContext: ['groups' => ['permission:read']],
|
||||||
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
|
security: "is_granted('ROLE_USER')",
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
normalizationContext: ['groups' => ['permission:read']],
|
normalizationContext: ['groups' => ['permission:read']],
|
||||||
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
|
security: "is_granted('ROLE_USER')",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
@@ -39,7 +31,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||||
#[ORM\Table(name: 'permission')]
|
#[ORM\Table(name: 'permission')]
|
||||||
#[Auditable]
|
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
@@ -65,7 +64,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||||
#[ORM\Table(name: '`role`')]
|
#[ORM\Table(name: '`role`')]
|
||||||
#[Auditable]
|
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||||
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
|||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||||
// Note architecture : User.php n'importe plus rien depuis le module Sites.
|
// Note architecture : User.php utilise SiteInterface (Shared) pour les
|
||||||
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
|
// type-hints afin de ne pas coupler le module Core au module Sites.
|
||||||
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
|
// La seule reference concrete a Site subsiste dans les metadonnees ORM
|
||||||
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
|
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
|
||||||
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
|
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -50,16 +49,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
),
|
),
|
||||||
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||||
// Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
|
|
||||||
// API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
|
|
||||||
// Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
|
|
||||||
// pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
|
|
||||||
new Get(
|
|
||||||
name: 'user_rbac_get',
|
|
||||||
uriTemplate: '/users/{id}/rbac',
|
|
||||||
security: "is_granted('core.users.manage')",
|
|
||||||
normalizationContext: ['groups' => ['user:rbac:read']],
|
|
||||||
),
|
|
||||||
new Patch(
|
new Patch(
|
||||||
name: 'user_rbac_patch',
|
name: 'user_rbac_patch',
|
||||||
uriTemplate: '/users/{id}/rbac',
|
uriTemplate: '/users/{id}/rbac',
|
||||||
@@ -74,7 +63,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
#[Auditable]
|
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
@@ -138,12 +126,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* Le groupe `user:list` a ete retire deliberement (securite : evite
|
* Le groupe `user:list` a ete retire deliberement (securite : evite
|
||||||
* de fuiter la liste des sites de chaque user via GET /api/users).
|
* de fuiter la liste des sites de chaque user via GET /api/users).
|
||||||
*
|
*
|
||||||
* @var Collection<int, SiteInterface>
|
* @var Collection<int, Site>
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
|
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
|
||||||
#[ORM\JoinTable(name: 'user_site')]
|
#[ORM\JoinTable(name: 'user_site')]
|
||||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
||||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
||||||
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
|
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
|
||||||
private Collection $sites;
|
private Collection $sites;
|
||||||
|
|
||||||
@@ -163,17 +149,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
|
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
|
||||||
* deliberement (securite : evite de fuiter le site actif via /api/users).
|
* deliberement (securite : evite de fuiter le site actif via /api/users).
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
|
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
|
||||||
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read'])]
|
||||||
private ?SiteInterface $currentSite = null;
|
private ?SiteInterface $currentSite = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[AuditIgnore]
|
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
#[Groups(['user:write'])]
|
#[Groups(['user:write'])]
|
||||||
#[AuditIgnore]
|
|
||||||
private ?string $plainPassword = null;
|
private ?string $plainPassword = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
@@ -379,7 +363,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, SiteInterface>
|
* @return Collection<int, Site>
|
||||||
*/
|
*/
|
||||||
public function getSites(): Collection
|
public function getSites(): Collection
|
||||||
{
|
{
|
||||||
@@ -393,8 +377,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* session Doctrine (cf. ticket 2 review point #1).
|
* session Doctrine (cf. ticket 2 review point #1).
|
||||||
*
|
*
|
||||||
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
|
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
|
||||||
* La classe concrete injectee au runtime est resolue par Doctrine via
|
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
|
||||||
* `resolve_target_entities` (cf. note architecture en tete de fichier).
|
|
||||||
*/
|
*/
|
||||||
public function addSite(SiteInterface $site): static
|
public function addSite(SiteInterface $site): static
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
|
|
||||||
|
|
||||||
use ApiPlatform\State\Pagination\PaginatorInterface;
|
|
||||||
use ArrayIterator;
|
|
||||||
use IteratorAggregate;
|
|
||||||
use Traversable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
|
|
||||||
*
|
|
||||||
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
|
|
||||||
* automatiquement la section `hydra:view` (first / next / previous / last)
|
|
||||||
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
|
|
||||||
*
|
|
||||||
* @template T of object
|
|
||||||
*
|
|
||||||
* @implements PaginatorInterface<T>
|
|
||||||
*/
|
|
||||||
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<T> $items Items deja decoupes sur la page courante
|
|
||||||
* @param int $currentPage Page courante (1-indexee)
|
|
||||||
* @param int $itemsPerPage Limite appliquee a la requete SQL
|
|
||||||
* @param int $totalItems Resultat du COUNT(*) sans limite
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private array $items,
|
|
||||||
private int $currentPage,
|
|
||||||
private int $itemsPerPage,
|
|
||||||
private int $totalItems,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getCurrentPage(): float
|
|
||||||
{
|
|
||||||
return (float) $this->currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastPage(): float
|
|
||||||
{
|
|
||||||
if ($this->itemsPerPage <= 0) {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getItemsPerPage(): float
|
|
||||||
{
|
|
||||||
return (float) $this->itemsPerPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTotalItems(): float
|
|
||||||
{
|
|
||||||
return (float) $this->totalItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function count(): int
|
|
||||||
{
|
|
||||||
return count($this->items);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Traversable<int, T>
|
|
||||||
*/
|
|
||||||
public function getIterator(): Traversable
|
|
||||||
{
|
|
||||||
return new ArrayIterator($this->items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
|
|
||||||
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
|
|
||||||
* d'audit). La liste evolue automatiquement avec les nouvelles entites
|
|
||||||
* `#[Auditable]` au fil des ecritures.
|
|
||||||
*/
|
|
||||||
#[ApiResource(
|
|
||||||
shortName: 'AuditLogEntityTypes',
|
|
||||||
operations: [
|
|
||||||
new Get(
|
|
||||||
uriTemplate: '/audit-log-entity-types',
|
|
||||||
security: "is_granted('core.audit_log.view')",
|
|
||||||
provider: AuditLogEntityTypesProvider::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
final class AuditLogEntityTypesResource
|
|
||||||
{
|
|
||||||
/** @param list<string> $entityTypes */
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $id = 'entity-types',
|
|
||||||
public readonly array $entityTypes = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use App\Module\Core\Application\DTO\AuditLogOutput;
|
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource API Platform en lecture seule sur le journal d'audit.
|
|
||||||
*
|
|
||||||
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
|
|
||||||
* conformement au caractere append-only de la table `audit_log`.
|
|
||||||
*
|
|
||||||
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
|
|
||||||
* provider lit via DBAL et retourne directement des instances du DTO
|
|
||||||
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
|
|
||||||
* l'ORM : aucune entite Doctrine n'est necessaire ici.
|
|
||||||
*
|
|
||||||
* Filtres query-param supportes par le provider :
|
|
||||||
* ?entity_type=core.User
|
|
||||||
* ?entity_id=42
|
|
||||||
* ?action=update
|
|
||||||
* ?performed_by=admin
|
|
||||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
|
||||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
|
||||||
*
|
|
||||||
* La pagination est assuree par le provider via DbalPaginator (implementant
|
|
||||||
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
|
||||||
* automatiquement hydra:view — aucune construction manuelle.
|
|
||||||
*/
|
|
||||||
#[ApiResource(
|
|
||||||
shortName: 'AuditLog',
|
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
uriTemplate: '/audit-logs',
|
|
||||||
paginationItemsPerPage: 30,
|
|
||||||
paginationClientItemsPerPage: true,
|
|
||||||
paginationMaximumItemsPerPage: 50,
|
|
||||||
security: "is_granted('core.audit_log.view')",
|
|
||||||
provider: AuditLogProvider::class,
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
uriTemplate: '/audit-logs/{id}',
|
|
||||||
requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
|
|
||||||
security: "is_granted('core.audit_log.view')",
|
|
||||||
provider: AuditLogProvider::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
output: AuditLogOutput::class,
|
|
||||||
)]
|
|
||||||
final class AuditLogResource {}
|
|
||||||
@@ -13,7 +13,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|||||||
/**
|
/**
|
||||||
* @implements ProcessorInterface<User, User>
|
* @implements ProcessorInterface<User, User>
|
||||||
*/
|
*/
|
||||||
final class UserPasswordHasherProcessor implements ProcessorInterface
|
class UserPasswordHasherProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ use ApiPlatform\State\ProcessorInterface;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\PersistentCollection;
|
use Doctrine\ORM\PersistentCollection;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
@@ -53,31 +51,12 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|||||||
*/
|
*/
|
||||||
final class UserRbacProcessor implements ProcessorInterface
|
final class UserRbacProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Mapping cle-payload → (property-path PHP, accesseur, setter utilise pour
|
|
||||||
* reattacher les items lors de la restauration). Permet au gardefou
|
|
||||||
* anti-ecrasement de savoir quelles collections restaurer si elles sont
|
|
||||||
* absentes du payload JSON.
|
|
||||||
*
|
|
||||||
* Note : la cle JSON "roles" correspond a la propriete PHP `rbacRoles`
|
|
||||||
* (renommee via #[SerializedName] pour eviter la collision avec
|
|
||||||
* UserInterface::getRoles()).
|
|
||||||
*
|
|
||||||
* @var array<string, array{getter: string, remover: string, adder: string}>
|
|
||||||
*/
|
|
||||||
private const array COLLECTION_MAP = [
|
|
||||||
'roles' => ['getter' => 'getRbacRoles', 'remover' => 'removeRbacRole', 'adder' => 'addRbacRole'],
|
|
||||||
'directPermissions' => ['getter' => 'getDirectPermissions', 'remover' => 'removeDirectPermission', 'adder' => 'addDirectPermission'],
|
|
||||||
'sites' => ['getter' => 'getSites', 'remover' => 'removeSite', 'adder' => 'addSite'],
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||||
private readonly RequestStack $requestStack,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -151,20 +130,6 @@ final class UserRbacProcessor implements ProcessorInterface
|
|||||||
$originalCurrentSiteId,
|
$originalCurrentSiteId,
|
||||||
&$result,
|
&$result,
|
||||||
): void {
|
): void {
|
||||||
// Garde anti-ecrasement (defense in depth) : PATCH merge-patch+json impose
|
|
||||||
// que les cles absentes du payload ne mutent PAS les proprietes
|
|
||||||
// correspondantes. La denormalisation API Platform ne respecte pas cet
|
|
||||||
// invariant pour les collections ManyToMany — elle reinstancie une
|
|
||||||
// ArrayCollection vide des que la cle n'est pas presente. Sans cette
|
|
||||||
// garde, un client qui PATCHe juste `{ "isAdmin": true }` verrait toutes
|
|
||||||
// ses roles/directPermissions/sites detruits.
|
|
||||||
//
|
|
||||||
// Execute dans la transaction (et non avant) : garantit que le snapshot
|
|
||||||
// Doctrine lu pour restauration reflete le meme etat BDD que celui sur
|
|
||||||
// lequel le persist va operer. Evite toute fenetre de race entre la
|
|
||||||
// lecture du snapshot et le flush.
|
|
||||||
$this->restoreAbsentCollections($data);
|
|
||||||
|
|
||||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
// Garde coherence currentSite (ticket 2 module Sites).
|
// Garde coherence currentSite (ticket 2 module Sites).
|
||||||
@@ -215,89 +180,4 @@ final class UserRbacProcessor implements ProcessorInterface
|
|||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pour chaque collection RBAC (roles, directPermissions, sites) absente du
|
|
||||||
* payload JSON, restaure l'etat d'origine a partir du snapshot Doctrine et
|
|
||||||
* marque la collection comme non-dirty. Idempotent : si la cle est presente
|
|
||||||
* dans le payload, no-op (la denormalisation fait foi).
|
|
||||||
*
|
|
||||||
* Cas d'usage : un client qui PATCHe partiellement (`{ "isAdmin": true }`)
|
|
||||||
* ne doit pas voir ses autres collections reinitialisees. API Platform
|
|
||||||
* reinstancie par defaut une collection vide pour les cles absentes, ce
|
|
||||||
* qui casse la semantique de merge-patch+json.
|
|
||||||
*
|
|
||||||
* Pas de fallback si la collection d'origine n'est pas une PersistentCollection
|
|
||||||
* (ex: User fraichement construit) : dans ce cas aucune restauration n'est
|
|
||||||
* possible puisqu'il n'y a pas d'etat persiste a restaurer.
|
|
||||||
*/
|
|
||||||
private function restoreAbsentCollections(User $user): void
|
|
||||||
{
|
|
||||||
$request = $this->requestStack->getCurrentRequest();
|
|
||||||
if (null === $request) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawBody = $request->getContent();
|
|
||||||
if ('' === $rawBody) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var null|array<string, mixed> $payload */
|
|
||||||
$payload = json_decode($rawBody, true);
|
|
||||||
if (!is_array($payload)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
|
||||||
// La garde ne doit sauter la restauration que si le payload fournit
|
|
||||||
// un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
|
|
||||||
// type doivent etre traites comme "cle absente" : sinon un payload
|
|
||||||
// `{"sites": null}` contourne la restauration et laisse API Platform
|
|
||||||
// vider la collection silencieusement (bypass de la garde).
|
|
||||||
if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var Collection<int, object> $currentCollection */
|
|
||||||
$currentCollection = $user->{$accessors['getter']}();
|
|
||||||
|
|
||||||
if (!$currentCollection instanceof PersistentCollection) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force l'initialisation LAZY avant de lire le snapshot : pour une
|
|
||||||
// association fetch=LAZY (ex: User::$sites), la PersistentCollection
|
|
||||||
// existe mais son snapshot est vide tant que la collection n'a pas
|
|
||||||
// ete materialisee. Sans cet init, `getSnapshot()` renvoie `[]` et
|
|
||||||
// la boucle de restauration ci-dessous appelle `remover()` sur
|
|
||||||
// chaque item charge par `toArray()` → **vide silencieusement la
|
|
||||||
// collection** au lieu de la preserver. Idempotent si deja initialisee.
|
|
||||||
if (!$currentCollection->isInitialized()) {
|
|
||||||
$currentCollection->initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot = etat charge depuis la BDD avant denormalisation.
|
|
||||||
// On restaure en retirant les items actuels et en ajoutant les
|
|
||||||
// originaux via l'adder/remover pour que les collections inverses
|
|
||||||
// (ex: Site::users) restent coherentes.
|
|
||||||
$snapshot = $currentCollection->getSnapshot();
|
|
||||||
|
|
||||||
foreach ($currentCollection->toArray() as $currentItem) {
|
|
||||||
if (!in_array($currentItem, $snapshot, true)) {
|
|
||||||
$user->{$accessors['remover']}($currentItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($snapshot as $originalItem) {
|
|
||||||
if (!$currentCollection->contains($originalItem)) {
|
|
||||||
$user->{$accessors['adder']}($originalItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme non-dirty pour que Doctrine ne detecte pas de diff
|
|
||||||
// et n'emette pas de requete UPDATE inutile sur la table de jointure.
|
|
||||||
$currentCollection->takeSnapshot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProviderInterface;
|
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
|
|
||||||
*
|
|
||||||
* @implements ProviderInterface<AuditLogEntityTypesResource>
|
|
||||||
*/
|
|
||||||
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
|
||||||
private Connection $connection,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
|
|
||||||
{
|
|
||||||
/** @var list<string> $types */
|
|
||||||
$types = $this->connection
|
|
||||||
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
|
|
||||||
->fetchFirstColumn()
|
|
||||||
;
|
|
||||||
|
|
||||||
return new AuditLogEntityTypesResource(entityTypes: $types);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\Pagination\Pagination;
|
|
||||||
use ApiPlatform\State\ProviderInterface;
|
|
||||||
use App\Module\Core\Application\DTO\AuditLogOutput;
|
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\ArrayParameterType;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Doctrine\DBAL\Query\QueryBuilder;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider API Platform pour la resource AuditLog.
|
|
||||||
*
|
|
||||||
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
|
|
||||||
* - une instance unique d'AuditLogOutput (operation Get) ;
|
|
||||||
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
|
|
||||||
*
|
|
||||||
* Le paginator implementant PaginatorInterface laisse API Platform generer
|
|
||||||
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
|
|
||||||
*
|
|
||||||
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
|
|
||||||
* reservee a l'ecriture hors transaction ORM).
|
|
||||||
*/
|
|
||||||
final readonly class AuditLogProvider implements ProviderInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
|
||||||
private Connection $connection,
|
|
||||||
private Pagination $pagination,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
|
|
||||||
{
|
|
||||||
if (!$operation instanceof CollectionOperationInterface) {
|
|
||||||
return $this->provideItem((string) $uriVariables['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->provideCollection($operation, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function provideItem(string $id): ?AuditLogOutput
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed>|false $row */
|
|
||||||
$row = $this->connection->fetchAssociative(
|
|
||||||
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
|
|
||||||
FROM audit_log WHERE id = :id',
|
|
||||||
['id' => $id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (false === $row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hydrate($row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
|
||||||
{
|
|
||||||
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
|
||||||
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
|
||||||
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
|
||||||
// minimum a 1 cote provider.
|
|
||||||
$page = max(1, $this->pagination->getPage($context));
|
|
||||||
$itemsPerPage = $this->pagination->getLimit($operation, $context);
|
|
||||||
$offset = ($page - 1) * $itemsPerPage;
|
|
||||||
$filters = $this->extractFilters($context['filters'] ?? []);
|
|
||||||
|
|
||||||
$dataQuery = $this->buildBaseQuery()
|
|
||||||
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
|
|
||||||
->orderBy('performed_at', 'DESC')
|
|
||||||
// Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
|
|
||||||
// totalement deterministe quand plusieurs lignes partagent la
|
|
||||||
// meme timestamp (ex: batch fixture, bulk flush < 1µs).
|
|
||||||
->addOrderBy('id', 'DESC')
|
|
||||||
->setFirstResult($offset)
|
|
||||||
->setMaxResults($itemsPerPage)
|
|
||||||
;
|
|
||||||
|
|
||||||
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
|
|
||||||
|
|
||||||
$this->applyFilters($dataQuery, $filters);
|
|
||||||
$this->applyFilters($countQuery, $filters);
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $rows */
|
|
||||||
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
|
|
||||||
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
|
|
||||||
|
|
||||||
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
|
|
||||||
|
|
||||||
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildBaseQuery(): QueryBuilder
|
|
||||||
{
|
|
||||||
return $this->connection->createQueryBuilder()->from('audit_log');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $raw
|
|
||||||
*
|
|
||||||
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
|
|
||||||
*/
|
|
||||||
private function extractFilters(array $raw): array
|
|
||||||
{
|
|
||||||
$filters = [];
|
|
||||||
|
|
||||||
// `entity_type` accepte soit une chaine, soit une liste (query syntax
|
|
||||||
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
|
|
||||||
// multi-selection cote front. On normalise en list<string> non-vide.
|
|
||||||
if (isset($raw['entity_type'])) {
|
|
||||||
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
|
|
||||||
$filters['entity_type'] = $raw['entity_type'];
|
|
||||||
} elseif (is_array($raw['entity_type'])) {
|
|
||||||
$cleaned = array_values(array_filter(
|
|
||||||
$raw['entity_type'],
|
|
||||||
static fn ($v): bool => is_string($v) && '' !== $v,
|
|
||||||
));
|
|
||||||
if ([] !== $cleaned) {
|
|
||||||
$filters['entity_type'] = $cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['entity_id', 'performed_by'] as $key) {
|
|
||||||
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
|
||||||
$filters[$key] = $raw[$key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `action` : whitelist stricte. Un input hors-liste provoquait avant
|
|
||||||
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
|
|
||||||
// le log applicatif a chaque variation ; on rejette en 400 explicite.
|
|
||||||
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
|
|
||||||
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
|
|
||||||
throw new BadRequestHttpException(
|
|
||||||
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$filters['action'] = $raw['action'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
|
|
||||||
// Sans validation, un input malforme remonte jusqu'a Postgres qui
|
|
||||||
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
|
|
||||||
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
|
|
||||||
// On valide en amont et on rejette en 400 explicite.
|
|
||||||
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
|
|
||||||
$range = $raw['performed_at'];
|
|
||||||
foreach (['after', 'before'] as $bound) {
|
|
||||||
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (false === strtotime($range[$bound])) {
|
|
||||||
throw new BadRequestHttpException(sprintf(
|
|
||||||
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
|
|
||||||
$bound,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
$filters['performed_at_'.$bound] = $range[$bound];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, list<string>|string> $filters
|
|
||||||
*/
|
|
||||||
private function applyFilters(QueryBuilder $qb, array $filters): void
|
|
||||||
{
|
|
||||||
if (isset($filters['entity_type'])) {
|
|
||||||
if (is_array($filters['entity_type'])) {
|
|
||||||
$qb->andWhere('entity_type IN (:entity_types)')
|
|
||||||
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
|
|
||||||
;
|
|
||||||
} else {
|
|
||||||
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($filters['entity_id'])) {
|
|
||||||
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
|
|
||||||
}
|
|
||||||
if (isset($filters['action'])) {
|
|
||||||
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
|
|
||||||
}
|
|
||||||
if (isset($filters['performed_by'])) {
|
|
||||||
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
|
|
||||||
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
|
|
||||||
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
|
|
||||||
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
|
|
||||||
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
|
|
||||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
|
|
||||||
$qb->andWhere('performed_by ILIKE :performed_by')
|
|
||||||
->setParameter('performed_by', '%'.$escaped.'%')
|
|
||||||
;
|
|
||||||
}
|
|
||||||
if (isset($filters['performed_at_after'])) {
|
|
||||||
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
|
|
||||||
}
|
|
||||||
if (isset($filters['performed_at_before'])) {
|
|
||||||
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $row
|
|
||||||
*/
|
|
||||||
private function hydrate(array $row): AuditLogOutput
|
|
||||||
{
|
|
||||||
/** @var string $rawChanges */
|
|
||||||
$rawChanges = $row['changes'] ?? '{}';
|
|
||||||
|
|
||||||
/** @var array<string, mixed> $changes */
|
|
||||||
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
return new AuditLogOutput(
|
|
||||||
id: (string) $row['id'],
|
|
||||||
entityType: (string) $row['entity_type'],
|
|
||||||
entityId: (string) $row['entity_id'],
|
|
||||||
action: (string) $row['action'],
|
|
||||||
changes: $changes,
|
|
||||||
performedBy: (string) $row['performed_by'],
|
|
||||||
performedAt: new DateTimeImmutable((string) $row['performed_at']),
|
|
||||||
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
|
|
||||||
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
/**
|
/**
|
||||||
* @implements ProviderInterface<object>
|
* @implements ProviderInterface<object>
|
||||||
*/
|
*/
|
||||||
final class MeProvider implements ProviderInterface
|
class MeProvider implements ProviderInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\Audit;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service bas-niveau responsable de l'ecriture dans la table `audit_log`.
|
|
||||||
*
|
|
||||||
* Utilise une connexion DBAL dediee `audit` (meme DSN que `default`, service
|
|
||||||
* separe) pour ecrire hors de la transaction ORM : indispensable pour que
|
|
||||||
* les lignes d'audit survivent meme si le flush applicatif est rollback,
|
|
||||||
* et pour eviter tout entanglement transactionnel en batch (fixtures).
|
|
||||||
*
|
|
||||||
* Les cles sensibles (password, plainPassword, token, secret) sont filtrees
|
|
||||||
* en defense-in-depth meme si les entites declarent deja ces proprietes
|
|
||||||
* #[AuditIgnore].
|
|
||||||
*
|
|
||||||
* Erreur silencieuse : en cas d'echec SQL, on lance pas l'exception plus
|
|
||||||
* haut — l'audit ne doit jamais faire crasher un flux metier. Le listener
|
|
||||||
* wrappe l'appel dans un try/catch + logger (cf. AuditListener).
|
|
||||||
*/
|
|
||||||
final class AuditLogWriter
|
|
||||||
{
|
|
||||||
/** @var list<string> cles systematiquement strippees du payload `changes` */
|
|
||||||
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'token', 'secret'];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'doctrine.dbal.audit_connection')]
|
|
||||||
private readonly Connection $connection,
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly RequestStack $requestStack,
|
|
||||||
private readonly RequestIdProvider $requestIdProvider,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ecrit une ligne d'audit.
|
|
||||||
*
|
|
||||||
* @param string $entityType Format "module.Entity" (ex: "core.User")
|
|
||||||
* @param string $entityId ID de l'entite (int ou UUID serialise)
|
|
||||||
* @param string $action create|update|delete
|
|
||||||
* @param array<string, mixed> $changes Payload JSON (filtre des cles sensibles)
|
|
||||||
*/
|
|
||||||
public function log(
|
|
||||||
string $entityType,
|
|
||||||
string $entityId,
|
|
||||||
string $action,
|
|
||||||
array $changes,
|
|
||||||
): void {
|
|
||||||
$filteredChanges = $this->stripSensitive($changes);
|
|
||||||
|
|
||||||
$this->connection->insert('audit_log', [
|
|
||||||
'id' => Uuid::v7()->toRfc4122(),
|
|
||||||
'entity_type' => $entityType,
|
|
||||||
'entity_id' => $entityId,
|
|
||||||
'action' => $action,
|
|
||||||
'changes' => $filteredChanges,
|
|
||||||
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
|
|
||||||
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
|
|
||||||
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
|
|
||||||
'request_id' => $this->requestIdProvider->getRequestId(),
|
|
||||||
], [
|
|
||||||
// Types de conversion DBAL : UUID natif PG + jsonb + datetimetz.
|
|
||||||
// Sans 'id' => GUID, DBAL passerait un varchar et Postgres ferait
|
|
||||||
// un cast implicite — ca marche mais l'intention est floue.
|
|
||||||
'id' => Types::GUID,
|
|
||||||
'changes' => Types::JSON,
|
|
||||||
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime recursivement les cles sensibles du payload.
|
|
||||||
*
|
|
||||||
* Utile pour les snapshots complets (create/delete) ou les changes
|
|
||||||
* d'update : le listener prefiltre deja mais on garde cette garde
|
|
||||||
* en defense-in-depth si un appelant direct oublie `#[AuditIgnore]`.
|
|
||||||
*
|
|
||||||
* Recursion : parcourt les sous-tableaux (ex: changes structures
|
|
||||||
* `{field: {old, new}}`, snapshots avec relations imbriquees, ou
|
|
||||||
* payload arbitraire pousse par un appelant direct).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function stripSensitive(array $data): array
|
|
||||||
{
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
|
||||||
unset($data[$key]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (is_array($value)) {
|
|
||||||
$data[$key] = $this->stripSensitive($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\Audit;
|
|
||||||
|
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fournit un identifiant de requete HTTP (UUID v4) partage par toutes les
|
|
||||||
* lignes d'audit produites au cours d'une meme requete principale.
|
|
||||||
*
|
|
||||||
* Utilite : retrouver d'un seul coup d'oeil toutes les ecritures liees a un
|
|
||||||
* meme appel utilisateur (ex: PATCH qui cascade des updates sur plusieurs
|
|
||||||
* entites). Null en CLI (fixtures, commandes batch).
|
|
||||||
*
|
|
||||||
* Service singleton (scope container par defaut) — un unique UUID est
|
|
||||||
* genere au kernel.request principal et reutilise pour toute la requete.
|
|
||||||
*/
|
|
||||||
final class RequestIdProvider
|
|
||||||
{
|
|
||||||
private ?string $requestId = null;
|
|
||||||
|
|
||||||
#[AsEventListener(event: 'kernel.request')]
|
|
||||||
public function onKernelRequest(RequestEvent $event): void
|
|
||||||
{
|
|
||||||
// Ignorer les sub-requests (ESI, forward interne) pour ne pas
|
|
||||||
// ecraser l'UUID de la requete principale.
|
|
||||||
if (!$event->isMainRequest()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->requestId = Uuid::v4()->toRfc4122();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestId(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\Console;
|
|
||||||
|
|
||||||
use App\Module\Core\Domain\Entity\User;
|
|
||||||
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
|
|
||||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
|
||||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
|
||||||
use App\Module\Core\Domain\Security\SystemRoles;
|
|
||||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed dedie aux tests E2E Playwright (frontend/tests/e2e).
|
|
||||||
*
|
|
||||||
* Cree 6 personas (e2e.*) qui couvrent les cases nominales de la matrice
|
|
||||||
* RBAC : super-admin, user-full, user-readonly, user-users-only,
|
|
||||||
* user-audit-only, user-nothing. Cette liste est la replique back du
|
|
||||||
* fichier `frontend/tests/e2e/_fixtures/personas.ts` — si tu modifies
|
|
||||||
* l'une des deux, met a jour l'autre.
|
|
||||||
*
|
|
||||||
* Idempotent : supprime les users prefixes `e2e.` avant de les recreer.
|
|
||||||
* Ne touche PAS aux fixtures dev (admin/alice/bob) ni aux sites.
|
|
||||||
*
|
|
||||||
* Pre-requis : `bin/console app:sync-permissions` doit avoir tourne pour
|
|
||||||
* que les permissions soient en base. La commande echoue en erreur explicite
|
|
||||||
* si une permission attendue est absente du catalogue.
|
|
||||||
*/
|
|
||||||
#[AsCommand(
|
|
||||||
name: 'app:seed-e2e',
|
|
||||||
description: 'Seed les 6 personas utilises par les tests E2E Playwright.',
|
|
||||||
)]
|
|
||||||
final class SeedE2ECommand extends Command
|
|
||||||
{
|
|
||||||
private const string SHARED_PASSWORD = 'e2e-secret';
|
|
||||||
private const string E2E_USERNAME_PREFIX = 'e2e.';
|
|
||||||
private const string DEFAULT_SITE_NAME = 'Chatellerault';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
private readonly UserRepositoryInterface $userRepository,
|
|
||||||
private readonly RoleRepositoryInterface $roleRepository,
|
|
||||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
|
||||||
private readonly SiteProviderInterface $siteProvider,
|
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
|
|
||||||
// Garde-fou : cette commande cree un compte admin avec un mot de passe
|
|
||||||
// hardcode. Elle ne doit JAMAIS tourner hors dev/test, meme si le
|
|
||||||
// fichier se retrouve embarque dans une image prod par accident (le
|
|
||||||
// .dockerignore a la racine est la premiere ligne de defense).
|
|
||||||
$env = $_SERVER['APP_ENV'] ?? 'prod';
|
|
||||||
if (!in_array($env, ['dev', 'test'], true)) {
|
|
||||||
$io->error(sprintf('app:seed-e2e est refuse en environnement "%s". Autorise uniquement en dev/test.', $env));
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
|
|
||||||
|
|
||||||
if (null === $userRole) {
|
|
||||||
$io->error(sprintf(
|
|
||||||
'Le role systeme "%s" est introuvable. Lance les migrations + fixtures ou `app:sync-permissions` avant ce seed.',
|
|
||||||
SystemRoles::USER_CODE,
|
|
||||||
));
|
|
||||||
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
|
|
||||||
|
|
||||||
// Pas de fail fatal si le site manque : les tests sidebar/login
|
|
||||||
// n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront.
|
|
||||||
if (null === $defaultSite) {
|
|
||||||
$io->note(sprintf(
|
|
||||||
'Site "%s" absent : les personas seront crees sans site. Lance `make fixtures` si tu as besoin des sites.',
|
|
||||||
self::DEFAULT_SITE_NAME,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->wipeExistingE2EUsers($io);
|
|
||||||
|
|
||||||
foreach ($this->personasDefinition() as $persona) {
|
|
||||||
$user = new User();
|
|
||||||
$user->setUsername($persona['username']);
|
|
||||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::SHARED_PASSWORD));
|
|
||||||
$user->setIsAdmin($persona['isAdmin']);
|
|
||||||
$user->addRbacRole($userRole);
|
|
||||||
|
|
||||||
foreach ($persona['permissions'] as $code) {
|
|
||||||
$permission = $this->permissionRepository->findByCode($code);
|
|
||||||
|
|
||||||
if (null === $permission) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Permission "%s" introuvable en base. Lance `app:sync-permissions` avant `app:seed-e2e`.',
|
|
||||||
$code,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->addDirectPermission($permission);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $defaultSite && 'e2e.user-nothing' !== $persona['username']) {
|
|
||||||
// user-nothing reste sans site pour pouvoir tester un flow
|
|
||||||
// "aucune permission et aucun site".
|
|
||||||
$user->addSite($defaultSite);
|
|
||||||
$user->setCurrentSite($defaultSite);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userRepository->save($user);
|
|
||||||
|
|
||||||
$io->text(sprintf(
|
|
||||||
' - %s (admin=%s, permissions=%d)',
|
|
||||||
$persona['username'],
|
|
||||||
$persona['isAdmin'] ? 'oui' : 'non',
|
|
||||||
count($persona['permissions']),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->success(sprintf('%d personas E2E seedes.', count($this->personasDefinition())));
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function wipeExistingE2EUsers(SymfonyStyle $io): void
|
|
||||||
{
|
|
||||||
$removed = 0;
|
|
||||||
|
|
||||||
foreach ($this->personasDefinition() as $persona) {
|
|
||||||
$existing = $this->userRepository->findByUsername($persona['username']);
|
|
||||||
|
|
||||||
if (null === $existing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->remove($existing);
|
|
||||||
++$removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($removed > 0) {
|
|
||||||
$this->em->flush();
|
|
||||||
$io->text(sprintf('Nettoyage : %d users E2E supprimes.', $removed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste des personas — source back, miroir de
|
|
||||||
* `frontend/tests/e2e/_fixtures/personas.ts`.
|
|
||||||
*
|
|
||||||
* @return list<array{username: string, isAdmin: bool, permissions: list<string>}>
|
|
||||||
*/
|
|
||||||
private function personasDefinition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'super-admin',
|
|
||||||
'isAdmin' => true,
|
|
||||||
'permissions' => [],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'user-full',
|
|
||||||
'isAdmin' => false,
|
|
||||||
'permissions' => [
|
|
||||||
'core.users.view',
|
|
||||||
'core.users.manage',
|
|
||||||
'core.roles.view',
|
|
||||||
'core.roles.manage',
|
|
||||||
'core.audit_log.view',
|
|
||||||
'sites.view',
|
|
||||||
'sites.manage',
|
|
||||||
'sites.bypass_scope',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'user-readonly',
|
|
||||||
'isAdmin' => false,
|
|
||||||
'permissions' => [
|
|
||||||
'core.users.view',
|
|
||||||
'core.roles.view',
|
|
||||||
'core.audit_log.view',
|
|
||||||
'sites.view',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'user-users-only',
|
|
||||||
'isAdmin' => false,
|
|
||||||
'permissions' => ['core.users.view', 'core.users.manage'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'user-audit-only',
|
|
||||||
'isAdmin' => false,
|
|
||||||
'permissions' => ['core.audit_log.view'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'username' => self::E2E_USERNAME_PREFIX.'user-nothing',
|
|
||||||
'isAdmin' => false,
|
|
||||||
'permissions' => [],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||||
use App\Module\Core\Domain\Security\SystemRoles;
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
||||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
private readonly RoleRepositoryInterface $roleRepository,
|
private readonly RoleRepositoryInterface $roleRepository,
|
||||||
private readonly SiteProviderInterface $siteProvider,
|
private readonly SiteRepositoryInterface $siteRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
return $role;
|
return $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireSite(string $name): SiteInterface
|
private function requireSite(string $name): Site
|
||||||
{
|
{
|
||||||
$site = $this->siteProvider->findByName($name);
|
$site = $this->siteRepository->findByName($name);
|
||||||
|
|
||||||
if (null === $site) {
|
if (null === $site) {
|
||||||
throw new RuntimeException(sprintf(
|
throw new RuntimeException(sprintf(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user