Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f47e9111 | ||
| 6cf5ef4cfc | |||
|
|
6b4868b261 | ||
| e8c2789435 | |||
|
|
b59d0f8a44 | ||
|
|
5cb8cff4ce | ||
|
|
c62f054da1 | ||
|
|
168dad4657 | ||
|
|
68bdb6ff72 | ||
|
|
7045debc66 | ||
|
|
180bc5c556 | ||
|
|
999cccabaf | ||
|
|
d42311f22f | ||
|
|
be57451d29 | ||
| 8ebdf56435 | |||
| 68d62c31ec | |||
|
|
bcfecb2281 | ||
|
|
90147bd93b | ||
|
|
4d106e9625 | ||
|
|
9748862684 | ||
|
|
1904c999ec | ||
|
|
81266dd64b | ||
|
|
c5e2800e4c | ||
|
|
ef1c14f8da | ||
|
|
7e5080859d | ||
|
|
414916a20d | ||
|
|
70c05946bd | ||
|
|
ede55b9f08 | ||
|
|
c61b24bea3 | ||
|
|
389bfbef13 | ||
|
|
34adb01cbb | ||
|
|
212a37f8dc | ||
|
|
5cd7fc305f | ||
|
|
9109e387b9 | ||
|
|
0d87574ea2 | ||
|
|
957e05342d | ||
|
|
e4f00c322d | ||
|
|
0cb063cdd7 | ||
|
|
282e2d3381 | ||
|
|
c471b7993f | ||
|
|
b1487c54d3 | ||
|
|
778a0a16e8 | ||
|
|
8fce19e3d4 | ||
|
|
74d87126ea | ||
|
|
4effafe3a1 | ||
|
|
cbe6326284 | ||
|
|
3a792c1a56 | ||
|
|
a14da5113f | ||
|
|
12e9326ccd | ||
|
|
39b462e274 | ||
|
|
cd51f3f945 | ||
|
|
2649e02f7b | ||
|
|
d33928b5f0 | ||
|
|
582339ca99 |
208
.claude/skills/create-module/SKILL.md
Normal file
208
.claude/skills/create-module/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: create-module
|
||||
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 Coltura module
|
||||
|
||||
Scaffolds a new module across backend and frontend following Coltura's modular monolith DDD architecture.
|
||||
|
||||
## Architecture reminder — read before acting
|
||||
|
||||
The module system has **two concerns that are decoupled**:
|
||||
|
||||
1. **Module** = code + routes + pages. A module is a bounded context that owns feature code. Declared in `config/modules.php` (backend) and scanned automatically on frontend via `nuxt.config.ts` (any directory under `frontend/modules/` becomes a Nuxt layer).
|
||||
2. **Sidebar** = navigation layout. Defined in `config/sidebar.php`. Each sidebar item references the module that owns it via the `module` key. Items whose module is not active are filtered out by the backend. **You can freely move a sidebar item from one section to another without touching module code.**
|
||||
|
||||
Consequences:
|
||||
- Frontend never hardcodes module metadata — no `.module.ts`, no `modules-loader.ts`, no manual registration.
|
||||
- Frontend never hardcodes the sidebar — it fetches `GET /api/sidebar`.
|
||||
- To add a new module in the sidebar: edit `config/sidebar.php`.
|
||||
- To move an item between sections: edit `config/sidebar.php`.
|
||||
- To disable a module: remove/comment it from `config/modules.php`. Its sidebar items are auto-filtered AND its routes land in `disabledRoutes`, which the frontend middleware `modules.global.ts` uses to redirect any direct URL access back to `/`.
|
||||
|
||||
The `/api/sidebar` response shape is:
|
||||
```json
|
||||
{
|
||||
"sections": [{ "label": "...", "icon": "...", "items": [...] }],
|
||||
"disabledRoutes": ["/commercial", "/commercial/orders"]
|
||||
}
|
||||
```
|
||||
Any item whose `module` key matches an inactive module is moved from `sections` into `disabledRoutes` automatically by `SidebarProvider`.
|
||||
|
||||
## When to use
|
||||
|
||||
User wants to create a new vendable module. Example triggers:
|
||||
- "Crée un module Paie"
|
||||
- "Ajoute un module Pointage"
|
||||
- "Scaffold a new Stock module"
|
||||
|
||||
## Prerequisites — gather before acting
|
||||
|
||||
Ask the user (via `AskUserQuestion` if not already provided):
|
||||
|
||||
1. **Module name** (French, human readable, e.g., "Paie", "Gestion RH", "Pointage"). Derive:
|
||||
- `PascalCase` for backend folders/classes: `Paie`, `GestionRh`, `Pointage`
|
||||
- `kebab-case` for frontend folders/routes: `paie`, `gestion-rh`, `pointage`
|
||||
- `camelCase` for i18n keys: `paie`, `gestionRh`, `pointage`
|
||||
- `snake_case` for module ID: `paie`, `gestion_rh`, `pointage`
|
||||
2. **Icon** — an mdi icon identifier for the sidebar section (e.g., `mdi:cash-multiple`). If not given, suggest a relevant one.
|
||||
3. **Should it appear in the sidebar immediately?** If yes, ask which section (new one or existing) and what nav items (label + route).
|
||||
|
||||
Don't guess these — ask.
|
||||
|
||||
## Placeholders used below
|
||||
|
||||
- `{Pascal}` = PascalCase (e.g., `Paie`)
|
||||
- `{kebab}` = kebab-case (e.g., `paie`)
|
||||
- `{camel}` = camelCase i18n slug (e.g., `paie`, `gestionRh`)
|
||||
- `{id}` = module ID for backend + config (same as `{camel}` for single-word names, `snake_case` otherwise)
|
||||
- `{label}` = French display label (e.g., `Paie`)
|
||||
- `{icon}` = mdi icon string (e.g., `mdi:cash-multiple`)
|
||||
|
||||
## Files to create/edit
|
||||
|
||||
### Backend — module declaration
|
||||
|
||||
**1. `src/Module/{Pascal}/{Pascal}Module.php`**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\{Pascal};
|
||||
|
||||
final class {Pascal}Module
|
||||
{
|
||||
public const string ID = '{id}';
|
||||
public const string LABEL = '{label}';
|
||||
public const bool REQUIRED = false;
|
||||
}
|
||||
```
|
||||
|
||||
**2. Edit `config/modules.php`** — append the module class:
|
||||
|
||||
```php
|
||||
return [
|
||||
\App\Module\Core\CoreModule::class,
|
||||
// ... existing modules
|
||||
\App\Module\{Pascal}\{Pascal}Module::class,
|
||||
];
|
||||
```
|
||||
|
||||
### Backend — sidebar config (only if the user wants sidebar entries)
|
||||
|
||||
**3. Edit `config/sidebar.php`** — either add items to an existing section or append a new section:
|
||||
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.{camel}.section',
|
||||
'icon' => '{icon}',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.{camel}.overview',
|
||||
'to' => '/{kebab}',
|
||||
'icon' => '{icon}',
|
||||
'module' => '{id}',
|
||||
],
|
||||
// more items from user input...
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Every item must have a `module` key matching an active module ID — otherwise the backend will filter it out.
|
||||
|
||||
### Frontend — module code (auto-detected)
|
||||
|
||||
**4a. `frontend/modules/{kebab}/nuxt.config.ts`** — required for Nuxt to treat the folder as a layer. Content:
|
||||
|
||||
```typescript
|
||||
export default defineNuxtConfig({})
|
||||
```
|
||||
|
||||
**4b. `frontend/modules/{kebab}/pages/{kebab}.vue`** — placeholder page. The filename becomes the route `/{kebab}`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('{camel}.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('{camel}.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({ title: t('{camel}.title') })
|
||||
</script>
|
||||
```
|
||||
|
||||
Create one page file per nav item the user specified. Derive filenames from the `to` paths (e.g., `/paie/bulletins` → `pages/paie/bulletins.vue`).
|
||||
|
||||
**Do NOT create any of these — they no longer exist in this architecture:**
|
||||
- ❌ `frontend/modules/{kebab}/{kebab}.module.ts`
|
||||
- ❌ `frontend/plugins/modules-loader.ts`
|
||||
- ❌ Any `extends` entry in `nuxt.config.ts` — modules are auto-detected from `frontend/modules/*/`.
|
||||
|
||||
### Frontend — translations
|
||||
|
||||
**5. Edit `frontend/i18n/locales/fr.json`** — add the sidebar keys and page content keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"sidebar": {
|
||||
"{camel}": {
|
||||
"section": "{label}",
|
||||
"overview": "Vue d'ensemble"
|
||||
}
|
||||
},
|
||||
"{camel}": {
|
||||
"title": "{label}",
|
||||
"welcome": "Module {label}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every `label` in the new `config/sidebar.php` entries must have a matching key here, and every page's `$t('{camel}.*')` calls must match too.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
Execute in this exact order:
|
||||
|
||||
1. **Clarify inputs** — use `AskUserQuestion` for name, icon, and sidebar decisions.
|
||||
2. **Derive naming** — confirm Pascal/kebab/camel/id are sensible (e.g., "Gestion RH" → `GestionRh`/`gestion-rh`/`gestionRh`/`gestion_rh`).
|
||||
3. **Read current state** — Read `config/modules.php`, `config/sidebar.php`, `frontend/i18n/locales/fr.json` so you know what exists before editing.
|
||||
4. **Backend: declare the module** — create `{Pascal}Module.php`, edit `config/modules.php`.
|
||||
5. **Frontend: create pages** — create the placeholder `.vue` files under `frontend/modules/{kebab}/pages/`.
|
||||
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
||||
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
||||
8. **Verify** — run:
|
||||
- `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-coltura-fpm php bin/console cache:clear` (validates backend)
|
||||
- `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.
|
||||
|
||||
## Rules — do not violate
|
||||
|
||||
- **Never create `.module.ts` files** — the old `ModuleDefinition` / `registerModule` pattern is gone. Activation and sidebar are 100% backend-driven.
|
||||
- **Never edit `extends` in `nuxt.config.ts`** — it auto-scans `frontend/modules/*/`. Adding entries manually causes duplicates.
|
||||
- **Never create `frontend/plugins/modules-loader.ts`** — it was deleted on purpose.
|
||||
- **All sidebar labels are i18n keys, not raw text** — the layout calls `t()` on every label. Raw text will display as-is and look broken.
|
||||
- **Every sidebar item needs a `module` key** — if omitted, the backend filters it out silently.
|
||||
- **Backend module must be in `config/modules.php`** — otherwise `/api/sidebar` will hide its items even though the sidebar config references them.
|
||||
- **No DDD scaffolding unless asked** — only create the `{Pascal}Module.php` initially. Don't create empty `Domain/`, `Application/`, `Infrastructure/` folders. Real domain code comes when the module gets features.
|
||||
- **Don't create a page per nav item blindly** — if the user didn't specify, create only the root `/{kebab}` page and ask whether they want stubs for subpages.
|
||||
- **Don't use `make cache-clear`** — it may hit permission issues. Use the docker commands directly (see Verify step).
|
||||
|
||||
## Naming derivation examples
|
||||
|
||||
| User input | Pascal | kebab | camel | id |
|
||||
|------------|--------|-------|-------|-----|
|
||||
| Paie | Paie | paie | paie | paie |
|
||||
| Pointage | Pointage | pointage | pointage | pointage |
|
||||
| Gestion RH | GestionRh | gestion-rh | gestionRh | gestion_rh |
|
||||
| Stock & Inventaire | StockInventaire | stock-inventaire | stockInventaire | stock_inventaire |
|
||||
| CRM | Crm | crm | crm | crm |
|
||||
|
||||
For accented characters (é, è, à, ç...): strip accents in folder/identifier names (`Forêt` → `Foret`/`foret`), keep them in the French `label`.
|
||||
30
CHANGELOG.md
Normal file
30
CHANGELOG.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
Liste des évolutions du projet Coltura
|
||||
|
||||
## [0.0.0]
|
||||
|
||||
### Parameters
|
||||
|
||||
Ajouter dans le fichier .env
|
||||
|
||||
- DEFAULT_URI
|
||||
- DATABASE_URL
|
||||
- PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
- PONT_BASCULE_URL
|
||||
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
|
||||
- JWT_PUBLIC_KEY
|
||||
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
|
||||
- COOKIE_SECURE=0 (en dev 0 et en prod 1)
|
||||
|
||||
Ajouter dans le fichier .env du frontend
|
||||
|
||||
- NUXT_PUBLIC_API_BASE
|
||||
|
||||
### Added
|
||||
|
||||
- [#ERP-7] Mise en place du modular monolith
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
275
CLAUDE.md
275
CLAUDE.md
@@ -1,112 +1,163 @@
|
||||
# Coltura
|
||||
|
||||
CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. **Architecture DDD (Domain-Driven Design).**
|
||||
CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. **Architecture Modular Monolith DDD.**
|
||||
|
||||
## Architecture DDD
|
||||
## Architecture Modulaire
|
||||
|
||||
Le projet suit une architecture DDD cote backend ET frontend. Le code est organise par **domaine metier** (Bounded Context), pas par type technique.
|
||||
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.
|
||||
|
||||
### Backend — Organisation par domaine
|
||||
**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/
|
||||
Domain/ # Couche domaine (logique metier pure, aucune dependance framework)
|
||||
{BoundedContext}/ # Ex: Customer, Sales, Catalog, Invoice...
|
||||
Entity/ # Entites et Aggregates du domaine
|
||||
ValueObject/ # Value Objects (Money, Address, Email...)
|
||||
Repository/ # Interfaces des repositories (ports)
|
||||
Service/ # Services domaine (logique metier)
|
||||
Event/ # Domain Events
|
||||
Exception/ # Exceptions metier
|
||||
Application/ # Couche application (cas d'usage, orchestration)
|
||||
{BoundedContext}/
|
||||
Command/ # Commands (write) + Handlers
|
||||
Query/ # Queries (read) + Handlers
|
||||
DTO/ # Data Transfer Objects
|
||||
Infrastructure/ # Couche infrastructure (implementations techniques)
|
||||
{BoundedContext}/
|
||||
Repository/ # Implementations Doctrine des repositories
|
||||
Persistence/ # Mapping Doctrine (si XML/YAML)
|
||||
Shared/ # Services techniques partages (mail, storage, etc.)
|
||||
Api/ # Couche API (exposition HTTP)
|
||||
{BoundedContext}/
|
||||
Resource/ # ApiResource API Platform
|
||||
State/ # Providers & Processors API Platform
|
||||
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)
|
||||
```
|
||||
|
||||
**Regles DDD backend :**
|
||||
- Le domaine (`Domain/`) ne depend de RIEN (pas de Doctrine, pas de Symfony, pas d'API Platform)
|
||||
- Les repositories dans `Domain/` sont des **interfaces** ; les implementations Doctrine sont dans `Infrastructure/`
|
||||
- Les entites API Platform (`Api/Resource/`) sont decouples des entites domaine si necessaire
|
||||
- Chaque Bounded Context est autonome — pas d'import croise entre contextes (communiquer via events ou services application)
|
||||
- `User` et `Auth` restent dans `src/` (hors DDD) car c'est du framework pur (Security Bundle)
|
||||
|
||||
### Frontend — Organisation par domaine
|
||||
### Frontend — Organisation modulaire (auto-detectee)
|
||||
|
||||
```
|
||||
frontend/
|
||||
domains/ # Modules metier
|
||||
{bounded-context}/ # Ex: customer, sales, catalog, invoice...
|
||||
components/ # Composants Vue specifiques au domaine
|
||||
composables/ # Composables specifiques au domaine
|
||||
services/ # Services API du domaine
|
||||
dto/ # Types TypeScript du domaine
|
||||
pages/ # Pages du domaine (optionnel, ou dans pages/)
|
||||
stores/ # Store Pinia du domaine (si necessaire)
|
||||
components/ # Composants UI partages (non lies a un domaine)
|
||||
composables/ # Composables partages (useApi, useAppVersion)
|
||||
stores/ # Stores globaux (auth, ui)
|
||||
services/ # Services partages
|
||||
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
|
||||
```
|
||||
|
||||
**Regles DDD frontend :**
|
||||
- Chaque domaine est un dossier autonome dans `frontend/domains/`
|
||||
- Un domaine ne doit pas importer depuis un autre domaine — utiliser les composables/stores partages
|
||||
- Les composants, services et types partages restent a la racine (`components/`, `composables/`, etc.)
|
||||
- Les pages peuvent etre dans `frontend/pages/` (routing Nuxt) et importer les composants du domaine
|
||||
### 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
|
||||
|
||||
- **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 5436)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
Domain/{Context}/Entity/ # Entites domaine
|
||||
Domain/{Context}/ValueObject/ # Value Objects
|
||||
Domain/{Context}/Repository/ # Interfaces repositories
|
||||
Domain/{Context}/Service/ # Services domaine
|
||||
Domain/{Context}/Event/ # Domain Events
|
||||
Application/{Context}/Command/ # Commands + Handlers
|
||||
Application/{Context}/Query/ # Queries + Handlers
|
||||
Application/{Context}/DTO/ # Data Transfer Objects
|
||||
Infrastructure/{Context}/Repository/ # Implementations Doctrine
|
||||
Api/{Context}/Resource/ # ApiResource API Platform
|
||||
Api/{Context}/State/ # Providers & Processors
|
||||
Entity/ # Entites framework (User)
|
||||
DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony
|
||||
config/jwt/ # Cles JWT
|
||||
migrations/ # Migrations Doctrine
|
||||
infra/dev/ # Docker dev
|
||||
infra/prod/ # Docker prod (multi-stage)
|
||||
frontend/
|
||||
domains/{context}/components/ # Composants du domaine
|
||||
domains/{context}/composables/ # Composables du domaine
|
||||
domains/{context}/services/ # Services API du domaine
|
||||
domains/{context}/dto/ # Types TS du domaine
|
||||
domains/{context}/stores/ # Store Pinia du domaine
|
||||
components/ # Composants UI partages
|
||||
composables/ # Composables partages (useApi, useAppVersion)
|
||||
stores/ # Stores globaux (auth, ui)
|
||||
pages/ # Pages (routing Nuxt)
|
||||
layouts/ # Layouts
|
||||
i18n/locales/ # Traductions
|
||||
```
|
||||
- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5437)
|
||||
|
||||
## Commandes
|
||||
|
||||
@@ -116,7 +167,7 @@ make stop # Arreter les containers
|
||||
make restart # Redemarrer les containers
|
||||
make install # Install complet (composer, migrations, fixtures, build Nuxt)
|
||||
make reset # Tout supprimer et reinstaller (supprime la BDD)
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3003)
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3004)
|
||||
make shell # Shell dans le container PHP
|
||||
make shell-root # Shell root dans le container PHP
|
||||
make cache-clear # Vider le cache Symfony
|
||||
@@ -128,6 +179,12 @@ 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
|
||||
|
||||
### Commits
|
||||
@@ -145,14 +202,34 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Faire un commit separe de bump : `chore : bump version to v<X.Y.Z>`
|
||||
- Puis creer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
|
||||
|
||||
### Nommage
|
||||
|
||||
| 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` |
|
||||
|
||||
### Backend
|
||||
|
||||
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
|
||||
- API Platform : utiliser ApiResource, Providers (`src/State/`), Processors — pas de controllers
|
||||
- **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
|
||||
@@ -161,11 +238,14 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
### 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
|
||||
- Traductions dans `frontend/i18n/locales/`
|
||||
- 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
|
||||
|
||||
@@ -177,7 +257,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
|
||||
- Container PHP : `php-coltura-fpm`
|
||||
- Container Nginx : `nginx-coltura`
|
||||
- Container DB : PostgreSQL sur port **5436** (interne et externe)
|
||||
- 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`
|
||||
@@ -186,3 +266,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
|
||||
- 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.
|
||||
|
||||
102
README.md
102
README.md
@@ -28,8 +28,8 @@ make dev-nuxt # Port 3003
|
||||
| Service | Port |
|
||||
|------------|------|
|
||||
| API (Nginx)| 8083 |
|
||||
| Frontend | 3003 |
|
||||
| PostgreSQL | 5436 |
|
||||
| Frontend | 3004 |
|
||||
| PostgreSQL | 5437 |
|
||||
|
||||
## Commandes
|
||||
|
||||
@@ -50,29 +50,89 @@ make dev-nuxt # Port 3003
|
||||
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
|
||||
| `make logs-dev` | Tail logs Symfony |
|
||||
|
||||
## 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.
|
||||
|
||||
- `config/modules.php` — liste des modules actifs
|
||||
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
|
||||
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
|
||||
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
|
||||
|
||||
Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
|
||||
|
||||
Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/ # Backend Symfony
|
||||
Entity/ # Entites Doctrine
|
||||
ApiResource/ # Ressources API Platform
|
||||
State/ # Providers & Processors
|
||||
Repository/ # Repositories Doctrine
|
||||
DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony
|
||||
migrations/ # Migrations Doctrine
|
||||
frontend/ # App Nuxt 4
|
||||
pages/ # Pages Vue
|
||||
layouts/ # Layouts
|
||||
components/ # Composants
|
||||
composables/ # Composables (useApi, useAppVersion)
|
||||
stores/ # Stores Pinia (auth, ui)
|
||||
services/ # Services API + DTOs
|
||||
i18n/ # Traductions
|
||||
src/ # Backend Symfony
|
||||
Kernel.php
|
||||
Shared/ # Noyau technique partage
|
||||
Domain/
|
||||
ValueObject/ # Email, ...
|
||||
Event/ # DomainEventInterface
|
||||
Contract/ # Interfaces inter-modules
|
||||
Application/
|
||||
Bus/ # CommandBusInterface, QueryBusInterface
|
||||
Infrastructure/
|
||||
ApiPlatform/
|
||||
Resource/ # AppVersion, ModulesResource, SidebarResource
|
||||
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
|
||||
Module/
|
||||
Core/ # Module obligatoire (auth, users)
|
||||
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
|
||||
Domain/
|
||||
Entity/ # User
|
||||
Repository/ # UserRepositoryInterface
|
||||
Event/ # 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 # Source de verite activation
|
||||
sidebar.php # Source de verite navigation
|
||||
version.yaml
|
||||
packages/ # Config Symfony
|
||||
jwt/ # Cles JWT
|
||||
migrations/ # Anciennes migrations
|
||||
frontend/ # App Nuxt 4 (SPA)
|
||||
app/
|
||||
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, UserData
|
||||
utils/ # api (Hydra)
|
||||
modules/ # Modules auto-detectes comme layers Nuxt
|
||||
core/
|
||||
nuxt.config.ts # Marqueur layer
|
||||
pages/ # index, login, logout
|
||||
commercial/
|
||||
nuxt.config.ts
|
||||
pages/ # commercial.vue
|
||||
app.vue
|
||||
nuxt.config.ts # Scanne modules/*/ automatiquement
|
||||
i18n/locales/ # Traductions (sidebar.*, etc.)
|
||||
assets/ # CSS, images
|
||||
public/ # Fichiers statiques
|
||||
infra/
|
||||
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
|
||||
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
|
||||
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
||||
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
|
||||
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
|
||||
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
||||
.claude/
|
||||
skills/create-module/ # Skill Claude Code pour scaffolder un module
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
@@ -90,6 +89,8 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0"
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
492
composer.lock
generated
492
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bfd26e903d79f710cfe95452c05f2a25",
|
||||
"content-hash": "75f8e672f2a401290886fbcf01befd3f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -4988,180 +4988,6 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/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/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.8",
|
||||
@@ -11018,6 +10844,322 @@
|
||||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/browser-kit",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/browser-kit.git",
|
||||
"reference": "f5a28fca785416cf489dd579011e74c831100cc3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3",
|
||||
"reference": "f5a28fca785416cf489dd579011e74c831100cc3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/dom-crawler": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^7.4|^8.0",
|
||||
"symfony/http-client": "^7.4|^8.0",
|
||||
"symfony/mime": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\BrowserKit\\": ""
|
||||
},
|
||||
"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": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/browser-kit/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/dom-crawler",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dom-crawler.git",
|
||||
"reference": "284ace90732b445b027728b5e0eec6418a17a364"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364",
|
||||
"reference": "284ace90732b445b027728b5e0eec6418a17a364",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\DomCrawler\\": ""
|
||||
},
|
||||
"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": "Eases DOM navigation for HTML and XML documents",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dom-crawler/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/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/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/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.8",
|
||||
|
||||
12
config/modules.php
Normal file
12
config/modules.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
];
|
||||
@@ -1,6 +1,14 @@
|
||||
api_platform:
|
||||
title: Coltura API
|
||||
version: 1.0.0
|
||||
# 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.
|
||||
# Sans ces paths, le compile pass d'API Platform ne declare pas les
|
||||
# services de filtres annotes (les filtres etaient silencieusement
|
||||
# ignores sur Permission — cf. ticket #344).
|
||||
mapping:
|
||||
paths:
|
||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -9,12 +9,22 @@ doctrine:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
alias: Core
|
||||
# Mapping inconditionnelle du module Sites : la structure DB
|
||||
# existe meme si SitesModule::class est retire de config/modules.php.
|
||||
# L'activation fonctionnelle (ex: exposition des permissions, futurs
|
||||
# endpoints API) passe exclusivement par config/modules.php.
|
||||
Sites:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||
prefix: 'App\Module\Sites\Domain\Entity'
|
||||
alias: Sites
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
@@ -22,6 +32,20 @@ when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
orm:
|
||||
mappings:
|
||||
# Entite fictive SiteAware utilisee uniquement en tests du
|
||||
# module Sites (ticket 4). Le mapping n'est charge qu'en
|
||||
# environnement test, donc aucun impact sur les schemas
|
||||
# dev/prod. La table est creee a la volee par les tests
|
||||
# d'integration (via `SchemaTool::createSchema`) dans le
|
||||
# setUp de SiteScopedQueryExtensionTest.
|
||||
TestFixtures:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/tests/Fixtures/SiteAware'
|
||||
prefix: 'App\Tests\Fixtures\SiteAware'
|
||||
alias: TestFixtures
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||
enable_profiler: false
|
||||
|
||||
@@ -8,7 +8,7 @@ security:
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
class: App\Module\Core\Domain\Entity\User
|
||||
property: username
|
||||
|
||||
firewalls:
|
||||
@@ -45,6 +45,8 @@ security:
|
||||
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/docs, 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 }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
|
||||
login_check:
|
||||
path: /login_check
|
||||
|
||||
api_logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -15,3 +15,18 @@ services:
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface:
|
||||
alias: App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository
|
||||
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface:
|
||||
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository
|
||||
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface:
|
||||
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository
|
||||
|
||||
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
||||
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||
|
||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||
|
||||
78
config/sidebar.php
Normal file
78
config/sidebar.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Sidebar configuration.
|
||||
*
|
||||
* This file defines the sidebar sections displayed in the frontend.
|
||||
* Each item references the module that owns it via the `module` key.
|
||||
* Items whose module is not active (see config/modules.php) are filtered out.
|
||||
* Items may also declare a `permission` key (RBAC permission code) : the item
|
||||
* is hidden from users who do not hold that permission.
|
||||
*
|
||||
* This config is decoupled from the modules themselves: you can freely
|
||||
* 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 [
|
||||
[
|
||||
'label' => 'sidebar.general.section',
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'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',
|
||||
'to' => '/admin/roles',
|
||||
'icon' => 'mdi:shield-account-outline',
|
||||
'module' => 'core',
|
||||
'permission' => 'core.roles.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.users',
|
||||
'to' => '/admin/users',
|
||||
'icon' => 'mdi:account-group-outline',
|
||||
'module' => 'core',
|
||||
'permission' => 'core.users.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.sites',
|
||||
'to' => '/admin/sites',
|
||||
'icon' => 'mdi:domain',
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.general.logout',
|
||||
'to' => '/logout',
|
||||
'icon' => 'mdi:logout',
|
||||
'module' => 'core',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.commercial.section',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'to' => '/suppliers',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'module' => 'commercial',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.8'
|
||||
app.version: '0.1.32'
|
||||
|
||||
364
doc/architecture-modulaire-malio.md
Normal file
364
doc/architecture-modulaire-malio.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Architecture Modulaire Monolith — MALIO
|
||||
|
||||
## Contexte
|
||||
|
||||
Projet monolith Symfony API Platform (back) + Nuxt (front dans un dossier `frontend/`).
|
||||
L'objectif est une architecture **modular monolith DDD** permettant de vendre des modules indépendamment à chaque client (ex : un client achète GestionRH + Formation, un autre GestionRH + Paie + Pointage).
|
||||
|
||||
---
|
||||
|
||||
## Principes fondamentaux
|
||||
|
||||
1. **Chaque module est un bounded context autonome** — son propre Domain, Application, Infrastructure.
|
||||
2. **Communication inter-modules uniquement par events ou contrats** — jamais d'import direct d'une entité d'un autre module.
|
||||
3. **Les modules sont activables/désactivables** par tenant sans casser le reste.
|
||||
4. **Le module `Core` est obligatoire** — il gère users, tenants, auth.
|
||||
5. **Le dossier `Shared/`** contient le noyau technique commun (interfaces, value objects de base, bus).
|
||||
6. **API Platform** : les `#[ApiResource]` sont sur des classes Resource dédiées dans `Infrastructure/ApiPlatform/Resource/`, jamais directement sur les entités du Domain.
|
||||
7. **CQRS** : Command/Query handlers dans la couche Application, DTO d'entrée/sortie découplés des entités.
|
||||
8. **Multi-tenant natif** : chaque entité porte un `tenantId`.
|
||||
|
||||
---
|
||||
|
||||
## Structure cible — Backend (`src/`)
|
||||
|
||||
```
|
||||
src/
|
||||
├── Kernel.php
|
||||
│
|
||||
├── Shared/
|
||||
│ ├── Domain/
|
||||
│ │ ├── ValueObject/
|
||||
│ │ │ ├── AggregateId.php
|
||||
│ │ │ └── Email.php
|
||||
│ │ ├── Event/
|
||||
│ │ │ └── DomainEventInterface.php
|
||||
│ │ └── Contract/ ← Interfaces inter-modules
|
||||
│ │ ├── UserResolverInterface.php
|
||||
│ │ └── TenantAwareInterface.php
|
||||
│ ├── Application/
|
||||
│ │ └── Bus/
|
||||
│ │ ├── CommandBusInterface.php
|
||||
│ │ └── QueryBusInterface.php
|
||||
│ └── Infrastructure/
|
||||
│ ├── Doctrine/
|
||||
│ ├── Messenger/
|
||||
│ └── ApiPlatform/
|
||||
│ └── OpenApi/
|
||||
│
|
||||
├── Module/
|
||||
│ ├── Core/ ← Module obligatoire
|
||||
│ │ ├── Domain/
|
||||
│ │ │ ├── Entity/
|
||||
│ │ │ │ ├── User.php
|
||||
│ │ │ │ └── Tenant.php
|
||||
│ │ │ ├── Repository/
|
||||
│ │ │ │ └── UserRepositoryInterface.php
|
||||
│ │ │ └── Event/
|
||||
│ │ │ └── UserCreated.php
|
||||
│ │ ├── Application/
|
||||
│ │ │ ├── Command/
|
||||
│ │ │ │ ├── CreateUser.php
|
||||
│ │ │ │ └── CreateUserHandler.php
|
||||
│ │ │ ├── Query/
|
||||
│ │ │ │ ├── GetUserById.php
|
||||
│ │ │ │ └── GetUserByIdHandler.php
|
||||
│ │ │ └── DTO/
|
||||
│ │ │ └── UserOutput.php
|
||||
│ │ ├── Infrastructure/
|
||||
│ │ │ ├── Doctrine/
|
||||
│ │ │ │ ├── DoctrineUserRepository.php
|
||||
│ │ │ │ └── mapping/
|
||||
│ │ │ └── ApiPlatform/
|
||||
│ │ │ ├── Resource/
|
||||
│ │ │ │ └── UserResource.php
|
||||
│ │ │ ├── State/
|
||||
│ │ │ │ ├── Provider/
|
||||
│ │ │ │ └── Processor/
|
||||
│ │ │ └── Filter/
|
||||
│ │ └── CoreModule.php ← Déclaration : config, routes, dépendances
|
||||
│ │
|
||||
│ ├── GestionRH/ ← Module vendable
|
||||
│ │ ├── Domain/
|
||||
│ │ │ ├── Entity/
|
||||
│ │ │ │ ├── Employee.php
|
||||
│ │ │ │ ├── Contract.php
|
||||
│ │ │ │ └── Leave.php
|
||||
│ │ │ ├── ValueObject/
|
||||
│ │ │ ├── Repository/
|
||||
│ │ │ │ └── EmployeeRepositoryInterface.php
|
||||
│ │ │ ├── Event/
|
||||
│ │ │ │ └── EmployeeHired.php
|
||||
│ │ │ ├── Exception/
|
||||
│ │ │ └── Service/
|
||||
│ │ ├── Application/
|
||||
│ │ │ ├── Command/
|
||||
│ │ │ ├── Query/
|
||||
│ │ │ ├── DTO/
|
||||
│ │ │ └── Listener/ ← Réagit aux events d'autres modules
|
||||
│ │ ├── Infrastructure/
|
||||
│ │ │ ├── Doctrine/
|
||||
│ │ │ │ ├── DoctrineEmployeeRepository.php
|
||||
│ │ │ │ ├── mapping/
|
||||
│ │ │ │ └── migrations/ ← Migrations propres au module
|
||||
│ │ │ └── ApiPlatform/
|
||||
│ │ │ ├── Resource/
|
||||
│ │ │ │ └── EmployeeResource.php
|
||||
│ │ │ └── State/
|
||||
│ │ │ ├── Provider/
|
||||
│ │ │ └── Processor/
|
||||
│ │ └── GestionRHModule.php
|
||||
│ │
|
||||
│ ├── Formation/ ← Module vendable
|
||||
│ │ ├── Domain/
|
||||
│ │ ├── Application/
|
||||
│ │ ├── Infrastructure/
|
||||
│ │ └── FormationModule.php
|
||||
│ │
|
||||
│ ├── Pointage/ ← Module vendable
|
||||
│ │ ├── Domain/
|
||||
│ │ ├── Application/
|
||||
│ │ ├── Infrastructure/
|
||||
│ │ └── PointageModule.php
|
||||
│ │
|
||||
│ └── Paie/ ← Module vendable
|
||||
│ ├── Domain/
|
||||
│ ├── Application/
|
||||
│ ├── Infrastructure/
|
||||
│ └── PaieModule.php
|
||||
│
|
||||
└── config/
|
||||
└── modules.php ← Liste des modules activés
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure cible — Frontend (`frontend/`)
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ ├── layouts/
|
||||
│ │ └── default.vue ← Menu dynamique selon modules activés
|
||||
│ ├── middleware/
|
||||
│ │ └── modules.global.ts ← Bloque les routes de modules désactivés
|
||||
│ └── app.vue
|
||||
│
|
||||
├── shared/
|
||||
│ ├── composables/
|
||||
│ │ ├── useAuth.ts
|
||||
│ │ ├── useApi.ts
|
||||
│ │ ├── useTenant.ts
|
||||
│ │ └── useModules.ts ← Expose les modules activés (via API)
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ ← Design system (boutons, tables, modals…)
|
||||
│ │ ├── AppSidebar.vue
|
||||
│ │ └── AppHeader.vue
|
||||
│ ├── types/
|
||||
│ │ └── index.ts
|
||||
│ ├── utils/
|
||||
│ └── stores/
|
||||
│ ├── auth.ts
|
||||
│ └── tenant.ts
|
||||
│
|
||||
├── modules/
|
||||
│ ├── core/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── login.vue
|
||||
│ │ │ ├── dashboard.vue
|
||||
│ │ │ └── users/
|
||||
│ │ │ ├── index.vue
|
||||
│ │ │ └── [id].vue
|
||||
│ │ ├── components/
|
||||
│ │ ├── composables/
|
||||
│ │ ├── stores/
|
||||
│ │ │ └── users.ts
|
||||
│ │ ├── types/
|
||||
│ │ └── core.module.ts
|
||||
│ │
|
||||
│ ├── gestion-rh/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── employees/
|
||||
│ │ │ │ ├── index.vue
|
||||
│ │ │ │ └── [id].vue
|
||||
│ │ │ ├── contracts/
|
||||
│ │ │ └── leaves/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── EmployeeCard.vue
|
||||
│ │ │ └── LeaveCalendar.vue
|
||||
│ │ ├── composables/
|
||||
│ │ │ └── useEmployees.ts
|
||||
│ │ ├── stores/
|
||||
│ │ │ └── employees.ts
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── gestion-rh.module.ts
|
||||
│ │
|
||||
│ ├── formation/
|
||||
│ │ ├── pages/
|
||||
│ │ ├── components/
|
||||
│ │ ├── composables/
|
||||
│ │ ├── stores/
|
||||
│ │ ├── types/
|
||||
│ │ └── formation.module.ts
|
||||
│ │
|
||||
│ ├── pointage/
|
||||
│ │ ├── pages/
|
||||
│ │ ├── components/
|
||||
│ │ ├── composables/
|
||||
│ │ ├── stores/
|
||||
│ │ ├── types/
|
||||
│ │ └── pointage.module.ts
|
||||
│ │
|
||||
│ └── paie/
|
||||
│ ├── pages/
|
||||
│ ├── components/
|
||||
│ ├── composables/
|
||||
│ ├── stores/
|
||||
│ ├── types/
|
||||
│ └── paie.module.ts
|
||||
│
|
||||
├── plugins/
|
||||
│ └── modules-loader.ts ← Charge dynamiquement les modules activés
|
||||
├── nuxt.config.ts
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Règles d'implémentation
|
||||
|
||||
### Backend
|
||||
|
||||
#### 1. Communication inter-modules
|
||||
- **INTERDIT** : `use Module\GestionRH\Domain\Entity\Employee` depuis le module Paie.
|
||||
- **AUTORISÉ** : passer par `Shared\Domain\Contract\EmployeeResolverInterface` ou écouter un domain event comme `EmployeeHired`.
|
||||
- Les contrats (interfaces) partagés vivent dans `Shared/Domain/Contract/`.
|
||||
|
||||
#### 2. Couche Domain (aucune dépendance Symfony)
|
||||
- Entités avec logique métier encapsulée (pas d'anemic model).
|
||||
- Value Objects pour la validation (Email, Money, OrderStatus…).
|
||||
- Repository = interface uniquement.
|
||||
- Domain Events pour notifier les autres modules.
|
||||
- Aucun `use Symfony\...` dans ce dossier.
|
||||
|
||||
#### 3. Couche Application
|
||||
- CQRS : Command (écriture) + Query (lecture) avec leurs Handlers.
|
||||
- Les Handlers orchestrent : ils appellent le Domain et les interfaces Repository.
|
||||
- DTO Input/Output pour le contrat d'entrée/sortie, découplés des entités.
|
||||
- Listeners pour réagir aux events d'autres modules.
|
||||
|
||||
#### 4. Couche Infrastructure
|
||||
- Implémentations Doctrine des repositories.
|
||||
- Mapping et migrations **propres à chaque module** (pas de migration centralisée).
|
||||
- API Platform :
|
||||
- `Resource/` : classes avec `#[ApiResource]`, jamais posé sur les entités Domain.
|
||||
- `State/Provider/` : fournisseurs de données (GET).
|
||||
- `State/Processor/` : traitement des mutations (POST/PUT/PATCH/DELETE), délègue au bus ou aux handlers.
|
||||
- `Filter/` : filtres API Platform spécifiques au module.
|
||||
- Les endpoints n'apparaissent dans l'OpenAPI que si le module est activé.
|
||||
|
||||
#### 5. Module declaration (`*Module.php`)
|
||||
Chaque module déclare :
|
||||
- Son identifiant unique.
|
||||
- Ses dépendances (ex : GestionRH dépend de Core).
|
||||
- Sa configuration de services.
|
||||
- Ses routes.
|
||||
|
||||
#### 6. Activation/désactivation
|
||||
- Fichier `config/modules.php` ou variable d'environnement listant les modules actifs.
|
||||
- Le Kernel ne charge que les services/routes/migrations des modules activés.
|
||||
- Endpoint API : `GET /api/tenant/modules` retourne la liste des modules activés pour le tenant courant.
|
||||
|
||||
#### 7. Multi-tenant
|
||||
- Chaque entité porte un `tenantId`.
|
||||
- Filtrage automatique Doctrine par tenant (Doctrine Filter ou listeners).
|
||||
|
||||
### Frontend
|
||||
|
||||
#### 1. Déclaration de module (`*.module.ts`)
|
||||
```typescript
|
||||
export default defineAppModule({
|
||||
id: 'gestion-rh',
|
||||
label: 'Gestion RH',
|
||||
icon: 'i-lucide-users',
|
||||
requiredModules: ['core'],
|
||||
navigation: [
|
||||
{ label: 'Employés', to: '/employees', icon: 'i-lucide-user' },
|
||||
{ label: 'Contrats', to: '/contracts', icon: 'i-lucide-file-text' },
|
||||
{ label: 'Congés', to: '/leaves', icon: 'i-lucide-calendar' },
|
||||
],
|
||||
permissions: ['employee.read', 'employee.write', 'leave.manage'],
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Chargement dynamique (`useModules`)
|
||||
```typescript
|
||||
export const useModules = () => {
|
||||
const enabledModules = useState<string[]>('modules', () => [])
|
||||
|
||||
const isEnabled = (moduleId: string) =>
|
||||
enabledModules.value.includes(moduleId)
|
||||
|
||||
return { enabledModules, isEnabled }
|
||||
}
|
||||
```
|
||||
Au boot de l'app, appel `GET /api/tenant/modules` pour récupérer les modules activés.
|
||||
|
||||
#### 3. Middleware de protection des routes
|
||||
```typescript
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { isEnabled } = useModules()
|
||||
const moduleId = resolveModuleFromRoute(to.path)
|
||||
if (moduleId && !isEnabled(moduleId)) {
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. Sidebar dynamique
|
||||
Le layout `default.vue` itère sur les modules activés et affiche leurs entrées `navigation`.
|
||||
|
||||
#### 5. Isolation
|
||||
- Chaque module front a ses propres pages, components, composables, stores, types.
|
||||
- Les composants partagés (design system) sont dans `shared/components/ui/`.
|
||||
- Un module front ne doit jamais importer depuis un autre module front. Si besoin de données croisées, passer par l'API ou par un composable partagé dans `shared/`.
|
||||
|
||||
---
|
||||
|
||||
## Résumé des conventions de nommage
|
||||
|
||||
| Élément | Convention | Exemple |
|
||||
|---------|-----------|---------|
|
||||
| Module back | PascalCase | `Module/GestionRH/` |
|
||||
| Module front | kebab-case | `modules/gestion-rh/` |
|
||||
| Entity | PascalCase singulier | `Employee.php` |
|
||||
| Repository interface | `*RepositoryInterface` | `EmployeeRepositoryInterface.php` |
|
||||
| Repository impl | `Doctrine*Repository` | `DoctrineEmployeeRepository.php` |
|
||||
| Command | Verbe + Nom | `CreateEmployee.php` |
|
||||
| Command Handler | `*Handler` | `CreateEmployeeHandler.php` |
|
||||
| DTO | `*Input` / `*Output` | `EmployeeOutput.php` |
|
||||
| API Resource | `*Resource` | `EmployeeResource.php` |
|
||||
| Provider | `*Provider` | `EmployeeProvider.php` |
|
||||
| Processor | `*Processor` | `CreateEmployeeProcessor.php` |
|
||||
| Module declaration back | `*Module.php` | `GestionRHModule.php` |
|
||||
| Module declaration front | `*.module.ts` | `gestion-rh.module.ts` |
|
||||
| Composable | `use*` | `useEmployees.ts` |
|
||||
| Store | nom du domaine | `employees.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Checklist de migration
|
||||
|
||||
Si le projet existe déjà avec une structure plate, voici l'ordre de migration recommandé :
|
||||
|
||||
1. Créer `Shared/` et y déplacer les interfaces/VO de base.
|
||||
2. Créer `Module/Core/` et y migrer users, auth, tenants.
|
||||
3. Pour chaque futur module vendable, créer le dossier `Module/<Nom>/` avec les 3 couches (Domain, Application, Infrastructure).
|
||||
4. Déplacer les entités, repositories, services dans le bon module.
|
||||
5. Remplacer les imports directs inter-modules par des contrats (`Shared/Domain/Contract/`) ou des events.
|
||||
6. Isoler les migrations Doctrine par module.
|
||||
7. Adapter les resources API Platform (les sortir des entités, créer les Providers/Processors).
|
||||
8. Côté front, créer `shared/` et `modules/`, migrer les pages/composants dans le bon module.
|
||||
9. Implémenter `useModules` + middleware de routes + sidebar dynamique.
|
||||
10. Tester l'activation/désactivation d'un module de bout en bout.
|
||||
314
doc/deployment-docker.md
Normal file
314
doc/deployment-docker.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Deploiement Docker — Coltura
|
||||
|
||||
## Pre-requis
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||
|
||||
### Nginx
|
||||
|
||||
```bash
|
||||
sudo apt install -y nginx
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||
Il doit etre installe et accessible avant de deployer Coltura.
|
||||
|
||||
Creer la base de donnees pour Coltura :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
docker compose exec postgres psql -U admin
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Si le user n'existe pas encore
|
||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||
|
||||
-- Creer la base
|
||||
CREATE DATABASE coltura_prod OWNER malio;
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Premiere installation (nouvelle machine)
|
||||
|
||||
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
|
||||
|
||||
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||
|
||||
### 2. Creer le dossier de deploiement
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/coltura
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/coltura
|
||||
cd /var/www/coltura
|
||||
```
|
||||
|
||||
### 3. Se connecter au registry Docker de Gitea
|
||||
|
||||
```bash
|
||||
docker login gitea.malio.fr
|
||||
```
|
||||
|
||||
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
|
||||
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
|
||||
|
||||
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
|
||||
|
||||
### 4. Creer les fichiers de deploiement
|
||||
|
||||
Creer `docker-compose.yml` :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
||||
container_name: coltura-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8083:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Creer `deploy.sh` :
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export COLTURA_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying coltura:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
Rendre executable :
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
### 5. Configurer l'environnement
|
||||
|
||||
Creer `.env` avec les variables suivantes :
|
||||
|
||||
```env
|
||||
# Symfony
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||
|
||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/coltura_prod?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=https://coltura.malio-dev.fr
|
||||
```
|
||||
|
||||
### 6. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
mkdir -p config/jwt
|
||||
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||
```
|
||||
|
||||
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
|
||||
|
||||
```bash
|
||||
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
|
||||
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
|
||||
```
|
||||
|
||||
### 7. Creer le dossier uploads
|
||||
|
||||
```bash
|
||||
mkdir -p uploads
|
||||
```
|
||||
|
||||
### 8. Configurer Nginx systeme (reverse proxy + maintenance)
|
||||
|
||||
Copier la config reverse proxy depuis le repo :
|
||||
|
||||
```bash
|
||||
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/coltura.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/coltura/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
||||
|
||||
Activer le site :
|
||||
|
||||
```bash
|
||||
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Mode maintenance
|
||||
|
||||
```bash
|
||||
# Activer la maintenance
|
||||
touch /var/www/coltura/maintenance.on
|
||||
|
||||
# Desactiver la maintenance
|
||||
rm /var/www/coltura/maintenance.on
|
||||
```
|
||||
|
||||
Optionnel : creer une page `/var/www/coltura/public/maintenance.html` personnalisee.
|
||||
|
||||
### 9. Deployer
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 10. Creer le premier user admin
|
||||
|
||||
```bash
|
||||
docker compose exec -T -u www-data app php bin/console security:hash-password --env=prod
|
||||
```
|
||||
|
||||
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
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) :
|
||||
|
||||
```bash
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --no-interaction --env=prod
|
||||
```
|
||||
|
||||
### Structure finale du dossier
|
||||
|
||||
```
|
||||
/var/www/coltura/
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh
|
||||
├── .env
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployer une nouvelle version
|
||||
|
||||
Quand l'app est deja installee, deployer une mise a jour :
|
||||
|
||||
```bash
|
||||
cd /var/www/coltura
|
||||
./deploy.sh # deploie la derniere version (latest)
|
||||
./deploy.sh v0.2.0 # deploie une version specifique
|
||||
```
|
||||
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
### Image seule (pas de changement de schema BDD)
|
||||
|
||||
```bash
|
||||
./deploy.sh v0.1.9
|
||||
```
|
||||
|
||||
### Avec rollback de migration
|
||||
|
||||
```bash
|
||||
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||
# 2. Deployer l'ancienne version
|
||||
./deploy.sh v0.1.9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||
1. Build l'image multi-stage
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Voir les logs
|
||||
|
||||
```bash
|
||||
cd /var/www/coltura
|
||||
docker compose logs -f # tous les logs
|
||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||
```
|
||||
|
||||
Logs Symfony :
|
||||
|
||||
```bash
|
||||
docker compose exec app cat var/log/prod.log
|
||||
```
|
||||
267
doc/review-PR1-ERP7-modular-monolith.md
Normal file
267
doc/review-PR1-ERP7-modular-monolith.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Code Review — PR #1 [ERP-7] Mise en place du modular monolith
|
||||
|
||||
**Branche** : `feature/ERP-7-mise-en-place-du-modular-monolith` → `develop`
|
||||
**Auteur** : Tristan
|
||||
**Date de review** : 2026-04-09
|
||||
**Scope** : 55 commits, 85 fichiers modifiés, ~30 000 lignes ajoutées
|
||||
|
||||
---
|
||||
|
||||
## Résumé de la PR
|
||||
|
||||
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
|
||||
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
||||
- **Modules activables** : `config/modules.php` comme source de vérité, activation/désactivation sans toucher au code
|
||||
- **Sidebar dynamique** : `config/sidebar.php` déclare la navigation, le backend filtre selon les modules actifs, le frontend consomme via `/api/sidebar`
|
||||
- **Frontend** : réorganisation en `app/` (shell), `shared/` (composables, stores, types), `modules/` (Nuxt layers auto-détectés)
|
||||
- **Infra** : migration Doctrine, Dockerfile prod, deploy.sh, nginx-proxy, maintenance mode
|
||||
|
||||
---
|
||||
|
||||
## Issues trouvées
|
||||
|
||||
### Issue 1 — CHANGELOG mentionne le mauvais projet
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Critique |
|
||||
| **Fichier** | `CHANGELOG.md`, ligne 3 |
|
||||
| **Confiance** | 100/100 |
|
||||
|
||||
**Constat** : Le header du CHANGELOG dit :
|
||||
|
||||
```
|
||||
Liste des évolutions du projet Ferme
|
||||
```
|
||||
|
||||
Ce fichier appartient à **Coltura**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
||||
|
||||
**Correction** : Remplacer "Ferme" par "Coltura".
|
||||
|
||||
---
|
||||
|
||||
### Issue 2 — Sidebar link `/suppliers` pointe vers une page inexistante (404)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichier** | `config/sidebar.php`, ligne 49 |
|
||||
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
||||
|
||||
**Constat** : La sidebar déclare un lien vers `/suppliers` pour le module commercial :
|
||||
|
||||
```php
|
||||
// config/sidebar.php
|
||||
[
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'icon' => 'i-heroicons-truck',
|
||||
'to' => '/suppliers', // ← route inexistante
|
||||
'module' => 'commercial',
|
||||
],
|
||||
```
|
||||
|
||||
Mais la seule page du module commercial est `frontend/modules/commercial/pages/commercial.vue`, que Nuxt mappe sur la route `/commercial` (pas `/suppliers`).
|
||||
|
||||
**Impact** : Cliquer sur ce lien dans la sidebar donnera un 404.
|
||||
|
||||
**Correction** : Soit renommer la page en `suppliers.vue`, soit changer le `'to'` en `'/commercial'`.
|
||||
|
||||
---
|
||||
|
||||
### Issue 3 — Port PostgreSQL changé de 5436 à 5437
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichier** | `infra/dev/.env.docker` |
|
||||
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / 3003 / **5436**" |
|
||||
| **Confiance** | 75/100 |
|
||||
|
||||
**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.
|
||||
|
||||
**Correction** : Revenir à `5436` ou mettre à jour les CLAUDE.md (workspace + projet).
|
||||
|
||||
---
|
||||
|
||||
### Issue 4 — Port frontend changé de 3003 à 3004 sans mise à jour de la documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
||||
| **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) |
|
||||
|
||||
**Constat** :
|
||||
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
devServer: { port: 3004 } // était 3003
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
ports:
|
||||
- "3004:3004" # était 3004:3003
|
||||
```
|
||||
|
||||
Les deux `CLAUDE.md` documentent toujours le port 3003.
|
||||
|
||||
**Correction** : Mettre à jour les deux CLAUDE.md pour refléter le nouveau port, ou revenir à 3003.
|
||||
|
||||
---
|
||||
|
||||
### Issue 5 — `useSidebar` : state singleton jamais réinitialisé au logout
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichier** | `frontend/shared/composables/useSidebar.ts`, lignes 3-5 |
|
||||
| **Confiance** | 75/100 |
|
||||
|
||||
**Constat** : Les refs `sections`, `disabledRoutes` et `loaded` sont déclarées au niveau module (en dehors de la fonction composable) :
|
||||
|
||||
```typescript
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
const disabledRoutes = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
```
|
||||
|
||||
Ce sont des singletons partagés sur toute la durée de vie de l'app. Après un logout + re-login :
|
||||
1. `loaded.value` reste `true`
|
||||
2. `loadSidebar()` n'est jamais rappelé
|
||||
3. La sidebar affiche les données de la session précédente
|
||||
|
||||
Le middleware `auth.global.ts` ne recharge que si `!loaded.value`, et `logout.vue` ne reset jamais `loaded`.
|
||||
|
||||
**Impact** : Sidebar périmée après re-connexion. Si les modules changent côté serveur, le frontend ne le saura jamais sans un hard refresh.
|
||||
|
||||
**Correction** : Ajouter une fonction `resetSidebar()` appelée au logout, ou conditionner le rechargement autrement (ex: toujours recharger après login).
|
||||
|
||||
---
|
||||
|
||||
### Issue 6 — `UserOutput` DTO : type mismatch `int` vs `?int` + dead code
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Moyenne |
|
||||
| **Fichier** | `src/Module/Core/Application/DTO/UserOutput.php`, lignes 13 et 23 |
|
||||
| **Confiance** | 75/100 |
|
||||
|
||||
**Constat** :
|
||||
|
||||
```php
|
||||
// Le constructeur attend un int non-nullable
|
||||
public function __construct(
|
||||
public int $id, // ← int, pas ?int
|
||||
// ...
|
||||
)
|
||||
|
||||
// Mais User::getId() retourne ?int
|
||||
public static function fromEntity(User $user): self
|
||||
{
|
||||
return new self(
|
||||
id: $user->getId(), // ← peut être null
|
||||
// ...
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Avec `declare(strict_types=1)`, passer `null` à un paramètre `int` lève un `TypeError`.
|
||||
|
||||
**De plus** : Ce DTO n'est utilisé nulle part. `MeProvider` retourne directement l'entité `User` via `$this->security->getUser()`. Le DTO est du dead code.
|
||||
|
||||
**Correction** : Soit utiliser le DTO dans `MeProvider` (comme l'architecture le prévoit), soit le supprimer. Dans tous les cas, changer `int $id` en `?int $id`.
|
||||
|
||||
---
|
||||
|
||||
### Issue 7 — `CreateUserCommand` contourne `UserRepositoryInterface`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Moyenne |
|
||||
| **Fichier** | `src/Module/Core/Infrastructure/Console/CreateUserCommand.php`, lignes 22-27 |
|
||||
| **Règle violée** | `CLAUDE.md` projet : "les repositories sont des interfaces" |
|
||||
| **Confiance** | 75/100 (confirmé par 2 agents indépendants) |
|
||||
|
||||
**Constat** :
|
||||
|
||||
```php
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em, // ← injection directe de Doctrine
|
||||
private UserPasswordHasherInterface $hasher,
|
||||
) {}
|
||||
|
||||
// ...
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
```
|
||||
|
||||
Le `UserRepositoryInterface::save(User $user)` existe et est implémenté par `DoctrineUserRepository`. La commande devrait l'utiliser :
|
||||
|
||||
```php
|
||||
// Correction attendue
|
||||
public function __construct(
|
||||
private UserRepositoryInterface $userRepository,
|
||||
private UserPasswordHasherInterface $hasher,
|
||||
) {}
|
||||
|
||||
// ...
|
||||
$this->userRepository->save($user);
|
||||
```
|
||||
|
||||
**Impact** : Viole le pattern DDD introduit dans cette même PR et crée un second chemin de persistance non contrôlé.
|
||||
|
||||
---
|
||||
|
||||
### Issue 8 — `auth.vue` : indentation 2 espaces au lieu de 4
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Sévérité** | Mineure |
|
||||
| **Fichier** | `frontend/app/layouts/auth.vue` |
|
||||
| **Règle violée** | `CLAUDE.md` projet : "4 espaces d'indentation" |
|
||||
| **Confiance** | 75/100 |
|
||||
|
||||
**Constat** : Le fichier utilise 2 espaces d'indentation :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Tous les autres fichiers Vue de la PR (`default.vue`, `login.vue`, `index.vue`, `logout.vue`, `commercial.vue`) utilisent correctement 4 espaces.
|
||||
|
||||
**Correction** : Passer en 4 espaces.
|
||||
|
||||
---
|
||||
|
||||
## Issues mineures non retenues (score < 75)
|
||||
|
||||
Pour information, ces points ont été identifiés mais jugés moins critiques :
|
||||
|
||||
- **`/api/sidebar` et `/api/modules` en PUBLIC_ACCESS** : intentionnel selon le CLAUDE.md qui documente ces endpoints comme publics
|
||||
- **`doctrine.yaml` ne mappe que le module Core** : les entités de futurs modules ne seront pas détectées automatiquement (à documenter)
|
||||
- **Middleware `modules.global.ts`** : boucle de redirection infinie possible si `/` est dans `disabledRoutes` (requiert une mauvaise config)
|
||||
- **Lien sidebar `/admin`** : pointe vers une page inexistante (pré-existant sur develop)
|
||||
- **Labels hardcodés en français dans `login.vue`** : `"Nom d'utilisateur"`, `"Mot de passe"`, `"Se connecter"` au lieu de `$t('auth.username')` etc. (les clés i18n existent dans `fr.json`)
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
La PR pose de bonnes bases architecturales (DDD, modules activables, sidebar dynamique). Les issues principales sont :
|
||||
|
||||
- **2 bugs fonctionnels** : sidebar `/suppliers` en 404 et state `useSidebar` jamais reset
|
||||
- **3 incohérences config/doc** : ports PG et frontend changés sans MAJ CLAUDE.md, CHANGELOG mauvais projet
|
||||
- **2 incohérences architecturales** : `CreateUserCommand` et `UserOutput` ne respectent pas les patterns introduits dans la PR
|
||||
- **1 style** : indentation auth.vue
|
||||
|
||||
Aucun de ces problèmes ne bloque le merge mais les 2 bugs fonctionnels (issues 2 et 5) devraient être corrigés avant.
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "3004:3004"
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
|
||||
287
docs/modules/site-aware.md
Normal file
287
docs/modules/site-aware.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Guide développeur — `SiteAwareInterface` (opt-in)
|
||||
|
||||
Ce guide explique comment adopter le pattern **site-aware** sur une entité
|
||||
d'un module métier pour que ses données soient automatiquement filtrées
|
||||
par le site courant de l'utilisateur connecté, et pour que les créations
|
||||
soient rattachées implicitement au site courant.
|
||||
|
||||
Ce pattern est **opt-in strict** : aucune entité n'est affectée tant qu'un
|
||||
module ne choisit pas explicitement d'implémenter `SiteAwareInterface`.
|
||||
|
||||
Livré par le ticket 4/4 du module Sites (cf. `docs/sites/ticket-04-spec.md`).
|
||||
|
||||
## 1. Quand adopter ?
|
||||
|
||||
Adopte le pattern si :
|
||||
|
||||
- Chaque ligne de l'entité appartient à **un et un seul site**.
|
||||
- Les utilisateurs du site A ne doivent **jamais** voir les lignes du site B.
|
||||
- Créer une ligne sans connaître le site n'a pas de sens métier.
|
||||
|
||||
Exemples typiques : `Supplier`, `Order`, `StockEntry`, `Employee` (si chaque
|
||||
site a sa propre équipe), `Invoice` (si facturation par site).
|
||||
|
||||
## 2. Quand NE PAS adopter ?
|
||||
|
||||
**Entités globales** : partagées par tous les sites, pas de notion de
|
||||
propriétaire. Ne pas adopter.
|
||||
|
||||
- `Role`, `Permission`, `User` (les users sont transverses, rattachés à
|
||||
plusieurs sites via la relation M2M `user_site`).
|
||||
- Catalogues mutualisés : produits, catégories, taxes — sauf si chaque
|
||||
site a son propre catalogue.
|
||||
- Documents / contrats multi-site (ex: contrat-cadre qui couvre plusieurs
|
||||
sites).
|
||||
|
||||
**Entités "par tenant"** : si le scope naturel est plus large que le site
|
||||
(ex: un groupe qui possède plusieurs sites comme entités filiales
|
||||
juridiquement distinctes), utilise plutôt `TenantAwareInterface` (déjà
|
||||
présent dans `src/Shared/Domain/Contract/`).
|
||||
|
||||
**Entités hybrides** : certaines lignes globales, d'autres par site. Le
|
||||
pattern ne supporte pas ce cas — crée deux entités distinctes si
|
||||
nécessaire.
|
||||
|
||||
## 3. Comment adopter ? Check-list
|
||||
|
||||
### 3.1 Entité
|
||||
|
||||
```php
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
|
||||
class Supplier implements SiteAwareInterface
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Site $site = null;
|
||||
|
||||
public function getSite(): ?Site
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(Site $site): void
|
||||
{
|
||||
$this->site = $site;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Points critiques :
|
||||
|
||||
- `nullable: false` au niveau de la `JoinColumn` — la table n'accepte
|
||||
jamais `site_id IS NULL` en régime nominal.
|
||||
- `onDelete: 'CASCADE'` — la suppression d'un site entraîne la suppression
|
||||
de toutes les lignes associées. À remplacer par `RESTRICT` (blocage) si
|
||||
ton métier exige d'empêcher la suppression d'un site contenant des
|
||||
données.
|
||||
- Le getter retourne `?Site` (nullable) pour permettre des états
|
||||
transitoires pré-persist (entité construite avant injection du site).
|
||||
|
||||
### 3.2 Migration
|
||||
|
||||
**Cas 1 — Nouvelle table** : ajoute directement `site_id INT NOT NULL`
|
||||
avec FK et index.
|
||||
|
||||
**Cas 2 — Table existante avec données legacy** : migration en trois étapes
|
||||
distinctes.
|
||||
|
||||
```php
|
||||
// Version1.php
|
||||
$this->addSql('ALTER TABLE supplier ADD site_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_supplier_site ON supplier (site_id)');
|
||||
$this->addSql('ALTER TABLE supplier ADD CONSTRAINT FK_supplier_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE');
|
||||
|
||||
// Backfill (manuellement ou via script custom selon ton métier)
|
||||
$this->addSql("UPDATE supplier SET site_id = (SELECT id FROM site WHERE name = 'Chatellerault') WHERE site_id IS NULL");
|
||||
|
||||
// Version2.php — après backfill confirmé
|
||||
$this->addSql('ALTER TABLE supplier ALTER COLUMN site_id SET NOT NULL');
|
||||
```
|
||||
|
||||
**Index obligatoire** : le filtre généré par `SiteScopedQueryExtension`
|
||||
est `WHERE x.site = :currentSite`. Sans index sur `site_id`, chaque
|
||||
requête fait un full-scan de la table. Ajoute-le dans la migration.
|
||||
|
||||
### 3.3 Sérialisation API
|
||||
|
||||
Expose la relation `site` dans le groupe de lecture de la ressource pour
|
||||
que le frontend sache à quel site appartient chaque ligne :
|
||||
|
||||
```php
|
||||
#[Groups(['supplier:read'])]
|
||||
private ?Site $site = null;
|
||||
```
|
||||
|
||||
Si tu veux aussi permettre à un admin de créer une ligne sur un autre
|
||||
site que son `currentSite` (ex: admin multi-site), ajoute aussi le groupe
|
||||
d'écriture :
|
||||
|
||||
```php
|
||||
#[Groups(['supplier:read', 'supplier:write'])]
|
||||
```
|
||||
|
||||
Dans ce cas, `SiteAwareInjectionProcessor` respecte la valeur explicite
|
||||
envoyée par le client (voir §4).
|
||||
|
||||
### 3.4 Processor custom
|
||||
|
||||
Si le module a déjà un processor custom sur les opérations POST/PATCH,
|
||||
assure-toi qu'il délègue à `api_platform.doctrine.orm.state.persist_processor`
|
||||
(et non à `$em->persist()` direct) pour que le decorator
|
||||
`SiteAwareInjectionProcessor` s'applique.
|
||||
|
||||
Pattern aligné sur `UserRbacProcessor` :
|
||||
|
||||
```php
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
// Gardes métier custom ici...
|
||||
|
||||
// Délègue au persist processor décoré : SiteAwareInjectionProcessor
|
||||
// interceptera l'appel et injectera le currentSite si besoin.
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Comportement du processor d'injection
|
||||
|
||||
Le decorator `SiteAwareInjectionProcessor` s'applique automatiquement à
|
||||
**toute** persistance API Platform. Son comportement :
|
||||
|
||||
| Cas | Action |
|
||||
|---|---|
|
||||
| `$data` n'implémente pas `SiteAwareInterface` | Délégation directe (no-op). |
|
||||
| `$data` est `SiteAware` avec `$site` déjà positionné (ex: payload POST avec `site` explicite) | Délégation directe, **la valeur explicite est préservée**. |
|
||||
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne un `Site` | Injection `$data->setSite($currentSite)` puis délégation. |
|
||||
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne `null` | **Throw `BadRequestHttpException`** avec message "aucun site sélectionné". |
|
||||
|
||||
Conséquence : un user sans `currentSite` ne peut **pas** créer de ligne
|
||||
sur une entité `SiteAware`. C'est intentionnel : mieux vaut un 400 clair
|
||||
que persister une ligne incohérente.
|
||||
|
||||
## 5. Comportement en mode dégradé
|
||||
|
||||
### 5.1 Module Sites désactivé
|
||||
|
||||
Si `SitesModule::class` est retiré de `config/modules.php`,
|
||||
`CurrentSiteProvider::get()` retourne **toujours `null`** :
|
||||
|
||||
- `SiteScopedQueryExtension` → no-op. Toutes les lignes visibles, comme
|
||||
si le filtre n'existait pas.
|
||||
- `SiteAwareInjectionProcessor` → **throw 400 sur tout POST/PATCH** sans
|
||||
site explicite. L'écriture d'entités `SiteAware` nécessite que le
|
||||
client envoie systématiquement `site` dans le payload.
|
||||
|
||||
**Conséquence** : un module qui adopte le pattern **ne peut pas vivre**
|
||||
sans le module Sites actif pour les opérations d'écriture. À documenter
|
||||
fortement dans le README du module adopté.
|
||||
|
||||
### 5.2 User sans site (sites = [], currentSite = null)
|
||||
|
||||
Même comportement qu'un module désactivé : lecture no-op (tout visible),
|
||||
écriture bloquée par 400. L'UX doit gérer ce cas (ex: écran d'onboarding
|
||||
qui force l'assignation d'un site avant d'accéder aux écrans métier).
|
||||
|
||||
### 5.3 Bypass admin via `sites.bypass_scope`
|
||||
|
||||
Un utilisateur avec la permission `sites.bypass_scope` (ou admin par
|
||||
bypass total via `isAdmin = true`) voit **toutes** les lignes, tous
|
||||
sites confondus. Pratique pour audit, reporting, consolidation groupe.
|
||||
|
||||
Le processor d'injection ne respecte **pas** ce bypass : même un user
|
||||
avec `bypass_scope` verra son `currentSite` injecté à la création s'il
|
||||
n'envoie pas de `site` explicite. Le bypass est un droit de lecture,
|
||||
pas d'écriture multi-site.
|
||||
|
||||
## 6. Anti-patterns et gotchas
|
||||
|
||||
### 6.1 Sous-collections (`/api/clients/{id}/contacts`)
|
||||
|
||||
Si seul `Client` est `SiteAware` (et `Contact` hérite du scope via son
|
||||
parent), le filtre **ne se propage pas automatiquement** aux contacts.
|
||||
Deux options :
|
||||
|
||||
- Rendre `Contact` aussi `SiteAware` (redondance mais simple).
|
||||
- Ajouter un filtre custom qui joint sur `contact.client.site` et compare
|
||||
au `currentSite`.
|
||||
|
||||
Ce ticket ne couvre pas le second cas : à implémenter par le module
|
||||
concerné.
|
||||
|
||||
### 6.2 Repositories custom
|
||||
|
||||
Le filtre API Platform ne s'applique **qu'aux requêtes** générées par
|
||||
API Platform (via `ItemProvider` / `CollectionProvider` Doctrine). Si un
|
||||
repository custom fait une requête DQL manuelle (ex: `findTopRated()`
|
||||
appelé depuis un service), **aucun filtre n'est appliqué**.
|
||||
|
||||
Responsabilité du développeur du module : injecter `CurrentSiteProvider`
|
||||
dans le repository / service et ajouter manuellement la clause WHERE.
|
||||
|
||||
### 6.3 Tests d'intégration
|
||||
|
||||
Les tests qui persistent des entités `SiteAware` doivent :
|
||||
|
||||
- Soit logger un user avec un `currentSite` positionné (cas nominal).
|
||||
- Soit utiliser un user avec `sites.bypass_scope` pour voir toutes les
|
||||
lignes (cas reporting).
|
||||
- Soit positionner le site **explicitement** sur chaque entité persistée
|
||||
via fixture (bypass du processor d'injection qui n'est pas actif hors
|
||||
contexte HTTP).
|
||||
|
||||
### 6.4 Cascade delete d'un site
|
||||
|
||||
La migration type du §3.2 déclare `onDelete: 'CASCADE'` sur la FK
|
||||
`site_id`. **Conséquence** : supprimer un site détruit **toutes** les
|
||||
lignes de **toutes** les tables `SiteAware` rattachées à ce site, en
|
||||
cascade. Pour un `Supplier`, ça signifie perte de l'historique fournisseur
|
||||
du site supprimé.
|
||||
|
||||
Alternatives selon le besoin métier :
|
||||
|
||||
- `onDelete: 'RESTRICT'` : bloque la suppression du site tant qu'il reste
|
||||
des lignes. L'admin doit nettoyer manuellement avant delete.
|
||||
- `onDelete: 'SET NULL'` : transforme les lignes en "globales" après
|
||||
suppression du site — mais incompatible avec `nullable: false`, donc
|
||||
nécessite de relâcher la contrainte. Généralement à éviter.
|
||||
|
||||
## 7. Exemple d'adoption minimale (pseudo-ticket)
|
||||
|
||||
Pour un futur ticket qui adopte `SiteAwareInterface` sur `Supplier` du
|
||||
module Commercial :
|
||||
|
||||
1. Modifier `src/Module/Commercial/Domain/Entity/Supplier.php` : ajouter
|
||||
`implements SiteAwareInterface` + relation `$site`.
|
||||
2. Créer migration `Version<timestamp>.php` : `ALTER TABLE supplier ADD
|
||||
site_id ...`, backfill, `SET NOT NULL`, `CREATE INDEX`.
|
||||
3. Ajouter `#[Groups(['supplier:read'])]` sur `$site`.
|
||||
4. Mettre à jour les fixtures `CommercialFixtures` pour rattacher chaque
|
||||
supplier à un site (`setSite(...)`).
|
||||
5. Ajouter un test d'intégration qui vérifie que la collection
|
||||
`/api/suppliers` retourne bien uniquement les suppliers du site
|
||||
courant pour un user donné.
|
||||
6. Documenter dans le README du module Commercial que les opérations
|
||||
d'écriture sur Supplier nécessitent le module Sites actif + un user
|
||||
avec `currentSite`.
|
||||
|
||||
## 8. Permission `sites.bypass_scope`
|
||||
|
||||
Déclarée par `SitesModule::permissions()`, synchronisée automatiquement
|
||||
en base par `app:sync-permissions`. Une fois synchronisée, elle est
|
||||
assignable :
|
||||
|
||||
- Directement à un user via `/admin/users` (drawer RBAC, section
|
||||
"Permissions directes").
|
||||
- Via un rôle personnalisé (ex: rôle "Auditeur groupe") qui la porte.
|
||||
|
||||
Les admins l'obtiennent automatiquement par bypass total (`isAdmin`), pas
|
||||
besoin d'assignation explicite.
|
||||
556
docs/rbac/ticket-343-spec.md
Normal file
556
docs/rbac/ticket-343-spec.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# Ticket #343 - 1/5 - Entités Permission et Role (Backend)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le stockage historique des roles Symfony dans `User::$roles` par un modele metier explicite `Role` + `Permission` + rattachements utilisateur. Le resultat attendu est un socle de persistance et de synchronisation utilisable par les tickets suivants pour exposer l'API, brancher les voters et alimenter les interfaces. Le ticket couvre aussi la migration de donnees depuis la colonne JSON existante et l'outillage necessaire pour synchroniser les permissions declarees par les modules actifs.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- Creer l'entite `Role` avec `id`, `code`, `label`, `description`, `isSystem` et relation ManyToMany vers `Permission`.
|
||||
- Creer l'entite `Permission` avec `id`, `code`, `label`, `module`, `orphan` et unicite sur `code`.
|
||||
- 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`.
|
||||
- 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/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 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`.
|
||||
- Ajouter une protection domaine empechant la suppression d'un role systeme via `SystemRoleDeletionException`.
|
||||
- Integrer les decisions de hardening demandees: fetch `EAGER`, constantes partagees pour les codes systeme, synchronisation non destructive, commentaires PHP en francais, identifiants anglais, `declare(strict_types=1)`, colonnes PostgreSQL en minuscules.
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#344` : ressources API Platform, providers, processors et traduction HTTP de `SystemRoleDeletionException` vers `403`.
|
||||
- Ticket `#345` : voter / authorisation applicative basee sur les permissions.
|
||||
- Ticket `#346` : interfaces d'administration RBAC.
|
||||
- Ticket `#347` : couche de traduction / UX des erreurs `403` et integration front complete.
|
||||
|
||||
## 3. Fichiers à créer
|
||||
|
||||
### Domaine - Entités
|
||||
|
||||
- `/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/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
||||
|
||||
### Domaine - Repositories
|
||||
|
||||
- `/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/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
||||
|
||||
### Domaine - Exceptions
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
||||
|
||||
### Infrastructure - Doctrine
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
||||
|
||||
### Infrastructure - Doctrine Migrations
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
|
||||
- Aucun nouveau fichier necessaire si la logique reste dans le fixture existant.
|
||||
|
||||
### Constantes domaine
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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/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
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
|
||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'],
|
||||
];
|
||||
}
|
||||
```
|
||||
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/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/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/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/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/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
||||
```yaml
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
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.
|
||||
- `/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
|
||||
|
||||
Plutot que de prescrire du SQL verbatim (risque de divergence avec la strategie `#[ORM\GeneratedValue]` par defaut utilisee par `User`, qui genere des sequences PostgreSQL, pas des colonnes `GENERATED IDENTITY`), on decrit le schema cible par les attributs Doctrine. Le SQL effectif sera produit par `bin/console doctrine:migrations:diff` puis augmente manuellement du data-migration step (section 6).
|
||||
|
||||
Conventions :
|
||||
- `declare(strict_types=1)` en tete de tous les fichiers.
|
||||
- Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`).
|
||||
- PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (ex: `isSystem` → `is_system`).
|
||||
- Les tables `user` et `role` sont des mots reserves PostgreSQL ; Doctrine les quote automatiquement via `#[ORM\Table(name: '`role`')]`.
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||
#[ORM\Table(name: 'permission')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||
class Permission
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $module;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
private bool $orphan = false;
|
||||
}
|
||||
```
|
||||
|
||||
Contraintes fonctionnelles :
|
||||
- `code` suit la convention `module.resource[.sub].action` (verifie par le sync command).
|
||||
- `module` contient l'identifiant declare dans `*Module::ID`, auto-injecte par le sync.
|
||||
- `orphan = true` signifie "permission conservee en base mais absente de la declaration courante".
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||
#[ORM\Table(name: '`role`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||
class Role
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(name: 'is_system', options: ['default' => false])]
|
||||
private bool $isSystem = false;
|
||||
|
||||
/** @var Collection<int, Permission> */
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'role_permission')]
|
||||
private Collection $permissions;
|
||||
}
|
||||
```
|
||||
|
||||
Contraintes fonctionnelles :
|
||||
- `code` porte la cle metier stable du role (`admin`, `user`, ...).
|
||||
- `isSystem = true` interdit la suppression via `Role::ensureDeletable()` au niveau domaine.
|
||||
- `fetch: EAGER` sur `$permissions` : evite qu'un `User::getEffectivePermissions()` cascade du lazy-loading hors contexte EntityManager (refresh JWT, serialisation asynchrone).
|
||||
|
||||
### Evolution de l'entite `User`
|
||||
|
||||
- Suppression de la propriete `private array $roles = []` (et donc de la colonne `roles JSON`).
|
||||
- Ajout :
|
||||
```php
|
||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||
private bool $isAdmin = false;
|
||||
|
||||
/** @var Collection<int, Role> */
|
||||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_role')]
|
||||
private Collection $roles;
|
||||
|
||||
/** @var Collection<int, Permission> */
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_permission')]
|
||||
private Collection $directPermissions;
|
||||
```
|
||||
- `$roles` et `$directPermissions` sont initialises en `ArrayCollection` dans le constructeur, comme toute collection Doctrine.
|
||||
- `fetch: EAGER` sur les 3 associations : critique pour eviter des `getRoles()` silencieusement tronques pendant un refresh de token JWT (cf. risque section 11).
|
||||
|
||||
### Evolution de la table `user` (SQL final apres diff)
|
||||
|
||||
La migration introduira :
|
||||
- `ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE NOT NULL;`
|
||||
- Creation de `user_role`, `user_permission` avec FKs `ON DELETE CASCADE`.
|
||||
- Apres data-migration (section 6), `ALTER TABLE "user" DROP COLUMN roles;`.
|
||||
|
||||
Etat final attendu :
|
||||
- La colonne historique `roles JSON NOT NULL` est supprimee.
|
||||
- La compatibilite Symfony passe par `User::getRoles()` derivee de `$isAdmin`, plus aucune persistence framework.
|
||||
- Les 3 associations `User::$roles`, `User::$directPermissions`, `Role::$permissions` sont explicitement EAGER.
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
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** :
|
||||
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
||||
2. Lancer `bin/console doctrine:migrations:diff` qui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN).
|
||||
3. Editer **manuellement** le fichier genere pour inserer le data-migration step entre la creation des tables et le `DROP COLUMN roles` — sinon les donnees admin sont perdues.
|
||||
4. Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans `doctrine_migrations.yaml`.
|
||||
|
||||
### `up()` - ordre recommande apres edition manuelle
|
||||
|
||||
1. Creer la colonne `"user".is_admin` avec `DEFAULT FALSE`.
|
||||
2. Creer les tables `permission`, `"role"`, `role_permission`, `user_role`, `user_permission` et leurs indexes/foreign keys.
|
||||
3. Inserer par SQL brut les roles systeme `admin` et `user` en s'appuyant sur les codes centralises dans `SystemRoles`.
|
||||
4. Mettre a jour `"user".is_admin` a `TRUE` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
|
||||
5. Inserer dans `user_role` le role `admin` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
|
||||
6. Inserer dans `user_role` le role `user` pour les utilisateurs qui ne portent pas `ROLE_ADMIN`, y compris si `roles` vaut `NULL`, `[]` ou `["ROLE_USER"]`.
|
||||
7. Verifier que les insertions utilisent `ON CONFLICT DO NOTHING` ou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev.
|
||||
8. Supprimer la colonne `"user".roles` uniquement apres la migration de donnees.
|
||||
|
||||
### SQL de migration de donnees - logique precise
|
||||
|
||||
Detection admin :
|
||||
|
||||
```sql
|
||||
UPDATE "user" u
|
||||
SET is_admin = TRUE
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
);
|
||||
```
|
||||
|
||||
Rattachement du role systeme `admin` :
|
||||
|
||||
```sql
|
||||
INSERT INTO user_role (user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM "user" u
|
||||
CROSS JOIN "role" r
|
||||
WHERE r.code = 'admin'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
Rattachement du role systeme `user` :
|
||||
|
||||
```sql
|
||||
INSERT INTO user_role (user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM "user" u
|
||||
CROSS JOIN "role" r
|
||||
WHERE r.code = 'user'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
Cas couverts explicitement :
|
||||
|
||||
- `roles = NULL` : traite comme tableau vide, utilisateur non admin, rattache au role `user`.
|
||||
- `roles = []` : utilisateur non admin, rattache au role `user`.
|
||||
- `roles = ["ROLE_USER"]` : utilisateur non admin, rattache au role `user`.
|
||||
- `roles = ["ROLE_ADMIN"]` : `is_admin = true`, rattache au role `admin`, pas de role `user`.
|
||||
- `roles = ["ROLE_ADMIN", "ROLE_USER"]` : meme resultat que ci-dessus, sans doublon.
|
||||
|
||||
### Caveat : type reel de la colonne `user.roles`
|
||||
|
||||
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
|
||||
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 | text` : remplacer `u.roles::jsonb` par `u.roles::text::jsonb` dans les 3 requetes.
|
||||
- Si la colonne est deja `jsonb` (rare) : remplacer `u.roles::jsonb` par `u.roles`.
|
||||
|
||||
### `down()` - rollback minimal et coherent
|
||||
|
||||
1. Recreer la colonne `"user".roles` en `JSON NOT NULL DEFAULT '[]'`.
|
||||
2. Rehydrater `"user".roles` avec `["ROLE_ADMIN"]` si `is_admin = true`, sinon `["ROLE_USER"]`.
|
||||
3. Supprimer les foreign keys et tables de jointure `user_permission`, `user_role`, `role_permission`.
|
||||
4. Supprimer les tables `permission` et `"role"`.
|
||||
5. Supprimer la colonne `"user".is_admin`.
|
||||
|
||||
Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable pour un `down()` technique, mais il doit restituer une application compatible avec le modele historique base sur `ROLE_ADMIN` / `ROLE_USER`.
|
||||
|
||||
## 7. Algorithme sync-permissions
|
||||
|
||||
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
|
||||
|
||||
- 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`.
|
||||
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
||||
|
||||
### Format de declaration attendu
|
||||
|
||||
Chaque entree retournee par `*Module::permissions()` contient STRICTEMENT deux cles :
|
||||
|
||||
- `code` — string suivant la convention `module.resource[.sub].action`, par exemple `core.users.view` ou `commercial.quote.line.delete`.
|
||||
- `label` — string lisible en francais, affichee dans l'UI d'administration des roles.
|
||||
|
||||
La cle `module` N'EST PAS dans le payload : elle est **auto-injectee** par la commande de sync a partir de `$moduleClass::ID`. Ainsi on ne peut pas declarer accidentellement une permission d'un autre module.
|
||||
|
||||
Garde anti-typo : le sync command verifie que chaque `code` commence obligatoirement par `$moduleClass::ID . '.'`. Si non, la commande echoue avec un message explicite citant la classe module et le code incrimine, sans toucher a la base.
|
||||
|
||||
### Pseudocode
|
||||
|
||||
```text
|
||||
begin transaction
|
||||
|
||||
load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php
|
||||
desired_permissions = empty map keyed by code
|
||||
|
||||
for each module class:
|
||||
if method permissions() exists:
|
||||
declared_permissions = module class::permissions()
|
||||
else:
|
||||
declared_permissions = []
|
||||
|
||||
for each declared permission in declared_permissions:
|
||||
assert array_keys(permission) == ['code', 'label'] // erreur explicite sinon
|
||||
assert str_starts_with(permission.code, module class::ID + '.') // erreur explicite sinon
|
||||
normalized = {
|
||||
code: permission.code,
|
||||
label: permission.label,
|
||||
module: module class::ID, // auto-injection
|
||||
}
|
||||
desired_permissions[code] = normalized
|
||||
|
||||
load existing permissions from database indexed by code
|
||||
|
||||
for each code in desired_permissions:
|
||||
if code exists in database:
|
||||
update label
|
||||
update module
|
||||
set orphan = false
|
||||
else:
|
||||
insert permission with orphan = false
|
||||
|
||||
for each database permission code not present in desired_permissions:
|
||||
set orphan = true
|
||||
|
||||
flush
|
||||
commit transaction
|
||||
```
|
||||
|
||||
### Propriétés attendues
|
||||
|
||||
- Idempotente : deux executions consecutives sans changement de declarations ne doivent produire aucun delta metier.
|
||||
- Non destructive : aucune suppression de ligne `permission`; les permissions disparues deviennent `orphan = true`.
|
||||
- Reversible metier : une permission orpheline redeclaree repasse a `orphan = false` et voit ses metadonnees mises a jour.
|
||||
- Transactionnelle : pas d'etat intermediaire visible si une validation ou un flush echoue.
|
||||
|
||||
## 8. Méthodes clés détaillées
|
||||
|
||||
### User
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function getRoles(): array
|
||||
```
|
||||
|
||||
Retourne toujours `ROLE_USER`. Ajoute `ROLE_ADMIN` si `is_admin = true`. Ne lit plus aucune colonne JSON. Le resultat doit etre deduplique et stable pour Symfony Security.
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function getEffectivePermissions(): array
|
||||
```
|
||||
|
||||
Retourne l'union des codes des permissions issues de `User::$roles[*]->permissions` et de `User::$directPermissions`. Le resultat est un tableau unique de chaines, sans doublon, trie de facon deterministe si possible pour des assertions de test stables.
|
||||
|
||||
### Role
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function addPermission(Permission $permission): self
|
||||
```
|
||||
|
||||
Ajoute la permission a la collection si absente, sans doublon.
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function removePermission(Permission $permission): self
|
||||
```
|
||||
|
||||
Retire la permission de la collection si presente.
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function ensureDeletable(): void
|
||||
```
|
||||
|
||||
Leve `SystemRoleDeletionException` si `isSystem = true`. Cette garde doit vivre dans le domaine, meme si la traduction HTTP vers `403` est hors scope de ce ticket.
|
||||
|
||||
### Permission
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function markOrphan(): self
|
||||
```
|
||||
|
||||
Passe `orphan` a `true` sans detruire la permission.
|
||||
|
||||
Signature :
|
||||
|
||||
```text
|
||||
public function revive(string $label, string $module): self
|
||||
```
|
||||
|
||||
Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declaration modulaire.
|
||||
|
||||
## 9. Fixtures mises à jour
|
||||
|
||||
Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
||||
|
||||
### Principe cle : decouplage via `is_admin`
|
||||
|
||||
Le role `admin` n'a **pas besoin** de contenir "toutes les permissions" pour rendre l'admin techniquement tout-puissant : cette capacite vient du bypass `is_admin = true` dans le futur `PermissionVoter` (ticket #345). Le role `admin` est donc un **conteneur metier semantique** (il represente le bundle "administrateur") mais n'est pas fonctionnellement requis pour que l'admin puisse tout faire.
|
||||
|
||||
Consequence directe : les fixtures deviennent auto-suffisantes. Elles ne dependent plus d'un passage prealable de `app:sync-permissions`. `make db-reset && make fixtures` reste un one-shot.
|
||||
|
||||
### Jeu de donnees attendu
|
||||
|
||||
- Role systeme `admin` (`SystemRoles::ADMIN_CODE`)
|
||||
- `code = admin`
|
||||
- `label = Administrateur`
|
||||
- `description = Role administrateur — bypass complet via is_admin`
|
||||
- `isSystem = true`
|
||||
- `permissions = []` — volontairement vide, le bypass fait tout le travail. Une fois `app:sync-permissions` passe, un admin pourra assigner via UI les permissions au role si besoin d'un scenario "quasi-admin sans bypass".
|
||||
- Role systeme `user` (`SystemRoles::USER_CODE`)
|
||||
- `code = user`
|
||||
- `label = Utilisateur`
|
||||
- `description = Role de base sans permission specifique`
|
||||
- `isSystem = true`
|
||||
- `permissions = []`
|
||||
|
||||
### Assignations utilisateurs
|
||||
|
||||
- `admin` (user) : `is_admin = true`, role `admin`
|
||||
- `alice` : `is_admin = false`, role `user`
|
||||
- `bob` : `is_admin = false`, role `user`
|
||||
- Aucune permission directe (`directPermissions`) n'est prechargee dans ce ticket.
|
||||
|
||||
### Autonomie du workflow
|
||||
|
||||
`make db-reset && make fixtures` fonctionne sans passer par `app:sync-permissions` au prealable, car aucune fixture ne depend du contenu de la table `permission`. Optionnellement, apres chargement des fixtures, l'utilisateur peut executer `bin/console app:sync-permissions` pour peupler la table `permission` avec les declarations de `CoreModule::permissions()`, mais c'est une etape independante et optionnelle a ce stade.
|
||||
|
||||
## 10. Plan de tests PHPUnit
|
||||
|
||||
### Unitaires - domaine
|
||||
|
||||
- `User::getRoles()` retourne `['ROLE_USER']` quand `is_admin = false`.
|
||||
- `User::getRoles()` retourne `['ROLE_USER', 'ROLE_ADMIN']` quand `is_admin = true`.
|
||||
- `User::getEffectivePermissions()` fusionne permissions de roles et permissions directes sans doublon.
|
||||
- `User::getEffectivePermissions()` retourne un tableau vide pour un utilisateur sans role ni permission directe.
|
||||
- `Role::addPermission()` n'ajoute pas de doublon.
|
||||
- `Role::removePermission()` retire correctement une permission existante.
|
||||
- `Role::ensureDeletable()` leve `SystemRoleDeletionException` pour un role systeme.
|
||||
- `Permission::markOrphan()` passe `orphan` a `true`.
|
||||
- `Permission::revive()` remet `orphan` a `false` et met a jour `label` / `module`.
|
||||
|
||||
### Integration - persistence et console
|
||||
|
||||
- La commande `app:sync-permissions` cree les permissions declarees par `CoreModule::permissions()`.
|
||||
- Deux executions successives de `app:sync-permissions` sur le meme jeu de modules sont idempotentes.
|
||||
- Une permission supprimee d'une declaration modulaire n'est pas deletee mais marquee `orphan = true`.
|
||||
- Une permission redeclaree apres etat orphelin est revivee avec `orphan = false`.
|
||||
- Les repositories Doctrine chargent bien `User::$roles`, `User::$directPermissions` et `Role::$permissions` sans lazy loading hors EntityManager grace a `fetch=EAGER`.
|
||||
- Les fixtures chargent les roles systeme et rattachent les utilisateurs attendus.
|
||||
|
||||
### Integration - migration
|
||||
|
||||
- `up()` sur une base contenant `roles = ["ROLE_ADMIN"]` cree `is_admin = true`, rattache le role `admin` et supprime la colonne JSON.
|
||||
- `up()` sur une base contenant `roles = ["ROLE_USER"]` rattache le role `user` et laisse `is_admin = false`.
|
||||
- `up()` sur une base contenant `roles = ["ROLE_ADMIN", "ROLE_USER"]` ne cree aucun doublon et conserve le comportement admin.
|
||||
- `up()` sur une base contenant `roles = []` ou `NULL` rattache quand meme le role `user`.
|
||||
- `down()` recree une colonne `roles` JSON exploitable et restaure `ROLE_ADMIN` ou `ROLE_USER` de facon coherente.
|
||||
|
||||
### Prerequis d'infrastructure de test
|
||||
|
||||
Les tests d'integration migration up/down exigent une base de test dediee avec un outillage pour jouer/rejouer les migrations. Verifier l'etat de l'infra avant d'ecrire ces tests :
|
||||
|
||||
- Si `make test` applique deja les migrations sur une base isolee : les tests peuvent etre ecrits en utilisant `KernelTestCase` + `EntityManager` + `MigrationRepository`.
|
||||
- Sinon, ajouter `DAMADoctrineTestBundle` (transactionne chaque test) ou une recipe dediee `make test-migration` qui monte une base jetable puis lance les migrations.
|
||||
- **Si l'outillage manque** : ne pas bloquer le ticket. Ecrire a la place un test SQL de bas niveau sur une base transactionnellement reinitialisee (via `BEGIN` / `ROLLBACK` a chaque cas) et poser une TODO explicite dans le ticket suivant pour normaliser l'infra de test migration.
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
- Risque de chargement paresseux pendant refresh JWT, serialisation ou security context hors EntityManager.
|
||||
- Mitigation : imposer `fetch=EAGER` sur `User::$roles`, `User::$directPermissions` et `Role::$permissions`, puis le verifier par tests d'integration.
|
||||
- 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.
|
||||
- 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/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.
|
||||
- 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()`.
|
||||
- Mitigation : traiter l'absence de methode comme `[]` pour la compatibilite immediate et documenter que chaque nouveau module devra ajouter `permissions()` dans les tickets suivants.
|
||||
- Risque de cout SQL/ORM du `fetch=EAGER` quand un utilisateur porte beaucoup de roles et permissions.
|
||||
- Mitigation : limiter pour l'instant le perimetre aux trois associations critiques et surveiller les requetes; un ajustement vers des requetes dediees pourra etre etudie si la volumetrie augmente.
|
||||
- Risque de semantique confuse entre `is_admin` et role systeme `admin`.
|
||||
- Mitigation : regle gravee a partir de ce ticket. `is_admin` est le SEUL levier technique de bypass — c'est lui qui fait qu'un admin peut tout faire, via le futur `PermissionVoter` (ticket #345). Le role `admin` est un **conteneur metier semantique** : il identifie visuellement les admins dans l'UI et laisse la porte ouverte a un scenario "quasi-admin sans bypass" (admin qui aurait beaucoup de permissions explicites mais pas le bypass). Les fixtures/migrations posent les deux (`is_admin = true` ET rattachement au role `admin`) pour le compte admin, mais la logique d'autorisation ne regarde QUE `is_admin` + les permissions effectives. Ne jamais coder `if ($user->hasRole('admin'))` : toujours `if ($user->isAdmin())` ou `is_granted('permission.code')`.
|
||||
|
||||
## 12. Ordre d'exécution recommandé
|
||||
|
||||
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
||||
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
||||
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.
|
||||
5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
||||
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/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/Coltura/config/services.yaml`.
|
||||
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
||||
|
||||
## 13. Critères d'acceptation (DoD)
|
||||
|
||||
- Les entites `Role` et `Permission` existent dans le module Core avec mappings Doctrine valides et identifiants anglais.
|
||||
- `User` ne persiste plus de colonne JSON `roles` apres migration, mais expose toujours un `getRoles()` compatible Symfony.
|
||||
- `User::getEffectivePermissions()` retourne l'union sans doublon des permissions de roles et des permissions directes.
|
||||
- `CoreModule` expose une methode statique `permissions()` servant de reference au mecanisme de sync.
|
||||
- La commande `app:sync-permissions` est transactionnelle, idempotente, non destructive et gere correctement `orphan = true` / revival.
|
||||
- Les roles systeme `admin` et `user` sont crees par la migration et par les fixtures avec `isSystem = true`.
|
||||
- La migration convertit de facon sure les etats historiques `ROLE_ADMIN`, `ROLE_USER`, tableau vide, `NULL` et combinaisons mixtes sans perte de comptes.
|
||||
- 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 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/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||
275
docs/rbac/ticket-344-spec.md
Normal file
275
docs/rbac/ticket-344-spec.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Ticket #344 - 2/5 - API CRUD Roles & Permissions (Backend)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Exposer via API Platform le socle RBAC livre par le ticket #343 (entites `Role`, `Permission`, relations `User->roles`/`directPermissions`, flag `isAdmin`). Ce ticket livre la surface HTTP minimale permettant :
|
||||
|
||||
- de lister et consulter les permissions synchronisees par `app:sync-permissions`,
|
||||
- de gerer le cycle de vie des roles (CRUD) tout en protegeant les roles systeme,
|
||||
- d'attribuer `isAdmin`, les roles RBAC et les permissions directes a un utilisateur sans polluer le groupe `user:write` (commit `0fc4e16`).
|
||||
|
||||
Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verification `is_granted('module.resource.action')` est traitee par le voter du ticket #345. A ce stade, les operations sont gardees par un simple `is_granted('ROLE_ADMIN')`, remplace au #345.
|
||||
|
||||
## 2. Perimetre
|
||||
|
||||
### IN
|
||||
|
||||
- Exposer l'entite `Permission` en API Platform en lecture seule (`GetCollection`, `Get`), groupe `permission:read`, filtres `module` et `orphan`.
|
||||
- Exposer l'entite `Role` en API Platform avec CRUD complet (`GetCollection`, `Get`, `Post`, `Patch`, `Delete`), groupes `role:read` et `role:write`, filtre `isSystem`.
|
||||
- Ajouter un processor `RoleProcessor` decorant `PersistProcessor` et `RemoveProcessor` pour :
|
||||
- refuser la suppression d'un role systeme en traduisant `SystemRoleDeletionException` en `403`,
|
||||
- empecher la mutation de `code` et `isSystem` sur un role systeme existant.
|
||||
- Ajouter une operation nommee `user_rbac_patch` (`PATCH /api/users/{id}/rbac`) sur l'entite `User` avec son propre groupe `user:rbac:write` exposant `isAdmin`, `roles` et `directPermissions`. Laisser `user:write` propre pour les champs profil (compatible avec la decision de `0fc4e16`). Le nom explicite est indispensable : API Platform 4 identifie les operations par nom, un `new Patch` sans `name:` entrerait en collision avec l'operation profil existante.
|
||||
- Ajouter un processor `UserRbacProcessor` qui persiste les mutations RBAC de l'utilisateur sans toucher au password hashing (decorator de `PersistProcessor`, pas du `UserPasswordHasherProcessor`).
|
||||
- Ajouter sur `Role` les contraintes Symfony Validator : `UniqueEntity(fields: ['code'])`, `Assert\NotBlank` et `Assert\Regex` sur `code`, `Assert\NotBlank` sur `label` (cf. section 6).
|
||||
- Garder toutes les operations sous `is_granted('ROLE_ADMIN')` avec un commentaire `// TODO ticket #345 : remplacer par is_granted('core.roles.manage')`.
|
||||
- Tests PHPUnit unitaires (processors) et fonctionnels (`ApiTestCase`) couvrant les chemins nominaux et les cas 403/422.
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#345` : voter `PermissionVoter`, remplacement du `is_granted('ROLE_ADMIN')` par les codes de permission, composable front `usePermissions`.
|
||||
- Ticket `#346` : ecrans d'administration front (liste/edition des roles et permissions).
|
||||
- Ticket `#347` : UX des erreurs 403 et integration front de l'ecran de gestion des permissions utilisateur.
|
||||
- Endpoint d'ecriture sur `Permission` : la table reste la propriete exclusive de `app:sync-permissions` (source de verite = code).
|
||||
- Lecture des permissions effectives d'un `User` via `/api/me` : traitee au #345 en meme temps que le voter.
|
||||
- Exposition d'un endpoint de bulk-assign permissions sur plusieurs utilisateurs : hors scope.
|
||||
|
||||
## 3. Fichiers a creer
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/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.
|
||||
|
||||
- `/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`.
|
||||
|
||||
### Tests unitaires
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
||||
- Normalization context : groupe `permission:read` uniquement.
|
||||
- Pas de `denormalizationContext` (lecture seule).
|
||||
- Security `is_granted('ROLE_ADMIN')` sur les deux operations (TODO #345).
|
||||
- Ajouter `#[Groups(['permission:read'])]` sur `$id`, `$code`, `$label`, `$module`, `$orphan`. Pas d'ajout du groupe `role:read` : on laisse API Platform serialiser la relation `Role::$permissions` en IRIs par defaut, le front resoudra les details en 2 appels si necessaire (decision explicite pour garder les payloads petits et les permissions paginable independamment).
|
||||
- Ajouter les filtres API Platform `SearchFilter` sur `module` (exact) et `BooleanFilter` sur `orphan`.
|
||||
|
||||
Extrait attendu :
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||
```
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
||||
- Processor `RoleProcessor::class` sur `Post`, `Patch` et `Delete`.
|
||||
- Security `is_granted('ROLE_ADMIN')` sur les 5 operations (TODO #345).
|
||||
- Groupes :
|
||||
- `$id` : `role:read`.
|
||||
- `$code` : `role:read`, `role:write`. L'immuabilite apres creation est portee par `RoleProcessor` (variante A, cf. section 5), pas par un decoupage de groupes.
|
||||
- `$label` : `role:read`, `role:write`.
|
||||
- `$description` : `role:read`, `role:write`.
|
||||
- `$isSystem` : `role:read` (jamais writable via API).
|
||||
- `$permissions` : `role:read`, `role:write`. Serialise en IRIs (comportement API Platform par defaut sur une relation ManyToMany).
|
||||
- Filtre `BooleanFilter` sur `isSystem`.
|
||||
- **Important** : le constructeur actuel `public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)` doit etre compatible avec la denormalisation API Platform sur `POST`. API Platform 4 resout les arguments du constructeur par nom de propriete denormalise. Verifier (ou adapter) que `isSystem` ne peut pas etre injecte par le POST car il n'est pas dans `role:write`.
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
||||
|
||||
```php
|
||||
new Patch(
|
||||
name: 'user_rbac_patch',
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||
processor: UserRbacProcessor::class,
|
||||
),
|
||||
```
|
||||
|
||||
Le `name:` est OBLIGATOIRE : sans lui, API Platform 4 deduit un nom par defaut qui peut collisionner avec la `Patch` profil existante (meme classe, meme methode HTTP) et provoquer un ecrasement silencieux de la route `/api/users/{id}`.
|
||||
|
||||
- Ajouter le groupe `user:rbac:write` sur les proprietes :
|
||||
- `$isAdmin`
|
||||
- `$roles`
|
||||
- `$directPermissions`
|
||||
- Ne PAS toucher `user:write` : la decision de `0fc4e16` est confirmee par ce ticket.
|
||||
|
||||
Raison de l'endpoint dedie (option B) :
|
||||
- Separation des preoccupations : un `PATCH /api/users/{id}` reste un endpoint "profil" ; la promotion admin et la gestion des permissions est un acte administratif explicite et tracable.
|
||||
- Facilite future l'ajout d'un audit log dedie (`#355` audit log project) sur l'endpoint RBAC sans polluer l'audit profil.
|
||||
- Contrat front simple : une seule route, un seul groupe, une seule validation.
|
||||
|
||||
## 5. Regles metier et cas limites
|
||||
|
||||
### Role
|
||||
|
||||
- **Creation (`POST /api/roles`)** :
|
||||
- `code`, `label` obligatoires. `description` optionnel. `permissions` optionnel (tableau d'IRIs).
|
||||
- `isSystem` est toujours `false` pour les roles crees via API (n'est pas dans `role:write`).
|
||||
- Unicite du `code` geree par la contrainte DB `uniq_role_code` → 422 via `UniqueEntity` validator a ajouter sur l'entite (voir section 6).
|
||||
|
||||
- **Modification (`PATCH /api/roles/{id}`)** :
|
||||
- `label`, `description`, `permissions` modifiables librement, y compris sur un role systeme (utile pour customiser l'apparence dans l'UI sans casser la relation).
|
||||
- `code` **immuable apres creation** — strategie retenue (variante A) : un seul groupe `role:write` contenant `code`, et une garde centralisee dans `RoleProcessor`. Le processor compare la valeur entrante a l'etat d'origine via `UnitOfWork::getOriginalEntityData($role)['code']` ; si elle differe, leve `BadRequestHttpException` avec un message francais explicite. Regle unique et uniforme : roles systeme ET roles customs sont concernes. Justification : garder la regle metier dans le domaine applicatif plutot que dupliquer les groupes de serialisation.
|
||||
|
||||
- **Suppression (`DELETE /api/roles/{id}`)** :
|
||||
- `RoleProcessor` appelle `$role->ensureDeletable()` avant de deleguer au `RemoveProcessor`.
|
||||
- `SystemRoleDeletionException` est catchee et re-levee en `Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException` (403).
|
||||
- Les relations `user_role` et `role_permission` sur ce role sont nettoyees automatiquement par le `ON DELETE CASCADE` des contraintes `FK_2DE8C6A3D60322AC` (`user_role.role_id`) et `FK_6F7DF886D60322AC` (`role_permission.role_id`) posees dans `migrations/Version20260414150034.php`. Aucun nettoyage manuel necessaire dans `RoleProcessor`. Verifier en test fonctionnel par un DELETE d'un role custom attache a un user, puis assert que le user existe toujours et que `user_role` est vide pour ce couple.
|
||||
|
||||
### Permission
|
||||
|
||||
- Lecture seule via API. Aucun endpoint de mutation.
|
||||
- Si un admin veut forcer une permission sur un utilisateur, il passe par `directPermissions` de `User`.
|
||||
|
||||
### User (operation RBAC)
|
||||
|
||||
- `PATCH /api/users/{id}/rbac` n'accepte que `isAdmin`, `roles`, `directPermissions`. Tout autre champ dans le payload est ignore (comportement par defaut d'API Platform avec un `denormalizationContext` restreint).
|
||||
- **Garde minimale auto-suicide** : `UserRbacProcessor` refuse (`BadRequestHttpException` 400) toute requete ou l'user cible est egal a l'user courant du `Security::getUser()` ET `isAdmin` passe de `true` a `false`. Sans cette garde, un admin peut se degrader seul et perdre acces a l'endpoint, creant une situation de recovery penible. C'est une garde locale et pragmatique, volontairement plus stricte que "le dernier admin" : on interdit l'auto-degradation, point. La garde "plus d'un admin restant" reste reportee au #345 ou un inventaire global fera sens avec le voter. TODO a placer dans le processor avec reference a #345.
|
||||
- Le password n'est jamais touche par cet endpoint (contrairement a `UserPasswordHasherProcessor` sur `PATCH /api/users/{id}`).
|
||||
|
||||
## 6. Validation
|
||||
|
||||
- Ajouter sur `Role` une contrainte `#[UniqueEntity(fields: ['code'])]` pour un 422 propre au lieu d'un 500 SQL en cas de conflit.
|
||||
- Ajouter sur `Role::$code` un `#[Assert\NotBlank]` et un `#[Assert\Regex('/^[a-z][a-z0-9_]*$/')]` (meme convention que les permissions).
|
||||
- Ajouter sur `Role::$label` un `#[Assert\NotBlank]`.
|
||||
|
||||
## 7. Plan de tests
|
||||
|
||||
### Unitaires
|
||||
|
||||
**`RoleProcessorTest`**
|
||||
|
||||
- `process()` d'un role non-systeme en DELETE delegue au `RemoveProcessor` sans lever.
|
||||
- `process()` d'un role systeme en DELETE leve `AccessDeniedHttpException` (403) et n'appelle pas le decorator.
|
||||
- `process()` d'un role systeme en PATCH dont le `code` a change leve `BadRequestHttpException`.
|
||||
- `process()` d'un role systeme en PATCH dont seuls `label`/`permissions` changent delegue au `PersistProcessor`.
|
||||
- `process()` d'un role non-systeme en POST delegue au `PersistProcessor`.
|
||||
|
||||
**`UserRbacProcessorTest`**
|
||||
|
||||
- `process()` persiste un user avec `isAdmin = true` via le decorator.
|
||||
- `process()` persiste une collection de `roles` mise a jour.
|
||||
- `process()` ne declenche jamais le hashing de password (verifier que `UserPasswordHasherProcessor` n'est pas dans la chaine).
|
||||
|
||||
### Fonctionnels (`ApiTestCase`)
|
||||
|
||||
**`PermissionApiTest`**
|
||||
|
||||
- `GET /api/permissions` en tant qu'admin retourne la liste des permissions synchronisees.
|
||||
- `GET /api/permissions?module=core` filtre par module.
|
||||
- `GET /api/permissions?orphan=true` retourne uniquement les orphelines.
|
||||
- `GET /api/permissions/{id}` retourne les champs attendus (groupe `permission:read`).
|
||||
- `POST /api/permissions` en tant qu'admin retourne `405 Method Not Allowed`.
|
||||
- `GET /api/permissions` non authentifie retourne `401`.
|
||||
- `GET /api/permissions` en tant que user standard retourne `403`.
|
||||
|
||||
**`RoleApiTest`**
|
||||
|
||||
- `POST /api/roles` avec `{code, label, description}` retourne `201` et persiste `isSystem = false`.
|
||||
- `POST /api/roles` avec un `code` deja utilise retourne `422`.
|
||||
- `POST /api/roles` avec un `code` invalide (`MAJ`, `space`) retourne `422`.
|
||||
- `PATCH /api/roles/{id}` sur un role custom modifie `label` et ajoute des permissions via IRIs → `200`.
|
||||
- `PATCH /api/roles/{id}` sur le role `admin` (systeme) modifiant seulement `label` → `200`.
|
||||
- `PATCH /api/roles/{id}` sur le role `admin` tentant de modifier `code` → `400`.
|
||||
- `DELETE /api/roles/{id}` sur un role custom → `204`.
|
||||
- `DELETE /api/roles/{id}` sur le role `admin` → `403` avec `SystemRoleDeletionException` traduite.
|
||||
- `DELETE /api/roles/{id}` d'un role custom attache a un user : le user reste, la relation `user_role` est nettoyee par le CASCADE.
|
||||
- Toute operation sans auth retourne `401`.
|
||||
- Toute operation en tant que user standard retourne `403`.
|
||||
|
||||
**`UserRbacApiTest`**
|
||||
|
||||
- `PATCH /api/users/{id}/rbac` en tant qu'admin avec `{isAdmin: true}` promeut le user.
|
||||
- `PATCH /api/users/{id}/rbac` avec `{roles: [IRI...]}` remplace la collection de roles RBAC.
|
||||
- `PATCH /api/users/{id}/rbac` avec `{directPermissions: [IRI...]}` remplace les permissions directes.
|
||||
- `PATCH /api/users/{id}/rbac` en tant que user standard retourne `403`.
|
||||
- `PATCH /api/users/{id}/rbac` non authentifie retourne `401`.
|
||||
- `PATCH /api/users/{id}/rbac` avec un champ `username` dans le payload n'est pas persiste (denormalization context restreint).
|
||||
- `PATCH /api/users/{id}` sans `/rbac` avec `{isAdmin: true}` ne modifie PAS `isAdmin` (confirme la decision `0fc4e16`).
|
||||
|
||||
## 8. Securite et traduction d'exceptions
|
||||
|
||||
- `SystemRoleDeletionException` → `AccessDeniedHttpException` (403) dans `RoleProcessor` (pas via un listener global : on garde la traduction locale au perimetre RBAC).
|
||||
- `BadRequestHttpException` pour la mutation de `code` sur un role systeme : message explicite en francais, dans le payload Hydra `hydra:description`.
|
||||
- Toutes les routes ont pour l'instant `security: "is_granted('ROLE_ADMIN')"`. Un commentaire `// TODO ticket #345` doit etre present sur chaque attribut pour faciliter le remplacement.
|
||||
|
||||
## 9. Conventions et architecture
|
||||
|
||||
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/` ou `tests/Module/Core/`. Aucun import depuis un autre module.
|
||||
- `declare(strict_types=1)` en tete des nouveaux fichiers.
|
||||
- Commentaires PHP en francais, identifiants anglais (`CLAUDE.md`).
|
||||
- Processors branches via l'autoconfiguration Symfony ; aucun wiring manuel dans `services.yaml` attendu si le constructeur est injecte proprement.
|
||||
- Pattern de decorator : utiliser `#[AsDecorator]` ou `#[Autoconfigure]` pour brancher le processor en tant que decorator du `PersistProcessor` API Platform, selon le pattern deja utilise par `UserPasswordHasherProcessor`.
|
||||
- Aucune nouvelle entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
|
||||
|
||||
## 10. Ordre d'execution recommande
|
||||
|
||||
1. Ajouter l'attribut `#[ApiResource]` et les `#[Groups]` sur `Permission`. Ecrire `PermissionApiTest`.
|
||||
2. Ajouter les contraintes Validator sur `Role`. Ajouter `#[ApiResource]` et les `#[Groups]` sur `Role` **sans** processor dans un premier temps pour valider le CRUD nominal.
|
||||
3. Creer `RoleProcessor` et le brancher en decorator. Ajouter les gardes systeme. Ecrire `RoleProcessorTest` + cas `RoleApiTest`.
|
||||
4. Creer `UserRbacProcessor`. Ajouter l'operation `/users/{id}/rbac` et le groupe `user:rbac:write` sur `User`. Ecrire `UserRbacProcessorTest` + `UserRbacApiTest`.
|
||||
5. `make test` complet + `make php-cs-fixer-allow-risky`.
|
||||
6. Documentation : referencer ce spec dans `docs/rbac/` et mettre a jour le fil conducteur RBAC si un index existe.
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
- **Constructeur de `Role` et denormalisation POST** : API Platform 4 resout les arguments du constructeur par nom ; `isSystem` est dans la signature mais pas dans `role:write`, donc un client ne peut pas l'injecter — a verifier par un test explicite ("POST avec `isSystem: true` est ignore").
|
||||
- **`code` immuable** : strategie retenue (garde dans processor) simple mais demande une lecture de l'etat initial du role avant persistance. Utiliser `UnitOfWork::getOriginalEntityData()` pour recuperer la valeur d'origine proprement.
|
||||
- **Cascade de delete role → user_role** : depend de `ON DELETE CASCADE` pose par la migration #343. Verifier explicitement en test fonctionnel qu'aucune `ForeignKeyConstraintViolationException` ne remonte.
|
||||
- **`UniqueEntity` sur `code`** : ne couvre pas les conflits en race condition, la DB reste la garde ultime. Acceptable.
|
||||
- **Pas de filtre sur le `module` de Permission cote front** au #346 sans le filtre API : s'assurer que le filtre est bien pose ici.
|
||||
- **Auto-retrait du dernier admin** : garde d'**auto-suicide** posee dans `UserRbacProcessor` (un admin ne peut pas se degrader lui-meme, cf. section 5). La garde "plus d'un admin restant" au niveau global reste reportee au voter #345.
|
||||
- **Infra de test fonctionnel (fixtures et isolation)** : les tests `*ApiTest` dependent de la presence en base des roles systeme `admin` et `user`. L'infra actuelle doit fournir soit un reload des fixtures par classe de test, soit `DAMADoctrineTestBundle` pour transactionner chaque test. A verifier au debut de l'etape 1 de l'ordre d'execution ; si absent, ajouter un trait de bootstrap minimal `RbacFixturesTrait` qui insere les deux roles systeme avant chaque classe de test (pas par test, trop couteux). Ne pas bloquer le ticket sur cette question, adapter au vol.
|
||||
|
||||
## 12. Criteres d'acceptation (DoD)
|
||||
|
||||
- `GET /api/permissions` et `GET /api/permissions/{id}` fonctionnent, filtres `module` et `orphan` operationnels.
|
||||
- CRUD complet sur `/api/roles` operationnel, avec `isSystem` en lecture seule cote API.
|
||||
- `DELETE /api/roles/{admin_id}` retourne `403` avec un message metier.
|
||||
- `PATCH /api/roles/{admin_id}` autorise la modification de `label`/`permissions` mais refuse la modification de `code` avec `400`.
|
||||
- `PATCH /api/users/{id}/rbac` permet de modifier `isAdmin`, `roles` et `directPermissions` ; `PATCH /api/users/{id}` (profil) ne les modifie jamais.
|
||||
- Les operations API sont gardees par `is_granted('ROLE_ADMIN')` et commentees avec la TODO pointant vers #345.
|
||||
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
|
||||
- Aucun import croise entre modules ; tous les fichiers crees vivent dans `Module/Core/` ou `tests/Module/Core/`.
|
||||
- Le spec est mergee avec le code (meme PR ou PR precedente) pour rester la reference du ticket.
|
||||
|
||||
## 13. Remarques de branche
|
||||
|
||||
- Le ticket enonce "Branche a creer : `feat/rbac-api` depuis develop apres merge de #2".
|
||||
- Branche courante locale : `feat/rbac-core`. A confirmer avec l'utilisateur si PR #2 est mergee : si oui, se rebaser sur `develop` et creer `feat/rbac-api` propre ; sinon, empiler `feat/rbac-api` sur `feat/rbac-core` en attendant le merge.
|
||||
574
docs/rbac/ticket-345-spec.md
Normal file
574
docs/rbac/ticket-345-spec.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Ticket #345 - 3/5 - Voter Symfony + composable usePermissions (Full-stack)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket remplace les gardes placeholder `is_granted('ROLE_ADMIN')` posees par le #344 sur les 13 operations API Platform du perimetre RBAC par des verifications metier basees sur les codes de permission livres au #343 (`core.users.view`, `core.roles.manage`, etc.). Il introduit le `PermissionVoter` Symfony qui interprete ces codes, avec un bypass total pour les utilisateurs `isAdmin = true` (decision gravee au #343 section 11). Il ferme la garde "dernier admin global" reportee par le #344 via un service domaine mutualise entre les chemins de mutation (`PATCH /users/{id}/rbac` et `DELETE /users/{id}`). Enfin il expose les permissions effectives de l'utilisateur courant via `/api/me` et livre le composable front `usePermissions()` qui les consomme.
|
||||
|
||||
A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applicatif reel, utilisable par les tickets #346 (ecrans d'admin RBAC) et #347 (UX des erreurs 403). Aucune interface d'administration n'est livree ici : le ticket est un socle full-stack sans ecran dedie.
|
||||
|
||||
## 2. Perimetre
|
||||
|
||||
### IN
|
||||
|
||||
- Ajouter la permission `core.roles.view` au catalogue `CoreModule::permissions()` et la synchroniser via `app:sync-permissions`. Documenter la regle par defaut "view + manage par ressource administrable" qui encadre les declarations futures.
|
||||
- Creer `PermissionVoter` Symfony qui :
|
||||
- supporte les attributs au format `module.resource[.sub].action` (regex explicite) sans interferer avec `ROLE_*`,
|
||||
- bypasse a `ACCESS_GRANTED` si `User::isAdmin() === true`,
|
||||
- sinon compare l'attribut a `User::getEffectivePermissions()`.
|
||||
- Remplacer les 13 `is_granted('ROLE_ADMIN')` places par le #344 (et les operations User heritees du profil pre-#344) par les codes metier adequats sur les entites `Permission`, `Role` et `User`. Supprimer les commentaires `// TODO ticket #345` en meme temps.
|
||||
- Creer un service domaine `AdminHeadcountGuard` dans `src/Module/Core/Domain/Security/` qui encapsule la regle "il doit toujours rester au moins un administrateur sur l'instance" et leve `LastAdminProtectionException` quand l'operation ferait tomber le compteur a zero.
|
||||
- Brancher le guard dans `UserRbacProcessor` (apres la garde auto-suicide existante) et dans un nouveau `UserProcessor` decorateur de `RemoveProcessor` qui intercepte `DELETE /api/users/{id}`.
|
||||
- Ajouter `UserRepositoryInterface::countAdmins(): int` et son implementation Doctrine.
|
||||
- Enrichir `/api/me` en exposant `effectivePermissions: list<string>` via un `#[Groups(['me:read'])]` sur la methode existante `User::getEffectivePermissions()`. Aucun changement de `MeProvider`.
|
||||
- Livrer `frontend/shared/composables/usePermissions.ts` consommant `useAuthStore().user` (qui porte deja le payload `/api/me`). API publique : `can(code)`, `canAny(codes)`, `canAll(codes)`.
|
||||
- Etendre `frontend/shared/types/user-data.ts` avec les champs `isAdmin: boolean` et `effectivePermissions: string[]`.
|
||||
- Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension de `UserRbacProcessorTest`.
|
||||
- Tests fonctionnels API : couverture 403 non-admin / 200 admin sur chaque operation des 3 ressources RBAC, cas "dernier admin global" sur PATCH et DELETE, expo `/api/me` avec `effectivePermissions`.
|
||||
- Test Vitest du composable `usePermissions`.
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#346` : ecrans d'administration RBAC front (liste/edition roles, picker permissions, admin user RBAC).
|
||||
- Ticket `#347` : UX des erreurs 403 (toasts, redirections, page 403 dediee), integration front complete des ecrans admin RBAC.
|
||||
- Decoration des items sidebar par permission : les items portent aujourd'hui un champ `module` owner ; le filtrage par permission individuelle sera ajoute au #346 quand l'UI en aura besoin.
|
||||
- Audit log des mutations RBAC : traite par le futur `#355` audit log project, deliberement independant.
|
||||
- Decoupe fine de `core.users.manage` en sous-permissions (`create`, `edit`, `delete`) : YAGNI, aucun use-case metier identifie a ce jour.
|
||||
- Cache des voter decisions : la verification est O(1) sur un `in_array` avec des collections deja `fetch=EAGER`, aucun cache necessaire.
|
||||
|
||||
## 3. Fichiers a creer
|
||||
|
||||
### Domaine - Securite
|
||||
|
||||
- `/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.
|
||||
|
||||
- `/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.
|
||||
|
||||
### Infrastructure - Security
|
||||
|
||||
- `/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`.
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/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`.
|
||||
|
||||
### Frontend - Composable
|
||||
|
||||
- `/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).
|
||||
|
||||
### Tests unitaires PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||
Couvre l'enrichissement du payload `/api/me`.
|
||||
- `/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}`.
|
||||
|
||||
### Tests frontend
|
||||
|
||||
- `/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.
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### `CoreModule.php`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php`
|
||||
|
||||
Ajouter une cinquieme entree au catalogue :
|
||||
|
||||
```php
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
|
||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la prochaine execution, sans migration Doctrine necessaire (le catalogue est propriete exclusive de la commande de sync depuis le #343).
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
Remplacer les 2 gardes placeholder :
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
```
|
||||
|
||||
Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
Remplacer les 5 gardes placeholder :
|
||||
|
||||
- `GetCollection` → `is_granted('core.roles.view')`
|
||||
- `Get` → `is_granted('core.roles.view')`
|
||||
- `Post` → `is_granted('core.roles.manage')`
|
||||
- `Patch` → `is_granted('core.roles.manage')`
|
||||
- `Delete` → `is_granted('core.roles.manage')`
|
||||
|
||||
Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
||||
|
||||
- `Get` (item) → `is_granted('core.users.view')`
|
||||
- `GetCollection` → `is_granted('core.users.view')`
|
||||
- `Post` → `is_granted('core.users.manage')`
|
||||
- `Patch` (profil, sans `name:`) → `is_granted('core.users.manage')`
|
||||
- `Patch` (`user_rbac_patch`) → `is_granted('core.users.manage')`
|
||||
- `Delete` → `is_granted('core.users.manage')`
|
||||
|
||||
Note : l'operation `Get /me` n'a aucune garde (seulement `IS_AUTHENTICATED_FULLY` implicite via `security.yaml`). Ce n'est pas une operation RBAC, elle reste inchangee.
|
||||
|
||||
Ajouter le processor `UserProcessor::class` sur l'operation `Delete` :
|
||||
|
||||
```php
|
||||
new Delete(
|
||||
security: "is_granted('core.users.manage')",
|
||||
processor: UserProcessor::class,
|
||||
),
|
||||
```
|
||||
|
||||
Exposer `getEffectivePermissions()` dans le groupe `me:read` — ajouter l'attribut sur la methode existante :
|
||||
|
||||
```php
|
||||
#[Groups(['me:read'])]
|
||||
public function getEffectivePermissions(): array
|
||||
{
|
||||
// implementation existante, inchangee
|
||||
}
|
||||
```
|
||||
|
||||
Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
||||
|
||||
### `UserRepositoryInterface`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
|
||||
Ajouter la methode :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Compte le nombre d'utilisateurs avec le flag isAdmin = true.
|
||||
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
||||
* "au moins un administrateur reste sur l'instance".
|
||||
*/
|
||||
public function countAdmins(): int;
|
||||
```
|
||||
|
||||
### `DoctrineUserRepository`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||
|
||||
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
||||
|
||||
```php
|
||||
public function countAdmins(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('u')
|
||||
->select('COUNT(u.id)')
|
||||
->where('u.isAdmin = true')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
```
|
||||
|
||||
### `UserRbacProcessor`
|
||||
|
||||
`/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.
|
||||
|
||||
Logique :
|
||||
|
||||
```text
|
||||
1. Garde auto-suicide existante (inchangee).
|
||||
2. Si l'operation entraine la perte du flag isAdmin (wasAdmin && !data.isAdmin):
|
||||
AdminHeadcountGuard::ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
||||
3. Delegation au persist processor.
|
||||
```
|
||||
|
||||
La detection "wasAdmin && !data.isAdmin" reutilise le meme `UnitOfWork::getOriginalEntityData()` deja utilise par la garde auto-suicide.
|
||||
|
||||
### `frontend/shared/types/user-data.ts`
|
||||
|
||||
Ajouter les champs :
|
||||
|
||||
```ts
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
effectivePermissions: string[]
|
||||
// ... champs existants
|
||||
}
|
||||
```
|
||||
|
||||
### `frontend/shared/services/auth.ts`
|
||||
|
||||
A verifier : si `getCurrentUser()` type deja le retour sur `UserData`, rien a changer — les nouveaux champs arrivent automatiquement car l'API les renvoie. Si un mapping manuel est fait dans le service, l'etendre pour ne pas perdre `isAdmin` et `effectivePermissions`. A valider au debut de la task frontend.
|
||||
|
||||
## 5. PermissionVoter - details d'implementation
|
||||
|
||||
### Regex de support
|
||||
|
||||
```php
|
||||
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
```
|
||||
|
||||
Garantit :
|
||||
- premier caractere alphabetique minuscule,
|
||||
- au moins un point de separation (ecarte les `ROLE_*`),
|
||||
- segments en snake_case minuscules coherents avec les permissions declarees par les modules.
|
||||
|
||||
### `supports(string $attribute, mixed $subject): bool`
|
||||
|
||||
Retourne `(bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute)`. Le `$subject` est ignore : les permissions sont portees par l'utilisateur, pas par une ressource ciblee. Pour l'instant l'autorisation est uniquement basee sur l'identite de l'acteur — les scopes ressource (ex. "edit this specific role") seront traites par un voter dedie si un module metier en a besoin.
|
||||
|
||||
### `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool`
|
||||
|
||||
```text
|
||||
$user = $token->getUser()
|
||||
if (!$user instanceof User) return false // ACCESS_DENIED
|
||||
if ($user->isAdmin()) return true // bypass total
|
||||
return in_array($attribute, $user->getEffectivePermissions(), true)
|
||||
```
|
||||
|
||||
### Interaction avec les autres voters
|
||||
|
||||
Strategie par defaut Symfony `affirmative` : des qu'un voter renvoie GRANTED, l'acces est accorde. `PermissionVoter` ne vote **jamais** sur les attributs `ROLE_*` (filtres par `supports()`), donc :
|
||||
|
||||
- l'authentification classique `IS_AUTHENTICATED_FULLY` et `ROLE_USER` continue de fonctionner via `AuthenticatedVoter` et `RoleVoter` de Symfony,
|
||||
- un eventuel `is_granted('ROLE_ADMIN')` residuel dans le code continuerait de fonctionner via `RoleVoter` sans interference.
|
||||
|
||||
Un test fonctionnel `make test` complet verifiera que l'auth standard marche toujours apres ajout du voter.
|
||||
|
||||
### Wiring
|
||||
|
||||
`autoconfigure: true` dans `services.yaml` (deja active) detecte la classe via l'interface `VoterInterface`. **Aucun** wiring manuel necessaire dans `services.yaml`.
|
||||
|
||||
## 6. AdminHeadcountGuard - regles metier
|
||||
|
||||
### Invariant global
|
||||
|
||||
> Apres toute operation terminee avec succes, `countAdmins() >= 1`.
|
||||
|
||||
### API publique
|
||||
|
||||
```php
|
||||
final class AdminHeadcountGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Leve si retirer le flag isAdmin a $user ferait tomber le total a zero.
|
||||
* A appeler UNIQUEMENT dans la branche "l'operation retire effectivement isAdmin".
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
||||
|
||||
/**
|
||||
* Leve si supprimer physiquement $user ferait tomber le total a zero.
|
||||
* A appeler UNIQUEMENT dans la branche DELETE sur un user admin.
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
||||
}
|
||||
```
|
||||
|
||||
Deux methodes semantiques distinctes plutot qu'une methode generique avec un parametre booleen : ca rend les call-sites lisibles et les tests auto-documentes.
|
||||
|
||||
### Logique
|
||||
|
||||
Pour les deux methodes, la regle effective est identique :
|
||||
|
||||
```text
|
||||
if ($this->userRepository->countAdmins() <= 1) {
|
||||
throw new LastAdminProtectionException(
|
||||
'Impossible : au moins un administrateur doit rester sur l\'instance.'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Les appelants ne passent le guard que si l'operation retire reellement un admin — le guard n'a donc pas a raisonner sur l'etat entrant. Cette separation des responsabilites (le processor decide "est-ce qu'on perd un admin ?", le guard applique "si oui, compte") garde les deux composants minimalistes et testables independamment.
|
||||
|
||||
### Cas couverts (tests)
|
||||
|
||||
1. `countAdmins() > 1` + demotion → OK (pas d'exception)
|
||||
2. `countAdmins() == 1` + demotion → LEVE
|
||||
3. `countAdmins() > 1` + deletion → OK
|
||||
4. `countAdmins() == 1` + deletion → LEVE
|
||||
5. `countAdmins() == 2` + demotion → OK (il en reste 1)
|
||||
6. `countAdmins() == 0` + demotion → LEVE (cas theorique, garde defensive)
|
||||
|
||||
## 7. Garde "dernier admin" - cohabitation avec l'auto-suicide
|
||||
|
||||
Les deux gardes sont distinctes et non fusionnables :
|
||||
|
||||
- **Auto-suicide (existante, #344)** : "un admin ne peut pas retirer ses PROPRES droits admin". S'applique meme s'il existe d'autres admins. Protege contre le recovery penible d'un admin qui se cliquerait degrade tout seul.
|
||||
- **Dernier admin global (nouveau, #345)** : "l'instance doit toujours avoir au moins un admin". S'applique meme si ce n'est pas l'operation d'un admin sur lui-meme (admin A degrade admin B alors qu'ils sont les deux seuls).
|
||||
|
||||
Ordre d'evaluation dans `UserRbacProcessor` :
|
||||
|
||||
```text
|
||||
1. Garde auto-suicide (cas particulier, message dedie)
|
||||
2. Garde dernier admin global (cas general, message dedie)
|
||||
3. Persist
|
||||
```
|
||||
|
||||
Les messages d'erreur distincts aident le front a afficher le bon feedback utilisateur. Le test `UserRbacProcessorTest` doit couvrir les deux branches.
|
||||
|
||||
### Cas limite : l'admin se degrade lui-meme ET il est le dernier
|
||||
|
||||
Les deux gardes s'appliqueraient. Comme auto-suicide est evalue en premier, c'est son message qui est retourne ("Vous ne pouvez pas retirer vos propres droits administrateur."). Comportement acceptable et coherent : le user voit d'abord la regle la plus specifique.
|
||||
|
||||
## 8. /api/me enrichi - contrat
|
||||
|
||||
Payload avant :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/User",
|
||||
"@id": "/api/users/5",
|
||||
"@type": "User",
|
||||
"id": 5,
|
||||
"username": "admin",
|
||||
"isAdmin": true
|
||||
}
|
||||
```
|
||||
|
||||
Payload apres :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/User",
|
||||
"@id": "/api/users/5",
|
||||
"@type": "User",
|
||||
"id": 5,
|
||||
"username": "admin",
|
||||
"isAdmin": true,
|
||||
"effectivePermissions": [
|
||||
"core.permissions.view",
|
||||
"core.roles.manage",
|
||||
"core.roles.view",
|
||||
"core.users.manage",
|
||||
"core.users.view"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Contrat :
|
||||
- `effectivePermissions` est toujours un tableau de strings (jamais `null`).
|
||||
- L'ordre est deterministe (trie alphabetique — implementation existante du #343).
|
||||
- Aucun doublon.
|
||||
- Pour un admin, le tableau contient les permissions effectives (non vides si le role `admin` a des permissions OU si l'user a des directPermissions, vide sinon). **Le bypass ne se refletera PAS dans ce tableau** : `isAdmin: true` reste la source de verite du bypass. Le front l'utilise en priorite dans le composable.
|
||||
|
||||
### Pourquoi le bypass n'est pas materialise dans `effectivePermissions`
|
||||
|
||||
Mettre "toutes les permissions connues" dans le tableau pour les admins serait tentant mais faux :
|
||||
- il faudrait enumerer dynamiquement toutes les permissions de tous les modules actifs, ce qui recouvre la responsabilite de `app:sync-permissions`,
|
||||
- le tableau gonflerait inutilement le payload `/api/me` a chaque requete,
|
||||
- et surtout il deviendrait faux si un module declare une nouvelle permission apres une execution de sync : l'admin aurait temporairement un tableau incomplet alors que son bypass reste effectif.
|
||||
|
||||
La source de verite du bypass est `isAdmin: boolean`. Le composable front regarde ce flag en premier.
|
||||
|
||||
## 9. usePermissions - composable front
|
||||
|
||||
### API publique
|
||||
|
||||
```ts
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Verifie si l'utilisateur courant a la permission demandee.
|
||||
// Bypass automatique si isAdmin = true, coherent avec PermissionVoter cote back.
|
||||
const can = (code: string): boolean => {
|
||||
const user = auth.user
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return user.effectivePermissions.includes(code)
|
||||
}
|
||||
|
||||
const canAny = (codes: string[]): boolean => codes.some(can)
|
||||
const canAll = (codes: string[]): boolean => codes.every(can)
|
||||
|
||||
return { can, canAny, canAll }
|
||||
}
|
||||
```
|
||||
|
||||
### Proprietes
|
||||
|
||||
- **Stateless** : aucun `ref` module-level, aucune reactivite dediee. Tout passe par `useAuthStore().user` qui est deja reactif via Pinia.
|
||||
- **Aucun fetch propre** : les permissions arrivent par `/api/me` au login (via `useAuthStore().ensureSession()` ou `.login()`), aucun appel supplementaire n'est necessaire.
|
||||
- **Aucun reset** : le logout efface deja `authStore.user`, donc `can()` retombe naturellement a `false`.
|
||||
- **Bypass synchrone avec le back** : la regle `if (user.isAdmin) return true` duplique deliberement le bypass du `PermissionVoter` cote back. Commentaire francais dans le composable pour rappeler que les deux doivent bouger ensemble si la regle change un jour.
|
||||
|
||||
### Pas de variante `can` reactive (computed)
|
||||
|
||||
Utiliser `computed(() => can('core.users.view'))` dans un composant fonctionne automatiquement puisque `auth.user` est reactif Pinia — Vue re-evalue le computed quand `user` change. Pas besoin d'API supplementaire du composable pour ca.
|
||||
|
||||
## 10. Validation
|
||||
|
||||
Aucune nouvelle contrainte Symfony Validator introduite par ce ticket. Les gardes metier (`AdminHeadcountGuard`, `SystemRoleDeletionException`, auto-suicide) vivent dans les processors et le domaine, pas dans la couche Validator.
|
||||
|
||||
## 11. Plan de tests
|
||||
|
||||
### Unitaires PHP
|
||||
|
||||
**`PermissionVoterTest`**
|
||||
|
||||
- `supports('core.users.view')` retourne `true`.
|
||||
- `supports('ROLE_ADMIN')` retourne `false` (n'interfere pas avec les voters core).
|
||||
- `supports('IS_AUTHENTICATED_FULLY')` retourne `false`.
|
||||
- `supports('invalid attribute')` retourne `false` (espace, majuscule).
|
||||
- `voteOnAttribute` avec un `User` admin retourne GRANTED quelle que soit la permission.
|
||||
- `voteOnAttribute` avec un user portant la permission retourne GRANTED.
|
||||
- `voteOnAttribute` avec un user ne portant pas la permission retourne DENIED.
|
||||
- `voteOnAttribute` avec un token non-authentifie (user null) retourne DENIED.
|
||||
|
||||
**`AdminHeadcountGuardTest`**
|
||||
|
||||
- `ensureAtLeastOneAdminRemainsAfterDemotion` : `countAdmins == 2` → OK.
|
||||
- Meme methode : `countAdmins == 1` → `LastAdminProtectionException`.
|
||||
- Meme methode : `countAdmins == 0` → leve aussi (garde defensive).
|
||||
- `ensureAtLeastOneAdminRemainsAfterDeletion` : memes 3 cas, memes resultats.
|
||||
- `UserRepositoryInterface::countAdmins()` est mockee avec une valeur fixe pour chaque cas (test unitaire isole, pas d'acces BDD).
|
||||
|
||||
**`UserProcessorTest`**
|
||||
|
||||
- `process()` sur un user non-admin en DELETE delegue au `RemoveProcessor`.
|
||||
- `process()` sur un user admin en DELETE avec `countAdmins() > 1` delegue.
|
||||
- `process()` sur un user admin en DELETE avec `countAdmins() == 1` leve `BadRequestHttpException` (traduction de `LastAdminProtectionException`).
|
||||
- `process()` avec `$data` non-`User` leve `LogicException` (fail-fast coherent avec `UserRbacProcessor` / `RoleProcessor`).
|
||||
|
||||
**`UserRbacProcessorTest` (extension)**
|
||||
|
||||
- Cas existants auto-suicide : gardes en l'etat.
|
||||
- Nouveau : PATCH RBAC par admin A sur admin B, `isAdmin: false`, `countAdmins() == 1` (apres perte = 0) → `BadRequestHttpException` "dernier admin".
|
||||
- Nouveau : meme operation avec `countAdmins() == 2` → delegue au persist processor.
|
||||
- Nouveau : PATCH RBAC qui ne touche pas `isAdmin` (change juste `roles` ou `directPermissions`) ne consulte jamais le guard, meme si `countAdmins() == 1`.
|
||||
|
||||
### Fonctionnels API PHP (`AbstractApiTestCase`)
|
||||
|
||||
Pour les 3 ressources (`Permission`, `Role`, `User`), pour chaque operation, 3 cas :
|
||||
|
||||
1. Admin → succes (confirme que le voter bypass fonctionne).
|
||||
2. User standard **avec** la permission requise (attachee via fixture dediee) → succes.
|
||||
3. User standard **sans** la permission → `403`.
|
||||
|
||||
**Fixtures de test** : ajouter des users "portant une permission specifique" n'est pas souhaitable dans `AppFixtures` (fixtures de dev). Creer a la place un trait ou une helper method `AbstractApiTestCase::createUserWithPermission(string $code): User` qui instancie a la volee un user + un role + l'attache dans le test lui-meme, transactionne si `DAMADoctrineTestBundle` est en place.
|
||||
|
||||
**Cas specifiques a ajouter** :
|
||||
|
||||
- `UserRbacApiTest` : PATCH `/api/users/{lastAdminId}/rbac` avec `isAdmin: false` par un **autre** admin → `400` avec message "dernier admin" (et pas "auto-suicide").
|
||||
- `UserApiTest` (nouveau ou extension) : DELETE `/api/users/{lastAdminId}` par un autre admin → `400` avec message "dernier admin".
|
||||
- `UserApiTest` : DELETE `/api/users/{nonAdminId}` fonctionne quel que soit le count (la garde ne doit pas etre appelee).
|
||||
- `MeApiTest` : `GET /api/me` en tant qu'admin retourne `effectivePermissions` (tableau, meme vide si pas de role populaire).
|
||||
- `MeApiTest` : `GET /api/me` en tant que user standard retourne `effectivePermissions` = list triee des codes issus de ses roles et directPermissions.
|
||||
|
||||
### Tests frontend (Vitest)
|
||||
|
||||
**`usePermissions.test.ts`**
|
||||
|
||||
- Utilisateur null → `can()` retourne `false` pour n'importe quel code.
|
||||
- Utilisateur admin → `can('core.users.view')` retourne `true` meme si `effectivePermissions` est vide.
|
||||
- Utilisateur non-admin avec `['core.users.view']` → `can('core.users.view')` = `true`, `can('core.users.manage')` = `false`.
|
||||
- `canAny(['a', 'b'])` retourne `true` si l'un des deux matche, `false` sinon.
|
||||
- `canAll(['a', 'b'])` retourne `true` uniquement si les deux matchent.
|
||||
|
||||
Convention de test frontend a valider avant : si le projet Nuxt a deja un setup Vitest, on s'y aligne ; sinon on note une TODO pour ajouter la conf (sans bloquer le ticket — le composable est assez simple pour etre revu manuellement).
|
||||
|
||||
## 12. Securite et traduction d'exceptions
|
||||
|
||||
- `LastAdminProtectionException` (domaine) → `BadRequestHttpException` (400) dans les processors. Message francais : "Impossible : au moins un administrateur doit rester sur l'instance."
|
||||
- `SystemRoleDeletionException` (existante) → traduction inchangee par le #344, rien a modifier.
|
||||
- Auto-suicide existante → message inchange : "Vous ne pouvez pas retirer vos propres droits administrateur."
|
||||
- Pas de listener global : traduction locale dans chaque processor, coherent avec le pattern du #344.
|
||||
|
||||
## 13. Conventions et architecture
|
||||
|
||||
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/`, `tests/Module/Core/`, ou `frontend/shared/`. Aucun import inter-modules.
|
||||
- `declare(strict_types=1)` en tete de tous les nouveaux fichiers PHP.
|
||||
- Commentaires PHP et TS en francais, identifiants en anglais (`CLAUDE.md`).
|
||||
- Autoconfigure Symfony detecte `PermissionVoter` via `VoterInterface`. `AdminHeadcountGuard` est autowire via son constructeur standard.
|
||||
- Les processors suivent le pattern du #344 : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast sur type invalide.
|
||||
- Aucune entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
|
||||
- Aucune migration Doctrine : le catalogue de permissions est synchronise par `app:sync-permissions` (commande existante #343), pas par une migration.
|
||||
|
||||
## 14. Ordre d'execution recommande (subagent-driven)
|
||||
|
||||
1. **Catalogue** — ajouter `core.roles.view` dans `CoreModule::permissions()`. Executer `app:sync-permissions` en local pour verifier l'ajout. Pas de test propre (couvert indirectement par les tests sync existants du #343).
|
||||
2. **Guard domaine** — creer `LastAdminProtectionException`, ajouter `UserRepositoryInterface::countAdmins()` + impl Doctrine, creer `AdminHeadcountGuard`. Ecrire `AdminHeadcountGuardTest`.
|
||||
3. **PermissionVoter** — implementation + `PermissionVoterTest`. Verifier via `make test` que l'auth standard reste verte (aucune regression sur `ROLE_*`).
|
||||
4. **UserProcessor DELETE** — creer le processor, wire sur l'operation `Delete` de `User`. Ecrire `UserProcessorTest`.
|
||||
5. **UserRbacProcessor extension** — injecter `AdminHeadcountGuard`, brancher apres la garde auto-suicide. Etendre `UserRbacProcessorTest` avec les nouveaux cas.
|
||||
6. **Remplacement des 13 gardes ROLE_ADMIN** — modifier `Permission`, `Role`, `User`. Supprimer tous les `// TODO ticket #345`.
|
||||
7. **`/api/me` enrichi** — ajouter `#[Groups(['me:read'])]` sur `getEffectivePermissions()`. Creer ou etendre `MeApiTest`.
|
||||
8. **Tests fonctionnels RBAC complets** — helper `createUserWithPermission()` dans `AbstractApiTestCase`, puis couverture 403 non-admin / 200 avec permission sur toutes les operations RBAC des 3 ressources. Cas "dernier admin global" PATCH et DELETE.
|
||||
9. **Frontend types + composable** — etendre `UserData`, creer `usePermissions.ts`, ecrire le test Vitest.
|
||||
10. **Verification finale** — `make test` vert, `make php-cs-fixer-allow-risky` sans delta, build Nuxt OK si modifie.
|
||||
|
||||
Chaque etape doit etre revue (spec compliance + code quality) avant de passer a la suivante, pattern subagent-driven-development retenu pour le #344.
|
||||
|
||||
## 15. Risques et points d'attention
|
||||
|
||||
- **Ordre des voters Symfony** : `PermissionVoter` ne vote jamais sur `ROLE_*` grace au regex de support. Risque quasi-nul d'interference avec `RoleVoter`/`AuthenticatedVoter`, a valider par un test fonctionnel `/login_check` + `GET /api/me` apres ajout du voter.
|
||||
- **Serialisation de `getEffectivePermissions()` via API Platform** : la methode existe depuis le #343 mais n'a jamais ete sous serializer. Risque de rencontrer un `ReflectionException` si le nom de propriete deduit ne matche pas (cas rare, API Platform gere les getters normalement). Mitigation : test fonctionnel `/api/me` en premiere validation.
|
||||
- **Cout SQL de `countAdmins()`** : 1 `COUNT(*)` par operation de mutation admin sensible. Index recommande sur `user.is_admin` (`idx_user_is_admin`) — a verifier si la migration #343 l'a deja cree. Si non, c'est un ajustement cosmetique qu'on peut reporter puisque la table `user` d'un CRM PME reste petite (< 1000 lignes).
|
||||
- **Bypass front/back desynchronise** : si un jour le bypass admin est affine cote back (ex: seulement sur certains modules), le composable front doit bouger en meme temps. Mitigation : commentaire francais explicite dans `usePermissions.ts` pointant vers cette spec.
|
||||
- **Tests fonctionnels et fixtures RBAC** : le #344 a introduit `AbstractApiTestCase`, mais les users de test portant une permission specifique (hors admin/user standard) n'existent pas dans les fixtures. Creer une helper `createUserWithPermission()` transactionnelle dans la classe de test, plutot que polluer `AppFixtures` avec des users de test dedies.
|
||||
- **Ordre d'evaluation auto-suicide vs dernier admin** : les deux gardes pourraient etre declenchees simultanement (admin unique qui se degrade lui-meme). L'auto-suicide gagne en premier par design. A couvrir explicitement par un test.
|
||||
- **Payload `/api/me` plus gros** : l'ajout de `effectivePermissions` alourdit chaque requete `/api/me`. Pour 5 permissions aujourd'hui c'est negligeable, mais si le catalogue grossit fortement (50+ permissions reparties sur plusieurs modules), il faudra peut-etre filtrer cote serveur (ne retourner que les permissions utiles au contexte front). Hors scope, mais a noter pour suivi.
|
||||
- **`UserData` partagee entre auth store et composable** : toute modification future de la shape `UserData` peut impacter `usePermissions`. Rester minimal dans le composable et laisser Pinia porter la verite.
|
||||
|
||||
## 16. Criteres d'acceptation (DoD)
|
||||
|
||||
- Le catalogue `CoreModule::permissions()` contient 5 entrees incluant `core.roles.view`.
|
||||
- `PermissionVoter` existe, supporte uniquement les attributs au format `module.resource.action`, bypass admin effectif, test unitaire complet.
|
||||
- Les 13 operations API Platform du perimetre RBAC sont toutes gardees par un code metier `core.*.*` et plus par `ROLE_ADMIN`. Les commentaires `// TODO ticket #345` ont disparu du code.
|
||||
- `AdminHeadcountGuard` existe comme service domaine, est consomme par `UserRbacProcessor` ET `UserProcessor`, teste en isolation.
|
||||
- `UserRepositoryInterface::countAdmins()` existe et est implementee.
|
||||
- `UserProcessor` intercepte `DELETE /api/users/{id}` et bloque la suppression du dernier admin avec un message explicite.
|
||||
- `UserRbacProcessor` bloque la demotion du dernier admin global (en plus de la garde auto-suicide existante) avec un message distinct.
|
||||
- `GET /api/me` retourne `effectivePermissions: string[]` et `isAdmin: boolean` dans son payload.
|
||||
- `frontend/shared/composables/usePermissions.ts` expose `can`, `canAny`, `canAll`, stateless, bypasse si `isAdmin`.
|
||||
- `frontend/shared/types/user-data.ts` inclut `isAdmin` et `effectivePermissions`.
|
||||
- Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension `UserRbacProcessorTest` — tous verts.
|
||||
- Tests fonctionnels API : couverture 403 non-admin / 200 admin-ou-porteur sur chaque operation RBAC des 3 ressources, cas dernier admin PATCH et DELETE, `/api/me` enrichi.
|
||||
- Test Vitest `usePermissions.test.ts` vert (ou TODO documentee si setup Vitest absent du projet).
|
||||
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
|
||||
- Aucun import croise entre modules ; tous les fichiers PHP crees vivent dans `Module/Core/` ou `tests/Module/Core/`, tous les fichiers front dans `frontend/shared/`.
|
||||
- Le spec est mergee avec le code (meme PR #3 empilee sur `feat/rbac-api`) pour rester la reference du ticket.
|
||||
|
||||
## 17. Remarques de branche
|
||||
|
||||
- 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`.
|
||||
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
||||
410
docs/sites/ticket-01-spec.md
Normal file
410
docs/sites/ticket-01-spec.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Ticket #01 — 1/4 — Brique fondatrice du module Sites (Backend)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket livre la couche de donnees du module optionnel Sites. Il cree le bounded context, declare le module a Symfony, enregistre ses permissions RBAC, installe la table `site` en base et seed trois etablissements de demonstration utilises par les tickets suivants.
|
||||
|
||||
Le resultat attendu est un socle de persistance activable par tenant via `config/modules.php`, sans UI, sans API publique, sans couplage au module Core, et sur lequel les tickets 2/3/4 pourront greffer : rattachement utilisateurs, selecteur de site dans la navbar, administration CRUD.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- 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'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/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.
|
||||
- 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/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/Coltura/config/services.yaml`.
|
||||
- Ajouter deux suites de tests PHPUnit :
|
||||
- `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.
|
||||
|
||||
### 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 `#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).
|
||||
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
||||
- Rattachement historique ou audit trail des modifications : hors scope.
|
||||
|
||||
## 3. Fichiers à créer
|
||||
|
||||
### Domaine — Entité
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
||||
|
||||
### Infrastructure — Migration
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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/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
|
||||
|
||||
- `/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/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/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
|
||||
|
||||
Comme pour le ticket RBAC (ticket-343), le schema est decrit par les attributs Doctrine plutot que par le SQL brut. Le fichier de migration contient le SQL final (section 6).
|
||||
|
||||
### Conventions respectées
|
||||
|
||||
- `declare(strict_types=1)` en tete de tous les fichiers PHP.
|
||||
- Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`).
|
||||
- PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (`postalCode` → `postal_code`, `fullAddress` → `full_address`, `createdAt` → `created_at`, `updatedAt` → `updated_at`).
|
||||
- Le nom de table `site` n'est pas un mot reserve PostgreSQL : pas de backtick necessaire.
|
||||
|
||||
### Entité `Site`
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||
#[ORM\Table(name: 'site')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||
class Site
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
|
||||
#[Assert\Length(max: 100, ...)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'La ville du site est requise.')]
|
||||
#[Assert\Length(max: 100, ...)]
|
||||
private string $city;
|
||||
|
||||
#[ORM\Column(name: 'postal_code', length: 10)]
|
||||
#[Assert\NotBlank(message: 'Le code postal est requis.')]
|
||||
#[Assert\Length(max: 10, ...)]
|
||||
#[Assert\Regex(pattern: '/^\d{5}$/', message: '...')]
|
||||
private string $postalCode;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Assert\NotBlank(message: 'La couleur est requise.')]
|
||||
#[Assert\Regex(pattern: '/^#[0-9A-Fa-f]{6}$/', message: '...')]
|
||||
private string $color;
|
||||
|
||||
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
|
||||
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
|
||||
#[Assert\Length(max: 500, ...)]
|
||||
private string $fullAddress;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
Contraintes fonctionnelles :
|
||||
- `name` est unique en base (`uniq_site_name`) et porte egalement la contrainte applicative `UniqueEntity` pour que le validator remonte une violation lisible avant d'atteindre la violation DB.
|
||||
- `color` est contraint par regex a un code hex strict de 7 caracteres `#RRGGBB`, majuscules ou minuscules. La colonne `VARCHAR(7)` est dimensionnee au plus juste car la regex est exhaustive.
|
||||
- `postalCode` est contraint a 5 chiffres exacts via regex (format FR). La colonne `VARCHAR(10)` est volontairement plus large que la regex pour laisser marge si le projet etend plus tard la regex a d'autres formats (UK, PT, ...). Choix assume : evite une migration DDL au ticket suivant, cout DB negligeable sur un champ court.
|
||||
- `fullAddress` est de type `TEXT` (PostgreSQL) pour permettre une adresse multi-ligne, mais borne cote applicatif a 500 caracteres via `Assert\Length(max: 500)` comme garde DoS basique (une adresse FR complete tient largement dans cette enveloppe).
|
||||
- `createdAt` est seede dans le constructeur et **ne change plus jamais** apres persistance.
|
||||
- `updatedAt` est seede dans le constructeur a la meme valeur que `createdAt`, puis refresh a chaque update via le callback `#[ORM\PreUpdate]`.
|
||||
|
||||
### Mapping Doctrine — `doctrine.yaml`
|
||||
|
||||
```yaml
|
||||
# Mapping inconditionnelle du module Sites : la structure DB existe meme
|
||||
# si SitesModule::class est retire de config/modules.php. L'activation
|
||||
# fonctionnelle (ex: exposition des permissions, futurs endpoints API)
|
||||
# passe exclusivement par config/modules.php.
|
||||
Sites:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||
prefix: 'App\Module\Sites\Domain\Entity'
|
||||
alias: Sites
|
||||
```
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
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
|
||||
|
||||
1. Creer la table `site` avec toutes les colonnes NOT NULL :
|
||||
- `id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL`
|
||||
- `name VARCHAR(100) NOT NULL`
|
||||
- `city VARCHAR(100) NOT NULL`
|
||||
- `postal_code VARCHAR(10) NOT NULL`
|
||||
- `color VARCHAR(7) NOT NULL`
|
||||
- `full_address TEXT NOT NULL`
|
||||
- `created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL`
|
||||
- `updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL`
|
||||
- `PRIMARY KEY (id)`
|
||||
2. Creer l'index unique `uniq_site_name` sur `site(name)` pour garantir l'invariant metier "un site porte un nom unique" au niveau DB. Le validator applicatif `UniqueEntity` s'appuie dessus en lecture avant qu'une tentative d'insertion concurrente ne remonte la violation DB.
|
||||
|
||||
### `down()` — rollback
|
||||
|
||||
1. `DROP TABLE site`. Aucune FK n'existe depuis ou vers cette table dans ce ticket ; le rollback est donc trivial et safe.
|
||||
|
||||
### Precision timestamp
|
||||
|
||||
PostgreSQL `TIMESTAMP(0) WITHOUT TIME ZONE` stocke a la seconde pres. Les DateTimeImmutable PHP portent une precision microseconde mais perdent cette precision au round-trip DB. Les tests unitaires de lifecycle doivent en tenir compte (cf. section 10 — usage de reflection plutot qu'un `sleep`).
|
||||
|
||||
## 7. Intégration avec sync-permissions
|
||||
|
||||
Le ticket ne modifie pas `SyncPermissionsCommand`. Il exploite l'algorithme existant (cf. ticket-343 section 7) en declarant `SitesModule::permissions()` dans un format strictement conforme au contrat attendu par la commande :
|
||||
|
||||
```php
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Regles de validation appliquees par `SyncPermissionsCommand` :
|
||||
- Chaque entree doit contenir exactement les cles `code` et `label`.
|
||||
- Le prefixe du code doit correspondre a `SitesModule::ID . '.'`, soit `sites.`.
|
||||
- Ni `code` ni `label` ne peuvent etre une chaine vide.
|
||||
|
||||
Comportement a attendre :
|
||||
- Apres `php bin/console app:sync-permissions`, les deux lignes `sites.view` et `sites.manage` sont presentes dans la table `permission` avec `module = 'sites'` et `orphan = false`.
|
||||
- Si `SitesModule::class` est retire de `config/modules.php` et la commande relancee, les deux lignes sont marquees `orphan = true` (non supprimees, pour preserver les assignations). Reactiver le module les remet a `orphan = false`.
|
||||
- La cle `module` n'est **pas** presente dans le payload : elle est auto-injectee par la commande depuis `SitesModule::ID`.
|
||||
|
||||
### Granularité des permissions
|
||||
|
||||
`sites.manage` est une permission **composite** couvrant creation, edition et suppression. Ce choix reste simple pour un ticket fondateur, mais le ticket 4 (administration CRUD) devra arbitrer si une granularite plus fine (`sites.create`, `sites.edit`, `sites.delete`) est necessaire pour les besoins UX. Si oui, la migration de permissions se fera naturellement via la commande de sync : ajouter les trois codes dans `permissions()`, retirer `sites.manage` → la sync marque l'ancien orphelin sans casser les roles deja existants.
|
||||
|
||||
## 8. Méthodes clés détaillées
|
||||
|
||||
### `Site::__construct`
|
||||
|
||||
Le constructeur prend les cinq champs metier obligatoires et positionne les deux timestamps a la meme valeur :
|
||||
|
||||
```php
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $city,
|
||||
string $postalCode,
|
||||
string $color,
|
||||
string $fullAddress,
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->city = $city;
|
||||
$this->postalCode = $postalCode;
|
||||
$this->color = $color;
|
||||
$this->fullAddress = $fullAddress;
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
```
|
||||
|
||||
Justification :
|
||||
- Tous les champs sont passes au constructeur pour forcer l'invariant "un Site instancie est toujours complet". L'alternative (setters post-new) autoriserait des etats transitoires invalides.
|
||||
- `createdAt` et `updatedAt` partagent la meme valeur a l'instanciation, ce qui garantit `updated_at >= created_at` au niveau base. Le premier appel a `onPreUpdate()` fera avancer uniquement `updatedAt`.
|
||||
|
||||
### `Site::onPreUpdate`
|
||||
|
||||
```php
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
```
|
||||
|
||||
Justification :
|
||||
- Callback Doctrine declenche **uniquement** quand Doctrine detecte au moins un changement sur l'entite en session de persistance. Pas de risque de tick silencieux sur un find pur.
|
||||
- `createdAt` n'est volontairement jamais touche ici : il est immuable apres persistance.
|
||||
- Pas de `#[ORM\PrePersist]` : le constructeur gere deja l'initialisation, inutile de dupliquer la logique dans un callback qui pourrait etre appele a vide.
|
||||
|
||||
### `SitesFixtures::ensureSite`
|
||||
|
||||
```php
|
||||
private function ensureSite(
|
||||
ObjectManager $manager,
|
||||
string $name,
|
||||
string $city,
|
||||
string $postalCode,
|
||||
string $color,
|
||||
string $fullAddress,
|
||||
): Site {
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
|
||||
$manager->persist($site);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
$site->setCity($city);
|
||||
$site->setPostalCode($postalCode);
|
||||
$site->setColor($color);
|
||||
$site->setFullAddress($fullAddress);
|
||||
|
||||
return $site;
|
||||
}
|
||||
```
|
||||
|
||||
Contrat honnete sur l'idempotence (cf. docblock en tete de fixture) :
|
||||
- **Supporte** : lookup par nom avec purger Doctrine actif (cas nominal de `doctrine:fixtures:load`).
|
||||
- **Supporte** : lookup par nom hors purger si la fixture est rejouee telle quelle sur une base deja seedee → les autres champs sont re-alignes sur les valeurs de reference.
|
||||
- **Non supporte** : chargement cumulatif apres qu'une autre fixture ait `persist` (sans `flush`) des Site dans la meme session → `findByName` via `findOneBy` n'inspecte pas l'unit-of-work et peut creer un doublon.
|
||||
- **Non supporte** : renommage d'un site dans la fixture → le lookup par `name` rate, un nouveau site est cree, l'ancien reste en base si le purger est desactive.
|
||||
|
||||
## 9. Fixtures Sites
|
||||
|
||||
Trois sites de demonstration, avec des couleurs distinctes suffisamment contrastees pour un futur affichage visuel (ticket 3 — navbar) :
|
||||
|
||||
| Nom | Ville | CP | Couleur | Commentaire |
|
||||
|-----|-------|-----|---------|-------------|
|
||||
| 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). |
|
||||
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
||||
|
||||
Les adresses completes sont des chaines multi-lignes (voie + CP/ville), cas nominal d'exploitation du type `TEXT` sur `full_address`.
|
||||
|
||||
### Ordre d'execution global des fixtures
|
||||
|
||||
`SitesFixtures` est une `Fixture` sans dependance : elle peut s'executer dans n'importe quel ordre relatif aux autres fixtures Core (`AppFixtures`). Aucune FK inter-modules dans ce ticket.
|
||||
|
||||
Le ticket 2 introduira probablement une relation `User ↔ Site` ; `SitesFixtures` devra alors etre declare comme dependance de `AppFixtures` (ou inversement, selon la direction de la FK) via `DependentFixtureInterface::getDependencies()`.
|
||||
|
||||
## 10. Plan de tests PHPUnit
|
||||
|
||||
Deux suites separees, motivation :
|
||||
- `SiteTest` reste en `TestCase` pur (pas de kernel) pour tester le comportement mecanique de l'entite — rapide, zero dependance DB.
|
||||
- `SiteValidationTest` utilise `KernelTestCase` pour avoir acces au validator applicatif, **indispensable** pour tester `UniqueEntity` dont le validator est backed par Doctrine et necessite donc un `ManagerRegistry` reel.
|
||||
|
||||
### `SiteTest` — tests unitaires purs
|
||||
|
||||
1. `testConstructorInitialState` : verifie que le constructeur positionne correctement les 5 champs metier et les deux timestamps (`DateTimeImmutable`).
|
||||
2. `testCreatedAtAndUpdatedAtAreInitiallyEqual` : verifie l'invariant "a l'instanciation, `createdAt == updatedAt`".
|
||||
3. `testOnPreUpdateAdvancesUpdatedAtOnly` : utilise `Reflection` pour forcer `updatedAt` a une valeur anterieure (`-1 hour`), appelle `onPreUpdate()`, et verifie que `updatedAt` avance strictement mais que `createdAt` reste immuable.
|
||||
- **Justification reflection** : eviter un `sleep/usleep` flaky en CI et lent.
|
||||
4. `testSettersMutateFields` : verifie que les setters publics modifient correctement les champs metier.
|
||||
|
||||
### `SiteValidationTest` — tests d'integration validator
|
||||
|
||||
Bootstrap : `self::bootKernel()` dans `setUp()`, recuperation de `ValidatorInterface` et `EntityManagerInterface` depuis le container.
|
||||
|
||||
Tests de validation scalaire (via `DataProvider` PHPUnit 12+, attribut `#[DataProvider]`) :
|
||||
1. `testValidSitePassesValidation` : un Site correct passe sans violation.
|
||||
2. `testColorMustBeHexRrggbb` / `testValidColorsAreAccepted` : jeu de donnees invalide (`red`, `#FFF`, `FFFFFF`, `rgb(...)`, `#1234567`, `#12345G`, `""`) vs valide (`#ABCDEF`, `#abcdef`, `#0a1B2c`, `#000000`, `#FFFFFF`).
|
||||
3. `testPostalCodeMustMatchFrFormat` / `testValidPostalCodesAreAccepted` : jeu de donnees invalide (`1234`, `123456`, `8610A`, `86-100`, `""`, `86 100`) vs valide (`86100`, `75001`, `97100`, `20000`).
|
||||
4. `testBlankNameIsRejected`, `testBlankCityIsRejected`, `testBlankFullAddressIsRejected` : `NotBlank` sur chaque champ obligatoire.
|
||||
5. `testNameLongerThan100CharsIsRejected`, `testCityLongerThan100CharsIsRejected` : `Length(max: 100)`.
|
||||
|
||||
Test d'unicite :
|
||||
6. `testDuplicateNameIsRejected` : **auto-suffisant** — persiste lui-meme un site porteur d'un nom unique (`Test-Duplicate-<uniqid>`), flush, tente de valider un second Site avec le meme nom, verifie qu'au moins une violation porte `UniqueEntity::NOT_UNIQUE_ERROR` sur la property `name`, puis supprime le site en `finally`.
|
||||
- **Justification** : pas de dependance aux fixtures (robustesse, pas de couplage sur `Chatellerault`). Assertion precise sur le `code` de violation + `propertyPath`, pas sur le message (resistant aux traductions).
|
||||
|
||||
### Pattern `finally` pour cleanup
|
||||
|
||||
```php
|
||||
try {
|
||||
$duplicate = new Site($name, ...);
|
||||
$violations = $this->validator->validate($duplicate);
|
||||
// assertions...
|
||||
} finally {
|
||||
$this->em->remove($original);
|
||||
$this->em->flush();
|
||||
}
|
||||
```
|
||||
|
||||
Garantit le cleanup meme si une assertion rate, sans dependre d'une transaction globale de test.
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
### Risque 1 — Mapping Doctrine inconditionnel
|
||||
|
||||
Le mapping `Sites:` est declare dans `doctrine.yaml` sans dependance a `config/modules.php`. Consequence : retirer `SitesModule::class` de `modules.php` ne desactive **pas** le mapping Doctrine ni la table `site`.
|
||||
|
||||
Decision assumee et alignee avec le traitement du module `Core` :
|
||||
- La structure DB est "toujours la" (migrations jouees inconditionnellement).
|
||||
- L'activation fonctionnelle (exposition des permissions, futurs endpoints) passe exclusivement par `modules.php`.
|
||||
|
||||
Cela doit etre **explicite dans `doctrine.yaml`** via un commentaire en tete du bloc `Sites:` pour eviter qu'un futur reviewer n'interprete le mapping comme un oubli.
|
||||
|
||||
### Risque 2 — Migration racine vs migration modulaire
|
||||
|
||||
La migration est placee dans `migrations/` et non dans `src/Module/Sites/Infrastructure/Doctrine/Migrations/`. C'est une exception documentee dans `CLAUDE.md` et dans le docblock de la migration elle-meme, motivee par un bug de tri alphabetique des `MigrationsComparator` en Doctrine Migrations 3.x lorsque plusieurs `migrations_paths` sont declares.
|
||||
|
||||
Consequence pour les tickets futurs :
|
||||
- Tant que le bug n'est pas resolu, **toute nouvelle migration d'initialisation** (creation de table sur base vide) continuera d'aller au namespace racine.
|
||||
- Les migrations applicatives (ajout de colonne, backfill) qui supposent un schema deja en place peuvent vivre dans le namespace modulaire, comme prevu.
|
||||
- Une fois le bug resolu (comparator custom ou upgrade Doctrine), migrer les fichiers vers le namespace modulaire sera un simple `git mv` + ajustement du namespace PHP.
|
||||
|
||||
### Risque 3 — Idempotence des fixtures non cumulative
|
||||
|
||||
Le docblock de `SitesFixtures` declare explicitement les cas d'idempotence supportes et non supportes (cf. section 8). Ne pas promettre une robustesse que le pattern ne tient pas : si un futur ticket introduit une fixture persistant des Site **avant** `SitesFixtures` sans flush intermediaire, un doublon peut apparaitre. Le contrat ecrit permet au reviewer de ce futur ticket de reagir.
|
||||
|
||||
### Risque 4 — Regex couleur non normalisee
|
||||
|
||||
La regex `/^#[0-9A-Fa-f]{6}$/` accepte majuscules et minuscules. Les fixtures utilisent des majuscules ; si l'UI du ticket 4 permet de saisir en minuscules, deux couleurs "visuellement identiques" pourront coexister en base avec casse differente, cassant toute comparaison naive (`$a->color === $b->color`). A decider au ticket 4 : normaliser en uppercase a la persistance, ou assumer le stockage tel quel et normaliser uniquement a la comparaison.
|
||||
|
||||
### Risque 5 — Precision timestamp PostgreSQL TIMESTAMP(0)
|
||||
|
||||
PostgreSQL `TIMESTAMP(0)` ecrete a la seconde pres. Deux updates espaces de moins d'une seconde produisent le meme `updated_at` en base. Pas un probleme pour les cas d'usage metier de ce ticket (edition manuelle), mais a garder en tete si un ticket futur introduit un `updatedAt` comme cle de tri ou de detection de version optimiste.
|
||||
|
||||
## 12. Ordre d'exécution recommandé
|
||||
|
||||
1. **Exploration** — Lire le module Core (`CoreModule.php`, `User.php`, `Role.php`) pour aligner le style.
|
||||
2. **Module declaration** — Creer `SitesModule.php` avec `permissions()`.
|
||||
3. **Entite** — Creer `Site.php` avec tous les attributs Doctrine et contraintes de validation.
|
||||
4. **Repository** — Creer `SiteRepositoryInterface.php` puis `DoctrineSiteRepository.php`.
|
||||
5. **Configuration** — Enregistrer le mapping dans `doctrine.yaml`, l'alias dans `services.yaml`, le module dans `modules.php`.
|
||||
6. **Migration** — Generer le fichier de migration (manuellement ou via `doctrine:migrations:diff` puis ajuster), jouer `make migration-migrate`.
|
||||
7. **Fixtures** — Creer `SitesFixtures.php`, jouer `make fixtures` puis `make sync-permissions`.
|
||||
8. **Tests unitaires** — Ecrire `SiteTest.php` (TestCase pur).
|
||||
9. **Tests validation** — Ecrire `SiteValidationTest.php` (KernelTestCase).
|
||||
10. **Validation DoD** — `make test-db-setup && make test` (doit passer 148/148), verifier que designer SitesModule ne casse rien.
|
||||
11. **CS fixer** — `make php-cs-fixer-allow-risky FILES="src/Module/Sites tests/Module/Sites migrations/Version<timestamp>.php config/..."`.
|
||||
|
||||
## 13. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `SitesModule.php` existe et declare exactement 2 permissions (`sites.view`, `sites.manage`) prefixees `sites.` conformement au format attendu par `SyncPermissionsCommand`.
|
||||
- [ ] `SitesModule::class` est enregistre dans `config/modules.php` et active par defaut.
|
||||
- [ ] Entite `Site` creee avec tous les champs, contraintes de validation (`NotBlank`, `Length`, `Regex hex`, `Regex CP FR`, `UniqueEntity`) et timestamps auto.
|
||||
- [ ] `SiteRepositoryInterface` expose au minimum `findById`, `findByName`, `findAllOrderedByName`, `save`, `remove` ; `DoctrineSiteRepository` l'implemente.
|
||||
- [ ] La migration existe dans `migrations/` (namespace `DoctrineMigrations`), cree la table `site` et l'index unique `uniq_site_name`, est jouable via `make migration-migrate`.
|
||||
- [ ] `SitesFixtures` cree les 3 sites avec couleurs distinctes et docblock honnete sur son idempotence.
|
||||
- [ ] `make fixtures` charge les 3 sites sans erreur et est rejouable apres purge.
|
||||
- [ ] Apres `app:sync-permissions`, la table `permission` contient `sites.view` et `sites.manage` avec `module = 'sites'` et `orphan = false`.
|
||||
- [ ] Le mapping `Sites:` est declare dans `doctrine.yaml` avec un commentaire explicite sur son caractere inconditionnel.
|
||||
- [ ] L'alias `SiteRepositoryInterface → DoctrineSiteRepository` est declare dans `services.yaml`.
|
||||
- [ ] `make test` passe 148/148 tests avec `SitesModule::class` active.
|
||||
- [ ] `make test` passe 148/148 tests avec `SitesModule::class` commente dans `config/modules.php`.
|
||||
- [ ] `make php-cs-fixer-allow-risky` ne signale aucune correction sur les fichiers du ticket.
|
||||
- [ ] Aucun import direct depuis `src/Module/Core/...` vers `src/Module/Sites/...` ni l'inverse (independance des bounded contexts).
|
||||
592
docs/sites/ticket-02-spec.md
Normal file
592
docs/sites/ticket-02-spec.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Ticket #02 — 2/4 — Exposition API, rattachement utilisateurs et admin CRUD
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket transforme la brique de donnees du ticket 1 en module fonctionnel : il expose la ressource `Site` via API Platform (CRUD admin avec RBAC), introduit la notion de **sites autorises** et de **site courant** sur chaque utilisateur, ouvre un endpoint dedie au basculement du site courant, et livre la page d'administration `/admin/sites` ainsi que l'assignation des sites dans le drawer RBAC d'un user.
|
||||
|
||||
Le resultat attendu est un module Sites utilisable de bout en bout cote admin (creer, editer, supprimer des sites et en assigner aux users), avec une API `/api/me` enrichie que le ticket 3 consommera pour alimenter le selecteur de site dans la navbar. Le ticket etablit le couplage Core → Sites **au niveau modele** (la table `user` gagne deux relations vers `site`) tout en conservant le contrat "desactiver Sites dans `config/modules.php` ne casse pas l'app" via des decisions DB/mapping assumees.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- Exposer `Site` comme ressource API Platform avec les operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`, securisees par les permissions `sites.view` (lecture) et `sites.manage` (ecriture).
|
||||
- Ajouter deux relations sur `User` (module Core) :
|
||||
- `$sites` (M2M, `user_site`) : sites autorises.
|
||||
- `$currentSite` (M2O nullable) : site actuellement selectionne.
|
||||
- Ajouter la relation inverse `$users` sur `Site` (non exposee API).
|
||||
- Generer la migration Doctrine creant la table `user_site` et la colonne `user.current_site_id` avec les bonnes strategies `ON DELETE` pour garantir les cascades attendues (suppression d'un site → `user_site` purge, `currentSite` mis a `NULL`).
|
||||
- Etendre `/api/me` pour exposer `sites: Site[]` et `currentSite: Site | null` en objets serialises (pas en IRI), via les groupes `me:read` sur `User` **et** sur `Site`.
|
||||
- Ajouter un endpoint dedie de switch du site courant, implemente comme une ressource API Platform virtuelle `CurrentSite` avec une operation `Patch uriTemplate: '/me/current-site'` et un processor dedie. Le processor garantit que le site cible fait partie des `sites` de l'utilisateur authentifie, sinon il leve une exception traduite en `403`.
|
||||
- Etendre `UserRbacProcessor` et l'operation `PATCH /api/users/{id}/rbac` pour accepter un champ `sites: string[]` (IRIs) en plus des roles et permissions directes. Cas limite : si le `currentSite` du user cible n'est plus dans la liste, le processor le bascule a `NULL`.
|
||||
- Etendre l'exception metier Core pour couvrir "site non autorise" via une nouvelle exception domaine `SiteNotAuthorizedException` placee dans le module Sites, traduite en `ForbiddenHttpException` au niveau API.
|
||||
- Ajouter l'entree sidebar `sidebar.admin.sites` filtree par `module: 'sites'` + `permission: 'sites.view'` dans `config/sidebar.php`, sous la section admin Core existante.
|
||||
- Livrer la page d'administration `/admin/sites` cote front (layer Nuxt `frontend/modules/sites/`) : DataTable + drawer creation/edition + modale suppression, alignee visuellement et structurellement sur `/admin/roles` et `/admin/users`.
|
||||
- Etendre le drawer `UserRbacDrawer.vue` (module Core) pour afficher et editer la liste des sites autorises d'un user via un multi-select.
|
||||
- Ajouter les fixtures : rattacher les 3 users existants (`admin`, `alice`, `bob`) a au moins un site et positionner un `currentSite` coherent.
|
||||
- Couverture de tests PHPUnit : CRUD `/api/sites`, endpoint `/me/current-site` (cas OK + 403), extension `/api/me`, cascade DB a la suppression d'un site, extension `UserRbacProcessor` (ajout/retrait sites, auto-reset currentSite).
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#03` : selecteur de site dans la navbar, persistance du site actif cote front, integration visuelle avec la couleur du site.
|
||||
- Ticket `#04` : filtrage metier par site (ex: bloquer l'acces aux ressources Commercial si l'user n'est pas rattache au site de la ressource).
|
||||
- Soft-delete des sites : non introduit.
|
||||
- Audit trail des modifications : hors scope.
|
||||
- Color picker avance : un input hex simple avec preview de la puce suffit.
|
||||
- Recherche / tri server-side sur `/api/sites` : non requis, le volume reste <20 sites par instance.
|
||||
- Gestion des site "globaux" ou "par defaut" pour les nouveaux users : non introduite, les users crees via `CreateUserCommand` ou `/api/users` POST auront `sites: []` et `currentSite: null` jusqu'a rattachement explicite.
|
||||
|
||||
## 3. Fichiers à créer
|
||||
|
||||
### Backend — Module Sites
|
||||
|
||||
- `/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/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/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/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
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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/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/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/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/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)
|
||||
|
||||
- `/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/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/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/Coltura/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
||||
|
||||
### Frontend — Types partages
|
||||
|
||||
- `/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)
|
||||
|
||||
- `/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
|
||||
|
||||
### Backend — Module Core
|
||||
|
||||
- `/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 ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
||||
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
||||
- 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).
|
||||
- `/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>`).
|
||||
- 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).
|
||||
- `/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).
|
||||
- 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`.
|
||||
|
||||
### Backend — Module Sites
|
||||
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` :
|
||||
- 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 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.
|
||||
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
[
|
||||
'label' => 'sidebar.core.sites',
|
||||
'to' => '/admin/sites',
|
||||
'icon' => 'mdi:domain',
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
```
|
||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `/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).
|
||||
- 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).
|
||||
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
||||
- `/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/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/Coltura/frontend/i18n/locales/fr.json` : cles
|
||||
- `sidebar.core.sites` = "Sites".
|
||||
- `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.form.{name, city, postalCode, color, fullAddress}`.
|
||||
- `admin.sites.delete.{title, message}`.
|
||||
- `admin.sites.toast.{created, updated, deleted}`.
|
||||
- `admin.users.drawer.sitesSection` = "Sites autorises".
|
||||
- `errors.sites.notAuthorized` = "Vous n'etes pas autorise a selectionner ce site.".
|
||||
|
||||
## 5. Schéma cible — ApiResource et Doctrine
|
||||
|
||||
### Entite `Site` — attributs ApiResource
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('sites.view')",
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('sites.view')",
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('sites.manage')",
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('sites.manage')",
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
),
|
||||
new Delete(security: "is_granted('sites.manage')"),
|
||||
],
|
||||
)]
|
||||
```
|
||||
|
||||
Groupes sur les proprietes de `Site` :
|
||||
- `id` : `site:read`, `me:read`.
|
||||
- `name`, `city`, `postalCode`, `color`, `fullAddress` : `site:read`, `site:write`, `me:read`.
|
||||
- `createdAt`, `updatedAt` : `site:read` uniquement (pas exposes en embed `me:read` pour garder le payload /me leger).
|
||||
|
||||
### Evolution de `User` — nouvelles relations
|
||||
|
||||
```php
|
||||
/** @var Collection<int, Site> */
|
||||
#[ORM\ManyToMany(targetEntity: Site::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_site')]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['me:read'])]
|
||||
private ?Site $currentSite = null;
|
||||
```
|
||||
|
||||
Justification fetch=EAGER :
|
||||
- Aligne sur les collections `$rbacRoles` et `$directPermissions` (cf. `User.php:87`).
|
||||
- Critique pour eviter un lazy-load silencieux pendant un refresh JWT (cf. ticket 343 section 11 risque 1).
|
||||
- Surcout SQL accepte a l'echelle d'un CRM PME (≤20 sites par instance).
|
||||
|
||||
### Relation inverse sur `Site`
|
||||
|
||||
```php
|
||||
/** @var Collection<int, User> */
|
||||
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
|
||||
private Collection $users;
|
||||
```
|
||||
|
||||
Pas de `#[Groups]` : la collection inverse n'est pas exposee dans la reponse API. Sa seule utilite est metier (compter les users d'un site, iterer pour un cascade applicatif si la cascade DB ne suffisait pas).
|
||||
|
||||
### Ressource virtuelle `CurrentSite`
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
shortName: 'CurrentSite',
|
||||
operations: [
|
||||
new Patch(
|
||||
uriTemplate: '/me/current-site',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
denormalizationContext: ['groups' => ['current-site:write']],
|
||||
processor: CurrentSiteProcessor::class,
|
||||
read: false,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class CurrentSiteResource
|
||||
{
|
||||
#[Groups(['current-site:write'])]
|
||||
public ?Site $site = null;
|
||||
}
|
||||
```
|
||||
|
||||
- `read: false` : API Platform ne tente pas de charger une entite existante via un Provider — il se contente de denormaliser le body et de passer la ressource au processor.
|
||||
- `shortName: 'CurrentSite'` : evite la collision de nommage avec l'entite `Site`.
|
||||
- `security: "is_granted('ROLE_USER')"` : tout user authentifie peut tenter un switch ; l'autorisation fine (appartenance du site aux `sites` du user) est verifiee par le processor, pas par la voter RBAC.
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
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
|
||||
|
||||
1. `ALTER TABLE "user" ADD current_site_id INT DEFAULT NULL` — colonne nullable, pas besoin de backfill.
|
||||
2. `CREATE TABLE user_site (user_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (user_id, site_id))`.
|
||||
3. `CREATE INDEX IDX_user_site_user ON user_site (user_id)`.
|
||||
4. `CREATE INDEX IDX_user_site_site ON user_site (site_id)`.
|
||||
5. `ALTER TABLE user_site ADD CONSTRAINT FK_user_site_user FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE`.
|
||||
6. `ALTER TABLE user_site ADD CONSTRAINT FK_user_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE`.
|
||||
7. `CREATE INDEX IDX_user_current_site ON "user" (current_site_id)`.
|
||||
8. `ALTER TABLE "user" ADD CONSTRAINT FK_user_current_site FOREIGN KEY (current_site_id) REFERENCES site (id) ON DELETE SET NULL`.
|
||||
|
||||
### `down()` — rollback
|
||||
|
||||
1. `ALTER TABLE "user" DROP CONSTRAINT FK_user_current_site`.
|
||||
2. `DROP INDEX IDX_user_current_site`.
|
||||
3. `ALTER TABLE "user" DROP current_site_id`.
|
||||
4. `ALTER TABLE user_site DROP CONSTRAINT FK_user_site_site`.
|
||||
5. `ALTER TABLE user_site DROP CONSTRAINT FK_user_site_user`.
|
||||
6. `DROP TABLE user_site`.
|
||||
|
||||
### Comportement des cascades
|
||||
|
||||
| Action | Effet |
|
||||
|--------|-------|
|
||||
| `DELETE FROM site WHERE id = X` | Toutes les lignes `user_site` avec `site_id = X` sont supprimees (FK `ON DELETE CASCADE`). Tous les users avec `current_site_id = X` voient leur `current_site_id` passer a `NULL` (FK `ON DELETE SET NULL`). |
|
||||
| `DELETE FROM "user" WHERE id = Y` | Toutes les lignes `user_site` avec `user_id = Y` sont supprimees. Pas d'effet sur `site`. |
|
||||
| `DELETE FROM user_site WHERE user_id = Y AND site_id = X` | Aucun effet auto sur `user.current_site_id` — si `X` etait le courant de `Y`, c'est le **UserRbacProcessor** qui doit le basculer a `NULL` en Php (cf. section 8). |
|
||||
|
||||
**Important** : la derniere ligne du tableau est la raison pour laquelle la logique de "retirer un site qui etait le courant remet currentSite a null" vit dans `UserRbacProcessor` cote applicatif et non dans la DB via un trigger. C'est un compromis assume : la regle est metier ("retirer un droit ne doit pas laisser l'user pointer sur un site interdit"), pas purement DB.
|
||||
|
||||
## 7. Algorithme du switch de site courant — `CurrentSiteProcessor`
|
||||
|
||||
### Entree
|
||||
|
||||
Body JSON envoye par le client :
|
||||
```json
|
||||
{ "site": "/api/sites/3" }
|
||||
```
|
||||
|
||||
API Platform denormalise vers `CurrentSiteResource { site: Site }` en resolvant l'IRI via son `IriConverter`.
|
||||
|
||||
### Algorithme
|
||||
|
||||
1. Recuperer l'user authentifie via `Security::getUser()`. Si absent → `LogicException` (l'operation exige `ROLE_USER`, ne doit pas arriver).
|
||||
2. Extraire `$targetSite = $resource->site`. Si `null` → `BadRequestHttpException('Le champ "site" est requis.')`.
|
||||
3. Verifier `$user->hasSite($targetSite)` :
|
||||
- Implementation : `$this->sites->contains($targetSite)` (comparaison par reference ; Doctrine garantit l'identite d'objet dans la meme session).
|
||||
- Si `false` → throw `SiteNotAuthorizedException($targetSite->getId())`.
|
||||
4. `$user->setCurrentSite($targetSite)`.
|
||||
5. `$this->entityManager->flush()`.
|
||||
6. Retourner `$user` — API Platform le normalise via les groupes `me:read` definis sur l'operation.
|
||||
|
||||
### Mapping d'exception
|
||||
|
||||
`SiteNotAuthorizedException` est convertie en `Symfony\Component\HttpKernel\Exception\HttpException` avec statut `403` par `SiteNotAuthorizedExceptionListener` (event `kernel.exception`, priority standard). Le corps de la reponse porte un code i18n-able `errors.sites.notAuthorized` pour le front.
|
||||
|
||||
## 8. Évolution du `UserRbacProcessor`
|
||||
|
||||
### Nouveau champ en entree
|
||||
|
||||
Le payload accepte desormais :
|
||||
```json
|
||||
{
|
||||
"isAdmin": false,
|
||||
"roles": ["/api/roles/2"],
|
||||
"directPermissions": [],
|
||||
"sites": ["/api/sites/1", "/api/sites/3"]
|
||||
}
|
||||
```
|
||||
|
||||
Le champ `sites` est optionnel : si absent, la collection n'est pas touchee (comportement PATCH standard). Si present, il remplace integralement la collection `$user->sites`.
|
||||
|
||||
### Garde "currentSite coherent"
|
||||
|
||||
Apres application des champs par le persist processor decore, `UserRbacProcessor` execute un controle final :
|
||||
|
||||
```php
|
||||
$currentSite = $data->getCurrentSite();
|
||||
if ($currentSite !== null && !$data->hasSite($currentSite)) {
|
||||
$data->setCurrentSite(null);
|
||||
}
|
||||
```
|
||||
|
||||
Justification : si un admin retire un site qui etait le `currentSite` de la cible, le modele serait incoherent (currentSite pointant vers un site non autorise). Le processor corrige automatiquement.
|
||||
|
||||
**Variante rejetee** : basculer vers "le premier site restant" plutot que `null`. Rejetee car :
|
||||
- "Premier restant" n'a pas de semantique metier claire (ordre de la collection non garanti strict).
|
||||
- `null` est une valeur deja supportee (user sans site courant) et explicite : le front du ticket 3 devra gerer ce cas de toute facon.
|
||||
|
||||
### Ordre d'execution dans le processor
|
||||
|
||||
1. Gardes auto-suicide admin + dernier admin global (code existant, inchange).
|
||||
2. `$this->persistProcessor->process($data, ...)` — applique tous les champs (roles, permissions directes, **sites**).
|
||||
3. Post-persist : controle coherence currentSite (code ajoute par ce ticket), flush si changement.
|
||||
4. Retour du user.
|
||||
|
||||
## 9. Fixtures — évolution de `AppFixtures`
|
||||
|
||||
`AppFixtures` devient dependant de `SitesFixtures` (inversion du "pas de dependance dure" declare au ticket 1 — justifie par le passage fonctionnel a la relation User ↔ Site).
|
||||
|
||||
```php
|
||||
class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [SitesFixtures::class];
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Dans `load()`, apres la creation des users et avant le `flush` final :
|
||||
|
||||
```php
|
||||
$chatellerault = $this->siteRepository->findByName('Chatellerault');
|
||||
$saintJean = $this->siteRepository->findByName('Saint-Jean');
|
||||
$pommevic = $this->siteRepository->findByName('Pommevic');
|
||||
|
||||
$admin->addSite($chatellerault);
|
||||
$admin->addSite($saintJean);
|
||||
$admin->addSite($pommevic);
|
||||
$admin->setCurrentSite($chatellerault);
|
||||
|
||||
$alice->addSite($chatellerault);
|
||||
$alice->setCurrentSite($chatellerault);
|
||||
|
||||
$bob->addSite($saintJean);
|
||||
$bob->setCurrentSite($saintJean);
|
||||
```
|
||||
|
||||
Le repository `SiteRepositoryInterface` est injecte dans le constructeur.
|
||||
|
||||
**Regle** : les 3 sites sont deja en base au moment ou `AppFixtures::load()` s'execute grace a `getDependencies()`. Si `findByName` retourne `null`, c'est une misconfiguration qui doit faire echouer fort (assertion via `\assert`).
|
||||
|
||||
## 10. Frontend — Page `/admin/sites`
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
frontend/modules/sites/
|
||||
├── nuxt.config.ts # marker layer Nuxt (vide)
|
||||
├── pages/
|
||||
│ └── admin/
|
||||
│ └── sites.vue # page listing
|
||||
└── components/
|
||||
├── SiteDrawer.vue # creation/edition
|
||||
└── SiteDeleteModal.vue # confirmation suppression
|
||||
```
|
||||
|
||||
### `pages/admin/sites.vue` — pattern
|
||||
|
||||
Aligne sur `frontend/modules/core/pages/admin/roles.vue` :
|
||||
- En-tete : titre + bouton `Nouveau site` (visible si `can('sites.manage')`).
|
||||
- `MalioDataTable` : colonnes `name`, `city`, `postalCode`, `color` (slot custom pour la puce), `fullAddress` (tronque).
|
||||
- Row click → ouvre `SiteDrawer` en mode edition si `can('sites.manage')`, sinon pas de clic (row-clickable guard).
|
||||
- `SiteDrawer` emet `saved` → reload de la liste, et `delete` → ouvre `SiteDeleteModal`.
|
||||
- `SiteDeleteModal` → DELETE `/api/sites/{id}` + reload.
|
||||
|
||||
### `components/SiteDrawer.vue`
|
||||
|
||||
Formulaire a 5 champs + preview de la couleur. Pattern `RoleDrawer.vue` :
|
||||
- `MalioInputText` pour `name`, `city`, `postalCode`.
|
||||
- `MalioInputText` pour `color` avec preview : une puce `<span>` 24×24 arrondie affichant la couleur en temps reel a cote du champ. Valider localement via regex avant submit (ne pas envoyer un hex invalide au backend).
|
||||
- `MalioInputTextArea` pour `fullAddress`.
|
||||
- Bouton save (variant primary), bouton delete (variant danger, visible uniquement en mode edition, **aucune garde system comme pour les roles** — tous les sites sont supprimables), bouton cancel (variant tertiary).
|
||||
|
||||
### `components/SiteDeleteModal.vue`
|
||||
|
||||
Pattern `RoleDeleteModal.vue` :
|
||||
- Modal avec message "Supprimer le site {name} ? Cette action est irreversible et retirera ce site a tous les utilisateurs rattaches."
|
||||
- Bouton cancel (secondary) + bouton delete (danger avec icone poubelle).
|
||||
- Emet `confirm` au clic delete.
|
||||
|
||||
### Extension de `UserRbacDrawer.vue`
|
||||
|
||||
Ajout d'une nouvelle section entre "Permissions directes" et "Resume des permissions effectives" :
|
||||
|
||||
```vue
|
||||
<!-- Section Sites autorises -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.sitesSection') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="site in allSites"
|
||||
:key="site.id"
|
||||
:id="`site-${site.id}`"
|
||||
:label="site.name"
|
||||
:model-value="selectedSiteIds.has(site.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Chargement : ajout a `loadData()` d'un `api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false })`.
|
||||
|
||||
Le `PATCH /api/users/{id}/rbac` envoie desormais `sites: Array.from(selectedSiteIds).map(id => `/api/sites/${id}`)`.
|
||||
|
||||
### Types TypeScript
|
||||
|
||||
`frontend/shared/types/sites.ts` :
|
||||
|
||||
```ts
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
city: string
|
||||
postalCode: string
|
||||
color: string
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
export interface SiteInput {
|
||||
name: string
|
||||
city: string
|
||||
postalCode: string
|
||||
color: string
|
||||
fullAddress: string
|
||||
}
|
||||
```
|
||||
|
||||
`frontend/shared/types/rbac.ts` : ajouter `sites: string[]` (IRIs) dans `UserListItem`.
|
||||
|
||||
`frontend/shared/types/` (fichier utilisateur courant, probablement `user.ts` ou expose dans l'auth store) : ajouter `sites: Site[]` et `currentSite: Site | null` sur le type expose via `/api/me`.
|
||||
|
||||
### Sidebar
|
||||
|
||||
Entree ajoutee dans `config/sidebar.php` (cf. section 4). Le `SidebarProvider` filtre deja par `module` actif et par `permission`, aucune modification backend nouvelle.
|
||||
|
||||
i18n :
|
||||
```json
|
||||
"sidebar": {
|
||||
"core": {
|
||||
"sites": "Sites"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Plan de tests PHPUnit
|
||||
|
||||
### `SiteApiTest` — CRUD `/api/sites`
|
||||
|
||||
1. `testAdminCanListSites` : admin → 200, 3 sites.
|
||||
2. `testUserWithSitesViewCanListSites` : user avec `sites.view` → 200.
|
||||
3. `testUserWithoutPermissionGetsForbidden` : user sans `sites.view` → 403.
|
||||
4. `testAdminCanCreateSite` : POST → 201, site present en base.
|
||||
5. `testAdminCanPatchSite` : PATCH `color` → 200.
|
||||
6. `testAdminCanDeleteSite` : DELETE → 204, site absent en base.
|
||||
7. `testUserWithViewButNotManageCannotDelete` : user avec `sites.view` mais pas `sites.manage` → 403 sur DELETE.
|
||||
8. `testCreateSiteWithDuplicateNameReturns422` : collision `uniq_site_name` → 422 avec message UniqueEntity.
|
||||
9. `testCreateSiteWithInvalidColorReturns422` : validation regex → 422.
|
||||
|
||||
### `CurrentSiteSwitchApiTest` — PATCH `/me/current-site`
|
||||
|
||||
1. `testUserCanSwitchToAuthorizedSite` : alice a `Chatellerault` dans ses sites → PATCH OK, 200, `currentSite.name == 'Chatellerault'`.
|
||||
2. `testUserCannotSwitchToUnauthorizedSite` : alice n'a pas `Pommevic` dans ses sites → PATCH → 403, pas de modification en base.
|
||||
3. `testSwitchWithMissingSiteFieldReturns400` : body `{}` → 400.
|
||||
4. `testSwitchWithInvalidIriReturns400` : body `{"site": "/api/sites/99999"}` (site inexistant) → 400 ou 404 (selon API Platform).
|
||||
5. `testAnonymousUserCannotSwitch` : client non authentifie → 401.
|
||||
|
||||
### `MeEndpointSitesTest` — extension `/api/me`
|
||||
|
||||
1. `testMeExposesSitesAsObjects` : alice → `sites[0]` est un objet avec `id`, `name`, `city`, ... (pas une string IRI).
|
||||
2. `testMeExposesCurrentSiteAsObject` : alice → `currentSite` est un objet, pas `null`.
|
||||
3. `testUserWithoutSitesHasEmptyArrayAndNullCurrent` : creer un user jetable sans sites → `sites: []`, `currentSite: null`.
|
||||
|
||||
### `SiteCascadeTest` — cascade DB a la suppression
|
||||
|
||||
1. `testDeletingSitePurgesUserSiteRows` : supprimer `Chatellerault` → les users qui l'avaient dans `sites` ne l'ont plus.
|
||||
2. `testDeletingSiteSetsCurrentSiteToNullOnReferencingUsers` : alice.currentSite = `Chatellerault`, supprimer `Chatellerault` → alice.currentSite = `null`.
|
||||
|
||||
### `UserRbacSitesApiTest` — extension `/rbac`
|
||||
|
||||
1. `testAdminCanAssignSitesToUser` : PATCH `/users/{alice}/rbac` avec `sites: ["/api/sites/2"]` → alice a desormais 1 site (`Saint-Jean`), plus `Chatellerault`.
|
||||
2. `testRemovingCurrentSiteResetsCurrentSiteToNull` : alice.currentSite = `Chatellerault`, PATCH avec `sites: []` → alice.currentSite = `null`.
|
||||
3. `testEmptySitesPayloadReplacesCollection` : alice avait 1 site, PATCH avec `sites: []` → 0 site.
|
||||
4. `testSitesPayloadWithDuplicateIrisIsAccepted` : PATCH avec `sites: ["/api/sites/1", "/api/sites/1"]` → 1 seul site (dedoublonnage via `ArrayCollection::contains`).
|
||||
|
||||
### Tests fixtures (sanity check)
|
||||
|
||||
Dans `AbstractApiTestCase` ou dans un test dedie `FixturesIntegrityTest` : verifier apres `make test-db-setup` que les 3 users fixtures ont bien leurs sites attendus. Evite qu'un renommage dans la fixture passe inapercu.
|
||||
|
||||
## 12. Risques et points d'attention
|
||||
|
||||
### Risque 1 — Couplage Core → Sites au niveau code PHP
|
||||
|
||||
L'ajout de `use App\Module\Sites\Domain\Entity\Site;` dans `User.php` introduit une dependance directe du module Core vers le module Sites. Consequence :
|
||||
|
||||
- **Desactiver `SitesModule::class` dans `config/modules.php` n'empeche pas Doctrine de charger le mapping `Site` ni `User`**, grace au caractere inconditionnel des mappings declares dans `doctrine.yaml` (choix assume ticket 1).
|
||||
- En revanche, la contrainte forte introduite ici est que **la table `site` doit exister** pour que la table `user` puisse etre creee (FK `user.current_site_id → site.id`). Si la migration Sites (ticket 1) n'a pas ete jouee, la migration de ce ticket echouera.
|
||||
- Conclusion : Sites n'est plus "optionnel au sens strict" apres ce ticket. Le declarer `REQUIRED = false` dans `SitesModule` reste vrai du point de vue de l'activation fonctionnelle (exposer les permissions et la sidebar), mais faux du point de vue DB. **A documenter explicitement dans le docblock de `SitesModule::REQUIRED`** au moment de ce ticket.
|
||||
|
||||
### Risque 2 — Cascade DB vs regle applicative
|
||||
|
||||
La cascade `user_site` → `ON DELETE CASCADE` gere la suppression d'un site, mais **n'est pas triggered** quand on retire un site d'un user (DELETE d'une ligne `user_site` uniquement). Dans ce cas, `user.current_site_id` peut rester pointe vers un site que l'user n'a plus — etat incoherent qui serait masque au niveau DB mais visible a l'usage.
|
||||
|
||||
La correction vit dans `UserRbacProcessor` (cf. section 8). Si un autre chemin applicatif modifie `user.sites` sans passer par ce processor (ex: une commande console custom), il devra dupliquer cette garde. **Point d'attention a consigner dans le docblock de `User::addSite()` / `User::removeSite()`** : "apres modification, verifier la coherence de `currentSite`".
|
||||
|
||||
### Risque 3 — Ressource virtuelle et routing API Platform
|
||||
|
||||
Le choix d'une ressource virtuelle `CurrentSite` avec `uriTemplate: '/me/current-site'` est fragile : si un futur ticket introduit une autre operation sur une URI qui commence par `/me/`, il faut verifier que le routing API Platform n'entre pas en conflit. Le pattern `priority: 1` (cf. `CLAUDE.md` section Backend) est recommande par prevention sur l'operation Patch. A valider par un test fonctionnel qui appelle explicitement `/api/me` (GET) et `/api/me/current-site` (PATCH) dans le meme scenario.
|
||||
|
||||
### Risque 4 — EAGER loading et payload `/api/me`
|
||||
|
||||
`User` a deja 3 collections EAGER (`$rbacRoles`, `$directPermissions`, plus les `permissions` de chaque role). Ajouter `$sites` (EAGER M2M) et `$currentSite` (EAGER M2O) augmente la taille du payload `/api/me` et le nombre de requetes SQL a chaque auth.
|
||||
|
||||
Mesure : apres implementation, verifier via le profiler Symfony que le nombre de requetes SQL sur `/api/me` reste raisonnable (≤ 6-8). Si >10, envisager une projection custom (cf. ticket 343 discussion `findForSecurity`). Pas bloquant dans ce ticket, mais a reverifier.
|
||||
|
||||
### Risque 5 — Tests fixtures-dependents
|
||||
|
||||
Les tests API existants (`UserApiTest`, `RoleApiTest`) s'appuient sur les users fixtures. L'evolution de `AppFixtures` (ajout de sites aux 3 users) modifie l'etat initial de la DB de test. Verifier que les tests existants continuent de passer (chaines d'assertions du type "user a 1 role" ne doivent pas casser). En particulier :
|
||||
- Les tests qui comptent les lignes d'une collection `member[]` sur `/api/users` peuvent voir le payload grossir (sites et currentSite ajoutes).
|
||||
- Les tests qui assertent sur la forme stricte d'un user (snapshot-like) devront etre adapter.
|
||||
|
||||
### Risque 6 — Serialisation infinie User ↔ Site
|
||||
|
||||
`User::$sites` expose `Site` en `me:read`. `Site::$users` est la collection inverse. Si un jour `Site::$users` recevait le groupe `me:read`, la serialisation entrerait dans une boucle infinie (User → sites → users → sites → ...). **Garde** : `Site::$users` ne doit **jamais** porter de `#[Groups]`. A verifier par un test qui serialise `/api/me` et asserte qu'aucun `Site` renvoye ne contient de cle `users`.
|
||||
|
||||
### Risque 7 — Pas de recours si l'utilisateur se retire tous ses sites
|
||||
|
||||
Le ticket autorise un user sans sites (`sites: []`, `currentSite: null`). Mais aucune garde ne l'empeche de se retirer tous ses sites via `/api/users/{mon_id}/rbac` si il porte `sites.manage`. Consequence : l'user se retrouve bloque sur l'app si le ticket 3 rend un site actif obligatoire pour naviguer. Compromis assume pour ce ticket : on ne bloque pas l'auto-retrait (coherence avec le pattern du ticket RBAC — l'auto-retrait admin est bloque, mais pas le reste). **A reevaluer au ticket 3** si le selecteur de navbar devient bloquant.
|
||||
|
||||
## 13. Ordre d'exécution recommandé
|
||||
|
||||
1. **Schema backend** — modifier `User.php` (ajout `$sites`, `$currentSite`, `$users` inverse sur `Site`). Ajouter attributs `ApiResource` sur `Site`.
|
||||
2. **Configuration** — aucun changement requis a `doctrine.yaml` ni `services.yaml` ni `modules.php`.
|
||||
3. **Migration** — ecrire `Version<timestamp2>.php` racine. Jouer `make migration-migrate`.
|
||||
4. **Fixtures** — modifier `AppFixtures` pour dependre de `SitesFixtures` et rattacher les users. Jouer `make fixtures && make sync-permissions`.
|
||||
5. **Endpoint CRUD sites** — verifier via `curl`/Postman que `GET /api/sites`, `POST /api/sites` etc. repondent avec les bonnes protections RBAC.
|
||||
6. **Endpoint switch** — creer `CurrentSiteResource`, `CurrentSiteProcessor`, `SiteNotAuthorizedException`, `SiteNotAuthorizedExceptionListener`. Tester via `curl`.
|
||||
7. **Extension MeProvider** — tester via `curl /api/me` que `sites` et `currentSite` apparaissent comme objets. Aucun code a changer dans `MeProvider` lui-meme, le travail est 100% fait via les groupes.
|
||||
8. **Extension UserRbacProcessor** — ajouter le champ `sites` et la garde `currentSite`. Tests d'integration.
|
||||
9. **Tests API** — ecrire et faire passer les 5 suites de tests decrites section 11.
|
||||
10. **Sidebar** — ajouter l'entree dans `config/sidebar.php` + cle i18n.
|
||||
11. **Frontend — types** — creer `shared/types/sites.ts`, etendre `shared/types/rbac.ts` et les types user.
|
||||
12. **Frontend — page admin** — creer `modules/sites/nuxt.config.ts`, `pages/admin/sites.vue`, `SiteDrawer.vue`, `SiteDeleteModal.vue`.
|
||||
13. **Frontend — extension UserRbacDrawer** — ajouter la section sites.
|
||||
14. **Frontend — i18n** — completer `fr.json`.
|
||||
15. **Validation end-to-end** — clique-droit sur chaque scenario UX : creer un site, l'editer, le supprimer, assigner sites a un user, switcher le site courant de l'user authentifie.
|
||||
16. **Tests front (si Vitest du ticket)** — smoke test du rendu de `/admin/sites`.
|
||||
17. **CS fixer** — `make php-cs-fixer-allow-risky` sur tous les fichiers touches.
|
||||
18. **DoD** — valider les 10 criteres section 14.
|
||||
|
||||
## 14. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `GET /api/sites`, `GET /api/sites/{id}` retournent 200 pour un user avec `sites.view`, 403 sinon.
|
||||
- [ ] `POST /api/sites`, `PATCH /api/sites/{id}`, `DELETE /api/sites/{id}` retournent le code attendu pour un user avec `sites.manage`, 403 sinon.
|
||||
- [ ] `GET /api/me` retourne `sites: Site[]` (objets complets) et `currentSite: Site | null`, avec les 3 sites pour `admin`, 1 pour `alice`, 1 pour `bob`.
|
||||
- [ ] `PATCH /api/me/current-site` avec un site autorise → 200, `currentSite` mis a jour. Avec un site non autorise → 403.
|
||||
- [ ] `DELETE /api/sites/{id}` cascade correctement : les lignes `user_site` sont purgees, les `current_site_id` pointant dessus repassent a `NULL`.
|
||||
- [ ] `PATCH /api/users/{id}/rbac` accepte le champ `sites` ; retirer le `currentSite` de la liste le bascule a `null`.
|
||||
- [ ] Page `/admin/sites` : liste, creation, edition, suppression fonctionnelles.
|
||||
- [ ] `UserRbacDrawer.vue` : section "Sites autorises" visible et fonctionnelle.
|
||||
- [ ] Sidebar : entree "Sites" visible pour un user avec `sites.view`, masquee sinon. Disparait si `SitesModule::class` est retire de `config/modules.php`.
|
||||
- [ ] `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.
|
||||
- [ ] 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).
|
||||
542
docs/sites/ticket-03-spec.md
Normal file
542
docs/sites/ticket-03-spec.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# Ticket #03 — 3/4 — Barre de sélection de site (navbar horizontale)
|
||||
|
||||
## 0. Pivots post-implémentation (2026-04-20)
|
||||
|
||||
Écarts assumés entre la spec initiale (écrite avant exploration de la lib) et
|
||||
le code livré après implémentation et test visuel. À lire en premier pour
|
||||
comprendre les divergences lors de la relecture.
|
||||
|
||||
1. **Contraste texte auto supprimé, texte blanc forcé conforme Figma.**
|
||||
La spec (sections 5, 6, 10) prévoyait un calcul de luminance WCAG pour
|
||||
décider entre texte noir et blanc sur chaque tile. Après test visuel, le
|
||||
choix design retenu est d'imposer **texte blanc partout** (default Malio
|
||||
`text-white font-bold uppercase tracking-wide`). Conséquence : charge à
|
||||
l'admin de choisir des couleurs de site suffisamment foncées pour que le
|
||||
blanc reste lisible. Les utilitaires `parseHex`, `getRelativeLuminance`,
|
||||
`getReadableTextColor` ont été supprimés comme code mort. Seul
|
||||
`isValidSiteColor(hex)` reste dans `shared/utils/color.ts` (consommé par
|
||||
`SiteDrawer`).
|
||||
|
||||
2. **Taille texte explicite `text-2xl` (24 px) appliquée via `labelClass`.**
|
||||
Malio applique `font-bold uppercase tracking-wide` sans taille explicite.
|
||||
Le wrapper `SiteSelector.vue` passe `labelClass="text-2xl"` pour garantir
|
||||
les 24 px de la maquette Figma.
|
||||
|
||||
3. **A11y : `ariaGroupLabel` au niveau radiogroup** au lieu de
|
||||
`ariaLabelActive` / `ariaLabelInactive` par tile. La raison : Malio rend
|
||||
déjà un `role="radio"` avec `aria-checked` par tile — le lecteur d'écran
|
||||
annonce "bouton radio coché/non coché" + le nom visible. Ajouter un
|
||||
`aria-label` par tile aurait dupliqué l'info et alourdi sans bénéfice.
|
||||
Le seul ajout nécessaire était un label au groupe, fait via
|
||||
`:aria-label="t('sites.selector.ariaGroupLabel')"` sur `MalioSiteSelector`.
|
||||
|
||||
4. **Auto-détection composables des layers dans `nuxt.config.ts`.**
|
||||
Pas prévu dans la spec. Ajouté car `imports.dirs` explicite override les
|
||||
auto-imports par défaut de Nuxt pour les composables de layer. Sans ça,
|
||||
`useCurrentSite` n'est pas résolu par Nuxt. Scan dynamique aligné sur le
|
||||
pattern `moduleLayers` existant.
|
||||
|
||||
5. **Couleurs fixtures finales :** `#056CF2` (Châtellerault), `#F3CB00`
|
||||
(Saint-Jean), `#74BF04` (Pommevic). Choix client post-maquette.
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket livre l'UI de consommation du module Sites pour l'utilisateur final : une barre horizontale en haut de l'application qui liste les sites autorises de l'utilisateur connecte, met en avant le site courant et permet de basculer d'un site a l'autre en un clic.
|
||||
|
||||
Le ticket consomme la donnee posee par le ticket 2 (`/api/me` expose `sites` et `currentSite`, `PATCH /api/me/current-site` permet le switch) et s'appuie sur un nouveau composant `MalioSiteSelector` fourni par la version a jour de `@malio/layer-ui`.
|
||||
|
||||
Resultat attendu : apres merge, un user avec ≥ 1 site voit une barre sous la navbar horizontale ; un clic sur un site non actif le rend actif, change l'etat global, et est persiste cote serveur.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- **Upgrade** de `@malio/layer-ui` (actuellement `^1.3.0`) vers la version contenant `MalioSiteSelector`. La signature exacte du composant (props, slots, events) doit etre lue dans `node_modules/@malio/layer-ui/COMPONENTS.md` apres installation — la spec decrit le contrat attendu, le developpeur adapte selon l'API reelle (cf. Risque 1).
|
||||
- Ajouter les champs `sites: Site[]` et `currentSite: Site | null` dans le type `UserData` (`frontend/shared/types/user-data.ts`) pour refleter le payload `/api/me` enrichi au ticket 2.
|
||||
- Ajouter le type partage `Site` dans `frontend/shared/types/sites.ts` (deja cree au ticket 2, sinon a creer).
|
||||
- Creer le composable `useCurrentSite()` dans `frontend/modules/sites/composables/` qui expose `currentSite`, `availableSites`, `switchSite(site)`, `resetCurrentSite()`. Pattern aligne sur `useSidebar()`.
|
||||
- Creer le composable `useModules()` dans `frontend/shared/composables/` qui consomme `/api/modules` et expose `isModuleActive(id: string)`. Necessaire car `isModuleActive` est requis par le ticket mais n'existe pas encore cote front.
|
||||
- Creer `SiteSelector.vue` dans `frontend/modules/sites/components/` : wrapper fin autour de `MalioSiteSelector` qui branche le composable `useCurrentSite()`, gere l'optimistic update avec rollback, emet un toast de succes/erreur.
|
||||
- Integrer le selecteur dans `frontend/app/layouts/default.vue` — render conditionnel sur `isModuleActive('sites') && user.sites.length > 0`.
|
||||
- Appeler `resetCurrentSite()` au logout (`frontend/modules/core/pages/logout.vue`), aligne sur `resetSidebar()` deja present.
|
||||
- Gestion du **contraste automatique** : le texte du bloc passe en noir ou en blanc selon la luminance de `site.color`. Fonction utilitaire `getReadableTextColor(hex: string): 'black' | 'white'` dans `frontend/shared/utils/color.ts` (nouveau fichier utilitaire partage).
|
||||
- Accessibilite : chaque bloc est un `<button>` natif avec `aria-pressed` sur le site courant, focus visible (ring Tailwind), navigation clavier Tab + Enter fonctionnelle.
|
||||
- Responsive minimal : `flex-1` sur chaque bloc avec `min-w-[200px]` et `overflow-x-auto` sur le conteneur pour les cas 4+ sites sur petits ecrans.
|
||||
- Tests Vitest : unite sur `useCurrentSite` (switch, rollback, reset), unite sur `getReadableTextColor`, smoke test sur `SiteSelector.vue` (rendu, emission du PATCH, rollback en cas d'echec).
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#04` : filtrage metier par site (ex: bloquer l'acces aux ressources Commercial si l'user n'est pas rattache au site cible). Le site courant est simplement un **contexte UX** dans ce ticket, aucune regle d'autorisation ne s'appuie encore dessus.
|
||||
- Modification du layout `auth.vue` (login) : le selecteur n'est **jamais** rendu hors session authentifiee. Le layout login reste inchange.
|
||||
- Persistance du site actif cote front (localStorage, cookies) : le backend est source de verite, le front ne cache pas independamment.
|
||||
- Gestion d'une image / d'un logo par site : les sites sont identifies par nom + couleur uniquement dans ce ticket.
|
||||
- Pre-mount du selecteur sans `/api/me` complet : le middleware `auth.global.ts` garantit deja que `auth.user` est resolu avant le rendu — pas besoin de gerer un etat "chargement" specifique dans le selecteur.
|
||||
- Validation cote back d'une couleur "trop claire" : non introduite. Le ticket 2 accepte `#FFFFFF`. La compensation est faite cote front via le calcul de contraste ; une contrainte back arrivera si un abus se materialise.
|
||||
|
||||
## 3. Fichiers à créer
|
||||
|
||||
### Frontend — Module Sites (layer deja cree au ticket 2)
|
||||
|
||||
- `/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/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
|
||||
|
||||
- `/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/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`.
|
||||
- `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).
|
||||
|
||||
### Frontend — 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).
|
||||
- Si la requete reussit, l'etat reste aligne.
|
||||
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
||||
- `resetCurrentSite` 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/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/Coltura/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/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/Coltura/frontend/shared/types/user-data.ts` : ajouter les champs
|
||||
```ts
|
||||
sites: Site[]
|
||||
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`.
|
||||
- `/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/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/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/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/Coltura/frontend/i18n/locales/fr.json` : ajouter les cles
|
||||
```json
|
||||
"sites": {
|
||||
"selector": {
|
||||
"switchSuccess": "Site courant change",
|
||||
"switchError": "Impossible de changer de site",
|
||||
"ariaLabelActive": "Site actif : {name}",
|
||||
"ariaLabelInactive": "Basculer sur le site {name}"
|
||||
}
|
||||
}
|
||||
```
|
||||
Ne **pas** mettre le nom du site en cle i18n : le nom est une donnee metier, pas un label.
|
||||
|
||||
## 5. Schéma cible — Composant `SiteSelector.vue`
|
||||
|
||||
### Render attendu (conforme Figma)
|
||||
|
||||
- Hauteur fixe : `h-[72px]`.
|
||||
- `width: 100%` (parent du `<main>` dans `layouts/default.vue`, donc occupe toute la zone a droite de la sidebar).
|
||||
- Flex horizontal, chaque bloc = `flex-1` avec `min-w-[200px]`.
|
||||
- Conteneur parent : `overflow-x-auto` pour scroll horizontal si 4+ sites sur ecran etroit.
|
||||
- Fond de chaque bloc : `site.color` (inline style car dynamique).
|
||||
- Texte : centre horizontalement et verticalement, `font-inter font-bold text-[24px] uppercase tracking-wide`, couleur calculee par `getReadableTextColor(site.color)`.
|
||||
- Opacite : `opacity-100` pour le site courant, `opacity-40` pour les autres.
|
||||
- Hover sur les inactifs : `hover:opacity-70 cursor-pointer transition-opacity`.
|
||||
- Focus clavier : `focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 focus:outline-none`.
|
||||
- Semantique : chaque bloc est un `<button type="button">` (pas `<div>`), avec :
|
||||
- `aria-pressed="true"` sur le site courant.
|
||||
- `aria-label` dynamique via i18n (`sites.selector.ariaLabelActive` ou `ariaLabelInactive`).
|
||||
|
||||
### Contrat du wrapper `SiteSelector.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MalioSiteSelector
|
||||
:sites="availableSites"
|
||||
:current-site-id="currentSite?.id"
|
||||
:disabled="switching"
|
||||
@switch="handleSwitch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { availableSites, currentSite, switching, switchSite } = useCurrentSite()
|
||||
|
||||
async function handleSwitch(siteId: number) {
|
||||
const target = availableSites.value.find(s => s.id === siteId)
|
||||
if (!target) return
|
||||
await switchSite(target)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Hypothese** : la signature exacte de `MalioSiteSelector` (nom du prop, nom de l'event) doit etre verifiee dans `@malio/layer-ui/COMPONENTS.md` apres upgrade. Si elle differe, adapter le wrapper sans toucher au composable. Le wrapper reste le seul point d'adherence a l'API externe.
|
||||
|
||||
Si `MalioSiteSelector` **n'embarque pas** le calcul de contraste texte, le wrapper doit le gerer en passant `:text-color` ou en injectant un style calcule. Si le composant delegue la couleur a un slot ou a un formatteur, ajuster l'appel.
|
||||
|
||||
### Composable `useCurrentSite()`
|
||||
|
||||
```ts
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const currentSite = ref<Site | null>(null)
|
||||
const availableSites = ref<Site[]>([])
|
||||
const switching = ref(false)
|
||||
|
||||
export function useCurrentSite() {
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Hydratation depuis le store auth — single source of truth
|
||||
function syncFromAuth() {
|
||||
availableSites.value = auth.user?.sites ?? []
|
||||
currentSite.value = auth.user?.currentSite ?? null
|
||||
}
|
||||
|
||||
async function switchSite(site: Site) {
|
||||
if (switching.value) return
|
||||
const previous = currentSite.value
|
||||
|
||||
// Optimistic update
|
||||
currentSite.value = site
|
||||
switching.value = true
|
||||
|
||||
try {
|
||||
await api.patch('/me/current-site', { site: `/api/sites/${site.id}` }, {
|
||||
toastSuccessMessage: t('sites.selector.switchSuccess'),
|
||||
})
|
||||
// Propage au store auth pour que tous les consommateurs soient alignes
|
||||
if (auth.user) {
|
||||
auth.user.currentSite = site
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback
|
||||
currentSite.value = previous
|
||||
throw error // useApi a deja toast l'erreur si toast est active
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetCurrentSite() {
|
||||
currentSite.value = null
|
||||
availableSites.value = []
|
||||
switching.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
currentSite,
|
||||
availableSites,
|
||||
switching,
|
||||
switchSite,
|
||||
resetCurrentSite,
|
||||
syncFromAuth,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern** : state singleton au niveau module (refs module-level), meme convention que `useSidebar()`. Le singleton est necessaire pour que le logout + les consommateurs multiples partagent le meme etat. `resetCurrentSite()` est appele explicitement au logout (cf. section 4).
|
||||
|
||||
**Hydratation** : `syncFromAuth()` est appele au mount de `SiteSelector.vue` (dans un `onMounted` ou un `watch` sur `auth.user`). Alternative : appeler dans `auth.global.ts` apres `ensureSession()`.
|
||||
|
||||
### Composable `useModules()`
|
||||
|
||||
Pattern strictement aligne sur `useSidebar()` (cf. `frontend/shared/composables/useSidebar.ts`) :
|
||||
|
||||
```ts
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
|
||||
}
|
||||
```
|
||||
|
||||
**Attention** : verifier la forme exacte de la reponse `/api/modules` via `curl /api/modules`. Les specs RBAC anterieurs suggerent `{ modules: string[] }` mais il faut valider.
|
||||
|
||||
## 6. Contraste automatique du texte
|
||||
|
||||
### Algorithme
|
||||
|
||||
Formule de luminance relative WCAG 2.1 (simplifiee) :
|
||||
|
||||
```ts
|
||||
function getRelativeLuminance({ r, g, b }: RGB): number {
|
||||
const [R, G, B] = [r, g, b].map(c => {
|
||||
const normalized = c / 255
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B
|
||||
}
|
||||
|
||||
export function getReadableTextColor(hex: string): 'black' | 'white' {
|
||||
const rgb = parseHex(hex)
|
||||
return getRelativeLuminance(rgb) > 0.5 ? 'black' : 'white'
|
||||
}
|
||||
```
|
||||
|
||||
Le seuil 0.5 est un compromis pragmatique : simple, lisible, pas parfait WCAG AAA mais suffisant pour distinguer blancs/jaunes pales (→ texte noir) des bleus/verts/rouges saturés (→ texte blanc).
|
||||
|
||||
### Integration dans le selecteur
|
||||
|
||||
Le composable ou le template calcule la couleur pour chaque site une seule fois :
|
||||
|
||||
```ts
|
||||
const textColorsBySiteId = computed(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const site of availableSites.value) {
|
||||
map.set(site.id, getReadableTextColor(site.color))
|
||||
}
|
||||
return map
|
||||
})
|
||||
```
|
||||
|
||||
Le template applique `:style="{ color: textColorsBySiteId.get(site.id) }"` sur chaque bloc, ou passe la map au composant `MalioSiteSelector` si son API l'accepte.
|
||||
|
||||
### Cas limite — hex invalide
|
||||
|
||||
`parseHex` leve une `Error` si le format ne matche pas `#[0-9A-Fa-f]{6}`. Au niveau du selecteur, le template entoure l'acces dans un try/catch logique : si un site a une couleur invalide (improbable car la regex backend du ticket 1 bloque), fallback a texte blanc.
|
||||
|
||||
## 7. Intégration dans `layouts/default.vue`
|
||||
|
||||
### Structure actuelle
|
||||
|
||||
```
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar ... />
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<main>...</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Structure cible
|
||||
|
||||
```
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar ... />
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector" />
|
||||
<main>...</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Script :
|
||||
```ts
|
||||
const auth = useAuthStore()
|
||||
const { isModuleActive } = useModules()
|
||||
|
||||
const showSiteSelector = computed(() =>
|
||||
isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
|
||||
)
|
||||
```
|
||||
|
||||
### Render conditionnel et flash
|
||||
|
||||
Le middleware `auth.global.ts` resout deja `auth.user` (via `ensureSession()`) avant le rendu des pages. Le middleware doit en plus declencher `loadModules()` pour que `isModuleActive` soit resolu au premier render. Sans ca, `showSiteSelector` sera `false` pendant un premier paint, puis `true` apres le chargement de `/api/modules` → flash visuel.
|
||||
|
||||
**Solution** : dans `auth.global.ts`, appeler `loadModules()` au meme niveau que `loadSidebar()`.
|
||||
|
||||
### Import statique vs dynamique
|
||||
|
||||
Deux options :
|
||||
- **Import statique** (`SiteSelector.vue` est toujours dans le bundle) : simple, le `v-if` gere l'affichage. Impact bundle minimal.
|
||||
- **Import dynamique** (`defineAsyncComponent`) : le composant n'est charge que si le module est actif. Plus propre au sens "desactiver Sites = zero code sites dans le bundle", mais le layer Nuxt rend le composant auto-importable → le code est deja dans le bundle de toute facon.
|
||||
|
||||
**Recommandation** : import statique. L'economie de bundle est marginale et le layer Nuxt charge deja tout le module.
|
||||
|
||||
## 8. i18n
|
||||
|
||||
### Clés ajoutées
|
||||
|
||||
```json
|
||||
{
|
||||
"sites": {
|
||||
"selector": {
|
||||
"switchSuccess": "Site courant change",
|
||||
"switchError": "Impossible de changer de site",
|
||||
"ariaLabelActive": "Site actif : {name}",
|
||||
"ariaLabelInactive": "Basculer sur le site {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Règles
|
||||
|
||||
- **Jamais** traduire le nom d'un site (`site.name`). C'est une donnee metier, affichee telle quelle. L'`uppercase` est applique en CSS (`text-transform: uppercase`), pas dans la donnee.
|
||||
- Les `aria-label` interpollent `{name}` directement.
|
||||
- `switchError` est consomme par le toast d'erreur de `useApi` si la route serveur renvoie un code non-2xx. Pour une erreur 403 "site non autorise" (cf. ticket 2), le serveur renvoie deja un message traduit ou un code i18n stable — a arbitrer au moment de l'implementation selon la decision prise au ticket 2.
|
||||
|
||||
## 9. Accessibilité
|
||||
|
||||
- Chaque bloc est un `<button type="button">` (pas un `<div>` avec `role="button"` — preferer la semantique native).
|
||||
- `aria-pressed="true"` sur le bloc du site courant, `aria-pressed="false"` sur les autres.
|
||||
- `aria-label` : l'uppercase CSS est visuel ; l'aria-label garde la casse originale du nom pour le screen reader (`aria-label="Site actif : Chatellerault"`).
|
||||
- Focus visible : `focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 focus:outline-none`.
|
||||
- Tab : parcourt les blocs de gauche a droite.
|
||||
- Enter / Espace : declenche le switch (comportement natif du `<button>`).
|
||||
- `tabindex="0"` n'est pas requis sur un `<button>` (deja focusable natif). Ne pas ajouter `tabindex="-1"` sur le bloc courant : l'user doit pouvoir revenir dessus.
|
||||
|
||||
## 10. Plan de tests
|
||||
|
||||
### Vitest — `useCurrentSite.spec.ts`
|
||||
|
||||
1. `switchSite met a jour currentSite localement immediatement` : avant la resolution de la promise, `currentSite.value` est deja le nouveau site.
|
||||
2. `switchSite persiste via /api/me/current-site` : mock `useApi`, verifier que la requete PATCH est appelee avec `site: '/api/sites/{id}'`.
|
||||
3. `switchSite rollback en cas d'erreur` : mock `useApi` pour rejeter, verifier que `currentSite.value` repasse a l'ancien site.
|
||||
4. `switchSite propagate au store auth apres succes` : `auth.user.currentSite` est mis a jour apres succes.
|
||||
5. `resetCurrentSite vide l'etat` : apres appel, `currentSite = null`, `availableSites = []`, `switching = false`.
|
||||
6. `switching est vrai pendant la requete, faux apres` : verifier le flag sur tout le cycle.
|
||||
7. `double switchSite concurrent est ignore` : si `switching = true`, un second appel retourne immediatement sans effet (garde anti-double-submit).
|
||||
|
||||
### Vitest — `useModules.spec.ts`
|
||||
|
||||
1. `loadModules charge /api/modules et alimente activeModuleIds`.
|
||||
2. `isModuleActive retourne true si l'id est present, false sinon`.
|
||||
3. `resetModules vide l'etat`.
|
||||
4. `loadModules swallow les erreurs et laisse activeModuleIds vide` (alignement avec `useSidebar`).
|
||||
|
||||
### Vitest — `color.spec.ts`
|
||||
|
||||
1. `getReadableTextColor('#FFFFFF') === 'black'`.
|
||||
2. `getReadableTextColor('#000000') === 'white'`.
|
||||
3. `getReadableTextColor('#056CF2') === 'white'` (bleu sature).
|
||||
4. `getReadableTextColor('#F59E0B') === 'black'` (ambre clair).
|
||||
5. `getReadableTextColor('#10B981') === 'white'` (vert medium-foncé). A verifier a l'implementation ; adapter l'assertion.
|
||||
6. `parseHex('red') → throw` (format invalide).
|
||||
7. `parseHex('#FFF') → throw` (hex court non supporte).
|
||||
8. `parseHex('#abcdef')` et `parseHex('#ABCDEF')` → meme resultat (tolere la casse).
|
||||
|
||||
### Vitest — `SiteSelector.spec.ts`
|
||||
|
||||
1. `Rendu : 3 sites rendus, bloc du site courant a opacity-100`.
|
||||
2. `Bloc inactif a opacity-40 et aria-pressed="false"`.
|
||||
3. `Clic sur un bloc inactif appelle switchSite avec le bon site`.
|
||||
4. `Si switchSite throw, l'UI affiche toujours l'ancien site courant` (via rollback).
|
||||
5. `Texte d'un site avec couleur claire (#FFFFFF) est rendu noir`.
|
||||
6. `Texte d'un site avec couleur foncee (#056CF2) est rendu blanc`.
|
||||
|
||||
### Tests PHPUnit
|
||||
|
||||
Pas de nouveau test backend dans ce ticket — le ticket 2 couvre deja l'endpoint `/api/me/current-site`. Si un comportement nouveau est introduit cote serveur (ce qui ne devrait pas arriver), ajouter les tests en consequence.
|
||||
|
||||
### Test visuel manuel
|
||||
|
||||
- `make dev-nuxt` (port 3004).
|
||||
- Login `admin` / `admin` → selecteur avec 3 blocs (Chatellerault actif, Saint-Jean et Pommevic a 40%).
|
||||
- Clic sur `Pommevic` → Pommevic devient actif (100%), Chatellerault passe a 40%, toast "Site courant change".
|
||||
- F5 → site actif persiste (Pommevic).
|
||||
- Logout puis re-login → Pommevic toujours actif.
|
||||
- Login `bob` / `bob` → un seul bloc (Saint-Jean), affiche par coherence (cf. regle metier "afficher meme pour 1 site").
|
||||
- Retirer tous les sites a `alice` via `/admin/users` → login alice → selecteur absent.
|
||||
- Desactiver `SitesModule::class` dans `config/modules.php`, restart backend, refresh front → selecteur absent, layout identique au comportement d'avant ce ticket.
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
### Risque 1 — Signature de `MalioSiteSelector` inconnue au moment de la spec
|
||||
|
||||
La version de `@malio/layer-ui` installee localement (1.3.0) ne contient pas `MalioSiteSelector`. La spec decrit le contrat attendu (props `sites`, `current-site-id`, event `switch`), mais la signature reelle est definie par la lib et peut differer (nom du prop, structure de l'event, slots disponibles, gestion du contraste texte).
|
||||
|
||||
**Mitigation** : apres `npm install` de la nouvelle version, consulter `node_modules/@malio/layer-ui/COMPONENTS.md` ou le fichier Vue du composant, adapter `SiteSelector.vue` (wrapper) sans toucher au composable `useCurrentSite()`. Le wrapper est le seul point d'adherence a l'API externe.
|
||||
|
||||
### Risque 2 — Flash au premier paint
|
||||
|
||||
Si `showSiteSelector` est `false` le temps de resoudre `/api/modules`, l'user voit le layout sans selecteur puis avec → flash desagreable. La solution est de bloquer le rendu sur `loaded.value` du composable modules dans le middleware `auth.global.ts` avant que le layout ne soit instancie.
|
||||
|
||||
A verifier apres implementation : ouvrir le devtools "Network throttling" en Slow 3G, login, observer. Si flash : ajouter une garde d'attente avant de rendre le layout ou utiliser un skeleton.
|
||||
|
||||
### Risque 3 — `auth.user` muté directement
|
||||
|
||||
Le composable `switchSite` mute `auth.user.currentSite = site` pour propager le changement au store auth. Pinia autorise cette mutation mais elle contourne les actions formelles. Alternative plus propre : ajouter une action `auth.setCurrentSite(site)` et l'appeler. Choix pragmatique dans cette spec → privilegier la mutation directe pour rester aligne sur le pattern existant (`auth.user.currentSite` est une propriete simple) ; si un reviewer prefere l'action formelle, c'est un changement localisé sans impact autre.
|
||||
|
||||
### Risque 4 — Composable singleton et tests
|
||||
|
||||
Les refs `currentSite`, `availableSites`, `switching` sont declarees au niveau module → partagees entre tous les appels a `useCurrentSite()`. En Vitest, cela fuit entre tests si on ne fait pas un `beforeEach(() => resetCurrentSite())`. A documenter en tete du fichier de tests pour eviter des bugs inter-tests.
|
||||
|
||||
### Risque 5 — Contraste texte et faux positifs
|
||||
|
||||
Le seuil de 0.5 sur la luminance peut donner des rendus sous-optimaux sur des couleurs "limite" (ex: vert emeraude `#10B981` a une luminance qui balance pres du seuil). Si un reviewer trouve le texte peu lisible en usage reel, deux options :
|
||||
- Raffiner le calcul : passer a la formule de contraste WCAG complete (ratio entre fond et texte, seuil a 4.5:1).
|
||||
- Contraindre la couleur a l'entree : ajouter une validation back (ticket 4 ?) qui rejette les couleurs trop claires si le texte noir donne < 4.5:1 de contraste.
|
||||
|
||||
Pour ce ticket, le seuil 0.5 suffit (fixtures testees : `#056CF2` bleu sombre → blanc, `#F59E0B` ambre clair → noir, `#10B981` vert → a voir ; l'admin peut toujours eviter les couleurs pales).
|
||||
|
||||
### Risque 6 — Debordement responsive avec 4+ sites
|
||||
|
||||
`flex-1` + `min-w-[200px]` + `overflow-x-auto` sur le conteneur gere le debordement de maniere acceptable. Mais sur ecran tres etroit (tablette portrait 768px) avec 4 sites a 200px chacun, le user doit scroller horizontalement — experience sous-optimale.
|
||||
|
||||
Alternative : `flex-wrap` + `h-auto` pour laisser les blocs passer a la ligne → le header n'est plus a hauteur fixe 72px. Compromis a trancher selon les usages reels. Ce ticket implemente la solution scroll car la contrainte Figma est "barre de 72px" ; relecture de cette contrainte au ticket 4 si besoin.
|
||||
|
||||
### Risque 7 — Auto-selection du currentSite au login si null
|
||||
|
||||
Le ticket mentionne : "si currentSite est null et user a ≥1 site, le backend doit avoir auto-selectionne le premier (ou a defaut, faire le switch cote frontend au premier mount du selecteur)".
|
||||
|
||||
Le ticket 2 **ne fait pas** d'auto-selection cote backend. Il faut donc gerer cote front : au mount du selecteur, si `currentSite === null && availableSites.length > 0`, appeler `switchSite(availableSites[0])` automatiquement. Cela genere un PATCH au premier chargement d'un user nouvellement rattache — acceptable.
|
||||
|
||||
**Alternative** : faire l'auto-selection cote backend au ticket 2. Si cette alternative est choisie en amont, retirer ce comportement cote front. A clarifier au sprint planning.
|
||||
|
||||
### Risque 8 — Conflit avec le scroll principal
|
||||
|
||||
Le selecteur est dans `flex-1 flex flex-col` au-dessus de `<main>`. `<main>` a `overflow-y-auto` qui permet son propre scroll. Le selecteur est en dehors du `overflow-y-auto` du `<main>` → il reste fige au top quand on scrolle le contenu. Verifier qu'il n'y a pas de collision avec le `sticky top-0 h-8` deja present dans `<main>` (ligne 19-21 de `default.vue`), qui sert de "gradient de lecture" sur le contenu.
|
||||
|
||||
## 12. Ordre d'exécution recommandé
|
||||
|
||||
1. **Upgrade Malio** — `npm install @malio/layer-ui@<version>`, verifier `node_modules/@malio/layer-ui/COMPONENTS.md` pour la signature de `MalioSiteSelector`.
|
||||
2. **Utilitaire couleur** — creer `frontend/shared/utils/color.ts` et ses tests. Isole et rapide a valider.
|
||||
3. **Types** — mettre a jour `frontend/shared/types/user-data.ts` et verifier que `frontend/shared/types/sites.ts` existe (sinon le creer).
|
||||
4. **Composable modules** — creer `useModules()` et ses tests.
|
||||
5. **Composable current site** — creer `useCurrentSite()` et ses tests.
|
||||
6. **Middleware** — brancher `loadModules()` dans `auth.global.ts`.
|
||||
7. **Composant SiteSelector** — creer `SiteSelector.vue`, implementer wrapper autour de `MalioSiteSelector`, gerer contraste texte.
|
||||
8. **Tests composant** — smoke test Vitest sur `SiteSelector.vue`.
|
||||
9. **Integration layout** — modifier `frontend/app/layouts/default.vue`, brancher `showSiteSelector`.
|
||||
10. **Logout reset** — ajouter `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` dans `frontend/modules/core/pages/logout.vue`.
|
||||
11. **i18n** — completer `frontend/i18n/locales/fr.json`.
|
||||
12. **Test visuel** — `make dev-nuxt`, scenarios section 10 "Test visuel manuel".
|
||||
13. **Nuxt-lint** — `make nuxt-lint`.
|
||||
14. **Vitest full run** — `make nuxt-test`, s'assurer que 100% des tests passent.
|
||||
|
||||
## 13. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `@malio/layer-ui` upgrade vers la version contenant `MalioSiteSelector`. `package-lock.json` committe.
|
||||
- [ ] Layer `frontend/modules/sites/` contient bien les dossiers `components/` et `composables/` (layer deja initialise au ticket 2 pour la page admin).
|
||||
- [ ] `SiteSelector.vue` : hauteur `h-[72px]`, blocs `flex-1 min-w-[200px]`, text uppercase Inter Bold 24, fond = `site.color`, opacity 100% sur actif / 40% sur inactifs, hover 70% + cursor pointer.
|
||||
- [ ] Contraste texte calcule dynamiquement : `#FFFFFF` → noir, `#056CF2` → blanc, `#F59E0B` → noir (tests Vitest verts).
|
||||
- [ ] Chaque bloc est un `<button type="button">` avec `aria-pressed` et `aria-label` i18n, focus visible, navigation Tab/Enter fonctionnelle.
|
||||
- [ ] Integre dans `layouts/default.vue`, rendu conditionnel `isModuleActive('sites') && user.sites.length > 0`.
|
||||
- [ ] Clic sur un bloc inactif → PATCH `/api/me/current-site` via `useApi`, optimistic update, toast succes.
|
||||
- [ ] Erreur PATCH → rollback du `currentSite`, toast d'erreur (celui de `useApi` par defaut).
|
||||
- [ ] Switch persistant : F5 conserve le nouveau site actif.
|
||||
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` → selecteur absent, layout identique a avant ce ticket.
|
||||
- [ ] User avec 0 site → selecteur absent (pas de "barre vide").
|
||||
- [ ] User avec 1 site → selecteur present (1 bloc unique, bloc actif).
|
||||
- [ ] User avec 4+ sites → scroll horizontal fonctionne, pas de debordement casse a 1280px.
|
||||
- [ ] `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` appeles au logout.
|
||||
- [ ] `make nuxt-lint` propre.
|
||||
- [ ] `make nuxt-test` passe tous les tests (existants + 4 nouveaux suites).
|
||||
- [ ] `make dev-nuxt` : aucun warning ni erreur console lors du switch et des cycles login/logout.
|
||||
531
docs/sites/ticket-04-spec.md
Normal file
531
docs/sites/ticket-04-spec.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# Ticket #04 — 4/4 — Outillage opt-in « site-scoped » pour modules métier
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket livre l'outillage qui permettra aux modules metier (Commercial, Stock, Production, etc.) de declarer leurs entites comme **scopees par site** : une fois l'adoption effectuee, un utilisateur ne verra en lecture que les lignes dont `site_id` correspond a son site courant, et les creations/editions injectent automatiquement ce site courant si le payload ne le precise pas.
|
||||
|
||||
Le ticket est volontairement **strictement infrastructurel** : il n'adopte le pattern sur aucune entite existante. Aucun module metier n'est modifie, aucune migration n'est jouee sur des tables deja en place. Les tickets futurs (ou des tickets annexes par module) adopteront l'interface au cas par cas apres arbitrage metier.
|
||||
|
||||
Le ticket livre aussi une documentation developpeur (`docs/modules/site-aware.md`) qui explique comment et quand adopter le pattern, et quelles entites **ne doivent pas** l'adopter (roles, permissions, users, catalogues globaux, etc.).
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- Creer le contrat `App\Shared\Domain\Contract\SiteAwareInterface` : interface minimale `getSite(): ?Site` / `setSite(Site $site): void`, place dans `Shared/Domain/Contract/` pour que les modules metier en dependent **sans** importer le module Sites.
|
||||
- Creer `CurrentSiteProvider` (module Sites, couche Application) qui resout le site courant a partir de `Security::getUser()` + `User::getCurrentSite()`, et renvoie `null` si : pas d'user authentifie, `currentSite` null, **ou** module Sites inactif dans `config/modules.php`.
|
||||
- Creer `SiteScopedQueryExtension` (module Sites, Infrastructure API Platform) implementant `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface` : ajoute la clause `WHERE <alias>.site = :currentSite` quand la resource cible implemente `SiteAwareInterface`, le provider retourne un site, et l'user n'a pas `sites.bypass_scope`.
|
||||
- Creer `SiteAwareInjectionProcessor` (module Sites, decorator de `api_platform.doctrine.orm.state.persist_processor`) : avant de deleguer la persistance, si `$data` est une instance de `SiteAwareInterface` et n'a pas deja de site positionne, injecte le `currentSite` fourni par le provider.
|
||||
- Declarer la permission `sites.bypass_scope` dans `SitesModule::permissions()`. Admin ou user avec cette permission → le filtre Query Extension saute, visibilite globale.
|
||||
- Ecrire `docs/modules/site-aware.md` : guide developpeur complet (cf. section 10).
|
||||
- Tests PHPUnit avec une entite fictive `FakeSiteAwareEntity` declaree uniquement dans la suite de tests (jamais en production) pour prouver que le filtrage et l'injection automatique fonctionnent bout en bout.
|
||||
- Tests du cas "Sites desactive" : desactiver `SitesModule::class` dans `config/modules.php` avant la suite, re-sync, verifier que l'outillage est no-op et qu'aucun test existant ne casse.
|
||||
|
||||
### OUT
|
||||
|
||||
- Adoption du pattern sur une entite metier reelle (ex: `Supplier`, `Client`, etc.) : **hors scope**. C'est aux tickets annexes ou aux tickets de feature de l'adopter quand necessaire, en suivant la doc.
|
||||
- Migration de donnees "legacy" : ce ticket documente le piege (entites existantes sans `site_id`) mais ne livre aucune migration par module.
|
||||
- Support CLI / commandes console : le filtre est uniquement actif dans le contexte API Platform (via les extensions). Une commande batch lira toutes les lignes sans filtre, comportement attendu pour les taches admin. Une eventuelle reimplementation via un Doctrine SQL Filter generique est citee en alternative non retenue (cf. Risque 4).
|
||||
- Double-ecriture avec un Doctrine `SQLFilter` : non retenu dans ce ticket. Le filtre via extension API Platform couvre 100% des usages HTTP, qui est le seul contexte ou le site courant a un sens metier (user authentifie). Les commandes CLI doivent gerer la portee explicitement.
|
||||
- Changement du comportement cote front : aucun. Le filtrage est transparent, le front continue de faire `GET /api/suppliers` et recoit une collection pre-filtree. Si une entite est adoptee au ticket futur, la page existante continue de fonctionner sans modification.
|
||||
- Support d'entites "partiellement site-aware" (colonne site_id nullable, certaines lignes globales partagees) : non retenu. Une entite est soit SiteAware, soit globale. Si un module a besoin de la semantique hybride, il devra creer deux entites distinctes.
|
||||
|
||||
## 3. Fichiers à créer
|
||||
|
||||
### Shared — Contrat
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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
|
||||
|
||||
- `/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/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
|
||||
|
||||
- `/home/m-tristan/workspace/Coltura/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
||||
|
||||
### Tests
|
||||
|
||||
- `/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 est no-op si `SiteAware` mais provider null.
|
||||
- Le filtre est no-op si resource non `SiteAware`.
|
||||
- Le filtre est no-op si user a `sites.bypass_scope`.
|
||||
- `totalItems` Hydra reflete bien le filtrage.
|
||||
- `/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 avec site deja positionne → pas d'overwrite.
|
||||
- `$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".
|
||||
- `/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 sans currentSite → null.
|
||||
- Pas d'user → null.
|
||||
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
||||
- `/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
|
||||
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
||||
```php
|
||||
['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.
|
||||
- `/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/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`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Contrat opt-in pour les entites dont la visibilite est scopee par site.
|
||||
*
|
||||
* Une entite implementant cette interface sera :
|
||||
* - filtree en lecture par SiteScopedQueryExtension (collection + item)
|
||||
* selon le site courant de l'utilisateur authentifie ;
|
||||
* - alimentee automatiquement en POST/PATCH par SiteAwareInjectionProcessor
|
||||
* si le payload ne precise pas de site.
|
||||
*
|
||||
* L'implementation concrete doit :
|
||||
* - Declarer une relation ManyToOne(Site::class) avec colonne `site_id` NOT NULL.
|
||||
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
|
||||
*
|
||||
* Ne PAS implementer cette interface pour :
|
||||
* - Des entites globales (catalogue partage, roles, permissions, users).
|
||||
* - Des entites dont le scope est "par tenant" plus large que le site
|
||||
* (utiliser TenantAwareInterface le cas echeant).
|
||||
* - Des entites transversales references par plusieurs sites.
|
||||
*
|
||||
* Voir `docs/modules/site-aware.md` pour le guide d'adoption complet.
|
||||
*/
|
||||
interface SiteAwareInterface
|
||||
{
|
||||
public function getSite(): ?Site;
|
||||
|
||||
public function setSite(Site $site): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Remarque sur le typage du getter
|
||||
|
||||
`getSite(): ?Site` retourne nullable pour deux raisons :
|
||||
- Coherence avec des entites en cours de construction (pre-persist, avant injection).
|
||||
- Compat avec des colonnes qui deviendraient nullable lors d'une migration progressive (ex: deploiement en 2 etapes avec backfill).
|
||||
|
||||
En regime nominal, apres persistance, `getSite()` ne doit jamais etre null. Un `assert($entity->getSite() !== null)` dans du code sensible est acceptable.
|
||||
|
||||
## 6. Service `CurrentSiteProvider`
|
||||
|
||||
### Responsabilite
|
||||
|
||||
Expose **une seule** methode `get(): ?Site`. Resout le site courant selon la chaine :
|
||||
1. Si `SitesModule::class` n'est pas present dans `config/modules.php` → `null`.
|
||||
2. Sinon, si `Security::getUser()` est null → `null`.
|
||||
3. Sinon, si `$user->getCurrentSite()` est null → `null`.
|
||||
4. Sinon → retourne le Site.
|
||||
|
||||
### Detection d'activation du module
|
||||
|
||||
Deux strategies possibles :
|
||||
|
||||
**Strategie A — lire `config/modules.php` au boot du service** (pattern `ModulesProvider`) :
|
||||
```php
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
string $projectDir,
|
||||
) {
|
||||
$moduleClasses = require $projectDir.'/config/modules.php';
|
||||
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
|
||||
}
|
||||
```
|
||||
|
||||
**Strategie B — extraire un service `ActiveModulesRegistry`** partage entre `ModulesProvider` et `CurrentSiteProvider` (refactor mineur).
|
||||
|
||||
**Recommandation** : strategie A dans ce ticket pour rester minimal. Si un troisieme consommateur apparait (probable), extraire le registry dans un ticket dedie.
|
||||
|
||||
### Contrat complet
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final class CurrentSiteProvider
|
||||
{
|
||||
private readonly bool $sitesActive;
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
string $projectDir,
|
||||
) {
|
||||
$moduleClasses = require $projectDir.'/config/modules.php';
|
||||
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
|
||||
}
|
||||
|
||||
public function get(): ?Site
|
||||
{
|
||||
if (!$this->sitesActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user->getCurrentSite();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Extensions API Platform
|
||||
|
||||
### `SiteScopedQueryExtension`
|
||||
|
||||
Implemente a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. La logique est commune et factorisee dans une methode privee `applyScope()`.
|
||||
|
||||
```php
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Resource SiteAware ?
|
||||
if (!is_subclass_of($resourceClass, SiteAwareInterface::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Bypass admin / permission dediee ?
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Site courant disponible ?
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
if ($currentSite === null) {
|
||||
// Decision assumee : no-op plutot que collection vide (cf. section 11 Risque 1).
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Applique WHERE site = :currentSite
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$parameterName = $queryNameGenerator->generateParameterName('currentSite');
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.site = :%s', $rootAlias, $parameterName))
|
||||
->setParameter($parameterName, $currentSite);
|
||||
}
|
||||
```
|
||||
|
||||
### Ordre de priorite
|
||||
|
||||
L'extension doit s'executer **apres** les filtres natifs API Platform (Pagination, Order, Search). Priorite par defaut (0) convient, mais si un autre filtre custom est ajoute plus tard, verifier qu'il ne court-circuite pas. Declarer la priorite explicitement via `#[AsTaggedItem(priority: -100)]` est une option pour s'executer en dernier et etre robuste a l'ordre d'ajout d'autres extensions.
|
||||
|
||||
### JSON-LD `totalItems`
|
||||
|
||||
API Platform execute un `COUNT(*)` separe pour produire le `totalItems` dans la reponse Hydra. Ce count passe par les memes extensions → le totalItems reflete automatiquement le filtrage. A verifier par un test dedie (cf. section 11).
|
||||
|
||||
### `applyToItem` et 404
|
||||
|
||||
Quand un GET `/api/suppliers/{id}` cible un supplier qui existe en base mais appartient a un autre site, la requete `SELECT ... WHERE id = :id AND site = :currentSite` retourne `null` → API Platform converti en 404. Comportement desire : l'user ne doit pas pouvoir distinguer "cet item n'existe pas" de "cet item existe mais pas dans mon site" (anti-enumeration).
|
||||
|
||||
## 8. Processor d'injection automatique `SiteAwareInjectionProcessor`
|
||||
|
||||
### Pattern decorator
|
||||
|
||||
Le plus propre en API Platform est de decorer le processor de persistance Doctrine :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProvider;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||
final class SiteAwareInjectionProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProcessorInterface $inner,
|
||||
private readonly CurrentSiteProvider $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if ($data instanceof SiteAwareInterface && $data->getSite() === null) {
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
|
||||
if ($currentSite === null) {
|
||||
throw new BadRequestHttpException(
|
||||
'Impossible de creer l\'enregistrement : aucun site selectionne.',
|
||||
);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
|
||||
return $this->inner->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Effets de bord et compatibilite
|
||||
|
||||
- **S'applique a TOUS les processors qui heritent du persist processor natif API Platform**. Si un processor custom (ex: `UserRbacProcessor`) delegue a `api_platform.doctrine.orm.state.persist_processor` via autowire, il passe aussi par ce decorator — transparent pour User (non SiteAware).
|
||||
- **N'ecrase jamais un site deja positionne** : un admin qui POST un supplier avec `site: '/api/sites/2'` garde cette valeur, meme si son `currentSite` est 1. La regle metier "site different autorise uniquement si l'user a plusieurs sites" du ticket n'est **pas** implementee dans ce decorator : c'est au voter de securite (hors scope de ce ticket) de l'enforcer si necessaire.
|
||||
- **Erreur explicite si pas de site** : BadRequestHttpException plutot qu'un `null` silencieux. Le user comprend que l'operation necessite un site actif.
|
||||
|
||||
### Alternative rejetee — EventListener Doctrine `prePersist`
|
||||
|
||||
Un listener Doctrine intercepterait toutes les persistances, y compris hors HTTP (CLI, fixtures). **Rejetee** car :
|
||||
- `CurrentSiteProvider` depend de `Security`, indisponible en CLI.
|
||||
- Les fixtures doivent positionner explicitement le site (cf. `AppFixtures` ticket 2), ce qui est plus correct metier.
|
||||
- Les commandes batch peuvent vouloir creer des entites sans site actif (backoffice multi-sites) — un listener silencieux les bloquerait.
|
||||
|
||||
Le decorator HTTP-only est plus aligne avec le principe "opt-in controle".
|
||||
|
||||
## 9. Permission `sites.bypass_scope`
|
||||
|
||||
### Déclaration
|
||||
|
||||
Dans `SitesModule::permissions()` :
|
||||
|
||||
```php
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Semantique
|
||||
|
||||
- User avec `sites.bypass_scope` → le filtre `WHERE site = :currentSite` n'est pas applique. La collection retournee est **globale** (toutes les lignes).
|
||||
- User **admin** (`isAdmin = true`) → `is_granted()` retourne toujours true pour toute permission → le bypass est automatique. Pas besoin d'assignation explicite.
|
||||
- Cas typique d'attribution : un admin financier qui veut consolider les suppliers a l'echelle groupe.
|
||||
|
||||
### Absence de bypass sur le processor
|
||||
|
||||
Le processor d'injection ne respecte **pas** `sites.bypass_scope` : meme un user avec bypass verra son `currentSite` injecte si le payload n'en precise pas. Justification : l'injection n'est pas une restriction, c'est un default value. Le user bypass peut toujours envoyer un site explicite different.
|
||||
|
||||
## 10. Documentation développeur — `docs/modules/site-aware.md`
|
||||
|
||||
Le fichier livre **5 sections** :
|
||||
|
||||
### 10.1 Quand adopter `SiteAwareInterface`
|
||||
|
||||
- Entite qui existe "par site" : chaque ligne appartient a un et un seul site, les users ne doivent voir que celles de leur site courant.
|
||||
- Exemples : `Supplier` (chaque site a ses fournisseurs), `Order`, `StockEntry`, `Employee` (si chaque site a sa propre equipe).
|
||||
|
||||
### 10.2 Quand NE PAS adopter
|
||||
|
||||
- Entites globales : `Role`, `Permission`, `User` (les users sont transverses, rattaches a plusieurs sites).
|
||||
- Catalogues partages : produits, categories, taxes — s'ils sont mutualises entre sites.
|
||||
- Entites transversales : `Invoice` globale, `Contract` multi-site.
|
||||
- Entites dont la portee naturelle est "par tenant" plus large que "par site" : utiliser `TenantAwareInterface` (si pertinent pour le projet multi-tenant futur).
|
||||
|
||||
### 10.3 Comment adopter (check-list)
|
||||
|
||||
1. **Entite** :
|
||||
- Implementer `App\Shared\Domain\Contract\SiteAwareInterface`.
|
||||
- Ajouter la relation `#[ORM\ManyToOne(targetEntity: Site::class)] #[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'CASCADE')] private Site $site`.
|
||||
- Implementer `getSite()` et `setSite()`.
|
||||
2. **Migration** :
|
||||
- Creer une migration dediee au module concerne (ou racine si init critique, voir `CLAUDE.md`).
|
||||
- `ALTER TABLE <table> ADD COLUMN site_id INT NOT NULL`.
|
||||
- **Gestion legacy** : si des lignes existent deja, la colonne ne peut pas etre NOT NULL directement. Strategie :
|
||||
1. Ajouter la colonne nullable.
|
||||
2. Backfill manuel ou par script (ex: tout rattacher au site "Chatellerault" par defaut, ou laisser l'admin arbitrer).
|
||||
3. Rendre la colonne NOT NULL via une seconde migration.
|
||||
- **Index** : `CREATE INDEX IDX_<table>_site ON <table> (site_id)`. **Obligatoire** — le filtre `WHERE site_id = ?` genere un full-scan sinon.
|
||||
3. **Serialisation** : ajouter `site` au groupe de lecture API (`#[Groups(['<resource>:read'])]`) pour que le front voie a quel site appartient la ligne.
|
||||
4. **Processor custom** : si le module a deja un processor sur l'operation POST/PATCH, s'assurer qu'il delegue a `api_platform.doctrine.orm.state.persist_processor` (et non `ObjectManager::persist` direct) pour que le decorator d'injection s'applique.
|
||||
|
||||
### 10.4 Comportement en mode degrade
|
||||
|
||||
- **Module Sites desactive** (`config/modules.php`) : `CurrentSiteProvider::get()` retourne `null` → le filtre ne s'applique plus → toutes les lignes sont visibles, comme avant l'adoption. L'app reste fonctionnelle, juste sans segmentation. **Mais** : la colonne `site_id` NOT NULL reste en place, et le processor d'injection leve une 400 sur tout POST/PATCH sans site explicite. Consequence : **un module adopte ne peut pas vivre sans Sites active** pour les operations d'ecriture, sauf a envoyer systematiquement un `site` explicite dans le payload. A documenter **fortement**.
|
||||
- **User sans site** (sites.length = 0, currentSite = null) : meme comportement → no-op en lecture, 400 en ecriture. Le module doit documenter l'UX degradee.
|
||||
|
||||
### 10.5 Gotchas et anti-patterns
|
||||
|
||||
- **Sous-collections** (`/api/clients/{id}/contacts`) : l'extension s'applique a la resource chargee, ici `Contact`. Si `Contact` est SiteAware, le filtre passe. Si seul `Client` est SiteAware (et Contact herite du scope via son parent), **le filtre ne se propage pas automatiquement** : il faut soit rendre Contact SiteAware aussi (redondance), soit ajouter un filtre custom qui verifie `contact.client.site == currentSite`. Ce ticket ne couvre pas le second cas.
|
||||
- **Jointures** : si un repository custom fait une requete sans passer par API Platform (ex: `findByX()` appele depuis un processor), le filtre ne s'applique pas. Responsabilite du developpeur du module d'ajouter `->andWhere('x.site = :currentSite')` manuellement ou de passer par le `CurrentSiteProvider`.
|
||||
- **Tests d'integration** : les tests existants d'un module adopte devront soit logger un user avec un site actif, soit utiliser `sites.bypass_scope` pour voir toute la donnee. La suite de fixtures devra positionner un site coherent sur les entites de test.
|
||||
- **Cascade delete d'un site** : le ticket 2 met `user.current_site_id` a NULL si le site est supprime. Si une entite adoptee declare `onDelete: CASCADE` sur sa FK site, elle perdra toutes ses lignes au delete d'un site. Choisir explicitement : cascade (aligne sur l'invariant "une ligne SiteAware a toujours un site") ou blocage (empecher la suppression d'un site s'il reste des lignes adoptees).
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
### Risque 1 — Comportement "no-op si pas de site courant"
|
||||
|
||||
La spec choisit **no-op plutot que collection vide** quand `CurrentSiteProvider::get() === null`. Arbitrage :
|
||||
|
||||
- **No-op** (retenu) : un user sans site voit tout, un admin sans site aussi. Risque de fuite de donnees d'un site a l'autre, mais l'app reste utilisable.
|
||||
- **Collection vide** : un user sans site ne voit rien. Plus strict, mais bloque un admin qui consulterait l'app avant d'avoir configure un site.
|
||||
|
||||
Le ticket retient **no-op** car l'app reste utilisable. La permission `sites.bypass_scope` est explicite pour les admins qui veulent voir tout. Si la decision metier evolue, le changement est localise dans `SiteScopedQueryExtension::applyScope()`.
|
||||
|
||||
### Risque 2 — Fuite de donnees entre sites
|
||||
|
||||
Si un module adopte `SiteAwareInterface` mais qu'un repository custom court-circuite API Platform, le filtre ne s'applique pas. Consequence : un endpoint custom (`GET /api/suppliers/top-rated`) pourrait exposer tous les suppliers sans filtrage.
|
||||
|
||||
**Mitigation** : la doc insiste sur la responsabilite du developpeur d'adopter le filtre manuellement dans les repositories custom. Un test d'integration par module adopte est **fortement recommande**.
|
||||
|
||||
### Risque 3 — `FakeSiteAwareEntity` en tests
|
||||
|
||||
L'entite fictive doit etre mappee par Doctrine pour que le QueryBuilder fonctionne. Trois options :
|
||||
|
||||
1. **Declaration via `when@test`** : ajouter `config/packages/doctrine.yaml` dans un bloc `when@test` avec un mapping dedie pointant vers `tests/Fixtures/SiteAware/`. Propre mais ajoute un fichier de config.
|
||||
2. **Attribute Doctrine dans le fichier de test** : fonctionne si le kernel de test decouvre le namespace. Pas elegant.
|
||||
3. **Mock integral du QueryBuilder** : pas d'entite reelle, on mock Doctrine. Tests plus unitaires mais moins realistes.
|
||||
|
||||
**Recommandation** : option 1 (mapping `when@test`). La classe reste dans `tests/` et ne pollue jamais la prod.
|
||||
|
||||
### Risque 4 — Pas de Doctrine SQL Filter
|
||||
|
||||
Un Doctrine `SQLFilter` appliquerait le filtrage a **toutes** les requetes Doctrine, y compris hors API Platform (CLI, fixtures, cron, reports). Plus defensif mais plus risque :
|
||||
|
||||
- Les commandes batch devraient l'activer/desactiver explicitement.
|
||||
- Les fixtures devraient le desactiver pour seeder plusieurs sites.
|
||||
- Les tests d'integration devraient le gerer.
|
||||
|
||||
Le ticket retient la strategie **API Platform only** car le site courant n'a de sens que dans un contexte HTTP authentifie. Si un besoin emerge (rapport automatique scope par site, webhook multi-site, etc.), le refactor vers un SQL filter sera localise.
|
||||
|
||||
### Risque 5 — Priorite des extensions
|
||||
|
||||
Si un autre module introduit plus tard une extension avec une clause `HAVING` ou un `setMaxResults` qui suppose que le filtre de base n'est pas modifie, il peut y avoir des surprises. Declarer explicitement une priorite negative (`priority: -100`) sur `SiteScopedQueryExtension` via `#[AsTaggedItem]` la fait s'executer apres la plupart des filtres natifs, ce qui est generalement souhaitable pour un filtre applicatif.
|
||||
|
||||
### Risque 6 — `UserRbacProcessor` et les autres processors custom
|
||||
|
||||
Le decorator `SiteAwareInjectionProcessor` decore `api_platform.doctrine.orm.state.persist_processor`. Si un module declare un processor custom qui **ne delegue pas** au persist processor (ex: fait `$em->persist($data); $em->flush()` directement), l'injection de site n'a **pas** lieu. Le module doit explicitement passer par le persist processor pour beneficier du pattern.
|
||||
|
||||
A mitiger par un test qui genere une entite `FakeSiteAwareEntity` via un POST `api_platform.doctrine.orm.state.persist_processor` mocke et verifie que le decorator a bien injecte le site.
|
||||
|
||||
### Risque 7 — Performance du `require` au boot
|
||||
|
||||
`CurrentSiteProvider` fait un `require 'config/modules.php'` au constructeur. Le fichier est un simple `return [...]` → l'overhead est minimal et le resultat est opcache par PHP. Meme pattern que `ModulesProvider`, sans regression perf documentee.
|
||||
|
||||
### Risque 8 — Doc developpeur en francais vs anglais
|
||||
|
||||
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
|
||||
|
||||
### Tests unitaires (`TestCase` pur)
|
||||
|
||||
#### `CurrentSiteProviderTest`
|
||||
|
||||
1. `testReturnsNullIfSitesModuleInactive` : config/modules.php de test ne contient pas SitesModule → null meme si user + site fixent.
|
||||
2. `testReturnsNullIfNoUser` : Security::getUser() = null → null.
|
||||
3. `testReturnsNullIfUserHasNoCurrentSite` : user.currentSite = null → null.
|
||||
4. `testReturnsSiteIfAllConditionsMet` : user + currentSite set → retourne le Site.
|
||||
|
||||
#### `SiteAwareInjectionProcessorTest`
|
||||
|
||||
1. `testInjectsCurrentSiteOnNewSiteAwareData` : $data SiteAware + getSite() = null + provider retourne Site → setSite appele avec le bon site.
|
||||
2. `testDoesNotOverrideExistingSite` : $data SiteAware + getSite() non-null → pas d'appel a setSite, delegation directe.
|
||||
3. `testSkipsNonSiteAwareData` : $data qui n'implemente pas SiteAwareInterface → aucune modification, delegation.
|
||||
4. `testThrowsBadRequestIfNoCurrentSite` : $data SiteAware + getSite() = null + provider retourne null → BadRequestHttpException 400.
|
||||
5. `testDelegatesToInnerAlways` : inner->process est appele dans tous les cas (sauf quand 400 throw).
|
||||
|
||||
### Tests d'intégration (`KernelTestCase`)
|
||||
|
||||
#### `SiteScopedQueryExtensionTest`
|
||||
|
||||
Fixture : 2 sites (siteA, siteB), 3 FakeSiteAwareEntity (2 sur siteA, 1 sur siteB), 1 user rattache a siteA.
|
||||
|
||||
1. `testCollectionFilteredByCurrentSite` : user avec currentSite=siteA → collection retourne 2 entites (celles de siteA).
|
||||
2. `testCollectionNotFilteredIfNoCurrentSite` : user sans currentSite → collection retourne 3 entites (no-op).
|
||||
3. `testCollectionNotFilteredIfResourceNotSiteAware` : query sur une entite non SiteAware → aucune clause additionnelle.
|
||||
4. `testCollectionNotFilteredIfBypassPermission` : user avec `sites.bypass_scope` → 3 entites.
|
||||
5. `testCollectionNotFilteredIfSitesModuleInactive` : desactiver SitesModule → provider null → no-op, 3 entites.
|
||||
6. `testItemNotFoundIfWrongSite` : GET sur un id dont le site est siteB alors que user sur siteA → 404 (ou `null` retourne par le QueryBuilder).
|
||||
7. `testItemFoundIfCorrectSite` : GET sur un id du site courant → 200.
|
||||
8. `testTotalItemsReflectsFilter` : collection Hydra `totalItems: 2` (et non 3) quand le filtre s'applique.
|
||||
|
||||
### Tests de non-régression
|
||||
|
||||
Apres implementation, **re-jouer toute la suite existante** en mode module Sites active et en mode module desactive. Aucun test existant ne doit changer.
|
||||
|
||||
## 13. Ordre d'exécution recommandé
|
||||
|
||||
1. **Contrat** — `SiteAwareInterface` dans `Shared/Domain/Contract/`.
|
||||
2. **Provider** — `CurrentSiteProvider` + tests unitaires.
|
||||
3. **Processor decorator** — `SiteAwareInjectionProcessor` + tests unitaires avec mocks.
|
||||
4. **Entite de test** — `FakeSiteAwareEntity` + mapping `when@test` si retenu.
|
||||
5. **Query extension** — `SiteScopedQueryExtension` + tests d'integration.
|
||||
6. **Permission bypass** — ajout dans `SitesModule::permissions()`, `make sync-permissions`, verifier en base.
|
||||
7. **Tests exhaustifs** — faire passer la matrice des 8 cas d'integration.
|
||||
8. **Tests non-regression** — `make test` avec SitesModule actif puis inactif.
|
||||
9. **Documentation** — rediger `docs/modules/site-aware.md` (5 sections).
|
||||
10. **CS fixer** — `make php-cs-fixer-allow-risky`.
|
||||
11. **DoD** — valider la check-list section 14.
|
||||
|
||||
## 14. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `App\Shared\Domain\Contract\SiteAwareInterface` existe avec les deux methodes `getSite(): ?Site` et `setSite(Site $site): void`.
|
||||
- [ ] `CurrentSiteProvider::get()` retourne `null` dans les 3 cas : pas d'user, pas de currentSite, module inactif. Retourne le Site sinon.
|
||||
- [ ] `SiteScopedQueryExtension` applique le WHERE sur les resources SiteAware quand un site courant est resolu et que l'user n'a pas `sites.bypass_scope`.
|
||||
- [ ] `SiteAwareInjectionProcessor` injecte automatiquement le site courant sur POST/PATCH d'entites SiteAware sans site explicite.
|
||||
- [ ] `SiteAwareInjectionProcessor` leve une 400 si l'entite SiteAware n'a pas de site ET que le provider retourne null.
|
||||
- [ ] Permission `sites.bypass_scope` declaree dans `SitesModule::permissions()` et presente en base apres `app:sync-permissions`.
|
||||
- [ ] `docs/modules/site-aware.md` livre les 5 sections (quand/comment adopter, anti-patterns, degrade, gotchas).
|
||||
- [ ] Tests d'integration : au moins 8 cas couvrant filtrage collection/item, no-op dans les 3 scenarios (pas de site, resource non SiteAware, bypass), et `totalItems` Hydra.
|
||||
- [ ] Tests unitaires sur `CurrentSiteProvider` et `SiteAwareInjectionProcessor`.
|
||||
- [ ] Aucune migration sur des tables metier existantes (`supplier`, `client`, `user`, ...) — seules les migrations du ticket 1 et 2 sont presentes. Verifier via `make migration-migrate` : aucun SQL attendu sur la suite existante.
|
||||
- [ ] `make test` passe avec `SitesModule::class` actif dans `config/modules.php`.
|
||||
- [ ] `make test` passe avec `SitesModule::class` desactive dans `config/modules.php`.
|
||||
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux.
|
||||
- [ ] Aucun module metier (Commercial, Core hors User, etc.) n'a ete modifie par ce ticket — diff ne touche que `src/Shared/`, `src/Module/Sites/`, `tests/`, et `docs/`.
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||
7
frontend/app/layouts/auth.vue
Normal file
7
frontend/app/layouts/auth.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
|
||||
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/app/layouts/default.vue
Normal file
67
frontend/app/layouts/default.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="translatedSections"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {t} = useI18n()
|
||||
const ui = useUiStore()
|
||||
const {sections} = useSidebar()
|
||||
const {isModuleActive} = useModules()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Le SiteSelector est rendu si :
|
||||
// - le module Sites est actif dans config/modules.php (sinon la feature
|
||||
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
|
||||
// - ET l'user connecte a au moins un site autorise (sinon "barre vide"
|
||||
// sans tile cliquable).
|
||||
// Les deux flags sont resolus par le middleware auth.global.ts avant
|
||||
// que le layout ne soit rendu (plan load parallele), donc pas de flash.
|
||||
const showSiteSelector = computed(() =>
|
||||
isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const translatedSections = computed(() =>
|
||||
sections.value.map(section => ({
|
||||
label: t(section.label),
|
||||
icon: section.icon,
|
||||
items: section.items.map(item => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => title || 'Coltura',
|
||||
})
|
||||
</script>
|
||||
30
frontend/app/middleware/auth.global.ts
Normal file
30
frontend/app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
const { loaded: sidebarLoaded, loadSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules } = useModules()
|
||||
|
||||
// Chargement parallele sidebar + modules actifs : les deux sont
|
||||
// consommes par layouts/default.vue (sidebar pour la nav, modules
|
||||
// pour conditionner le SiteSelector). Charger en parallele evite
|
||||
// le flash au premier paint de la barre.
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
}
|
||||
})
|
||||
18
frontend/app/middleware/modules.global.ts
Normal file
18
frontend/app/middleware/modules.global.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Don't block routes for unauthenticated users — auth middleware handles them first.
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
|
||||
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
|
||||
if (isRouteDisabled(to.path)) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
export type ApiClient = {
|
||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
}
|
||||
|
||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||
FetchOptions<ResponseType> & {
|
||||
toast?: boolean
|
||||
toastOn401?: boolean
|
||||
toastTitle?: string
|
||||
toastErrorMessage?: string
|
||||
toastSuccessMessage?: string
|
||||
toastErrorKey?: string
|
||||
toastSuccessKey?: string
|
||||
}
|
||||
|
||||
let isHandlingUnauthorized = false
|
||||
|
||||
export function useApi(): ApiClient {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
t: (key: string) => string
|
||||
te?: (key: string) => boolean
|
||||
}
|
||||
| undefined
|
||||
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
||||
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
const methodErrorKeys: Record<string, string> = {
|
||||
GET: 'errors.http.get',
|
||||
POST: 'errors.http.post',
|
||||
PUT: 'errors.http.put',
|
||||
PATCH: 'errors.http.patch',
|
||||
DELETE: 'errors.http.delete'
|
||||
}
|
||||
|
||||
const client = $fetch.create({
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.status && response.status >= 400) {
|
||||
return
|
||||
}
|
||||
|
||||
const successKey = apiOptions?.toastSuccessKey
|
||||
const successMessage =
|
||||
apiOptions?.toastSuccessMessage ||
|
||||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
|
||||
|
||||
if (successMessage) {
|
||||
toast.success({
|
||||
title: 'Succes',
|
||||
message: successMessage
|
||||
})
|
||||
}
|
||||
},
|
||||
async onResponseError({ response, error, options }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (response?.status === 401) {
|
||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||
const isLoginCheck = requestUrl.includes('/login_check')
|
||||
const isLogout = requestUrl.includes('/logout')
|
||||
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
|
||||
|
||||
if (shouldToast401) {
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
if (!isLoginCheck && !isLogout) {
|
||||
if (!isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
auth.clearSession()
|
||||
const route = useRoute()
|
||||
if (route.path !== '/login') {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
isHandlingUnauthorized = false
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const method =
|
||||
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
|
||||
const defaultKey = methodErrorKeys[method]
|
||||
const defaultMessage =
|
||||
defaultKey && te(defaultKey) ? t(defaultKey) : ''
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
defaultMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
options: ApiFetchOptions<'json'> = {}
|
||||
) {
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (!isFormData) {
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
}
|
||||
|
||||
return {
|
||||
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('GET', url, { ...options, query })
|
||||
},
|
||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('POST', url, { ...options, body })
|
||||
},
|
||||
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PUT', url, { ...options, body })
|
||||
},
|
||||
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PATCH', url, { ...options, body })
|
||||
},
|
||||
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('DELETE', url, { ...options, query })
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/eslint.config.mjs
Normal file
60
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import nuxt from '@nuxt/eslint-config'
|
||||
|
||||
export default await nuxt(
|
||||
{
|
||||
features: {
|
||||
stylistic: false,
|
||||
typescript: true,
|
||||
nuxt: {
|
||||
sortConfigKeys: false,
|
||||
},
|
||||
},
|
||||
dirs: {
|
||||
root: ['.', './app'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'coltura/custom-overrides',
|
||||
rules: {
|
||||
// Indentation 4 espaces (convention CLAUDE.md)
|
||||
'vue/html-indent': ['error', 4],
|
||||
indent: ['error', 4, { SwitchCase: 1 }],
|
||||
|
||||
// Vue — relaxed
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-multiple-template-root': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/attributes-order': 'off',
|
||||
'vue/v-on-event-hyphenation': 'off',
|
||||
|
||||
// Console — allow console.error only
|
||||
'no-console': ['warn', { allow: ['error'] }],
|
||||
|
||||
// Unused vars — warn, ignore underscore-prefixed
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
|
||||
// TypeScript — progressive strictness
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-dynamic-delete': 'off',
|
||||
'@typescript-eslint/no-invalid-void-type': 'off',
|
||||
|
||||
// Formatting — leave to stylistic tools
|
||||
'require-await': 'off',
|
||||
'comma-dangle': 'off',
|
||||
curly: 'off',
|
||||
semi: 'off',
|
||||
quotes: 'off',
|
||||
'no-trailing-spaces': 'off',
|
||||
'no-multiple-empty-lines': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -12,14 +12,31 @@
|
||||
"no": "Non",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"admin": "Administration"
|
||||
"sidebar": {
|
||||
"general": {
|
||||
"section": "Général",
|
||||
"dashboard": "Tableau de bord",
|
||||
"admin": "Administration",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"commercial": {
|
||||
"section": "Commercial",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
"sites": "Sites"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"welcome": "Bienvenue sur Coltura"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Commercial",
|
||||
"welcome": "Module Commercial"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -29,7 +46,7 @@
|
||||
"errors": {
|
||||
"auth": {
|
||||
"login": "Identifiants invalides",
|
||||
"session": "Session expir\u00e9e",
|
||||
"session": "Session expirée",
|
||||
"logout": "Erreur lors de la deconnexion"
|
||||
},
|
||||
"http": {
|
||||
@@ -38,11 +55,115 @@
|
||||
"put": "Erreur lors de la mise a jour",
|
||||
"patch": "Erreur lors de la modification",
|
||||
"delete": "Erreur lors de la suppression"
|
||||
},
|
||||
"sites": {
|
||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
"ariaGroupLabel": "Sélecteur de site actif",
|
||||
"switchSuccess": "Site courant changé"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
"title": "Gestion des rôles",
|
||||
"newRole": "Nouveau rôle",
|
||||
"editRole": "Modifier le rôle",
|
||||
"createRole": "Créer un rôle",
|
||||
"noRoles": "Aucun rôle configuré",
|
||||
"table": {
|
||||
"label": "Libellé",
|
||||
"code": "Code",
|
||||
"permissions": "Permissions",
|
||||
"system": "Système"
|
||||
},
|
||||
"form": {
|
||||
"label": "Libellé",
|
||||
"code": "Code",
|
||||
"description": "Description",
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le rôle",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer le rôle \"{label}\" ? Cette action est irréversible.",
|
||||
"systemTooltip": "Rôle système non supprimable"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Rôle créé avec succès",
|
||||
"updated": "Rôle mis à jour avec succès",
|
||||
"deleted": "Rôle supprimé avec succès"
|
||||
},
|
||||
"permissions": {
|
||||
"selectAll": "Tout selectionner",
|
||||
"noPermissions": "Aucune permission disponible"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des utilisateurs",
|
||||
"noUsers": "Aucun utilisateur",
|
||||
"table": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"admin": "Administrateur",
|
||||
"roles": "Roles",
|
||||
"directPermissions": "Permissions directes",
|
||||
"sites": "Sites"
|
||||
},
|
||||
"drawer": {
|
||||
"title": "Permissions de {username}",
|
||||
"selfWarning": "Vous modifiez vos propres droits",
|
||||
"adminToggle": "Administrateur (bypass total)",
|
||||
"rolesSection": "Rôles",
|
||||
"directPermissionsSection": "Permissions directes",
|
||||
"sitesSection": "Sites autorisés",
|
||||
"summarySection": "Résumé des permissions effectives",
|
||||
"noEffectivePermissions": "Aucune permission effective",
|
||||
"sourceRole": "via {role}",
|
||||
"sourceDirect": "Direct",
|
||||
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin"
|
||||
},
|
||||
"toast": {
|
||||
"updated": "Permissions mises à jour avec succès"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"title": "Gestion des sites",
|
||||
"newSite": "Nouveau site",
|
||||
"editSite": "Modifier le site",
|
||||
"createSite": "Créer un site",
|
||||
"noSites": "Aucun site configuré",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"city": "Ville",
|
||||
"postalCode": "Code postal",
|
||||
"color": "Couleur",
|
||||
"fullAddress": "Adresse complète"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"street": "Rue",
|
||||
"complement": "Complément d'adresse",
|
||||
"complementPlaceholder": "Bâtiment, escalier, BP... (optionnel)",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"color": "Couleur (format #RRGGBB)",
|
||||
"colorInvalid": "Format attendu : #RRGGBB (6 caractères hexadécimaux)"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le site",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer le site \"{name}\" ? Cette action est irréversible et retirera ce site à tous les utilisateurs rattachés."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Site créé avec succès",
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="[
|
||||
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||
<span v-if="!sidebarIsCollapsed" class="px-4 py-3 text-lg font-bold text-white">
|
||||
Coltura
|
||||
</span>
|
||||
<span v-else class="px-2 py-3 text-sm font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<SidebarLink
|
||||
to="/"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
:label="$t('nav.dashboard')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
:label="$t('nav.admin')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<p v-if="!sidebarIsCollapsed" class="font-bold text-white">v {{ version }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Collapse toggle button -->
|
||||
<button
|
||||
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Reduire le menu'"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<Icon
|
||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
size="18"
|
||||
/>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
return ui.sidebarCollapsed
|
||||
})
|
||||
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => title || 'Coltura',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
1
frontend/modules/commercial/nuxt.config.ts
Normal file
1
frontend/modules/commercial/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
12
frontend/modules/commercial/pages/commercial.vue
Normal file
12
frontend/modules/commercial/pages/commercial.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({ title: t('commercial.title') })
|
||||
</script>
|
||||
68
frontend/modules/core/components/EffectivePermissions.vue
Normal file
68
frontend/modules/core/components/EffectivePermissions.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="permissions.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.users.drawer.noEffectivePermissions') }}
|
||||
</div>
|
||||
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
v-for="perm in groupedPermissions"
|
||||
:key="perm.module"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<!-- En-tête du module -->
|
||||
<p class="text-xs font-semibold uppercase text-neutral-400 mb-1">
|
||||
{{ perm.module }}
|
||||
</p>
|
||||
<div
|
||||
v-for="item in perm.items"
|
||||
:key="item.code"
|
||||
class="flex items-center justify-between py-1"
|
||||
>
|
||||
<span class="text-sm text-neutral-700">{{ item.label }}</span>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-for="source in item.sources"
|
||||
:key="source"
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
source === t('admin.users.drawer.sourceDirect')
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
]"
|
||||
>
|
||||
{{ source }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EffectivePermission } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
permissions: EffectivePermission[]
|
||||
}>()
|
||||
|
||||
// Grouper par module pour l'affichage
|
||||
interface PermissionModuleGroup {
|
||||
module: string
|
||||
items: EffectivePermission[]
|
||||
}
|
||||
|
||||
const groupedPermissions = computed<PermissionModuleGroup[]>(() => {
|
||||
const groups = new Map<string, EffectivePermission[]>()
|
||||
for (const perm of props.permissions) {
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, items]) => ({ module, items }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
</script>
|
||||
66
frontend/modules/core/components/PermissionGroup.vue
Normal file
66
frontend/modules/core/components/PermissionGroup.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
||||
<MalioCheckbox
|
||||
:id="`group-${module}`"
|
||||
:label="moduleLabel"
|
||||
:model-value="allSelected"
|
||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-neutral-400">
|
||||
{{ selectedCount }}/{{ permissions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des permissions individuelles -->
|
||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in permissions"
|
||||
:key="perm.id"
|
||||
:id="`perm-${perm.id}`"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from '~/shared/types/rbac'
|
||||
|
||||
const props = defineProps<{
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: Permission[]
|
||||
selectedIds: Set<number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
toggleAll: [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Nombre de permissions selectionnees dans ce groupe
|
||||
const selectedCount = computed(() =>
|
||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
)
|
||||
|
||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
||||
const allSelected = computed(() =>
|
||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
||||
)
|
||||
|
||||
// Emet l'evenement de bascule pour une permission individuelle
|
||||
function togglePermission(id: number, selected: boolean) {
|
||||
emit('toggle', id, selected)
|
||||
}
|
||||
|
||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
||||
function toggleAll(selected: boolean) {
|
||||
emit('toggleAll', props.module, selected)
|
||||
}
|
||||
</script>
|
||||
79
frontend/modules/core/components/RoleDeleteModal.vue
Normal file
79
frontend/modules/core/components/RoleDeleteModal.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="cancel"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.roles.delete.title') }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ t('admin.roles.delete.message', { label: roleLabel }) }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
roleLabel: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
// Ferme la modale sans confirmer
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// Emet l'evenement de confirmation de suppression
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
// Fermer la modale avec la touche Escape
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
224
frontend/modules/core/components/RoleDrawer.vue
Normal file
224
frontend/modules/core/components/RoleDrawer.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="t('admin.roles.form.label')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:label="t('admin.roles.form.code')"
|
||||
input-class="w-full"
|
||||
required
|
||||
:readonly="isEditMode"
|
||||
/>
|
||||
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="t('admin.roles.form.description')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Permissions groupees par module -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.roles.form.permissions') }}
|
||||
</h4>
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
role: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
|
||||
const form = ref({
|
||||
label: '',
|
||||
code: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const selectedPermissionIds = ref(new Set<number>())
|
||||
|
||||
const isEditMode = computed(() => props.role !== null)
|
||||
|
||||
// Grouper les permissions par module
|
||||
const permissionsByModule = computed<PermissionModule[]>(() => {
|
||||
const groups = new Map<string, Permission[]>()
|
||||
for (const perm of allPermissions.value) {
|
||||
if (perm.orphan) continue
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
// Charger les permissions au montage
|
||||
async function loadPermissions() {
|
||||
const data = await api.get<{ member: Permission[] }>(
|
||||
'/permissions',
|
||||
{ 'orphan': false, itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
allPermissions.value = data.member
|
||||
}
|
||||
|
||||
// Remplir le formulaire quand le role change
|
||||
watch(() => props.role, (role) => {
|
||||
if (role) {
|
||||
form.value.label = role.label
|
||||
form.value.code = role.code
|
||||
form.value.description = role.description || ''
|
||||
selectedPermissionIds.value = new Set(role.permissions.map(p => {
|
||||
// L'API peut retourner des objets Permission ou des IRIs string
|
||||
if (typeof p === 'string') {
|
||||
return Number(p.split('/').pop())
|
||||
}
|
||||
return p.id
|
||||
}))
|
||||
} else {
|
||||
form.value.label = ''
|
||||
form.value.code = ''
|
||||
form.value.description = ''
|
||||
selectedPermissionIds.value = new Set()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Charger les permissions quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) loadPermissions()
|
||||
})
|
||||
|
||||
// Basculer une permission individuelle
|
||||
function handleTogglePermission(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedPermissionIds.value)
|
||||
if (selected) {
|
||||
ids.add(id)
|
||||
} else {
|
||||
ids.delete(id)
|
||||
}
|
||||
selectedPermissionIds.value = ids
|
||||
}
|
||||
|
||||
// Basculer toutes les permissions d'un module
|
||||
function handleToggleAll(module: string, selected: boolean) {
|
||||
const ids = new Set(selectedPermissionIds.value)
|
||||
const group = permissionsByModule.value.find(g => g.module === module)
|
||||
if (!group) return
|
||||
for (const perm of group.permissions) {
|
||||
if (selected) {
|
||||
ids.add(perm.id)
|
||||
} else {
|
||||
ids.delete(perm.id)
|
||||
}
|
||||
}
|
||||
selectedPermissionIds.value = ids
|
||||
}
|
||||
|
||||
// Sauvegarder le role (creation ou edition)
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const permissions = Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`)
|
||||
|
||||
if (isEditMode.value && props.role) {
|
||||
// Le code est immuable apres creation (garde backend RoleProcessor)
|
||||
await api.patch(`/roles/${props.role.id}`, {
|
||||
label: form.value.label,
|
||||
description: form.value.description || null,
|
||||
permissions,
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.updated'),
|
||||
})
|
||||
} else {
|
||||
await api.post('/roles', {
|
||||
label: form.value.label,
|
||||
code: form.value.code,
|
||||
description: form.value.description || null,
|
||||
permissions,
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.created'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
296
frontend/modules/core/components/UserRbacDrawer.vue
Normal file
296
frontend/modules/core/components/UserRbacDrawer.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Avertissement auto-edition -->
|
||||
<div
|
||||
v-if="isSelfEdit"
|
||||
class="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
|
||||
>
|
||||
<Icon name="mdi:alert-outline" class="size-5 shrink-0" />
|
||||
{{ t('admin.users.drawer.selfWarning') }}
|
||||
</div>
|
||||
|
||||
<!-- Toggle Administrateur -->
|
||||
<MalioCheckbox
|
||||
id="admin-toggle"
|
||||
:label="t('admin.users.drawer.adminToggle')"
|
||||
:model-value="form.isAdmin"
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="form.isAdmin = $event"
|
||||
/>
|
||||
|
||||
<!-- Section Roles -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.rolesSection') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
:id="`role-${role.id}`"
|
||||
:label="role.label"
|
||||
:model-value="selectedRoleIds.has(role.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => toggleRole(role.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Permissions directes -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.directPermissionsSection') }}
|
||||
</h4>
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.sitesSection') }}
|
||||
</h4>
|
||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.sites.noSites') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="site in allSites"
|
||||
:id="`site-${site.id}`"
|
||||
:key="site.id"
|
||||
:label="site.name"
|
||||
:model-value="selectedSiteIds.has(site.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Resume permissions effectives -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.summarySection') }}
|
||||
</h4>
|
||||
<EffectivePermissions :permissions="effectivePermissions" />
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserListItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
const allRoles = ref<Role[]>([])
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
const form = ref({ isAdmin: false })
|
||||
const selectedRoleIds = ref(new Set<number>())
|
||||
const selectedDirectPermissionIds = ref(new Set<number>())
|
||||
const selectedSiteIds = ref(new Set<number>())
|
||||
|
||||
// Detecter l'auto-edition
|
||||
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
|
||||
|
||||
// Extraire un ID depuis une IRI API Platform
|
||||
function iriToId(iri: string): number {
|
||||
return Number(iri.split('/').pop())
|
||||
}
|
||||
|
||||
// Grouper les permissions par module (pour les checkboxes)
|
||||
const permissionsByModule = computed<PermissionModule[]>(() => {
|
||||
const groups = new Map<string, Permission[]>()
|
||||
for (const perm of allPermissions.value) {
|
||||
if (perm.orphan) continue
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
// Calculer les permissions effectives avec leurs sources
|
||||
const effectivePermissions = computed<EffectivePermission[]>(() => {
|
||||
const permMap = new Map<number, Permission>()
|
||||
for (const p of allPermissions.value) {
|
||||
if (!p.orphan) permMap.set(p.id, p)
|
||||
}
|
||||
|
||||
// Construire la map permissionId -> sources[]
|
||||
const result = new Map<number, string[]>()
|
||||
|
||||
// Permissions heritees des roles
|
||||
for (const roleId of selectedRoleIds.value) {
|
||||
const role = allRoles.value.find(r => r.id === roleId)
|
||||
if (!role) continue
|
||||
for (const p of role.permissions) {
|
||||
const pid = typeof p === 'string' ? iriToId(p) : p.id
|
||||
const sources = result.get(pid) || []
|
||||
sources.push(t('admin.users.drawer.sourceRole', { role: role.label }))
|
||||
result.set(pid, sources)
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions directes
|
||||
for (const pid of selectedDirectPermissionIds.value) {
|
||||
const sources = result.get(pid) || []
|
||||
sources.push(t('admin.users.drawer.sourceDirect'))
|
||||
result.set(pid, sources)
|
||||
}
|
||||
|
||||
// Construire la liste finale
|
||||
return Array.from(result.entries())
|
||||
.map(([pid, sources]) => {
|
||||
const perm = permMap.get(pid)
|
||||
if (!perm) return null
|
||||
return { code: perm.code, label: perm.label, module: perm.module, sources }
|
||||
})
|
||||
.filter((p): p is EffectivePermission => p !== null)
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
})
|
||||
|
||||
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
|
||||
// a l'ouverture du drawer.
|
||||
async function loadData() {
|
||||
const [rolesData, permsData, sitesData] = await Promise.all([
|
||||
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||
])
|
||||
allRoles.value = rolesData.member
|
||||
allPermissions.value = permsData.member
|
||||
allSites.value = sitesData.member
|
||||
}
|
||||
|
||||
// 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
|
||||
selectedRoleIds.value = new Set()
|
||||
selectedDirectPermissionIds.value = new Set()
|
||||
selectedSiteIds.value = new Set()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Charger les donnees quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) loadData()
|
||||
})
|
||||
|
||||
function toggleRole(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedRoleIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedRoleIds.value = ids
|
||||
}
|
||||
|
||||
function handleTogglePermission(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedDirectPermissionIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedDirectPermissionIds.value = ids
|
||||
}
|
||||
|
||||
function handleToggleAll(module: string, selected: boolean) {
|
||||
const ids = new Set(selectedDirectPermissionIds.value)
|
||||
const group = permissionsByModule.value.find(g => g.module === module)
|
||||
if (!group) return
|
||||
for (const perm of group.permissions) {
|
||||
if (selected) ids.add(perm.id)
|
||||
else ids.delete(perm.id)
|
||||
}
|
||||
selectedDirectPermissionIds.value = ids
|
||||
}
|
||||
|
||||
function toggleSite(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedSiteIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedSiteIds.value = ids
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.user) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/users/${props.user.id}/rbac`, {
|
||||
isAdmin: form.value.isAdmin,
|
||||
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
|
||||
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
|
||||
sites: Array.from(selectedSiteIds.value).map(id => `/api/sites/${id}`),
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.users.toast.updated'),
|
||||
})
|
||||
// Rafraichir les donnees du user courant si auto-edition
|
||||
if (isSelfEdit.value) {
|
||||
await auth.refreshUser()
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1
frontend/modules/core/nuxt.config.ts
Normal file
1
frontend/modules/core/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
161
frontend/modules/core/pages/admin/roles.vue
Normal file
161
frontend/modules/core/pages/admin/roles.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.roles.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
</template>
|
||||
<template #cell-permissions="{ item }">
|
||||
{{ item.permissions }}
|
||||
</template>
|
||||
<template #cell-system="{ item }">
|
||||
<span
|
||||
v-if="item.isSystem"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{{ t('admin.roles.table.system') }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer creation/edition -->
|
||||
<RoleDrawer
|
||||
v-model="drawerOpen"
|
||||
:role="selectedRole"
|
||||
@saved="onRoleSaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de suppression -->
|
||||
<RoleDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:role-label="roleToDelete?.label ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
{ key: 'code', label: t('admin.roles.table.code') },
|
||||
{ key: 'permissions', label: t('admin.roles.table.permissions') },
|
||||
{ key: 'system', label: t('admin.roles.table.system') },
|
||||
]
|
||||
|
||||
// Transformer les roles en items compatibles MalioDataTable
|
||||
const roleItems = computed(() =>
|
||||
roles.value.map(role => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
code: role.code,
|
||||
permissions: role.permissions.length,
|
||||
isSystem: role.isSystem,
|
||||
system: '', // colonne geree par le slot
|
||||
}))
|
||||
)
|
||||
|
||||
function getRoleById(id: number): Role | undefined {
|
||||
return roles.value.find(r => r.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const role = getRoleById(item.id as number)
|
||||
if (role) openEditDrawer(role)
|
||||
}
|
||||
const drawerOpen = ref(false)
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(role: Role) {
|
||||
selectedRole.value = role
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedRole.value || selectedRole.value.isSystem) return
|
||||
roleToDelete.value = selectedRole.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!roleToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete(`/roles/${roleToDelete.value.id}`, {}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.deleted'),
|
||||
})
|
||||
deleteModalOpen.value = false
|
||||
roleToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await loadRoles()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSaved() {
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
})
|
||||
</script>
|
||||
124
frontend/modules/core/pages/admin/users.vue
Normal file
124
frontend/modules/core/pages/admin/users.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.users.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
v-if="item.admin"
|
||||
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
|
||||
>
|
||||
{{ t('admin.users.table.admin') }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer RBAC -->
|
||||
<UserRbacDrawer
|
||||
v-model="drawerOpen"
|
||||
:user="selectedUser"
|
||||
@saved="onUserSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const sitesById = ref(new Map<number, Site>())
|
||||
const loading = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ 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(() =>
|
||||
users.value.map(user => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
admin: user.isAdmin,
|
||||
roles: user.roles.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() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 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
|
||||
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDrawer(user: UserListItem) {
|
||||
selectedUser.value = user
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const user = getUserById(item.id as number)
|
||||
if (user) openDrawer(user)
|
||||
}
|
||||
|
||||
function onUserSaved() {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
<img src="/coltura.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
@@ -13,22 +13,16 @@
|
||||
label="Nom d'utilisateur"
|
||||
autocomplete="username"
|
||||
group-class="mt-0"
|
||||
inputClass="w-full"
|
||||
input-class="w-full"
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
31
frontend/modules/core/pages/logout.vue
Normal file
31
frontend/modules/core/pages/logout.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-neutral-500">{{ $t('auth.logout') }}...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'auth' })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await auth.logout()
|
||||
} finally {
|
||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
||||
// peuvent pas throw (juste des assignations reactives).
|
||||
// navigateTo est dans le finally pour garantir la redirection
|
||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="cancel"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.sites.delete.title') }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ t('admin.sites.delete.message', { name: siteName }) }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
siteName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.street"
|
||||
:label="t('admin.sites.form.street')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.complement"
|
||||
:label="t('admin.sites.form.complement')"
|
||||
:placeholder="t('admin.sites.form.complementPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
|
||||
maxLength en double securite. La regex backend validera la
|
||||
forme finale, le masque empeche juste la saisie de
|
||||
caracteres non numeriques. -->
|
||||
<MalioInputText
|
||||
v-model="form.postalCode"
|
||||
:label="t('admin.sites.form.postalCode')"
|
||||
input-class="w-full"
|
||||
mask="#####"
|
||||
max-length="5"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.city"
|
||||
:label="t('admin.sites.form.city')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Champ couleur avec preview puce -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.sites.form.color') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioInputText
|
||||
v-model="form.color"
|
||||
placeholder="#RRGGBB"
|
||||
input-class="w-full font-mono"
|
||||
required
|
||||
/>
|
||||
<span
|
||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
||||
:class="{ 'border-dashed': !isValidHex }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||
{{ t('admin.sites.form.colorInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { isValidSiteColor } from '~/shared/utils/color'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
site: Site | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
street: '',
|
||||
complement: '',
|
||||
postalCode: '',
|
||||
city: '',
|
||||
color: '#000000',
|
||||
})
|
||||
|
||||
const isEditMode = computed(() => props.site !== null)
|
||||
|
||||
// Validation locale du format hex #RRGGBB avant envoi backend.
|
||||
const isValidHex = computed(() => isValidSiteColor(form.value.color))
|
||||
|
||||
// Remplir le formulaire quand le site change
|
||||
watch(() => props.site, (site) => {
|
||||
if (site) {
|
||||
form.value.name = site.name
|
||||
form.value.street = site.street
|
||||
form.value.complement = site.complement ?? ''
|
||||
form.value.postalCode = site.postalCode
|
||||
form.value.city = site.city
|
||||
form.value.color = site.color
|
||||
} else {
|
||||
form.value.name = ''
|
||||
form.value.street = ''
|
||||
form.value.complement = ''
|
||||
form.value.postalCode = ''
|
||||
form.value.city = ''
|
||||
form.value.color = '#056CF2'
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleSave() {
|
||||
if (!isValidHex.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
// Le champ complement est optionnel cote DB : on envoie null si vide
|
||||
// pour que le backend stocke NULL plutot qu'une chaine vide.
|
||||
const trimmedComplement = form.value.complement.trim()
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
street: form.value.street,
|
||||
complement: trimmedComplement === '' ? null : trimmedComplement,
|
||||
postalCode: form.value.postalCode,
|
||||
city: form.value.city,
|
||||
color: form.value.color,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.site) {
|
||||
await api.patch(`/sites/${props.site.id}`, payload, {
|
||||
toastSuccessMessage: t('admin.sites.toast.updated'),
|
||||
})
|
||||
} else {
|
||||
await api.post('/sites', payload, {
|
||||
toastSuccessMessage: t('admin.sites.toast.created'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
92
frontend/modules/sites/components/SiteSelector.vue
Normal file
92
frontend/modules/sites/components/SiteSelector.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<MalioSiteSelector
|
||||
:sites="mappedSites"
|
||||
:model-value="currentSite ? String(currentSite.id) : undefined"
|
||||
:group-class="groupClass"
|
||||
:tile-class="tileClass"
|
||||
:label-class="labelClass"
|
||||
:aria-label="t('sites.selector.ariaGroupLabel')"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { currentSite, availableSites, syncFromAuth, switchSite } = useCurrentSite()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Hydratation initiale + watcher : garde le state aligne sur auth.user
|
||||
// meme si un autre composant modifie auth.user.currentSite (ex: switch
|
||||
// depuis un autre onglet via /api/me/current-site, ou refresh du token).
|
||||
// Le rollback de switchSite restaure AUSSI auth.user.currentSite (voir
|
||||
// useCurrentSite::switchSite) pour eviter tout cycle watchEffect -> sync
|
||||
// qui ecraserait l'etat local apres une erreur PATCH.
|
||||
watchEffect(() => {
|
||||
void auth.user?.currentSite
|
||||
void auth.user?.sites
|
||||
syncFromAuth()
|
||||
})
|
||||
|
||||
// Conversion id number -> string : l'API de MalioSiteSelector (v1.4.0)
|
||||
// travaille en string alors que notre type metier Site utilise un int
|
||||
// (ID Doctrine). On reconvertit dans onChange.
|
||||
const mappedSites = computed(() =>
|
||||
availableSites.value.map(site => ({
|
||||
id: String(site.id),
|
||||
name: site.name,
|
||||
color: site.color,
|
||||
})),
|
||||
)
|
||||
|
||||
// Note de rendu : MalioSiteSelector v1.4.0 utilise UNE SEULE `activeColor`
|
||||
// (couleur du site courant) comme fond pour TOUS les tiles. Les inactifs
|
||||
// sont differencies uniquement par `opacity: 0.4`. Le texte est TOUJOURS
|
||||
// blanc (conforme maquette Figma) — charge aux admins de choisir des
|
||||
// couleurs de site suffisamment foncees pour garantir la lisibilite.
|
||||
// On surcharge `labelClass` uniquement pour imposer la taille 24px
|
||||
// (Figma), le reste des attributs tex (blanc, bold, uppercase, tracking)
|
||||
// vient du default Malio via twMerge.
|
||||
|
||||
// Classes Tailwind passees a MalioSiteSelector via twMerge :
|
||||
// - groupClass : hauteur fixe 72px (spec Figma) + scroll horizontal si
|
||||
// debordement de 4+ sites sur petits ecrans.
|
||||
// - tileClass : largeur minimale pour lisibilite + focus ring WCAG.
|
||||
// - labelClass : taille de texte 24px imposee par la maquette Figma.
|
||||
// Tailwind `text-2xl` = 1.5rem = 24px. Merge avec le default Malio
|
||||
// (`text-white font-bold uppercase tracking-wide`).
|
||||
const groupClass = 'h-[72px] overflow-x-auto'
|
||||
const tileClass = 'min-w-[200px] flex items-center justify-center focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2'
|
||||
const labelClass = 'text-2xl'
|
||||
|
||||
async function onChange(site: { id: string; name: string; color: string }): Promise<void> {
|
||||
const target = availableSites.value.find(s => String(s.id) === site.id)
|
||||
if (!target) {
|
||||
// Divergence entre mappedSites et availableSites (peut arriver si
|
||||
// un refresh concurrent a vide la collection). On ignore mais on
|
||||
// trace en dev pour faciliter le debug.
|
||||
if (import.meta.dev) {
|
||||
// Utilise console.error (pas warn) car la convention projet
|
||||
// eslint n'autorise que error (no-console avec allow: ['error']).
|
||||
console.error(`[SiteSelector] Site inconnu emis par MalioSiteSelector : id=${site.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(cross-tab) : si l'utilisateur a change de site dans un autre
|
||||
// onglet, currentSite.value ici peut etre obsolete (state singleton
|
||||
// non synchronise entre onglets). La garde ci-dessous est donc
|
||||
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
||||
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
||||
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
||||
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
|
||||
// sans clic via auth.fetchUser() / auth.refreshUser().
|
||||
|
||||
try {
|
||||
await switchSite(target)
|
||||
} catch {
|
||||
// L'erreur est deja toastee par useApi ; le composable a rollback
|
||||
// le state local ET le store auth. Rien a faire ici au-dela de
|
||||
// silencer pour eviter une unhandledRejection dans la console.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { computed, defineComponent, h, ref, watchEffect } from 'vue'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite'
|
||||
import SiteSelector from '../SiteSelector.vue'
|
||||
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockAuthUser = vi.hoisted(() => ({
|
||||
value: null as { sites: Site[]; currentSite: Site | null } | null,
|
||||
}))
|
||||
|
||||
// Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite,
|
||||
// useAuthStore, useI18n, watchEffect, computed sans import explicite
|
||||
// (pattern Nuxt). En Vitest on les expose comme globals.
|
||||
vi.stubGlobal('useCurrentSite', useCurrentSite)
|
||||
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
|
||||
vi.stubGlobal('useAuthStore', () => ({
|
||||
get user() {
|
||||
return mockAuthUser.value
|
||||
},
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (mockAuthUser.value) {
|
||||
mockAuthUser.value.currentSite = site
|
||||
}
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('watchEffect', watchEffect)
|
||||
vi.stubGlobal('computed', computed)
|
||||
vi.stubGlobal('ref', ref)
|
||||
// useSidebar et refreshNuxtData sont consommes par useCurrentSite apres
|
||||
// un switch reussi — stubs minimaux pour eviter ReferenceError au mount.
|
||||
vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() }))
|
||||
vi.stubGlobal('refreshNuxtData', vi.fn())
|
||||
|
||||
// Stub de MalioSiteSelector : on se contente de tracker les props recues
|
||||
// et de re-emettre `change` quand on le simule via `trigger`. Evite de
|
||||
// monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge).
|
||||
const MalioSiteSelectorStub = defineComponent({
|
||||
name: 'MalioSiteSelector',
|
||||
props: {
|
||||
sites: { type: Array, required: true },
|
||||
modelValue: { type: String, default: undefined },
|
||||
groupClass: { type: String, default: '' },
|
||||
tileClass: { type: String, default: '' },
|
||||
labelClass: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'malio-site-selector',
|
||||
'data-sites-count': String((props.sites as unknown[]).length),
|
||||
'data-active-id': String(props.modelValue ?? ''),
|
||||
'data-label-class': props.labelClass,
|
||||
}, [
|
||||
...(props.sites as Array<{ id: string; name: string; color: string }>).map(site =>
|
||||
h('button', {
|
||||
'data-testid': `tile-${site.id}`,
|
||||
// Emet les deux events comme le vrai MalioSiteSelector
|
||||
// (update:modelValue + change). Le wrapper n'ecoute que
|
||||
// change aujourd'hui, mais tracker les deux grave la
|
||||
// signature et prepare un eventuel v-model futur.
|
||||
onClick: () => {
|
||||
emit('update:modelValue', site.id)
|
||||
emit('change', site)
|
||||
},
|
||||
}, site.name),
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const SITE_A: Site = {
|
||||
id: 1,
|
||||
name: 'Chatellerault',
|
||||
street: '14 All.',
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
fullAddress: '14 All.\n86100 Châtellerault',
|
||||
}
|
||||
const SITE_B: Site = {
|
||||
id: 2,
|
||||
name: 'Saint-Jean',
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
fullAddress: 'Z i\n17400 Fontenet',
|
||||
}
|
||||
|
||||
function mountSelector() {
|
||||
return mount(SiteSelector, {
|
||||
global: {
|
||||
stubs: { MalioSiteSelector: MalioSiteSelectorStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('SiteSelector', () => {
|
||||
beforeEach(() => {
|
||||
mockPatch.mockReset()
|
||||
mockAuthUser.value = {
|
||||
sites: [SITE_A, SITE_B],
|
||||
currentSite: SITE_A,
|
||||
}
|
||||
})
|
||||
|
||||
it('rend un tile par site autorise', () => {
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
expect(stub.attributes('data-sites-count')).toBe('2')
|
||||
})
|
||||
|
||||
it('marque le site courant via modelValue (string)', () => {
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
// Chatellerault id=1 => '1'
|
||||
expect(stub.attributes('data-active-id')).toBe('1')
|
||||
})
|
||||
|
||||
it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => {
|
||||
// Decision design : texte blanc par defaut Malio mais taille 24px
|
||||
// imposee par la maquette. Le reste des attributs text (white, bold,
|
||||
// uppercase, tracking-wide) provient du default Malio via twMerge.
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
expect(stub.attributes('data-label-class')).toBe('text-2xl')
|
||||
})
|
||||
|
||||
it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const wrapper = mountSelector()
|
||||
|
||||
await wrapper.find('[data-testid="tile-2"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/2' },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => {
|
||||
// Le court-circuit "si deja actif, ne rien faire" a ete supprime
|
||||
// pour couvrir le cas ou un autre onglet a modifie le site courant
|
||||
// cote serveur : un clic sur la tile localement "active" (etat
|
||||
// potentiellement stale) force une resync via PATCH. Le prix est un
|
||||
// PATCH superflu quand l'etat local est effectivement a jour.
|
||||
const wrapper = mountSelector()
|
||||
|
||||
await wrapper.find('[data-testid="tile-1"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/1' },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {
|
||||
// Scenario : admin clique sur Saint-Jean alors que Chatellerault est
|
||||
// actif, mais le serveur rejette (ex : 500). Apres rollback dans
|
||||
// useCurrentSite, le composant doit re-afficher Chatellerault actif.
|
||||
mockPatch.mockRejectedValueOnce(new Error('server down'))
|
||||
const wrapper = mountSelector()
|
||||
|
||||
// Avant : Chatellerault (id=1) actif.
|
||||
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
|
||||
.toBe('1')
|
||||
|
||||
await wrapper.find('[data-testid="tile-2"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Apres rollback : Chatellerault (id=1) de nouveau actif.
|
||||
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
|
||||
.toBe('1')
|
||||
// Le store auth ne doit PAS avoir ete laisse avec SITE_B.
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { useCurrentSite } from '../useCurrentSite'
|
||||
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockAuthUser = vi.hoisted(() => ({
|
||||
value: null as { sites: Site[]; currentSite: Site | null } | null,
|
||||
}))
|
||||
|
||||
// Stub des auto-imports Nuxt consommes par le composable.
|
||||
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
|
||||
vi.stubGlobal('useAuthStore', () => ({
|
||||
get user() {
|
||||
return mockAuthUser.value
|
||||
},
|
||||
// Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute
|
||||
// user.currentSite si user present, no-op sinon.
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (mockAuthUser.value) {
|
||||
mockAuthUser.value.currentSite = site
|
||||
}
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}))
|
||||
// useSidebar est consomme par useCurrentSite pour rafraichir la sidebar
|
||||
// apres un switch reussi. Stub minimal retournant un loadSidebar no-op.
|
||||
vi.stubGlobal('useSidebar', () => ({
|
||||
loadSidebar: vi.fn(),
|
||||
}))
|
||||
// refreshNuxtData est appele apres un switch pour invalider les donnees
|
||||
// de page precedemment fetchees. Stub no-op pour les tests unitaires.
|
||||
vi.stubGlobal('refreshNuxtData', vi.fn())
|
||||
|
||||
const SITE_A: Site = {
|
||||
id: 1,
|
||||
name: 'Chatellerault',
|
||||
street: '14 All. d\'Argenson',
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
fullAddress: '14 All. d\'Argenson\n86100 Châtellerault',
|
||||
}
|
||||
const SITE_B: Site = {
|
||||
id: 2,
|
||||
name: 'Saint-Jean',
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
fullAddress: 'Z i\n17400 Fontenet',
|
||||
}
|
||||
|
||||
describe('useCurrentSite', () => {
|
||||
beforeEach(() => {
|
||||
mockPatch.mockReset()
|
||||
mockAuthUser.value = {
|
||||
sites: [SITE_A, SITE_B],
|
||||
currentSite: SITE_A,
|
||||
}
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
resetCurrentSite()
|
||||
})
|
||||
|
||||
it('syncFromAuth hydrate le state depuis le store auth', () => {
|
||||
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
|
||||
|
||||
syncFromAuth()
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_A)
|
||||
expect(availableSites.value).toEqual([SITE_A, SITE_B])
|
||||
})
|
||||
|
||||
it('syncFromAuth gere le cas user null (deconnecte)', () => {
|
||||
mockAuthUser.value = null
|
||||
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
|
||||
|
||||
syncFromAuth()
|
||||
|
||||
expect(currentSite.value).toBeNull()
|
||||
expect(availableSites.value).toEqual([])
|
||||
})
|
||||
|
||||
it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => {
|
||||
mockPatch.mockImplementation(async () => {
|
||||
// Au moment du resolve, currentSite est deja basculé.
|
||||
const state = useCurrentSite()
|
||||
expect(state.currentSite.value).toEqual(SITE_B)
|
||||
return {}
|
||||
})
|
||||
|
||||
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_B)
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/2' },
|
||||
expect.objectContaining({ toastSuccessMessage: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_B)
|
||||
})
|
||||
|
||||
it('switchSite rollback le currentSite local si la requete echoue', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('network'))
|
||||
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await expect(switchSite(SITE_B)).rejects.toThrow('network')
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_A)
|
||||
})
|
||||
|
||||
it('switchSite ne propage pas au store auth en cas d\'echec', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('network'))
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await expect(switchSite(SITE_B)).rejects.toThrow()
|
||||
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
|
||||
})
|
||||
|
||||
it('switching est vrai pendant la requete et faux apres', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
mockPatch.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
|
||||
const { syncFromAuth, switchSite, switching } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
const pending = switchSite(SITE_B)
|
||||
expect(switching.value).toBe(true)
|
||||
|
||||
resolveRequest({})
|
||||
await pending
|
||||
|
||||
expect(switching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('double switchSite concurrent : le second appel est un no-op silencieux', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
mockPatch.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
const first = switchSite(SITE_B)
|
||||
await switchSite(SITE_A) // doit etre no-op (switching=true)
|
||||
|
||||
// Le second appel ne declenche pas de PATCH additionnel.
|
||||
expect(mockPatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRequest({})
|
||||
await first
|
||||
})
|
||||
|
||||
it('resetCurrentSite vide tout l\'etat singleton', () => {
|
||||
const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
expect(currentSite.value).not.toBeNull()
|
||||
|
||||
resetCurrentSite()
|
||||
|
||||
expect(currentSite.value).toBeNull()
|
||||
expect(availableSites.value).toEqual([])
|
||||
expect(switching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => {
|
||||
// Historique : une premiere version du composable appelait useI18n()
|
||||
// dans `switchSite` plutot qu'au top du setup. Consequence en runtime :
|
||||
// l'appel depuis un event handler (click) hors contexte setup levait
|
||||
// "Must be called at the top of a setup function". Ce test grave le
|
||||
// contrat : useCurrentSite() DOIT capturer les 3 services a
|
||||
// l'initialisation, pas paresseusement.
|
||||
//
|
||||
// Verification : on remplace useI18n par un mock qui throw au 2e appel.
|
||||
// Si switchSite invoque useI18n() lui-meme, ce test cassera.
|
||||
let i18nCallCount = 0
|
||||
vi.stubGlobal('useI18n', () => {
|
||||
i18nCallCount++
|
||||
if (i18nCallCount > 1) {
|
||||
throw new Error('useI18n called more than once — regression bug runtime')
|
||||
}
|
||||
return { t: (key: string) => key }
|
||||
})
|
||||
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
// Si switchSite appelait useI18n() en interne, ce call incrementerait
|
||||
// i18nCallCount a 2 et throw. La garde du test passe uniquement si
|
||||
// la capture a bien eu lieu au setup (i18nCallCount reste a 1).
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(i18nCallCount).toBe(1)
|
||||
|
||||
// Restaure le stub par defaut pour les tests suivants.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
})
|
||||
})
|
||||
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal file
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Composable de gestion du site courant (ticket 3 module Sites).
|
||||
*
|
||||
* Pattern aligne sur `useSidebar` : state singleton au niveau module,
|
||||
* hydrate depuis `useAuthStore().user`, mute de maniere optimistic avec
|
||||
* rollback si la requete PATCH `/api/me/current-site` echoue.
|
||||
*
|
||||
* Garantie d'unicite : le flag `switching` bloque les double-clicks
|
||||
* concurrents. Le reset explicite est appele au logout
|
||||
* (voir `modules/core/pages/logout.vue`).
|
||||
*
|
||||
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
|
||||
* garantit deja l'invariant "user avec sites non vide => currentSite non null"
|
||||
* apres tout PATCH /rbac. Le front consomme l'etat renvoye tel quel.
|
||||
*
|
||||
* Contrainte d'appel : `useCurrentSite()` doit etre invoque au top du
|
||||
* `setup()` d'un composant (ou d'un autre composable appele au setup).
|
||||
* Les dependances `useI18n`, `useApi` et `useAuthStore` sont resolues
|
||||
* a l'initialisation et reutilisees par `switchSite` — ceci evite le
|
||||
* "Must be called at the top of a setup function" qui se produirait
|
||||
* si on les appelait paresseusement depuis une fonction async declenchee
|
||||
* par un handler d'event (hors contexte setup).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
const currentSite = ref<Site | null>(null)
|
||||
const availableSites = ref<Site[]>([])
|
||||
const switching = ref(false)
|
||||
|
||||
// Enregistrement unique au niveau module (singleton) : quand clearSession()
|
||||
// est appelee par l'intercepteur 401 de useApi, le state local est purgé
|
||||
// de la meme facon qu'au logout explicite (logout.vue).
|
||||
onAuthSessionCleared(() => {
|
||||
currentSite.value = null
|
||||
availableSites.value = []
|
||||
switching.value = false
|
||||
})
|
||||
|
||||
export function useCurrentSite() {
|
||||
// Resolution au setup : les 3 services doivent etre invoques dans un
|
||||
// contexte composant. Leur capture ici permet a switchSite() de
|
||||
// s'executer plus tard (handler de click, async) sans crash.
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const { loadSidebar } = useSidebar()
|
||||
|
||||
/**
|
||||
* Synchronise le state singleton depuis le store auth. A appeler au
|
||||
* mount du SiteSelector (ou via un watcher sur `auth.user`).
|
||||
*/
|
||||
function syncFromAuth(): void {
|
||||
availableSites.value = auth.user?.sites ?? []
|
||||
currentSite.value = auth.user?.currentSite ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule le site courant. Optimistic UI : la mutation locale precede
|
||||
* la requete HTTP. En cas d'echec (`api.patch` throw), l'etat local est
|
||||
* restaure — le store auth n'a PAS ete muté a ce stade (la propagation
|
||||
* `auth.setCurrentSite` se fait uniquement apres un succes HTTP), donc
|
||||
* aucun rollback cote auth n'est necessaire.
|
||||
*
|
||||
* Garde anti-double-submit : si un switch est deja en vol, le second
|
||||
* appel est un no-op silencieux.
|
||||
*/
|
||||
async function switchSite(site: Site): Promise<void> {
|
||||
if (switching.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousLocal = currentSite.value
|
||||
currentSite.value = site
|
||||
switching.value = true
|
||||
|
||||
try {
|
||||
await api.patch(
|
||||
'/me/current-site',
|
||||
{ site: `/api/sites/${site.id}` },
|
||||
{ toastSuccessMessage: t('sites.selector.switchSuccess') },
|
||||
)
|
||||
// Propage au store auth via l'action dediee — plus tracable que
|
||||
// la mutation directe et garantit la notification des watchers.
|
||||
// N'est appele qu'apres un succes HTTP donc pas de rollback a
|
||||
// prevoir sur cette ligne.
|
||||
auth.setCurrentSite(site)
|
||||
|
||||
// Apres un switch reussi : recharger la sidebar (les filtres de
|
||||
// modules peuvent dependre du site courant via SiteScopedQueryExtension)
|
||||
// et invalider toutes les donnees de page pour eviter que l'utilisateur
|
||||
// voie les donnees de l'ancien site sous un toast "Site change".
|
||||
try {
|
||||
await loadSidebar()
|
||||
} catch {
|
||||
// No-op : la sidebar non rafraichie n'est pas bloquante.
|
||||
}
|
||||
try {
|
||||
await refreshNuxtData()
|
||||
} catch {
|
||||
// No-op : certaines pages n'ont pas de useAsyncData a invalider.
|
||||
}
|
||||
} catch (error) {
|
||||
currentSite.value = previousLocal
|
||||
throw error
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide l'etat singleton. Appele au logout pour eviter qu'un user
|
||||
* suivant (connecte sur le meme onglet) voie les sites de l'ancien.
|
||||
*/
|
||||
function resetCurrentSite(): void {
|
||||
currentSite.value = null
|
||||
availableSites.value = []
|
||||
switching.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
currentSite,
|
||||
availableSites,
|
||||
switching,
|
||||
switchSite,
|
||||
syncFromAuth,
|
||||
resetCurrentSite,
|
||||
}
|
||||
}
|
||||
1
frontend/modules/sites/nuxt.config.ts
Normal file
1
frontend/modules/sites/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
170
frontend/modules/sites/pages/admin/sites.vue
Normal file
170
frontend/modules/sites/pages/admin/sites.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.sites.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.sites.noSites')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
:style="{ backgroundColor: item.color }"
|
||||
class="inline-block size-5 rounded-full border border-neutral-200"
|
||||
/>
|
||||
<span class="font-mono text-xs">{{ item.color }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-fullAddress="{ item }">
|
||||
<span class="line-clamp-2 text-xs text-neutral-600">
|
||||
{{ item.fullAddress }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer creation/edition -->
|
||||
<SiteDrawer
|
||||
v-model="drawerOpen"
|
||||
:site="selectedSite"
|
||||
@saved="onSiteSaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de suppression -->
|
||||
<SiteDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:site-name="siteToDelete?.name ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('sites.manage'))
|
||||
|
||||
useHead({ title: t('admin.sites.title') })
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.sites.table.name') },
|
||||
{ key: 'city', label: t('admin.sites.table.city') },
|
||||
{ key: 'postalCode', label: t('admin.sites.table.postalCode') },
|
||||
{ key: 'color', label: t('admin.sites.table.color') },
|
||||
{ key: 'fullAddress', label: t('admin.sites.table.fullAddress') },
|
||||
]
|
||||
|
||||
// Transformer les sites en items compatibles MalioDataTable.
|
||||
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
|
||||
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
|
||||
const siteItems = computed(() =>
|
||||
sites.value.map(site => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
city: site.city,
|
||||
postalCode: site.postalCode,
|
||||
color: site.color,
|
||||
fullAddress: site.fullAddress.split('\n').join(', '),
|
||||
})),
|
||||
)
|
||||
|
||||
function getSiteById(id: number): Site | undefined {
|
||||
return sites.value.find(s => s.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const site = getSiteById(item.id as number)
|
||||
if (site) openEditDrawer(site)
|
||||
}
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedSite = ref<Site | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const siteToDelete = ref<Site | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadSites() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Site[] }>(
|
||||
'/sites',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
sites.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedSite.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(site: Site) {
|
||||
selectedSite.value = site
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedSite.value) return
|
||||
siteToDelete.value = selectedSite.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!siteToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
|
||||
toastSuccessMessage: t('admin.sites.toast.deleted'),
|
||||
})
|
||||
deleteModalOpen.value = false
|
||||
siteToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await loadSites()
|
||||
// Rafraichit auth.user apres suppression d'un site : le backend
|
||||
// applique ON DELETE SET NULL sur user.current_site_id, donc
|
||||
// auth.user.currentSite peut etre devenu null sans que le front
|
||||
// le sache. refreshUser() resynchronise depuis GET /api/me.
|
||||
await auth.refreshUser()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSiteSaved() {
|
||||
loadSites()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSites()
|
||||
})
|
||||
</script>
|
||||
@@ -1,14 +1,39 @@
|
||||
import { readdirSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer.
|
||||
const modulesDir = resolve(__dirname, 'modules')
|
||||
const moduleDirs = existsSync(modulesDir)
|
||||
? readdirSync(modulesDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
: []
|
||||
const moduleLayers = moduleDirs.map(name => `./modules/${name}`)
|
||||
|
||||
// Auto-detect composables dirs pour chaque layer module. Necessaire car le
|
||||
// `imports.dirs` explicite ci-dessous override le comportement par defaut
|
||||
// de Nuxt (qui scannerait composables/ de chaque layer automatiquement).
|
||||
// Sans ca, useCurrentSite / autres composables des modules ne seraient pas
|
||||
// resolus a l'execution — cf. ticket 3 bug detecte apres review.
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map(name => `./modules/${name}/composables`)
|
||||
.filter(path => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
srcDir: '.',
|
||||
css: ['~/assets/css/main.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
: '/'
|
||||
},
|
||||
extends: ['@malio/layer-ui'],
|
||||
extends: [
|
||||
'@malio/layer-ui',
|
||||
...moduleLayers,
|
||||
],
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
@@ -22,11 +47,23 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
devServer: {
|
||||
port: 3003,
|
||||
port: 3004,
|
||||
},
|
||||
dir: {
|
||||
layouts: 'app/layouts',
|
||||
middleware: 'app/middleware',
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
{path: '~/shared/components', pathPrefix: false},
|
||||
],
|
||||
imports: {
|
||||
dirs: [
|
||||
'shared/composables',
|
||||
'shared/utils',
|
||||
'shared/stores',
|
||||
...moduleComposableDirs,
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
|
||||
17039
frontend/package-lock.json
generated
Normal file
17039
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,41 @@
|
||||
{
|
||||
"name": "coltura-frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
"name": "coltura-frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.2",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"vitest": "^4.1.4",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/LOGO_MALIO.png
Normal file
BIN
frontend/public/LOGO_MALIO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/public/LOGO_MALIO_COLLAPSED.png
Normal file
BIN
frontend/public/LOGO_MALIO_COLLAPSED.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
2
frontend/public/robots.txt
Normal file
2
frontend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useModules } from '../useModules'
|
||||
|
||||
// Mock de useApi : on peut scripter la reponse de /api/modules.
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le
|
||||
// mock comme global pour que l'appel sans import dans useModules.ts
|
||||
// (pattern aligne sur useSidebar) fonctionne.
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
describe('useModules', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
// Reset l'etat singleton entre tests.
|
||||
const { resetModules } = useModules()
|
||||
resetModules()
|
||||
})
|
||||
|
||||
it('charge la liste des modules actifs depuis /api/modules', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, activeModuleIds, loaded } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false })
|
||||
expect(activeModuleIds.value).toEqual(['core', 'sites'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne true pour un id present', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(true)
|
||||
expect(isModuleActive('core')).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne false pour un id absent', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
expect(isModuleActive('inexistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau et laisse la liste vide', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetModules vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, resetModules, activeModuleIds, loaded } = useModules()
|
||||
await loadModules()
|
||||
expect(activeModuleIds.value.length).toBeGreaterThan(0)
|
||||
|
||||
resetModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
65
frontend/shared/composables/__tests__/usePermissions.test.ts
Normal file
65
frontend/shared/composables/__tests__/usePermissions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePermissions } from '../usePermissions'
|
||||
|
||||
// Mock du store auth : le composable ne depend que de auth.user.
|
||||
const mockUser = vi.hoisted(() => ({
|
||||
value: null as { isAdmin: boolean; effectivePermissions: string[] } | null,
|
||||
}))
|
||||
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
get user() {
|
||||
return mockUser.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('usePermissions', () => {
|
||||
beforeEach(() => {
|
||||
mockUser.value = null
|
||||
})
|
||||
|
||||
it('refuse toute permission quand aucun utilisateur n\'est connecte', () => {
|
||||
const { can, canAny, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(false)
|
||||
expect(canAny(['core.users.view', 'core.roles.view'])).toBe(false)
|
||||
expect(canAll(['core.users.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('accorde toutes les permissions a un admin via le bypass', () => {
|
||||
mockUser.value = { isAdmin: true, effectivePermissions: [] }
|
||||
const { can, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
expect(can('module.inexistante.action')).toBe(true)
|
||||
expect(canAll(['a.b.c', 'd.e.f'])).toBe(true)
|
||||
})
|
||||
|
||||
it('accorde une permission presente dans effectivePermissions', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuse une permission absente pour un non-admin', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.roles.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('canAny retourne true si au moins un code matche', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { canAny } = usePermissions()
|
||||
expect(canAny(['core.roles.manage', 'core.users.view'])).toBe(true)
|
||||
expect(canAny(['core.roles.manage', 'core.permissions.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('canAll retourne true uniquement si tous les codes matchent', () => {
|
||||
mockUser.value = {
|
||||
isAdmin: false,
|
||||
effectivePermissions: ['core.users.view', 'core.roles.view'],
|
||||
}
|
||||
const { canAll } = usePermissions()
|
||||
expect(canAll(['core.users.view', 'core.roles.view'])).toBe(true)
|
||||
expect(canAll(['core.users.view', 'core.roles.manage'])).toBe(false)
|
||||
})
|
||||
})
|
||||
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSidebar } from '../useSidebar'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests de l'invariant "loadSidebar ne reject jamais".
|
||||
*
|
||||
* Garantie utilisee par le middleware auth.global.ts qui fait un
|
||||
* Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le
|
||||
* middleware echoue et toute l'app avec. Le swallow interne est donc
|
||||
* load-bearing et ce test le verrouille.
|
||||
*/
|
||||
describe('useSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
const { resetSidebar } = useSidebar()
|
||||
resetSidebar()
|
||||
})
|
||||
|
||||
it('charge sections et disabledRoutes depuis /api/sidebar', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
await loadSidebar()
|
||||
|
||||
expect(sections.value).toHaveLength(1)
|
||||
expect(disabledRoutes.value).toEqual(['/foo'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
// Assertion principale : la promise resout normalement meme sur erreur.
|
||||
await expect(loadSidebar()).resolves.toBeUndefined()
|
||||
expect(sections.value).toEqual([])
|
||||
expect(disabledRoutes.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isRouteDisabled matche exactement un chemin', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] })
|
||||
const { loadSidebar, isRouteDisabled } = useSidebar()
|
||||
await loadSidebar()
|
||||
|
||||
expect(isRouteDisabled('/foo')).toBe(true)
|
||||
expect(isRouteDisabled('/foo/bar')).toBe(true)
|
||||
expect(isRouteDisabled('/other')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetSidebar vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, resetSidebar, sections, loaded } = useSidebar()
|
||||
await loadSidebar()
|
||||
expect(loaded.value).toBe(true)
|
||||
|
||||
resetSidebar()
|
||||
|
||||
expect(sections.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
202
frontend/shared/composables/useApi.ts
Normal file
202
frontend/shared/composables/useApi.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
export type ApiClient = {
|
||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
}
|
||||
|
||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||
FetchOptions<ResponseType> & {
|
||||
toast?: boolean
|
||||
toastOn401?: boolean
|
||||
toastTitle?: string
|
||||
toastErrorMessage?: string
|
||||
toastSuccessMessage?: string
|
||||
toastErrorKey?: string
|
||||
toastSuccessKey?: string
|
||||
}
|
||||
|
||||
let isHandlingUnauthorized = false
|
||||
|
||||
export function useApi(): ApiClient {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
t: (key: string) => string
|
||||
te?: (key: string) => boolean
|
||||
}
|
||||
| undefined
|
||||
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
|
||||
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
const methodErrorKeys: Record<string, string> = {
|
||||
GET: 'errors.http.get',
|
||||
POST: 'errors.http.post',
|
||||
PUT: 'errors.http.put',
|
||||
PATCH: 'errors.http.patch',
|
||||
DELETE: 'errors.http.delete'
|
||||
}
|
||||
|
||||
const client = $fetch.create({
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.status && response.status >= 400) {
|
||||
return
|
||||
}
|
||||
|
||||
const successKey = apiOptions?.toastSuccessKey
|
||||
const successMessage =
|
||||
apiOptions?.toastSuccessMessage ||
|
||||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
|
||||
|
||||
if (successMessage) {
|
||||
toast.success({
|
||||
title: 'Succes',
|
||||
message: successMessage
|
||||
})
|
||||
}
|
||||
},
|
||||
async onResponseError({ response, error, options }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (response?.status === 401) {
|
||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||
const isLoginCheck = requestUrl.includes('/login_check')
|
||||
const isLogout = requestUrl.includes('/logout')
|
||||
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
|
||||
|
||||
if (shouldToast401) {
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
if (!isLoginCheck && !isLogout) {
|
||||
if (!isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
auth.clearSession()
|
||||
await navigateTo('/login')
|
||||
isHandlingUnauthorized = false
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const method =
|
||||
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
|
||||
const defaultKey = methodErrorKeys[method]
|
||||
const defaultMessage =
|
||||
defaultKey && te(defaultKey) ? t(defaultKey) : ''
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
defaultMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
options: ApiFetchOptions<'json'> = {}
|
||||
) {
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (!isFormData) {
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
}
|
||||
|
||||
return {
|
||||
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('GET', url, { ...options, query })
|
||||
},
|
||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('POST', url, { ...options, body })
|
||||
},
|
||||
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PUT', url, { ...options, body })
|
||||
},
|
||||
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('PATCH', url, { ...options, body })
|
||||
},
|
||||
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('DELETE', url, { ...options, query })
|
||||
}
|
||||
}
|
||||
}
|
||||
49
frontend/shared/composables/useModules.ts
Normal file
49
frontend/shared/composables/useModules.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||
*
|
||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
||||
* (voir pages/logout.vue).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>(
|
||||
'/modules',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// Swallow volontaire aligne sur useSidebar : un echec reseau ne
|
||||
// doit pas bloquer le rendu, l'app affichera juste sans la
|
||||
// granularite module (selector masque par defaut).
|
||||
activeModuleIds.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
activeModuleIds,
|
||||
loaded,
|
||||
loadModules,
|
||||
isModuleActive,
|
||||
resetModules,
|
||||
}
|
||||
}
|
||||
38
frontend/shared/composables/usePermissions.ts
Normal file
38
frontend/shared/composables/usePermissions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Composable d'autorisation cote front.
|
||||
*
|
||||
* Source de verite : `useAuthStore().user`, qui porte le payload /api/me
|
||||
* incluant `isAdmin` et `effectivePermissions` (tableau trie sans doublons).
|
||||
*
|
||||
* Regle de bypass dupliquee avec `PermissionVoter` (back) :
|
||||
* si `user.isAdmin === true`, toutes les permissions sont accordees.
|
||||
* Cette duplication est volontaire pour offrir un feedback UI immediat
|
||||
* sans aller-retour serveur. Si la regle de bypass change cote back
|
||||
* (decision architecturale #343 section 11), ce composable DOIT evoluer
|
||||
* en meme temps.
|
||||
*
|
||||
* Stateless : aucun ref module-level, tout passe par Pinia. Le reset est
|
||||
* assure automatiquement par `authStore.logout()` qui efface `user`.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
function can(code: string): boolean {
|
||||
const user = auth.user
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return user.effectivePermissions.includes(code)
|
||||
}
|
||||
|
||||
function canAny(codes: string[]): boolean {
|
||||
return codes.some(can)
|
||||
}
|
||||
|
||||
function canAll(codes: string[]): boolean {
|
||||
return codes.every(can)
|
||||
}
|
||||
|
||||
return { can, canAny, canAll }
|
||||
}
|
||||
47
frontend/shared/composables/useSidebar.ts
Normal file
47
frontend/shared/composables/useSidebar.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SidebarSection } from '~/shared/types'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
const disabledRoutes = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSidebar() {
|
||||
async function loadSidebar() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>(
|
||||
'/sidebar',
|
||||
{},
|
||||
{ toast: false }
|
||||
)
|
||||
sections.value = data.sections ?? []
|
||||
disabledRoutes.value = data.disabledRoutes ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
sections.value = []
|
||||
disabledRoutes.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isRouteDisabled(path: string): boolean {
|
||||
return disabledRoutes.value.some(
|
||||
disabled => path === disabled || path.startsWith(disabled + '/')
|
||||
)
|
||||
}
|
||||
|
||||
function resetSidebar() {
|
||||
sections.value = []
|
||||
disabledRoutes.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
disabledRoutes,
|
||||
loaded,
|
||||
loadSidebar,
|
||||
resetSidebar,
|
||||
isRouteDisabled,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserData } from './dto/user-data'
|
||||
import type { UserData } from '~/shared/types/user-data'
|
||||
|
||||
export function getCurrentUser() {
|
||||
const api = useApi()
|
||||
@@ -1,6 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getCurrentUser, login, logout } from '~/services/auth'
|
||||
import type { UserData } from '~/shared/types/user-data'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { getCurrentUser, login, logout } from '~/shared/services/auth'
|
||||
|
||||
/**
|
||||
* Callbacks enregistres par les composables singletons qui doivent
|
||||
* reinitialiser leur etat quand la session est invalidee (ex: expiration
|
||||
* JWT, logout depuis un intercepteur 401). Utilise le pattern
|
||||
* "callback registration" (Option C) pour eviter une dependance croisee
|
||||
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
|
||||
*/
|
||||
const onSessionClearedCallbacks: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
|
||||
* Typiquement invoque au setup-time du composable (module-level), donc
|
||||
* une seule fois par instance de composable singleton.
|
||||
*/
|
||||
export function onAuthSessionCleared(cb: () => void): void {
|
||||
onSessionClearedCallbacks.push(cb)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
@@ -16,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
// Notifie les composables singletons (useCurrentSite, etc.) afin
|
||||
// qu'ils reinitialisation leur etat — necessaire quand la session
|
||||
// est invalidee par un intercepteur 401 sans passer par logout.vue.
|
||||
onSessionClearedCallbacks.forEach((cb) => cb())
|
||||
},
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
@@ -66,6 +89,18 @@ export const useAuthStore = defineStore('auth', {
|
||||
} catch {
|
||||
// Silently fail — user session might have expired
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Action dediee au switch du site courant (ticket 3 module Sites).
|
||||
* Utilisee par useCurrentSite apres la confirmation serveur, et en
|
||||
* rollback si la requete PATCH echoue apres une mutation optimistic.
|
||||
* Passer explicitement par une action plutot que muter user.currentSite
|
||||
* directement garantit la tracabilite Pinia (devtools).
|
||||
*/
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (this.user) {
|
||||
this.user.currentSite = site
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
11
frontend/shared/types/index.ts
Normal file
11
frontend/shared/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface SidebarItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
label: string
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
33
frontend/shared/types/rbac.ts
Normal file
33
frontend/shared/types/rbac.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Permission {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
module: string
|
||||
orphan: boolean
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
description: string | null
|
||||
isSystem: boolean
|
||||
permissions: (Permission | string)[]
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
directPermissions: string[]
|
||||
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||
sites: string[]
|
||||
}
|
||||
|
||||
export interface EffectivePermission {
|
||||
code: string
|
||||
label: string
|
||||
module: string
|
||||
sources: string[]
|
||||
}
|
||||
24
frontend/shared/types/sites.ts
Normal file
24
frontend/shared/types/sites.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
|
||||
* (computed cote backend) ni champs read-only (id, timestamps).
|
||||
*/
|
||||
export interface SiteInput {
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
}
|
||||
15
frontend/shared/types/user-data.ts
Normal file
15
frontend/shared/types/user-data.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Site } from './sites'
|
||||
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
/** Vrai si l'utilisateur a le bypass admin total (voir ticket #343 section 11). */
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
/** Sites autorises pour l'utilisateur (ticket 2 module Sites). */
|
||||
sites: Site[]
|
||||
/** Site actuellement selectionne par l'utilisateur, ou null si aucun. */
|
||||
currentSite: Site | null
|
||||
}
|
||||
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidSiteColor } from '../color'
|
||||
|
||||
describe('isValidSiteColor', () => {
|
||||
it('accepte un hex majuscule', () => {
|
||||
expect(isValidSiteColor('#ABCDEF')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex minuscule', () => {
|
||||
expect(isValidSiteColor('#abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex mixte', () => {
|
||||
expect(isValidSiteColor('#0a1B2c')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte les couleurs fixtures du projet', () => {
|
||||
expect(isValidSiteColor('#056CF2')).toBe(true)
|
||||
expect(isValidSiteColor('#F3CB00')).toBe(true)
|
||||
expect(isValidSiteColor('#74BF04')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette un nom CSS', () => {
|
||||
expect(isValidSiteColor('red')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex court', () => {
|
||||
expect(isValidSiteColor('#FFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex sans diese', () => {
|
||||
expect(isValidSiteColor('FFFFFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un caractere non hex', () => {
|
||||
expect(isValidSiteColor('#12345G')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette une chaine vide', () => {
|
||||
expect(isValidSiteColor('')).toBe(false)
|
||||
})
|
||||
})
|
||||
19
frontend/shared/utils/color.ts
Normal file
19
frontend/shared/utils/color.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Utilitaires de couleur partages.
|
||||
*
|
||||
* Aligne sur la regex backend stricte #RRGGBB (voir Site.php).
|
||||
*/
|
||||
|
||||
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
|
||||
|
||||
/**
|
||||
* Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres,
|
||||
* 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules,
|
||||
* minuscules, mixte).
|
||||
*
|
||||
* Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi
|
||||
* backend — miroir du pattern Symfony Assert\Regex sur Site::$color.
|
||||
*/
|
||||
export function isValidSiteColor(hex: string): boolean {
|
||||
return HEX_COLOR_REGEX.test(hex)
|
||||
}
|
||||
@@ -1,12 +1,44 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
/**
|
||||
* Config Tailwind du projet Coltura.
|
||||
*
|
||||
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
||||
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
||||
* apporte deja :
|
||||
* - borderRadius.malio (var CSS --m-radius)
|
||||
* - colors.m.{primary,surface,border,text,muted,bg,disabled,danger,
|
||||
* success,btn-*,site-blue,site-yellow,site-green}
|
||||
* - fontFamily.sans (Helvetica Neue)
|
||||
*
|
||||
* Cette config locale ne redeclare QUE ce qui est specifique a Coltura
|
||||
* ou absent de la config Malio — evite la duplication et les derives.
|
||||
*/
|
||||
export default <Partial<Config>>{
|
||||
content: [
|
||||
'./components/**/*.{vue,js,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./composables/**/*.{js,ts}',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'./app.vue',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Couleurs applicatives Coltura (hors namespace `m` reserve
|
||||
// au design system Malio partage).
|
||||
primary: {
|
||||
500: '#222783',
|
||||
},
|
||||
secondary: {
|
||||
500: '#304998'
|
||||
},
|
||||
tertiary: {
|
||||
500: '#F3F4F8'
|
||||
},
|
||||
blue: {
|
||||
500: '#056CF2'
|
||||
},
|
||||
// Extensions au namespace `m` non couvertes par Malio 1.4.1.
|
||||
m: {
|
||||
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
frontend/vitest.config.ts
Normal file
17
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,5 +5,5 @@ APP_USER=www-data
|
||||
POSTGRES_DB=coltura
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5436
|
||||
POSTGRES_PORT=5437
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
|
||||
@@ -2,11 +2,7 @@ APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
||||
|
||||
POSTGRES_DB=coltura
|
||||
POSTGRES_USER=coltura
|
||||
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||
|
||||
APP_PORT=80
|
||||
DATABASE_URL="postgresql://coltura:CHANGE_ME@host.docker.internal:5432/coltura?serverVersion=16&charset=utf8"
|
||||
|
||||
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
||||
JWT_COOKIE_SECURE=1
|
||||
|
||||
@@ -1,86 +1,84 @@
|
||||
ARG DOCKER_PHP_VERSION=8.4.6
|
||||
# --- Stage 1: Build backend ---
|
||||
FROM php:8.4-cli AS backend-build
|
||||
|
||||
FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye AS php-base
|
||||
|
||||
ARG DOCKER_NODE_VERSION=24.12.0
|
||||
ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}"
|
||||
|
||||
# Installer les dépendances et extensions PHP nécessaires
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev \
|
||||
libpq-dev \
|
||||
libpng-dev \
|
||||
libzip-dev \
|
||||
libxml2-dev \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
libbz2-dev \
|
||||
libgmp-dev \
|
||||
libldap2-dev \
|
||||
libonig-dev \
|
||||
libsodium-dev \
|
||||
libxslt1-dev \
|
||||
zlib1g-dev \
|
||||
libssl-dev \
|
||||
wget \
|
||||
git \
|
||||
unzip \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
intl \
|
||||
zip \
|
||||
bcmath \
|
||||
bz2 \
|
||||
calendar \
|
||||
exif \
|
||||
gd \
|
||||
gettext \
|
||||
gmp \
|
||||
ldap \
|
||||
pcntl \
|
||||
pdo_pgsql \
|
||||
soap \
|
||||
sockets \
|
||||
sysvsem \
|
||||
xsl \
|
||||
&& docker-php-ext-enable opcache \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/*
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
unzip curl git \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Installation de node
|
||||
RUN wget -qO- "https://nodejs.org/dist/v${DOCKER_NODE_VERSION}/node-v${DOCKER_NODE_VERSION}-linux-x64.tar.xz" | tar xJC /tmp/ && \
|
||||
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/bin /usr/ && \
|
||||
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/include /usr/ && \
|
||||
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/lib /usr/ && \
|
||||
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/share /usr/ && \
|
||||
rm -rf /tmp/*
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Installation de composer
|
||||
RUN curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock ./
|
||||
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY bin bin/
|
||||
COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
|
||||
# Copier les fichiers projet
|
||||
COPY . /var/www/html
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
# Installation des dépendances PHP (prod)
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||
# --- Stage 2: Build frontend ---
|
||||
FROM node:22-alpine AS frontend-build
|
||||
|
||||
# Génération des clés JWT si absentes
|
||||
RUN php bin/console lexik:jwt:generate-keypair --skip-if-exists
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Build du frontend
|
||||
RUN cd frontend && npm ci && npm run build:dist && rm -rf node_modules
|
||||
COPY frontend/ ./
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE=/api \
|
||||
NUXT_PUBLIC_APP_BASE=/
|
||||
RUN npm run generate
|
||||
|
||||
# --- Stage 3: Production image ---
|
||||
FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# PHP production config
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# PHP-FPM: forward worker output to stderr for docker logs
|
||||
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Nginx: log to stdout/stderr
|
||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
&& ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
# Remove default nginx site
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/coltura.conf
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
|
||||
# Frontend from stage 2
|
||||
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
|
||||
|
||||
# Maintenance page
|
||||
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
|
||||
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN chown -R www-data:www-data /var/www/html/var /var/www/html/frontend/dist
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/config/jwt \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
# PHP prod config
|
||||
COPY infra/prod/php-prod.ini /usr/local/etc/php/php.ini
|
||||
WORKDIR /var/www/html
|
||||
EXPOSE 80
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
# ── Nginx stage ──
|
||||
FROM nginx:1.27-alpine AS nginx
|
||||
|
||||
COPY infra/prod/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=php-base /var/www/html/public /var/www/html/public
|
||||
COPY --from=php-base /var/www/html/frontend/dist /var/www/html/frontend/dist
|
||||
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||
|
||||
39
infra/prod/deploy.sh
Executable file
39
infra/prod/deploy.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export COLTURA_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying coltura:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
sudo chown -R www-data:www-data public
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
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
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,42 +1,16 @@
|
||||
services:
|
||||
php:
|
||||
container_name: php-coltura-fpm
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: infra/deploy/Dockerfile
|
||||
target: php-base
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: 0
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
volumes:
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
container_name: nginx-coltura
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: infra/deploy/Dockerfile
|
||||
target: nginx
|
||||
depends_on:
|
||||
- php
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
||||
container_name: coltura-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "${APP_PORT:-80}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
- "8086:80"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- coltura_logs:/var/www/html/var/log
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads_data:
|
||||
coltura_logs:
|
||||
|
||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #111827;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise a jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
31
infra/prod/nginx-proxy.conf
Normal file
31
infra/prod/nginx-proxy.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name coltura.malio-dev.fr;
|
||||
|
||||
root /var/www/coltura/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/coltura/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8086;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,24 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html/frontend/dist;
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
@@ -19,19 +27,15 @@ server {
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass php:9000;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri =404;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_pass php:9000;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
|
||||
28
infra/prod/supervisord.conf
Normal file
28
infra/prod/supervisord.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm -F
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
44
makefile
44
makefile
@@ -38,7 +38,7 @@ restart: env-init
|
||||
$(DOCKER_COMPOSE) down
|
||||
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
|
||||
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)
|
||||
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
||||
@@ -53,6 +53,16 @@ build-nuxtJS:
|
||||
dev-nuxt:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run dev"
|
||||
|
||||
nuxt-lint:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run lint"
|
||||
|
||||
nuxt-lint-fix:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run lint:fix"
|
||||
|
||||
# Lance les tests unitaires frontend (Vitest)
|
||||
nuxt-test:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run test"
|
||||
|
||||
delete_built_dir:
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
|
||||
@@ -73,9 +83,36 @@ build-without-cache:
|
||||
migration-migrate:
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||
|
||||
# Cree et initialise la base de test utilisee par PHPUnit
|
||||
# (le suffixe "_test" est applique automatiquement par Doctrine en APP_ENV=test)
|
||||
#
|
||||
# Ordre :
|
||||
# 1. migrations : crees le schema metier reel.
|
||||
# 2. schema:update : cree les tables mappees en `when@test` uniquement
|
||||
# (ex: fake_site_aware_entity du ticket 4) qui n'ont pas de migration.
|
||||
# `--force` sans `--complete` : ajoute les tables manquantes aux
|
||||
# mappings sans drop les tables DB non mappees (no-op sur un schema
|
||||
# deja aligne avec les migrations). Necessaire car le purger
|
||||
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
|
||||
# via les mappings — si fake_site_aware_entity est mappe mais absent
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
# Synchronise le catalogue de permissions RBAC avec les declarations
|
||||
# des modules actifs (CoreModule::permissions() etc.). Idempotent.
|
||||
sync-permissions:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
@@ -84,6 +121,8 @@ db-reset:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists
|
||||
$(MAKE) migration-migrate
|
||||
$(MAKE) fixtures
|
||||
$(MAKE) sync-permissions
|
||||
$(MAKE) test-db-setup
|
||||
|
||||
# Restart la bdd
|
||||
db-restart:
|
||||
@@ -121,5 +160,8 @@ php-cs-fixer-allow-risky:
|
||||
test:
|
||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||
|
||||
# Lance l'ensemble des tests (PHPUnit back + Vitest front)
|
||||
test-all: test nuxt-test
|
||||
|
||||
wait:
|
||||
sleep 10
|
||||
|
||||
32
migrations/Version20260407095546.php
Normal file
32
migrations/Version20260407095546.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407095546 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649F85E0677 ON "user" (username)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE "user"');
|
||||
}
|
||||
}
|
||||
223
migrations/Version20260414150034.php
Normal file
223
migrations/Version20260414150034.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Migration RBAC - Task 5.
|
||||
*
|
||||
* Ce que fait cette migration :
|
||||
* - Cree les tables du modele RBAC relationnel : permission, role,
|
||||
* role_permission, user_role et user_permission.
|
||||
* - Ajoute la colonne "user".is_admin (booleen, defaut false) qui sert de
|
||||
* bypass complet pour les super-administrateurs.
|
||||
* - Migre les donnees existantes depuis la colonne JSON "user".roles vers
|
||||
* le nouveau modele relationnel : les users ayant ROLE_ADMIN passent a
|
||||
* is_admin = TRUE et sont rattaches au role systeme 'admin', les autres
|
||||
* sont rattaches au role systeme 'user'.
|
||||
* - Supprime enfin la colonne "user".roles devenue obsolete.
|
||||
*
|
||||
* Ordonnancement critique :
|
||||
* Les INSERT de data-migration DOIVENT s'executer AVANT le DROP COLUMN
|
||||
* "user".roles, sinon l'information d'appartenance a ROLE_ADMIN est perdue.
|
||||
* L'ordre respecte donc strictement : creation des tables -> ADD is_admin
|
||||
* -> seed des roles systeme -> migration des donnees -> DROP roles.
|
||||
*
|
||||
* Dependance avec Task 6 (fixtures) :
|
||||
* Les fixtures applicatives reposent sur l'existence des roles systeme
|
||||
* 'admin' et 'user' seedes ici par SQL brut. Cette migration est donc
|
||||
* auto-suffisante et n'a pas besoin que les fixtures soient executees.
|
||||
*/
|
||||
final class Version20260414150034 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'RBAC : tables permission/role + jointures + is_admin + migration des donnees depuis user.roles';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1) Creation des tables RBAC (permission, role, jointures).
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE permission (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
module VARCHAR(100) NOT NULL,
|
||||
orphan BOOLEAN DEFAULT false NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
|
||||
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_permission_code ON permission (code)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE "role" (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(100) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
is_system BOOLEAN DEFAULT false NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_role_code ON "role" (code)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE role_permission (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
|
||||
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE user_role (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE user_permission (
|
||||
user_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, permission_id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
role_permission
|
||||
ADD
|
||||
CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
role_permission
|
||||
ADD
|
||||
CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
user_role
|
||||
ADD
|
||||
CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
user_role
|
||||
ADD
|
||||
CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
user_permission
|
||||
ADD
|
||||
CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE
|
||||
user_permission
|
||||
ADD
|
||||
CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
// 2) Ajout de la colonne is_admin sur "user" (avant la data-migration
|
||||
// qui en a besoin pour marquer les super-admins).
|
||||
$this->addSql('ALTER TABLE "user" ADD is_admin BOOLEAN DEFAULT false NOT NULL');
|
||||
|
||||
// 3) Seed des roles systeme avant toute migration de donnees utilisateurs.
|
||||
// Les codes sont centralises dans App\Module\Core\Domain\Security\SystemRoles
|
||||
// mais dupliques ici volontairement pour que la migration reste auto-suffisante.
|
||||
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('admin', 'Administrateur', 'Role administrateur - bypass complet via is_admin', TRUE)");
|
||||
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('user', 'Utilisateur', 'Role de base sans permission specifique', TRUE)");
|
||||
|
||||
// 4) Bascule is_admin a TRUE pour tout user dont le JSON roles contient ROLE_ADMIN.
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE "user" u
|
||||
SET is_admin = TRUE
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
)
|
||||
SQL);
|
||||
|
||||
// 5) Rattachement des admins au role systeme 'admin'.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO user_role (user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM "user" u
|
||||
CROSS JOIN "role" r
|
||||
WHERE r.code = 'admin'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
SQL);
|
||||
|
||||
// 6) Rattachement des autres users (y compris roles vide ou NULL) au role 'user'.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO user_role (user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM "user" u
|
||||
CROSS JOIN "role" r
|
||||
WHERE r.code = 'user'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
|
||||
WHERE role_code = 'ROLE_ADMIN'
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
SQL);
|
||||
|
||||
// 7) Drop de la colonne "user".roles (DOIT etre la derniere instruction,
|
||||
// apres la migration des donnees qui la lit).
|
||||
$this->addSql('ALTER TABLE "user" DROP roles');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// 1) Recreation de la colonne roles (avec un defaut pour permettre le
|
||||
// backfill). Le NOT NULL est conserve comme dans le schema d'origine.
|
||||
$this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\'');
|
||||
|
||||
// 2) Rehydratation du JSON roles depuis is_admin avant de perdre la colonne.
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE "user"
|
||||
SET roles = CASE
|
||||
WHEN is_admin THEN '["ROLE_ADMIN"]'::json
|
||||
ELSE '["ROLE_USER"]'::json
|
||||
END
|
||||
SQL);
|
||||
|
||||
// 3) Drop des FK puis des tables de jointure (enfants d'abord).
|
||||
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC');
|
||||
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA');
|
||||
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395');
|
||||
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC');
|
||||
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395');
|
||||
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA');
|
||||
$this->addSql('DROP TABLE user_permission');
|
||||
$this->addSql('DROP TABLE user_role');
|
||||
$this->addSql('DROP TABLE role_permission');
|
||||
|
||||
// 4) Drop des tables parentes permission et role.
|
||||
$this->addSql('DROP TABLE permission');
|
||||
$this->addSql('DROP TABLE "role"');
|
||||
|
||||
// 5) Drop de is_admin, la colonne roles ayant ete rehydratee en amont.
|
||||
$this->addSql('ALTER TABLE "user" DROP is_admin');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user