Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a88cb1bc35 | |||
| 7686904c43 | |||
| 9b26b43aca | |||
| e7af415a1f | |||
| 90b8ca15cd | |||
| 8c3699a9b0 | |||
| d8553f06f5 | |||
| 934cf0835f | |||
| fda03bd1f5 | |||
| 4760c386ed | |||
| 511353c3f5 | |||
| 544d4cf44f | |||
| 1a9eba93a0 | |||
| 48c67a5fb9 | |||
| 5060fb689b | |||
| ac662e701b | |||
| ffed224979 | |||
| fdc72573ea | |||
| 52de07ce23 | |||
| 117c2ff2e3 | |||
| a98ea3df37 | |||
| f1a9b42930 | |||
| 0b4874e94d | |||
| d70925b812 | |||
| f8fc4d6bd9 | |||
| 6ca91cbd3b | |||
| 8865bf51e6 | |||
| d1a980d1c2 | |||
| fdcf8df518 | |||
| 977e74f669 | |||
| a620833550 | |||
| fcfb16fc5b | |||
| b00e92bdd3 | |||
| 1aa43a5356 | |||
| 51de96c797 | |||
| 0ee82c8b62 | |||
| 111f37a0c9 | |||
| 5fbdda1983 | |||
| b301c543bb | |||
| 3053c09522 | |||
| 52399b35d9 | |||
| 748289b61a | |||
| 2d0e9de155 | |||
| a510b2ca73 | |||
| d0a49322e1 | |||
| 3e26c12052 | |||
| 6c32110288 | |||
| da8beb2b2d | |||
| 16748cce40 | |||
| aee279eb5f | |||
| 1351bbf1b1 | |||
| 9e63f3d268 | |||
| 390f2a40a8 | |||
| 7d87af6774 | |||
| d874aebbed | |||
| df0fec0272 | |||
| 4d3879156d | |||
| 4e430cca43 | |||
| 7f20c2ae13 | |||
| 0dd253e483 | |||
| 1964ea5fb4 | |||
| 682b5747b1 | |||
| 309f0b10ee | |||
| 73a34ef438 | |||
| 0f1eeeba1c | |||
| f8acdd9817 | |||
| 920539a050 | |||
| 5a3be7a170 | |||
| 5014dd063e | |||
| 0a6a88e2fa | |||
| 4ffa19e53f | |||
| 74b6d298fb | |||
| c1415d20f4 | |||
| 1d4dbaa766 | |||
| ef7b6c13da | |||
| c125566efc | |||
| 947d95b1f7 | |||
| 027c1305fd | |||
| f25f3fa634 | |||
| 224176d9d7 | |||
| 8c66e73e8d | |||
| f9428f5c5d | |||
| f12ff87b87 | |||
| d0aff0fa51 | |||
| 879f961d88 | |||
| 6de7dfde4e | |||
| 83d938fd91 |
@@ -54,8 +54,116 @@
|
||||
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
|
||||
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
|
||||
|
||||
## Session 2026-06-19 (LST-56 / 0.1 — Socle back modular monolith)
|
||||
|
||||
### Contexte
|
||||
- Ticket exécuté via plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-56-socle-back.md`) délégué à un sous-agent (contexte isolé), pilotage MCP/chrono/vérif depuis la session principale.
|
||||
- 4 tâches, 14 nouveaux tests (110 total, 216 assertions, vert), 4 commits (un par tâche).
|
||||
|
||||
### Patterns
|
||||
- **Strangler 100 % additif** : nouveau noyau `src/Shared/` (Domain/Contract, Domain/Module, Domain/Sidebar, Domain/Trait, Application, Infrastructure/{ApiPlatform,Doctrine,Security,Database}) sans toucher au métier — `make test` reste vert sans migration.
|
||||
- **Endpoints DTO purs** : logique métier dans classes pures testées unitairement (`ModuleRegistry`, `SidebarFilter`), exposées par Providers API Platform minces (`ModulesProvider`/`SidebarProvider`) sur des Resources DTO.
|
||||
- **resolve_target_entities** : contrat `Shared\Domain\Contract\UserInterface` mappé sur `App\Entity\User` (sera re-pointé vers `Module\Core\User` en 1.1). Inert tant qu'aucune entité n'utilise le trait.
|
||||
|
||||
### Gotchas
|
||||
- **API Platform 4 découvre les Resources sous `src/Shared/...` sans config `mapping.paths`** — le 404 anticipé dans le plan ne s'est pas produit, aucun ajout dans `api_platform.yaml` nécessaire.
|
||||
- **Hook pre-commit php-cs-fixer** normalise le style du code fourni dans le plan : `\DateTimeImmutable`→`DateTimeImmutable` importé, FQN→`use`, `static::createClient()`→`self::`. Pur style, tests inchangés. Ne pas lutter contre.
|
||||
- **`config/reference.php`** : fichier auto-généré qui apparaît modifié dans `git status` — ne jamais le committer.
|
||||
|
||||
### Time tracking
|
||||
- Le sous-agent a stoppé lui-même le timer d'implémentation (id 1005, 35 min) — garder le time-tracking sur la session principale pour rester maître du chrono si un sous-agent a accès aux tools MCP lesstime.
|
||||
|
||||
## Session 2026-06-19 (LST-62 / 0.2 — Socle front : shell + auto-détection layers Nuxt)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-62-socle-front.md`), 7 tasks. Exécution en 3 sous-agents (Task 1 back ; Tasks 2-4 fondations front ; Tasks 5-7 middlewares/layout/i18n), pilotage chrono/MCP/vérif sur la session principale.
|
||||
- 7 commits + 1 commit doc de correction du plan. Back : 115 tests verts (110 + 5 nouveaux cas gate rôle).
|
||||
|
||||
### Patterns
|
||||
- **Gate de rôle additif dans la sidebar** : clé `roles` optionnelle sur section/item dans `config/sidebar.php` ; `SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [])` masque sans polluer `disabledRoutes` (réservé au filtrage par module). `SidebarProvider` injecte `Symfony\Bundle\SecurityBundle\Security` et passe `array_values($user->getRoles())`. ROLE_ADMIN seulement (pas le RBAC fin, qui viendra en 1.1/1.2).
|
||||
- **Layout front aligné Starseed** (vérifié dans le code Starseed) : `srcDir: '.'`, `dir.layouts/middleware → app/`, code transverse auto-importé sous `shared/{composables,stores,utils}` via `imports.dirs` EXPLICITE, scan `readdirSync('modules/')` → `extends` + dossiers `modules/*/composables` ajoutés dynamiquement à `imports.dirs`. `useApi`/`auth`/`ui` déplacés par `git mv` (historique préservé) ; `timer.ts`/`mail.ts` restent dans `stores/` (métier non migré).
|
||||
- **Singletons module-level** : `useSidebar`/`useModules` portent leur état en `ref` au niveau module ; reset explicite au logout depuis `auth.global.ts` (l'approche Starseed via callback `onAuthSessionCleared()` est une alternative non retenue ici).
|
||||
|
||||
### Gotchas
|
||||
- **`nuxt typecheck` n'est PAS un gate vert sur ce stack** : le baseline Lesstime est rouge (~230 lignes `error TS`) et la RÉFÉRENCE Starseed (même Nuxt 4.3.1, même layout) ship en prod avec **325 erreurs**. Classes structurelles tolérées : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'…` dans `shared/` (Nuxt 4 type `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les expose au RUNTIME — vérifié dans `.nuxt/imports.d.ts`), erreurs `nuxt.config.ts` (`node:fs`/`process`/`__dirname`, pas de `@types/node`, compilé au runtime par Nuxt), `useApi.ts` 'Property url'. **Le vrai gate** = zéro `Cannot find module '~/shared/…'` (= vrai import cassé) + auto-imports présents dans `.nuxt/imports.d.ts` + smoke runtime. Un sous-agent consciencieux s'est arrêté à tort sur ces erreurs ("bloqueur irréductible") → toujours vérifier le gate contre la réf Starseed avant de conclure à un blocage.
|
||||
- **Vérif backend live > typecheck front** : le gate de rôle a été prouvé via curl réel (`/api/login_check` → cookie BEARER → `GET /api/sidebar`) : `alice` (ROLE_USER) n'a que la section générale, `admin` (ROLE_ADMIN) a Administration, non-auth = 401. Plus fiable que le typecheck sur ce stack.
|
||||
- **i18n `fr.json`** : une clé racine `sidebar` préexistait (avec un `myTasks` orphelin) → fusionner les sous-namespaces plutôt que dupliquer la clé racine (JSON invalide sinon).
|
||||
|
||||
### Statut / time tracking
|
||||
- Ticket laissé en **"En attente de validation" (4)**, pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du **délta cosmétique d'ordre de sidebar** (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
|
||||
- Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils `mcp__lesstime__*`) — respecté.
|
||||
|
||||
## Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-63-module-core.md`, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
|
||||
- 5 commits impl (`6ca91cb` A, `f8fc4d6`+`d70925b` B, `0b4874e` C, `f1a9b42` D, `a98ea3d` E, `117c2ff` F) + plan `8865bf5`. Tests : 110→120 verts. Timer impl 1012 = 43 min.
|
||||
|
||||
### Patterns
|
||||
- **Move d'entité « strangler » sans migration** : `git mv` `src/Entity/User.php` → `src/Module/Core/Domain/Entity/User.php` (table + colonnes + backticks VERBATIM) ; mapping Doctrine `Core` ajouté (dir `src/Module/Core/Domain/Entity`, prefix `App\Module\Core\Domain\Entity`) à côté de `App` ; `resolve_target_entities: UserInterface → Core\User`. `migrations:diff` reste vide (hors dérive préexistante `messenger_messages`) → AUCUNE migration. Idem Notification en Phase D.
|
||||
- **Alias temporaire pour découpler le move des relations** : Phase B pose un `class_alias(App\Entity\User::class → Core\User)` (fichier `_compat_user_alias.php` en `autoload.files`, exclu de l'autowiring `App\:` via `exclude` services.yaml + `notPath` php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT `UserInterface::class` (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand `grep App\Entity\User` est vide.
|
||||
- **Règle contrat-vs-concret pour migrer les consommateurs** (Phase C, ~50 fichiers) : type-hint `App\Shared\Domain\Contract\UserInterface` si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret `App\Module\Core\Domain\Entity\User` si besoin de getters HR, `apiToken`, `avatarFileName`, setters, `new User()`. Les deux éliminent `App\Entity\User`. Collision de nom avec `Symfony\...\UserInterface` → aliaser en `SharedUserInterface`.
|
||||
- **Notifier (Phase D)** : `NotifierInterface` (Shared) = API publique inter-modules ; impl `Notifier` (Core) persiste + flush. `TaskNotificationListener` appelle `notify()` UNIQUEMENT en `postFlush` (jamais `onFlush` — le flush interne y est dangereux). Comportement identique conservé.
|
||||
- **Layer front d'un module (Phase F)** : `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`) + `git mv` des pages d'identité sous `modules/core/pages/`. Les imports `~/...` (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (`/login`, `/profile`) restent identiques (fusion auto des `pages/` de layers).
|
||||
|
||||
### Gotchas
|
||||
- **`admin.vue` = shell admin MULTI-domaines** (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet `AdminUserTab`) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de `admin.vue` viendra avec les modules respectifs.
|
||||
- **Vérifier la résolution des routes d'un layer Nuxt en SPA** : `ssr:false` → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un `curl /login` = 200 ne prouve RIEN (testé : `/route-bidon-xyz` = 200 aussi). `nuxt prepare` ne génère pas le manifeste de routes. **Preuve déterministe** = `npx nuxt build` puis `grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs` (+ chunk CSS `profile.*.css` généré). Ne pas perturber un dev server déjà lancé (config `extends`/`imports.dirs` figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
|
||||
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
|
||||
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
|
||||
|
||||
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
|
||||
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
|
||||
|
||||
### Décision d'architecture majeure (actée, à valider PO)
|
||||
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
|
||||
|
||||
### Patterns
|
||||
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
|
||||
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
|
||||
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
|
||||
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
|
||||
|
||||
### Gotchas
|
||||
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
|
||||
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
|
||||
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
|
||||
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
|
||||
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
|
||||
|
||||
### MR / Git
|
||||
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62→#56, #63→#62, #57→#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
|
||||
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
|
||||
|
||||
## Meta-learnings
|
||||
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
||||
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
|
||||
- **Gate de vérif fourni par le plan**: si un plan fixe un seuil (ex "typecheck 0 erreur"), le confronter à la réalité du projet/réf AVANT de bloquer dessus ; corriger le plan si le seuil est faux.
|
||||
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
||||
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
||||
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
||||
|
||||
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
|
||||
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
|
||||
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
|
||||
|
||||
### Patterns
|
||||
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
|
||||
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
|
||||
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>` → `core.User`).
|
||||
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
|
||||
|
||||
### Gotchas
|
||||
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
|
||||
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
|
||||
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
|
||||
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
|
||||
|
||||
### Time-tracking / orchestration
|
||||
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
|
||||
|
||||
@@ -13,32 +13,25 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
|
||||
## Structure
|
||||
|
||||
> Le détail (entités, providers, services, composants…) se découvre dans le code. Carte d'orientation :
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||
src/Enum/ # PHP enums (RecurrenceType)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task + métadonnées Task*, TimeEntry, Notification, *Configuration…)
|
||||
src/ApiResource/ # Ressources API Platform découplées des entités
|
||||
src/State/ # Providers & Processors API Platform (Me, ActiveTimeEntry, TaskNumber, Notification, Gitea*, Zimbra*, RecurrenceHandler…)
|
||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||
src/Command/ # Commandes console (GenerateApiTokenCommand)
|
||||
src/Repository/ # Repositories Doctrine
|
||||
src/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (default)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, notifications, task-documents, zimbra, task-recurrences)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
src/Controller/ # Controllers custom (notifications, avatar, download document)
|
||||
src/Mcp/Tool/ # MCP tools par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # ApiTokenAuthenticator (MCP HTTP)
|
||||
src/Command/ src/Repository/ src/DataFixtures/
|
||||
config/ # security, api_platform, lexik_jwt, nelmio_cors, doctrine — config/jwt/ = clés
|
||||
migrations/ docs/plans/ docs/superpowers/
|
||||
frontend/pages/ # index, login, my-tasks, profile, projects/[id]/*, time-tracking, admin
|
||||
frontend/components/ # Sous-dossiers ui/ client/ project/ task/ user/ admin/ time-tracking/ notification/
|
||||
frontend/composables/# useApi, useAppVersion, useNotifications, useAvatarService
|
||||
frontend/stores/ # Pinia : auth, ui, timer
|
||||
frontend/services/ # 1 service par ressource API (+ services/dto/ pour les types)
|
||||
frontend/i18n/locales/ # Traductions (langDir résolu depuis i18n/)
|
||||
```
|
||||
|
||||
## Commandes
|
||||
@@ -102,6 +95,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||
- 4 espaces d'indentation
|
||||
- MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
|
||||
- **Pagination API Platform & `extractHydraMembers` (piège de troncature)** : API Platform pagine par défaut à **30 éléments/page**. Le helper `extractHydraMembers()` (`frontend/utils/api.ts`) ne lit **que la première page** (il ignore `hydra:view.next`) → toute liste > 30 éléments est tronquée **silencieusement** (bug LST-51/LST-52). Règle : toute collection consommée via `extractHydraMembers` doit **soit** être servie par une ressource non paginée (`paginationEnabled: false` sur le `GetCollection`, quand le volume est borné/modéré et qu'on veut tout afficher — c'est le cas des référentiels et de Client/Project/User/Task/TimeEntry), **soit** gérer explicitement la pagination via le helper `fetchAllHydra()` (suit toutes les pages, à réserver aux volumes non bornés comme `/notifications`), **soit** passer par une route dédiée bornée (ex `/time_entries/range`). Ne **jamais** lire une seule page d'une collection potentiellement > 30 éléments.
|
||||
|
||||
### Composants UI
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"icewind/smb": "^3.8",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
|
||||
Generated
+73
-1
@@ -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": "dc72ee68996f3f738763eafd350bc0e0",
|
||||
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2508,6 +2508,78 @@
|
||||
},
|
||||
"time": "2026-02-08T16:21:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "icewind/smb",
|
||||
"version": "3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/icewind/SMB",
|
||||
"reference": "97063a63b44edc6554966f6121679506b8d85103"
|
||||
},
|
||||
"require": {
|
||||
"icewind/streams": ">=0.7.3",
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "v3.89.0",
|
||||
"phpstan/phpstan": "^0.12.57",
|
||||
"phpunit/phpunit": "10.5.58",
|
||||
"psalm/phar": "6.*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Icewind\\SMB\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Robin Appelman",
|
||||
"email": "robin@icewind.nl"
|
||||
}
|
||||
],
|
||||
"description": "php wrapper for smbclient and libsmbclient-php",
|
||||
"time": "2025-11-13T16:17:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "icewind/streams",
|
||||
"version": "v0.7.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/icewind/streams",
|
||||
"reference": "cb2bd3ed41b516efb97e06e8da35a12ef58ba48b"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^2",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Icewind\\Streams\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Robin Appelman",
|
||||
"email": "icewind@owncloud.com"
|
||||
}
|
||||
],
|
||||
"description": "A set of generic stream wrappers",
|
||||
"time": "2024-12-05T14:36:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/collections",
|
||||
"version": "v13.8.0",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
|
||||
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
|
||||
*/
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
];
|
||||
@@ -1,18 +1,27 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
default_connection: default
|
||||
connections:
|
||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
||||
# service) to write outside the ORM transaction so audit rows survive
|
||||
# an application-side rollback and avoid transactional entanglement.
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
||||
# from schema comparison so migrations:diff / schema:validate stay
|
||||
# clean. Creation/teardown stay driven by migrations.
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
identity_generation_preferences:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
@@ -20,14 +29,24 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
# Propagate the _test suffix to BOTH connections: the audit
|
||||
# connection must write to the test DB, not the dev DB.
|
||||
connections:
|
||||
default:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
audit:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -10,7 +10,7 @@ security:
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
class: App\Module\Core\Domain\Entity\User
|
||||
property: username
|
||||
|
||||
firewalls:
|
||||
@@ -62,6 +62,8 @@ security:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
# Liste des modules actifs en public (consommée au boot du front)
|
||||
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
# Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker)
|
||||
|
||||
@@ -64,3 +64,13 @@ services:
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
||||
|
||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||||
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
|
||||
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||
* Filtrée par SidebarFilter :
|
||||
* - `module` : route ajoutée à disabledRoutes si module inactif ;
|
||||
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
|
||||
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
|
||||
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
|
||||
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||
*/
|
||||
return [
|
||||
[
|
||||
'label' => 'sidebar.general.section',
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.admin.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||
],
|
||||
],
|
||||
];
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.24'
|
||||
app.version: '0.4.30'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
# Task Notifications Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Recréer un producteur de notifications en notifiant le nouvel assigné d'une tâche et les collaborateurs ajoutés, via un listener Doctrine couvrant tous les chemins d'écriture.
|
||||
|
||||
**Architecture:** Un unique `TaskNotificationListener` Doctrine écoute `onFlush` (collecte les destinataires à partir des changesets d'assignation et des ajouts de collaborateurs) et `postFlush` (persiste les `Notification` puis re-flush). L'acteur courant est lu via `Security`; on ne se notifie jamais soi-même, et sans acteur authentifié aucune notification n'est créée.
|
||||
|
||||
**Tech Stack:** PHP 8.4, Symfony 8, Doctrine ORM 3.6, PHPUnit (KernelTestCase).
|
||||
|
||||
---
|
||||
|
||||
## Référence spec
|
||||
|
||||
`docs/superpowers/specs/2026-06-15-task-notifications-design.md`
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `src/EventListener/TaskNotificationListener.php` — listener Doctrine, seul producteur de notifications de tâche. Responsabilité unique : traduire les changements d'assignation/collaboration en entités `Notification`.
|
||||
- **Create** `tests/Functional/EventListener/TaskNotificationListenerTest.php` — tests fonctionnels (KernelTestCase) couvrant tous les cas de la spec.
|
||||
- Aucune migration, aucun changement d'entité, aucun changement frontend.
|
||||
|
||||
### Détails de plateforme vérifiés
|
||||
|
||||
- Doctrine ORM 3.6 : le mapping d'une `PersistentCollection` s'obtient via `$collection->getMapping()->fieldName` (objet `AssociationMapping`, **pas** un tableau).
|
||||
- `Task` non-nullables : `number` (int), `title` (string), `project` (relation). `assignee` est nullable, `collaborators` est une `Collection`.
|
||||
- En test, on réutilise un `Project` existant (chargé par les fixtures) et on crée des `User` frais (isolation par `uniqid`).
|
||||
- L'acteur courant : `Security::getUser()` lit le token storage. En test, on pose un token via `TokenStorageInterface::setToken()`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Listener + notifications d'assignation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/EventListener/TaskNotificationListener.php`
|
||||
- Test: `tests/Functional/EventListener/TaskNotificationListenerTest.php`
|
||||
|
||||
- [ ] **Step 1: Écrire les tests d'assignation (échouent)**
|
||||
|
||||
Créer `tests/Functional/EventListener/TaskNotificationListenerTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\EventListener;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class TaskNotificationListenerTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private NotificationRepository $notifications;
|
||||
private TokenStorageInterface $tokenStorage;
|
||||
private Project $project;
|
||||
private User $actor;
|
||||
private User $alice;
|
||||
private User $bob;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$c = self::getContainer();
|
||||
$this->em = $c->get(EntityManagerInterface::class);
|
||||
$this->notifications = $c->get(NotificationRepository::class);
|
||||
$this->tokenStorage = $c->get(TokenStorageInterface::class);
|
||||
|
||||
$project = $this->em->getRepository(Project::class)->findOneBy([]);
|
||||
self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.');
|
||||
$this->project = $project;
|
||||
|
||||
$this->actor = $this->makeUser('actor');
|
||||
$this->alice = $this->makeUser('alice');
|
||||
$this->bob = $this->makeUser('bob');
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testAssignmentToOtherUserCreatesNotification(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$task->setAssignee($this->alice);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->notifications->findBy(['user' => $this->alice]);
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('task_assigned', $rows[0]->getType());
|
||||
self::assertStringContainsString((string) $task->getTitle(), (string) $rows[0]->getMessage());
|
||||
}
|
||||
|
||||
public function testSelfAssignmentCreatesNoNotification(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$task->setAssignee($this->actor);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(0, $this->notifications->findBy(['user' => $this->actor]));
|
||||
}
|
||||
|
||||
public function testReassignmentNotifiesOnlyNewAssignee(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$task->setAssignee($this->alice);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$task->setAssignee($this->bob);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->notifications->findBy(['user' => $this->alice]));
|
||||
self::assertCount(1, $this->notifications->findBy(['user' => $this->bob]));
|
||||
}
|
||||
|
||||
public function testAssigneeSetToNullCreatesNoNotificationForNull(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$task->setAssignee($this->alice);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$task->setAssignee(null);
|
||||
$this->em->flush();
|
||||
|
||||
// alice a reçu la 1re notif, mais le passage à null n'en crée aucune autre.
|
||||
self::assertCount(1, $this->notifications->findBy(['user' => $this->alice]));
|
||||
}
|
||||
|
||||
public function testNoActorCreatesNoNotification(): void
|
||||
{
|
||||
$this->tokenStorage->setToken(null);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$task->setAssignee($this->alice);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(0, $this->notifications->findBy(['user' => $this->alice]));
|
||||
}
|
||||
|
||||
private function makeUser(string $prefix): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($prefix.'-'.uniqid());
|
||||
$user->setPassword('x');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
$this->em->persist($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function makeTask(): Task
|
||||
{
|
||||
$task = new Task();
|
||||
$task->setNumber(random_int(100000, 999999));
|
||||
$task->setTitle('Tâche de test '.uniqid());
|
||||
$task->setProject($this->project);
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
private function loginAs(User $user): void
|
||||
{
|
||||
$this->tokenStorage->setToken(
|
||||
new UsernamePasswordToken($user, 'main', $user->getRoles()),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php`
|
||||
Expected: FAIL — aucune `Notification` créée (le listener n'existe pas encore), `assertCount(1, ...)` échoue.
|
||||
|
||||
- [ ] **Step 3: Créer le listener**
|
||||
|
||||
Créer `src/EventListener/TaskNotificationListener.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Event\PostFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
#[AsDoctrineListener(event: Events::postFlush)]
|
||||
final class TaskNotificationListener
|
||||
{
|
||||
/** @var list<array{user: User, type: string, task: Task}> */
|
||||
private array $pending = [];
|
||||
|
||||
public function __construct(private readonly Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$actor = $this->security->getUser();
|
||||
if (!$actor instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $args->getObjectManager()->getUnitOfWork();
|
||||
|
||||
// Assignation sur une tâche nouvellement créée.
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Task) {
|
||||
continue;
|
||||
}
|
||||
$assignee = $entity->getAssignee();
|
||||
if ($assignee instanceof User && $assignee !== $actor) {
|
||||
$this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity];
|
||||
}
|
||||
}
|
||||
|
||||
// Changement d'assignation sur une tâche existante.
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Task) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['assignee'])) {
|
||||
continue;
|
||||
}
|
||||
$new = $changeSet['assignee'][1];
|
||||
if ($new instanceof User && $new !== $actor) {
|
||||
$this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function postFlush(PostFlushEventArgs $args): void
|
||||
{
|
||||
if ([] === $this->pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pending = $this->pending;
|
||||
$this->pending = [];
|
||||
|
||||
$em = $args->getObjectManager();
|
||||
foreach ($pending as $item) {
|
||||
$em->persist($this->buildNotification($item['user'], $item['type'], $item['task']));
|
||||
}
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
private function buildNotification(User $user, string $type, Task $task): Notification
|
||||
{
|
||||
[$title, $message] = $this->render($type, $task);
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->setUser($user);
|
||||
$notification->setType($type);
|
||||
$notification->setTitle($title);
|
||||
$notification->setMessage($message);
|
||||
$notification->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function render(string $type, Task $task): array
|
||||
{
|
||||
$projectName = $task->getProject()?->getName() ?? '';
|
||||
$suffix = '' !== $projectName ? sprintf(' — %s', $projectName) : '';
|
||||
$context = sprintf('« %s »%s', (string) $task->getTitle(), $suffix);
|
||||
|
||||
return match ($type) {
|
||||
'task_assigned' => ['Nouvelle tâche assignée', $context],
|
||||
'task_collaborator_added' => ['Ajout à une tâche', $context],
|
||||
default => ['Notification', $context],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php
|
||||
git commit -m "feat(notification) : notifier le nouvel assigné d'une tâche"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Notifications d'ajout de collaborateur
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/EventListener/TaskNotificationListener.php` (méthode `onFlush`)
|
||||
- Test: `tests/Functional/EventListener/TaskNotificationListenerTest.php` (ajout de tests)
|
||||
|
||||
- [ ] **Step 1: Ajouter les tests collaborateurs (échouent)**
|
||||
|
||||
Ajouter ces deux méthodes dans `TaskNotificationListenerTest` :
|
||||
|
||||
```php
|
||||
public function testAddingCollaboratorCreatesNotification(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$task->addCollaborator($this->alice);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->notifications->findBy(['user' => $this->alice]);
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('task_collaborator_added', $rows[0]->getType());
|
||||
}
|
||||
|
||||
public function testAddingSelfAsCollaboratorCreatesNoNotification(): void
|
||||
{
|
||||
$this->loginAs($this->actor);
|
||||
|
||||
$task = $this->makeTask();
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
$task->addCollaborator($this->actor);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(0, $this->notifications->findBy(['user' => $this->actor]));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les nouveaux tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php --filter Collaborator`
|
||||
Expected: FAIL — `testAddingCollaboratorCreatesNotification` échoue (aucune notification créée).
|
||||
|
||||
- [ ] **Step 3: Étendre `onFlush` pour gérer les collaborateurs**
|
||||
|
||||
Dans `src/EventListener/TaskNotificationListener.php`, ajouter ce bloc à la fin de `onFlush()`, juste avant la fin de méthode (après la boucle `getScheduledEntityUpdates`) :
|
||||
|
||||
```php
|
||||
// Ajout de collaborateur(s) (tâche nouvelle ou existante).
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$owner = $collection->getOwner();
|
||||
if (!$owner instanceof Task) {
|
||||
continue;
|
||||
}
|
||||
if ('collaborators' !== $collection->getMapping()->fieldName) {
|
||||
continue;
|
||||
}
|
||||
foreach ($collection->getInsertDiff() as $user) {
|
||||
if ($user instanceof User && $user !== $actor) {
|
||||
$this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer toute la classe de tests pour vérifier qu'elle passe**
|
||||
|
||||
Run: `docker exec php-lesstime-fpm php bin/phpunit tests/Functional/EventListener/TaskNotificationListenerTest.php`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/EventListener/TaskNotificationListener.php tests/Functional/EventListener/TaskNotificationListenerTest.php
|
||||
git commit -m "feat(notification) : notifier les collaborateurs ajoutés à une tâche"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Vérification globale & style
|
||||
|
||||
**Files:** aucun nouveau fichier.
|
||||
|
||||
- [ ] **Step 1: Lancer la suite de tests complète**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (aucune régression).
|
||||
|
||||
- [ ] **Step 2: Corriger le style PHP**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
Expected: les nouveaux fichiers sont conformes (strict types, ordre des imports).
|
||||
|
||||
- [ ] **Step 3: Commit si php-cs-fixer a modifié des fichiers**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "style(notification) : php-cs-fixer sur le listener de notifications"
|
||||
```
|
||||
|
||||
(Sauter cette étape si php-cs-fixer n'a rien changé.)
|
||||
|
||||
---
|
||||
|
||||
## Self-review (auteur du plan)
|
||||
|
||||
- **Couverture spec :** assignation (création + update) ✔ Task 1 ; collaborateur ajouté ✔ Task 2 ; auto-exclusion ✔ (tests self) ; pas d'acteur → rien ✔ ; réassignation A→B ✔ ; `assignee=null` ✔ ; contenu réutilisant l'entité existante ✔ ; aucun changement front ✔.
|
||||
- **Placeholders :** aucun — tout le code (listener + tests) est complet.
|
||||
- **Cohérence des types :** `pending` typé `list<array{user,type,task}>` ; types `task_assigned` / `task_collaborator_added` identiques entre listener et tests ; `getMapping()->fieldName` (ORM 3) ; `addCollaborator()` confirmé sur l'entité Task.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
||||
# LST-61 (1.3) · Audit log — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
|
||||
|
||||
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
|
||||
|
||||
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
|
||||
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
|
||||
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
|
||||
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
|
||||
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
|
||||
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
|
||||
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
|
||||
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Créés :**
|
||||
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
|
||||
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
|
||||
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
|
||||
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
|
||||
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
||||
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
|
||||
- `tests/Functional/Module/Core/AuditListenerTest.php`
|
||||
- `tests/Functional/Module/Core/AuditLogApiTest.php`
|
||||
- `frontend/modules/core/services/audit-logs.ts`
|
||||
- `frontend/components/admin/AdminAuditTab.vue`
|
||||
|
||||
**Modifiés :**
|
||||
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
|
||||
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
|
||||
- `src/Module/Core/Domain/Entity/User.php` — `#[Auditable]` + `#[AuditIgnore]` password/apiToken
|
||||
- `src/Module/Core/Domain/Entity/Role.php` — `#[Auditable]`
|
||||
- `src/Module/Core/Domain/Entity/Permission.php` — `#[Auditable]`
|
||||
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
|
||||
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
|
||||
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
|
||||
|
||||
---
|
||||
|
||||
## Task A: Marquage + table + connexion DBAL audit
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
|
||||
- Create: `migrations/Version20260619XXXXXX.php`
|
||||
- Modify: `config/packages/doctrine.yaml`
|
||||
|
||||
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
|
||||
|
||||
- [ ] **Step A1: Attributs** — créer les deux fichiers :
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Shared/Domain/Attribute/Auditable.php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marker placed on a Doctrine entity to enable audit tracking.
|
||||
*
|
||||
* Located in Shared (not Core) so every module can use it without a
|
||||
* circular dependency on Core. Any migrated business entity that should be
|
||||
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_CLASS)]
|
||||
final class Auditable
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Shared/Domain/Attribute/AuditIgnore.php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marker placed on an entity property to exclude it from audit tracking.
|
||||
*
|
||||
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
|
||||
* carries an exact-match blacklist on the most dangerous names as
|
||||
* defense-in-depth, but the base rule is to annotate explicitly here.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class AuditIgnore
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Audit log (LST-61) : append-only `audit_log` table.
|
||||
*
|
||||
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
|
||||
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
|
||||
* flushes from the Doctrine listener. Columns are lowercase snake_case.
|
||||
* Additive only — no DROP/ALTER on existing tables.
|
||||
*/
|
||||
final class Version20260619XXXXXX extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Audit log: create append-only audit_log table + indexes (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_log (
|
||||
id uuid NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL,
|
||||
entity_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(10) NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
performed_by VARCHAR(100) NOT NULL,
|
||||
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
ip_address VARCHAR(45) DEFAULT NULL,
|
||||
request_id VARCHAR(36) DEFAULT NULL,
|
||||
PRIMARY KEY(id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE audit_log');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
|
||||
|
||||
Remplacer :
|
||||
```yaml
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
```
|
||||
par :
|
||||
```yaml
|
||||
dbal:
|
||||
default_connection: default
|
||||
connections:
|
||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
||||
# service) to write outside the ORM transaction so audit rows survive
|
||||
# an application-side rollback and avoid transactional entanglement.
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
||||
# from schema comparison so migrations:diff / schema:validate stay
|
||||
# clean. Creation/teardown stay driven by migrations.
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
```
|
||||
|
||||
Et remplacer le bloc `when@test` :
|
||||
```yaml
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
```
|
||||
par :
|
||||
```yaml
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# Propagate the _test suffix to BOTH connections: the audit
|
||||
# connection must write to the test DB, not the dev DB.
|
||||
connections:
|
||||
default:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
audit:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
```
|
||||
|
||||
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
|
||||
|
||||
- [ ] **Step A5: Appliquer la migration (dev + test)** :
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
|
||||
```
|
||||
Expected: table créée, `audit_log` absente de tout diff généré.
|
||||
|
||||
- [ ] **Step A6: Commit**
|
||||
```bash
|
||||
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
|
||||
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task B: AuditLogWriter + RequestIdProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
|
||||
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
|
||||
|
||||
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
|
||||
|
||||
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Provides an HTTP request identifier (UUID v4) shared by every audit row
|
||||
* produced during a single main request. Null in CLI (fixtures, batch).
|
||||
*/
|
||||
final class RequestIdProvider
|
||||
{
|
||||
private ?string $requestId = null;
|
||||
|
||||
#[AsEventListener(event: 'kernel.request')]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requestId = Uuid::v4()->toRfc4122();
|
||||
}
|
||||
|
||||
public function getRequestId(): ?string
|
||||
{
|
||||
return $this->requestId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Low-level service responsible for writing into the `audit_log` table.
|
||||
*
|
||||
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
|
||||
* outside the ORM transaction: audit rows survive an application-side
|
||||
* rollback and avoid transactional entanglement in batch (fixtures).
|
||||
*
|
||||
* Sensitive keys are stripped in defense-in-depth even when entities already
|
||||
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
|
||||
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
|
||||
* business flow.
|
||||
*/
|
||||
final class AuditLogWriter
|
||||
{
|
||||
/** @var list<string> keys always stripped from the `changes` payload */
|
||||
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.audit_connection')]
|
||||
private readonly Connection $connection,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RequestIdProvider $requestIdProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $entityType Format "module.Entity" (e.g. "core.User")
|
||||
* @param string $entityId Entity id (int or serialized UUID)
|
||||
* @param string $action create|update|delete
|
||||
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
|
||||
*/
|
||||
public function log(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $action,
|
||||
array $changes,
|
||||
): void {
|
||||
$filteredChanges = $this->stripSensitive($changes);
|
||||
|
||||
$this->connection->insert('audit_log', [
|
||||
'id' => Uuid::v7()->toRfc4122(),
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'action' => $action,
|
||||
'changes' => $filteredChanges,
|
||||
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
|
||||
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
|
||||
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
|
||||
'request_id' => $this->requestIdProvider->getRequestId(),
|
||||
], [
|
||||
'id' => Types::GUID,
|
||||
'changes' => Types::JSON,
|
||||
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively removes sensitive keys from the payload.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function stripSensitive(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
||||
unset($data[$key]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->stripSensitive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
|
||||
```
|
||||
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
|
||||
|
||||
- [ ] **Step B4: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/Infrastructure/Audit/
|
||||
git commit -m "feat(core) : add audit log writer and request id provider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task C: AuditListener + marquage des entités Core
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
|
||||
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
|
||||
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
|
||||
|
||||
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
|
||||
|
||||
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)** — `tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditListenerTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $auditConnection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
|
||||
// Clean slate for deterministic assertions.
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
}
|
||||
|
||||
public function testCreateUserIsAudited(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_create_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('create', $rows[0]['action']);
|
||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
||||
self::assertArrayHasKey('username', $changes);
|
||||
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
|
||||
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
|
||||
}
|
||||
|
||||
public function testUpdateUserIsAuditedWithDiff(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_update_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
|
||||
$user->setFirstName('Changed');
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('update', $rows[0]['action']);
|
||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
||||
self::assertArrayHasKey('firstName', $changes);
|
||||
self::assertSame('Changed', $changes['firstName']['new']);
|
||||
}
|
||||
|
||||
public function testDeleteUserIsAudited(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_delete_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$id = (string) $user->getId();
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
|
||||
$this->em->remove($user);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', $id);
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('delete', $rows[0]['action']);
|
||||
}
|
||||
|
||||
private function makeUser(string $username): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setPassword('hashed-secret');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fetchLogs(string $entityType, string $entityId): array
|
||||
{
|
||||
return $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
|
||||
['t' => $entityType, 'id' => $entityId],
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
unset($this->em, $this->auditConnection);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
|
||||
|
||||
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
||||
```
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
|
||||
|
||||
- [ ] **Step C4: Marquer les entités Core.**
|
||||
|
||||
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
|
||||
```php
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||
```
|
||||
```php
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
class User implements ...
|
||||
```
|
||||
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
|
||||
|
||||
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
|
||||
|
||||
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
|
||||
|
||||
- [ ] **Step C5: Run le test → succès** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
||||
```
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step C6: Suite complète + cs-fixer** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
Expected: tout vert.
|
||||
|
||||
- [ ] **Step C7: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
|
||||
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task D: API de lecture `/api/audit-logs` + permission
|
||||
|
||||
**Files:**
|
||||
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
|
||||
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
|
||||
|
||||
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
|
||||
|
||||
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
|
||||
```php
|
||||
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
||||
```
|
||||
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
|
||||
|
||||
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
|
||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
||||
|
||||
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
|
||||
|
||||
- [ ] **Step D3: Écrire le test API (échec attendu)** — `tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
|
||||
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
|
||||
- filtre `?action=update` → ne renvoie que des updates.
|
||||
- filtre `?entity_type=core.User`.
|
||||
- `?action=bogus` → 400.
|
||||
- utilisateur sans permission (alice) : 403.
|
||||
- non authentifié : 401.
|
||||
|
||||
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
|
||||
|
||||
- [ ] **Step D4: Run → échec, puis vérifier la route** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
|
||||
```
|
||||
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
|
||||
|
||||
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
|
||||
```
|
||||
|
||||
- [ ] **Step D6: Suite complète + cs-fixer**
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step D7: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/ tests/
|
||||
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task E: Front — page consultation gated RBAC
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
|
||||
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
|
||||
|
||||
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
|
||||
|
||||
- [ ] **Step E1: Service** — `frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
|
||||
|
||||
- [ ] **Step E2: Composant onglet** — `frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
|
||||
|
||||
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
|
||||
|
||||
- [ ] **Step E4: i18n** — `frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
|
||||
|
||||
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
|
||||
```bash
|
||||
cd frontend && npx nuxt build 2>&1 | tail -5
|
||||
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
|
||||
```
|
||||
Expected: build OK (la page admin reste enregistrée).
|
||||
|
||||
- [ ] **Step E6: Commit**
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task F: Validation finale + statut
|
||||
|
||||
- [ ] **Step F1: Suite complète verte + login fumée**
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
|
||||
|
||||
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
|
||||
```
|
||||
Expected: 0.
|
||||
|
||||
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
|
||||
|
||||
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
|
||||
|
||||
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture spec)
|
||||
|
||||
| Critère d'acceptation | Tâche |
|
||||
|---|---|
|
||||
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
|
||||
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
|
||||
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
|
||||
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
|
||||
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
|
||||
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
|
||||
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
|
||||
|
||||
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
|
||||
@@ -0,0 +1,976 @@
|
||||
# LST-62 (0.2) — Socle front : shell + auto-détection des layers Nuxt — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Poser l'ossature frontend modulaire (shell `app/`, code partagé `shared/`, auto-détection des layers `modules/*/`, sidebar dynamique alimentée par `/api/sidebar`, redirection des routes désactivées) **sans déplacer aucune page métier** — l'app reste « plate » et la navigation ne régresse pas.
|
||||
|
||||
**Architecture:** On s'aligne sur le pattern Starseed : `srcDir: '.'`, layouts/middleware sous `frontend/app/`, composables/stores transverses sous `frontend/shared/` (auto-importés via `imports.dirs`), et un scan `readdirSync('modules/')` qui ajoute chaque `modules/*/` à `extends`. Le backend `/api/modules` + `/api/sidebar` existe déjà (LST-56). On ajoute un **gate de rôle minimal** côté `SidebarProvider`/`SidebarFilter` (ROLE_ADMIN) pour préserver la visibilité de l'Administration sans attendre le RBAC fin (#1.2). Les items **contextuels** (Kanban/Groupes/Archives), **feature-flag** (Documents, Mail) et **user-flag** (Mes absences) restent rendus côté layout, hors `/api/sidebar`.
|
||||
|
||||
**Tech Stack:** Nuxt 4.3, Vue 3.5, Pinia 3, @malio/layer-ui 1.7, @nuxtjs/i18n 10, @nuxt/icon — côté back PHP 8.4 / Symfony 8 / API Platform 4 / PHPUnit 13.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Aucune page métier déplacée** : `frontend/pages/` reste tel quel ; on ne crée AUCUN `frontend/modules/<x>/pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan).
|
||||
- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only).
|
||||
- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement.
|
||||
- **Commits** : format `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers).
|
||||
- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`.
|
||||
- **TS** : strict, 4 espaces d'indentation, pas de `any`.
|
||||
- **Pas de migration BDD** dans ce lot (aucune entité touchée).
|
||||
|
||||
## Décisions de conception (actées avec le PO)
|
||||
|
||||
1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER.
|
||||
2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui.
|
||||
3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu.
|
||||
|
||||
## Vérification (pas de runner de tests JS dans ce projet)
|
||||
|
||||
- **Back (Task 1)** : vraie TDD PHPUnit.
|
||||
- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt (en LECTURE différentielle, cf. ci-dessous) + smoke test runtime. Commandes :
|
||||
- Typecheck : `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
- Runtime : dev server `make dev-nuxt` (port 3002, proxy `/api` → nginx) ; vérifier manuellement la navigation + `curl` des endpoints via nginx (`http://localhost:8082/api/...`). Les containers sont up.
|
||||
|
||||
> **⚠️ `nuxt typecheck` n'est PAS un gate vert sur ce projet (constat 2026-06-19).** Le baseline Lesstime est déjà rouge (~230 lignes `error TS`), et le projet de référence **Starseed (même Nuxt 4.3.1, même layout `shared/` + `srcDir: '.'`) ship en prod avec 325 erreurs `error TS`**. Ces erreurs sont des classes structurelles attendues, pas des régressions :
|
||||
> - dans `shared/composables/*` et `shared/stores/*` : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'…` — Nuxt 4 type le dossier `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les rend bien disponibles au RUNTIME (vérifié dans `.nuxt/imports.d.ts`). Starseed a exactement ces 15 erreurs et fonctionne.
|
||||
> - dans `nuxt.config.ts` : `node:fs`/`node:path`/`__dirname`/`process` (pas de `@types/node` — comme Starseed) ; ce fichier est compilé par Nuxt au runtime, pas par `tsc`.
|
||||
> - dans `useApi.ts` : `Property 'url' does not exist…` (préexistant, code forké de Starseed).
|
||||
>
|
||||
> **Le vrai gate front** = (1) **ZÉRO erreur `Cannot find module '~/shared/…'` / chemin cassé** (sinon un import a vraiment été cassé par un déplacement) ; (2) les auto-imports attendus présents dans `.nuxt/imports.d.ts` ; (3) smoke runtime sur le dev server. Ne JAMAIS s'arrêter sur les classes d'erreurs structurelles ci-dessus — elles sont identiques à la référence Starseed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — gate de rôle dans la sidebar (`roles`) + config complète
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (signature + gate `roles`)
|
||||
- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (injecter `Security`, passer les rôles)
|
||||
- Modify: `config/sidebar.php` (navigation globale + section Administration gated ROLE_ADMIN ; retrait de `/absences` qui reste client-side)
|
||||
- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (adapter à la nouvelle signature + cas `roles`)
|
||||
- Modify: `tests/Functional/Shared/SidebarEndpointTest.php` (vérifier le gate admin)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : `SidebarFilter::filter(array $sections, array $activeModuleIds, array $activeRoles = []): array`. Règles ajoutées : une **section** ou un **item** portant une clé `roles` (non vide) n'est conservé que si `$activeRoles` contient au moins un des rôles listés ; sinon la section/l'item est retiré (les `to` des items retirés **par rôle** ne sont PAS ajoutés à `disabledRoutes` — `disabledRoutes` reste réservé au filtrage **par module**, qui pilote la redirection front). Les clés internes `module` et `roles` sont retirées de la sortie.
|
||||
- Consumes : `Symfony\Bundle\SecurityBundle\Security` (rôles via `getUser()`).
|
||||
|
||||
- [ ] **Step 1: Adapter le test unitaire existant + ajouter les cas `roles`**
|
||||
|
||||
Remplace INTÉGRALEMENT `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Sidebar;
|
||||
|
||||
use App\Shared\Domain\Sidebar\SidebarFilter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SidebarFilterTest extends TestCase
|
||||
{
|
||||
public function testItemWithoutModuleIsAlwaysVisible(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
|
||||
['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]);
|
||||
}
|
||||
|
||||
public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
|
||||
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertSame([], $result['sections']);
|
||||
self::assertSame(['/time-tracking'], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testItemWithActiveModuleIsVisible(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
|
||||
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, ['time_tracking'], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testSectionWithRolesIsHiddenWhenRoleMissing(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
|
||||
['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertSame([], $result['sections']);
|
||||
// Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module).
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testSectionWithRolesIsVisibleWhenRolePresent(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
|
||||
['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/admin', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertArrayNotHasKey('roles', $result['sections'][0]);
|
||||
}
|
||||
|
||||
public function testItemWithRolesIsHiddenWhenRoleMissing(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [
|
||||
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']],
|
||||
['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertCount(1, $result['sections'][0]['items']);
|
||||
self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
|
||||
Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges).
|
||||
|
||||
- [ ] **Step 3: Étendre `SidebarFilter`**
|
||||
|
||||
Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Sidebar;
|
||||
|
||||
final class SidebarFilter
|
||||
{
|
||||
/**
|
||||
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
|
||||
* @param list<string> $activeModuleIds
|
||||
* @param list<string> $activeRoles
|
||||
*
|
||||
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
|
||||
*/
|
||||
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
|
||||
{
|
||||
$outSections = [];
|
||||
$disabledRoutes = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
// Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module).
|
||||
if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($section['items'] as $item) {
|
||||
// Gate de rôle au niveau item.
|
||||
if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
|
||||
$module = $item['module'] ?? null;
|
||||
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
|
||||
$disabledRoutes[] = $item['to'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
|
||||
}
|
||||
|
||||
if ([] !== $items) {
|
||||
$outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
|
||||
}
|
||||
}
|
||||
|
||||
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $required
|
||||
* @param list<string> $activeRoles
|
||||
*/
|
||||
private static function rolesSatisfied(?array $required, array $activeRoles): bool
|
||||
{
|
||||
if (null === $required || [] === $required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($required as $role) {
|
||||
if (in_array($role, $activeRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer le test unitaire, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`**
|
||||
|
||||
Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Shared\Domain\Module\ModuleRegistry;
|
||||
use App\Shared\Domain\Sidebar\SidebarFilter;
|
||||
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class SidebarProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private string $projectDir,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
|
||||
{
|
||||
/** @var list<class-string> $moduleClasses */
|
||||
$moduleClasses = require $this->projectDir.'/config/modules.php';
|
||||
|
||||
/** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sidebar */
|
||||
$sidebar = require $this->projectDir.'/config/sidebar.php';
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$roles = null !== $user ? $user->getRoles() : [];
|
||||
|
||||
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles));
|
||||
|
||||
$dto = new SidebarResource();
|
||||
$dto->sections = $filtered['sections'];
|
||||
$dto->disabledRoutes = $filtered['disabledRoutes'];
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Compléter `config/sidebar.php`**
|
||||
|
||||
Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
|
||||
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
|
||||
* le RBAC fin par permission arrive en #1.2).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||
*/
|
||||
return [
|
||||
[
|
||||
'label' => 'sidebar.general.section',
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.admin.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)**
|
||||
|
||||
Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Shared;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SidebarEndpointTest extends WebTestCase
|
||||
{
|
||||
public function testSidebarRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/sidebar');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testSidebarReturnsSectionsForAuthenticatedUser(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertArrayHasKey('sections', $data);
|
||||
self::assertArrayHasKey('disabledRoutes', $data);
|
||||
self::assertNotEmpty($data['sections']);
|
||||
}
|
||||
|
||||
public function testAdminSectionHiddenForNonAdmin(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$labels = array_column($data['sections'], 'label');
|
||||
|
||||
self::assertNotContains('sidebar.admin.section', $labels);
|
||||
}
|
||||
|
||||
public function testAdminSectionVisibleForAdmin(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$labels = array_column($data['sections'], 'label');
|
||||
|
||||
self::assertContains('sidebar.admin.section', $labels);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Lancer la suite complète, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md).
|
||||
|
||||
- [ ] **Step 9: php-cs-fixer + commit**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php
|
||||
git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/shared/types/sidebar.ts`
|
||||
- Create: `frontend/shared/composables/useModules.ts`
|
||||
- Create: `frontend/shared/composables/useSidebar.ts`
|
||||
|
||||
> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers).
|
||||
|
||||
**Interfaces:**
|
||||
- Produces :
|
||||
- `useModules(): { activeModuleIds: Ref<string[]>, loaded: Ref<boolean>, loadModules(): Promise<void>, isModuleActive(id: string): boolean, resetModules(): void }`
|
||||
- `useSidebar(): { sections: Ref<SidebarSection[]>, disabledRoutes: Ref<string[]>, loaded: Ref<boolean>, loadSidebar(): Promise<void>, isRouteDisabled(path: string): boolean, resetSidebar(): void }`
|
||||
- `SidebarSection`, `SidebarItem` (types).
|
||||
- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom).
|
||||
|
||||
- [ ] **Step 1: Créer les types**
|
||||
|
||||
`frontend/shared/types/sidebar.ts` :
|
||||
|
||||
```ts
|
||||
export type SidebarItem = {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type SidebarSection = {
|
||||
label: string
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Créer `useModules`**
|
||||
|
||||
`frontend/shared/composables/useModules.ts` (état singleton au niveau module) :
|
||||
|
||||
```ts
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules(): Promise<void> {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules(): void {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
|
||||
}
|
||||
```
|
||||
|
||||
> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get<T>(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`.
|
||||
|
||||
- [ ] **Step 3: Créer `useSidebar`**
|
||||
|
||||
`frontend/shared/composables/useSidebar.ts` :
|
||||
|
||||
```ts
|
||||
import type { SidebarSection } from '~/shared/types/sidebar'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
const disabledRoutes = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSidebar() {
|
||||
async function loadSidebar(): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
function isRouteDisabled(path: string): boolean {
|
||||
return disabledRoutes.value.some(
|
||||
(disabled) => path === disabled || path.startsWith(disabled + '/'),
|
||||
)
|
||||
}
|
||||
|
||||
function resetSidebar(): void {
|
||||
sections.value = []
|
||||
disabledRoutes.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts
|
||||
git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/`
|
||||
|
||||
**Files:**
|
||||
- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts`
|
||||
- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts`
|
||||
- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts`
|
||||
|
||||
> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config).
|
||||
|
||||
- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p shared/stores
|
||||
git mv composables/useApi.ts shared/composables/useApi.ts
|
||||
git mv stores/auth.ts shared/stores/auth.ts
|
||||
git mv stores/ui.ts shared/stores/ui.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"`
|
||||
Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction.
|
||||
|
||||
> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/nuxt.config.ts`
|
||||
- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan)
|
||||
- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue)
|
||||
- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé.
|
||||
|
||||
- [ ] **Step 1: Déplacer layouts et middleware sous `app/`**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p app modules
|
||||
git mv layouts app/layouts
|
||||
git mv middleware app/middleware
|
||||
touch modules/.gitkeep
|
||||
git add modules/.gitkeep
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Réécrire `nuxt.config.ts`**
|
||||
|
||||
Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) :
|
||||
|
||||
```ts
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
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}`)
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/composables`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
const moduleStoreDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/stores`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
srcDir: '.',
|
||||
css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
: '/',
|
||||
},
|
||||
extends: ['@malio/layer-ui', ...moduleLayers],
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon',
|
||||
],
|
||||
dir: {
|
||||
layouts: 'app/layouts',
|
||||
middleware: 'app/middleware',
|
||||
},
|
||||
imports: {
|
||||
dirs: [
|
||||
'shared/composables',
|
||||
'shared/stores',
|
||||
'shared/utils',
|
||||
'composables',
|
||||
'stores',
|
||||
'utils',
|
||||
...moduleComposableDirs,
|
||||
...moduleStoreDirs,
|
||||
],
|
||||
},
|
||||
pinia: {
|
||||
storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'],
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
components: [
|
||||
{ path: '~/components', pathPrefix: false },
|
||||
],
|
||||
// ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`,
|
||||
// `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés).
|
||||
typescript: { strict: true },
|
||||
build: { transpile: ['@vuepic/vue-datepicker'] },
|
||||
})
|
||||
```
|
||||
|
||||
> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`.
|
||||
|
||||
- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur. Pièges probables :
|
||||
- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`.
|
||||
- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`.
|
||||
- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2).
|
||||
|
||||
- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante.
|
||||
Expected: app fonctionnelle, identique à avant (les déplacements sont transparents).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout)
|
||||
- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés).
|
||||
|
||||
- [ ] **Step 1: Étendre `auth.global.ts`**
|
||||
|
||||
Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par :
|
||||
|
||||
```ts
|
||||
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('/')
|
||||
}
|
||||
|
||||
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
} else {
|
||||
// Logout / session expirée : purge l'état partagé pour le prochain login.
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Créer `modules.global.ts`**
|
||||
|
||||
`frontend/app/middleware/modules.global.ts` :
|
||||
|
||||
```ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
|
||||
if (isRouteDisabled(to.path)) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées).
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection**
|
||||
|
||||
Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub).
|
||||
Expected: appels présents, redirection effective.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts
|
||||
git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/layouts/default.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM.
|
||||
|
||||
> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le `<script setup>` non lié à la sidebar (timer, drawer, head, mail polling, refData) est conservé à l'identique.
|
||||
|
||||
- [ ] **Step 1: Réécrire le bloc `<nav>` et l'en-tête du `<script setup>` de `frontend/app/layouts/default.vue`**
|
||||
|
||||
Dans le `<template>`, remplace le contenu de `<nav class="flex-1 overflow-hidden" …>…</nav>` (lignes ~40-167 de l'original) par :
|
||||
|
||||
```vue
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
|
||||
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
{{ section.label }}
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
|
||||
<template v-if="sIndex === 0">
|
||||
<!-- Contextuel projet -->
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
<!-- Feature-flag : Documents -->
|
||||
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<!-- Feature-flag : Mail + badge -->
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
|
||||
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
```
|
||||
|
||||
Dans le `<script setup lang="ts">`, **ajoute** en tête (après les `useXxxStore()` existants) :
|
||||
|
||||
```ts
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
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,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
```
|
||||
|
||||
**Conserve** tout le reste du `<script setup>` (`isAdmin`, `isEmployee`, `isMailVisible`, `isDocumentsVisible`, `currentProjectId`, `sidebarIsCollapsed`, timer/drawer/head/mail/refData…) et le `<style scoped>` à l'identique. `isAdmin`/`isAbsenceSectionVisible` deviennent inutilisés pour la sidebar (l'admin est gated côté serveur) — si le typecheck signale une variable inutilisée, retirer `isAbsenceSectionVisible` ; garder `isAdmin` s'il sert ailleurs, sinon le retirer aussi.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur (corriger toute variable / tout import inutilisé signalé).
|
||||
|
||||
- [ ] **Step 3: Smoke test visuel — non-régression de navigation**
|
||||
|
||||
Dev server. Se connecter successivement :
|
||||
- `alice` (ROLE_USER) : sidebar affiche Tableau de bord / Mes tâches / Projets / Suivi de temps (dynamiques), + Documents/Mail si visibles, + Mes absences si employé ; **PAS** de section Administration ni Absences équipe.
|
||||
- `admin` (ROLE_ADMIN) : en plus, section **Administration** avec Absences équipe + Administration.
|
||||
- Entrer dans un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent (contextuel conservé).
|
||||
Expected: tous les liens d'avant atteignables ; gating admin respecté. Noter tout délta visuel (ordre) pour validation PO (cf. décision 3).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/layouts/default.vue
|
||||
git commit -m "feat(front) : render dynamic sidebar from /api/sidebar in default layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — clés i18n `sidebar.*` + vérification bout-en-bout
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json` (ajouter le namespace `sidebar`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : les labels renvoyés par `/api/sidebar` (`sidebar.general.*`, `sidebar.admin.*`) traduits par `t()` dans `translatedSections`.
|
||||
|
||||
- [ ] **Step 1: Repérer la structure du fichier i18n**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && head -20 i18n/locales/fr.json`
|
||||
Objectif : connaître l'indentation et confirmer que c'est un objet JSON imbriqué (ajouter une clé racine `sidebar`).
|
||||
|
||||
- [ ] **Step 2: Ajouter le namespace `sidebar`**
|
||||
|
||||
Ajoute (à la racine de l'objet JSON, en respectant l'indentation existante) :
|
||||
|
||||
```json
|
||||
"sidebar": {
|
||||
"general": {
|
||||
"section": "Gestion de projet",
|
||||
"dashboard": "Tableau de bord",
|
||||
"myTasks": "Mes tâches",
|
||||
"projects": "Projets",
|
||||
"timeTracking": "Suivi de temps"
|
||||
},
|
||||
"admin": {
|
||||
"section": "Administration",
|
||||
"teamAbsences": "Absences équipe",
|
||||
"administration": "Administration"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Les libellés reprennent ceux du layout actuel. `sidebar.general.section` = « Gestion de projet » (regroupe désormais le Tableau de bord — délta cosmétique acté, décision 3).
|
||||
|
||||
- [ ] **Step 3: Typecheck + smoke i18n**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Dev server : confirmer que les en-têtes/labels de sidebar s'affichent **traduits** (pas les clés brutes `sidebar.general.*`).
|
||||
Expected: libellés FR corrects.
|
||||
|
||||
- [ ] **Step 4: Vérification bout-en-bout de l'activation/désactivation (AC)**
|
||||
|
||||
Test manuel documenté (aucun module réel en 0.2) :
|
||||
1. Ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'`, `'to' => '/projects'` (route existante) dans une section visible.
|
||||
2. `config/modules.php` reste vide (module `demo` inactif) → `GET /api/sidebar` doit lister `/projects` dans `disabledRoutes` et masquer l'item ; naviguer vers `/projects` doit rediriger vers `/`.
|
||||
3. Ajouter une classe `DemoModule implements ModuleInterface { id()='demo' … }` + `config/modules.php` = `[DemoModule::class]` → l'item réapparaît, `/projects` n'est plus dans `disabledRoutes`, la navigation fonctionne.
|
||||
4. **Tout retirer** (item démo + DemoModule + entrée modules.php). Confirmer l'état initial.
|
||||
Documenter le résultat dans le message de fin. **Ne rien committer de ce stub.**
|
||||
|
||||
- [ ] **Step 5: Suite back + cs-fixer (non-régression globale) + commit**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: vert (inchangé vs Task 1).
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` → 0 erreur.
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(front) : add sidebar i18n labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance check (après toutes les tasks)
|
||||
|
||||
- [ ] `frontend/app/{layouts,middleware}`, `frontend/shared/{composables,stores,types}`, `frontend/modules/` (vide) en place ; `nuxt.config.ts` scanne `modules/*/`.
|
||||
- [ ] Sidebar **dynamique** alimentée par `/api/sidebar` pour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pour `alice`).
|
||||
- [ ] Route d'un module désactivé → **redirigée** vers `/` (vérifié via le stub démo).
|
||||
- [ ] **Aucune page métier déplacée** ; `frontend/pages/` intact ; tous les liens actuels atteignables.
|
||||
- [ ] `npx nuxt typecheck` = 0 erreur ; suite PHPUnit verte ; aucune migration BDD.
|
||||
- [ ] Délta cosmétique d'ordre de sidebar présenté au PO pour validation.
|
||||
|
||||
## Notes pour le ticket suivant (1.1 — Module Core)
|
||||
|
||||
Le 1.1 migrera `User`/Auth dans `src/Module/Core/`, re-pointera `resolve_target_entities` vers `Module\Core\User`, déclarera `CoreModule` (REQUIRED) dans `config/modules.php`, et créera le premier vrai layer front `frontend/modules/core/` (login, profile, admin users) — c'est là que le scan de layers et `useModules`/`useSidebar` prennent tout leur sens (premier item de sidebar avec une clé `module` réelle).
|
||||
@@ -0,0 +1,732 @@
|
||||
# LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & Notifications — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Migrer l'identité (`User` + Auth/JWT + password hashing + `MeProvider`) et les notifications dans `src/Module/Core/`, exposer le contrat `UserInterface` enrichi + `NotifierInterface`, déclarer `CoreModule` (REQUIRED), et créer le premier vrai layer front `modules/core/` — **sans aucune migration destructive et sans casser le login à aucune étape**.
|
||||
|
||||
**Architecture:** Strangler 100 % additif, phasé. On déplace physiquement la classe `User` vers `App\Module\Core\Domain\Entity\User` (table `user` inchangée → zéro migration), on re-pointe `resolve_target_entities` et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret `App\Entity\User` vers le **contrat** `App\Shared\Domain\Contract\UserInterface` (enrichi des accessors réellement utilisés). Les notifications passent par un `NotifierInterface` (impl Core). Chaque phase laisse `make test` vert ET le login JWT fonctionnel (re-vérifié par curl).
|
||||
|
||||
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **`declare(strict_types=1);`** en tête de chaque fichier PHP.
|
||||
- **Zéro migration destructive** : le déplacement de namespace ne change ni la table (`user`) ni les colonnes → `doctrine:migrations:diff` doit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration.
|
||||
- **Login JWT fonctionnel à chaque phase** : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant `User`/sécurité.
|
||||
- **AC ticket** : (1) login/JWT OK via le module ; (2) aucun `use App\Entity\User;` hors `src/Module/Core/` ; (3) `make test` vert, aucune migration destructive.
|
||||
- **Commits** : `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic.
|
||||
- **`config/reference.php`** : auto-généré, **jamais committé** (apparaît modifié dans `git status`).
|
||||
- **Tests** : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. Baseline avant ce ticket : **115 tests, 227 assertions** (16 PHPUnit Notices préexistantes, non bloquantes).
|
||||
- **Front** : `nuxt typecheck` n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro `Cannot find module`, auto-imports présents dans `.nuxt/imports.d.ts`, smoke runtime.
|
||||
- **PostgreSQL** : noms de colonnes en minuscules dans le SQL brut.
|
||||
|
||||
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
|
||||
|
||||
```bash
|
||||
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
|
||||
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
|
||||
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
|
||||
-o /dev/null -w "login http=%{http_code}\n"
|
||||
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
|
||||
# MCP apiToken (ApiTokenAuthenticator) — admin
|
||||
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
|
||||
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
|
||||
```
|
||||
Attendu : `login http=204`, `me http=200` avec le JSON de l'utilisateur (`username`, `roles`), MCP répond (200). **Si l'un casse, arrêter la phase et corriger avant de committer.**
|
||||
|
||||
## Décisions de conception (actées, à valider PO a posteriori)
|
||||
|
||||
1. **`UserInterface` enrichi (contrat de lecture)** — plutôt que de garder `App\Entity\User` partout, on enrichit `App\Shared\Domain\Contract\UserInterface` des accessors **réellement consommés** hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.
|
||||
2. **Move physique, table inchangée** — `User` change de namespace mais garde `#[ORM\Table(name: '`user`')]` et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mapping `Core`).
|
||||
3. **Relations via le contrat** — les 8 entités passent à `targetEntity: UserInterface::class` + type `?UserInterface`, résolu par `resolve_target_entities → Core\User`. C'est le pattern Starseed.
|
||||
4. **Notification dans Core + `NotifierInterface`** — `Notification` migre dans Core (couplée à l'identité) ; la création de notif passe par `NotifierInterface` (impl Core), `TaskNotificationListener` (qui reste legacy en Phase D) en dépend par contrat. L'API REST `/api/notifications` est préservée à l'identique.
|
||||
5. **Front layer `modules/core/`** — login, profile, admin users **déplacés** de `frontend/pages/` vers `frontend/modules/core/pages/` (premier layer réel ; le scan `readdirSync('modules/')` de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Squelette Core + contrats (100 % additif, app inchangée)
|
||||
|
||||
### Task 1: `CoreModule` + `UserRepositoryInterface` + `NotifierInterface` + contrat `UserInterface` enrichi
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/CoreModule.php`
|
||||
- Create: `src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
- Create: `src/Shared/Domain/Contract/NotifierInterface.php`
|
||||
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (enrichir)
|
||||
- Create: `tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces :
|
||||
- `App\Module\Core\CoreModule implements ModuleInterface` : `id()='core'`, `label()='Core'`, `isRequired()=true`, `permissions()` (stub pour 1.2, voir code).
|
||||
- `App\Module\Core\Domain\Repository\UserRepositoryInterface` : `findByRole(string $role): array`, `findActiveEmployees(\DateTimeInterface $date): array`, `findOneByUsername(string $username): ?UserInterface`.
|
||||
- `App\Shared\Domain\Contract\NotifierInterface` : `notify(UserInterface $user, string $type, string $title, string $message): void`.
|
||||
- `UserInterface` enrichi (lecture) : `getId(): ?int`, `getUserIdentifier(): string`, `getUsername(): string`, `getRoles(): array`, `getFirstName(): ?string`, `getLastName(): ?string`, `getAvatarUrl(): ?string`, `isEmployee(): bool`.
|
||||
- Consumes : `App\Shared\Domain\Module\ModuleInterface` (existant).
|
||||
|
||||
- [ ] **Step 1: Écrire le test unitaire `CoreModule`**
|
||||
|
||||
`tests/Unit/Module/Core/CoreModuleTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Module\Core;
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoreModuleTest extends TestCase
|
||||
{
|
||||
public function testItIsAModule(): void
|
||||
{
|
||||
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
|
||||
}
|
||||
|
||||
public function testIdentity(): void
|
||||
{
|
||||
self::assertSame('core', CoreModule::id());
|
||||
self::assertTrue(CoreModule::isRequired());
|
||||
self::assertNotSame('', CoreModule::label());
|
||||
}
|
||||
|
||||
public function testPermissionsAreWellFormed(): void
|
||||
{
|
||||
foreach (CoreModule::permissions() as $permission) {
|
||||
self::assertArrayHasKey('code', $permission);
|
||||
self::assertArrayHasKey('label', $permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
Expected: FAIL (classe `CoreModule` inexistante).
|
||||
|
||||
- [ ] **Step 3: Créer `CoreModule`**
|
||||
|
||||
`src/Module/Core/CoreModule.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
final class CoreModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'core';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Core';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
|
||||
*
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
|
||||
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
|
||||
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Confirmer la signature EXACTE de `ModuleInterface` (`src/Shared/Domain/Module/ModuleInterface.php`) avant d'écrire : la cartographie indique `id()`, `label()`, `isRequired()`, `permissions()` statiques. Si une méthode diffère (ex. non statique), aligner `CoreModule` ET le test dessus.
|
||||
|
||||
- [ ] **Step 4: Lancer le test, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Enrichir le contrat `UserInterface`**
|
||||
|
||||
Remplace `src/Shared/Domain/Contract/UserInterface.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat de LECTURE de l'identité, consommé hors du module Core.
|
||||
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
|
||||
*/
|
||||
interface UserInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getUserIdentifier(): string;
|
||||
|
||||
public function getUsername(): string;
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array;
|
||||
|
||||
public function getFirstName(): ?string;
|
||||
|
||||
public function getLastName(): ?string;
|
||||
|
||||
public function getAvatarUrl(): ?string;
|
||||
|
||||
public function isEmployee(): bool;
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité `User` (la cartographie confirme `getId`, `getUserIdentifier`, `getUsername`, `getRoles`, `getFirstName`, `getLastName`, `getAvatarUrl`, `isEmployee`). Si une signature diffère (ex. `getAvatarUrl(): string` non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de `User`.
|
||||
|
||||
- [ ] **Step 6: Créer `UserRepositoryInterface`**
|
||||
|
||||
`src/Module/Core/Domain/Repository/UserRepositoryInterface.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Repository;
|
||||
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
|
||||
interface UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return list<UserInterface>
|
||||
*/
|
||||
public function findByRole(string $role): array;
|
||||
|
||||
/**
|
||||
* @return list<UserInterface>
|
||||
*/
|
||||
public function findActiveEmployees(\DateTimeInterface $date): array;
|
||||
|
||||
public function findOneByUsername(string $username): ?UserInterface;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Créer `NotifierInterface`**
|
||||
|
||||
`src/Shared/Domain/Contract/NotifierInterface.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
interface NotifierInterface
|
||||
{
|
||||
public function notify(UserInterface $user, string $type, string $title, string $message): void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Suite complète + commit**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité `User` implémente déjà ces méthodes ; `resolve_target_entities` pointe encore `App\Entity\User`).
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
|
||||
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Déplacer `User` + Auth dans Core (re-pointage, zéro migration)
|
||||
|
||||
### Task 2: Déplacer la classe `User` vers Core + mapping Doctrine + provider sécurité
|
||||
|
||||
**Files:**
|
||||
- Move: `src/Entity/User.php` → `src/Module/Core/Domain/Entity/User.php` (namespace `App\Module\Core\Domain\Entity`)
|
||||
- Modify: `config/packages/doctrine.yaml` (mapping `Core` + `resolve_target_entities`)
|
||||
- Modify: `config/packages/security.yaml` (`app_user_provider.entity.class`)
|
||||
- Modify: `config/packages/api_platform.yaml` (mapping paths : ajouter le dir entité Core)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : entité `App\Module\Core\Domain\Entity\User` (table `user` inchangée), résolue par `resolve_target_entities`.
|
||||
|
||||
- [ ] **Step 1: Déplacer le fichier (git mv) et changer le namespace**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime
|
||||
mkdir -p src/Module/Core/Domain/Entity
|
||||
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
|
||||
```
|
||||
Puis éditer `src/Module/Core/Domain/Entity/User.php` :
|
||||
- `namespace App\Entity;` → `namespace App\Module\Core\Domain\Entity;`
|
||||
- Adapter les `use` internes devenus nécessaires (l'entité référençait `UserRepository`, `MeProvider`, `UserPasswordHasherProcessor`, l'enum `ContractType`, le contrat `UserInterface as SharedUserInterface`). Mettre les `use` complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers `App\Repository\UserRepository`, `App\State\MeProvider`, `App\State\UserPasswordHasherProcessor`, `App\Entity\Enum\ContractType` ou l'emplacement réel — vérifier les `use` d'origine et les conserver tels quels tant que ces classes n'ont pas bougé).
|
||||
- Garder VERBATIM : tous les attributs `#[ORM\...]` (dont `#[ORM\Table(name: '`user`')]`), `#[ApiResource(...)]`, `#[ApiProperty(...)]`, toutes les propriétés/méthodes, `implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface`.
|
||||
|
||||
> ⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les `use`. Ne changer QUE le `namespace` et, si besoin, garder les `use` pointant vers les emplacements actuels des classes non encore déplacées.
|
||||
|
||||
- [ ] **Step 2: Mapping Doctrine + resolve_target_entities**
|
||||
|
||||
Dans `config/packages/doctrine.yaml`, sous `orm:` :
|
||||
- `resolve_target_entities` :
|
||||
```yaml
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
```
|
||||
- Ajouter un mapping pour les entités Core (en plus du mapping `App` existant qui scanne `src/Entity`) :
|
||||
```yaml
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
```
|
||||
|
||||
> Le mapping `App` (src/Entity) ne contient plus `User.php` (déplacé) → cohérent. Aucune entité orpheline.
|
||||
|
||||
- [ ] **Step 3: Provider de sécurité**
|
||||
|
||||
Dans `config/packages/security.yaml` :
|
||||
```yaml
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Module\Core\Domain\Entity\User
|
||||
property: username
|
||||
```
|
||||
|
||||
- [ ] **Step 4: API Platform mapping paths**
|
||||
|
||||
Dans `config/packages/api_platform.yaml`, ajouter au `mapping.paths` le dossier entité Core (l'`#[ApiResource]` est porté par l'entité `User` déplacée) :
|
||||
```yaml
|
||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
```
|
||||
> Conserver tous les paths existants. Si `api_platform.yaml` n'a pas de `mapping.paths` explicite (auto-discovery), vérifier que les Resources sous `src/Module/...` sont bien découvertes (comme `src/Shared/...` l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
|
||||
|
||||
- [ ] **Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire**
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
|
||||
```
|
||||
Expected : schema VALID (mapping ok, sync DB ok). Le `diff` doit annoncer **« No changes detected »** (table/colonnes identiques). **Si une migration est générée, la SUPPRIMER** (`git status` → retirer le fichier sous `migrations/`) : un diff non vide = mapping mal recopié, corriger l'entité.
|
||||
|
||||
- [ ] **Step 6: Vérif login + suite complète**
|
||||
|
||||
Exécuter le bloc « Vérification login » (curl) → `login http=204`, `me http=200`, MCP 200.
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (118). Les consommateurs importent encore `App\Entity\User` → **ERREUR attendue** : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). **Voir note ci-dessous.**
|
||||
|
||||
> 🔴 **Note d'ordonnancement** : déplacer `User` casse les 26 `use App\Entity\User;`. Pour garder l'app bootable entre Task 2 et Task 3, **ajouter un alias de compatibilité TEMPORAIRE** au tout début de Task 2 et le retirer en fin de Task 3 :
|
||||
> Créer `src/Module/Core/_compat_user_alias.php` (chargé via `composer.json` `autoload.files`) :
|
||||
> ```php
|
||||
> <?php
|
||||
> declare(strict_types=1);
|
||||
> if (!class_exists(\App\Entity\User::class, false)) {
|
||||
> class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class);
|
||||
> }
|
||||
> ```
|
||||
> Ajouter `"files": ["src/Module/Core/_compat_user_alias.php"]` sous `autoload` dans `composer.json`, puis `composer dump-autoload`. Cela garde les 26 consommateurs fonctionnels (et Doctrine `targetEntity: User::class` résolu via l'alias) le temps de la Task 3. **L'alias est SUPPRIMÉ en Task 3 Step final** (avec le retrait du fichier, l'entrée composer et un nouveau `dump-autoload`) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
|
||||
|
||||
- [ ] **Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
|
||||
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
|
||||
|
||||
### Task 3: Relations d'entités → `UserInterface::class`
|
||||
|
||||
**Files (8 entités):**
|
||||
- Modify: `src/Entity/Task.php` (assignee ManyToOne, collaborators ManyToMany)
|
||||
- Modify: `src/Entity/TimeEntry.php` (user)
|
||||
- Modify: `src/Entity/AbsenceRequest.php` (user)
|
||||
- Modify: `src/Entity/AbsenceBalance.php` (user)
|
||||
- Modify: `src/Entity/TaskDocument.php` (user)
|
||||
- Modify: `src/Entity/TaskMailLink.php` (user)
|
||||
- Modify: `src/Module/Core/Domain/Entity/Notification.php` (user) — **après son déplacement en Phase D** ; en Phase C, `Notification` est encore `src/Entity/Notification.php`, la traiter ici aussi.
|
||||
|
||||
> Pour CHAQUE relation vers User : remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;`, le `targetEntity: User::class` par `targetEntity: UserInterface::class`, et le type de propriété/param `?User` → `?UserInterface` (idem getters/setters). Doctrine résout via `resolve_target_entities`. La colonne FK et son nom restent identiques → **aucune migration**.
|
||||
|
||||
- [ ] **Step 1: Modifier les relations (entité par entité)**
|
||||
|
||||
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple `Task.php` (assignee) :
|
||||
```php
|
||||
// avant
|
||||
use App\Entity\User;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private ?User $assignee = null;
|
||||
public function getAssignee(): ?User { return $this->assignee; }
|
||||
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
|
||||
// collaborators
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
private Collection $collaborators;
|
||||
|
||||
// après
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
private ?UserInterface $assignee = null;
|
||||
public function getAssignee(): ?UserInterface { return $this->assignee; }
|
||||
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
|
||||
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
|
||||
private Collection $collaborators;
|
||||
```
|
||||
> ⚠️ Conserver tous les autres attributs de relation (`inversedBy`, `joinTable`, `joinColumn`, `nullable`, `onDelete`, Groups…) VERBATIM. Ne changer que le type et `targetEntity`.
|
||||
|
||||
- [ ] **Step 2: Valider le schéma (toujours zéro migration)**
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
|
||||
```
|
||||
Expected : « No changes detected ». Sinon corriger (un `joinColumn`/`onDelete` a été perdu).
|
||||
|
||||
### Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
|
||||
|
||||
**Files:** les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
|
||||
- Move: `src/Repository/UserRepository.php` → `src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` (implémente `UserRepositoryInterface`, namespace `App\Module\Core\Infrastructure\Doctrine`)
|
||||
- Move: `src/State/MeProvider.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php`
|
||||
- Move: `src/State/UserPasswordHasherProcessor.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php`
|
||||
- Modify: l'`#[ApiResource]` de l'entité `User` (les `provider:`/`processor:` pointent vers les nouveaux FQCN Core).
|
||||
- Delete (en fin de task): `src/Module/Core/_compat_user_alias.php` + entrée `composer.json`.
|
||||
|
||||
- [ ] **Step 1: Déplacer le repository et l'aligner sur l'interface**
|
||||
|
||||
```bash
|
||||
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
|
||||
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
|
||||
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
|
||||
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
|
||||
```
|
||||
Éditer `DoctrineUserRepository.php` : `namespace App\Module\Core\Infrastructure\Doctrine;`, `class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface`, `use App\Module\Core\Domain\Entity\User;`, `use App\Module\Core\Domain\Repository\UserRepositoryInterface;`, et passer `User::class` au constructeur parent. Ajouter `findOneByUsername()` si absent (`return $this->findOneBy(['username' => $username]);`). Conserver `findByRole()` (SQL natif `roles::text LIKE`) et `findActiveEmployees()`.
|
||||
Éditer `User.php` : `#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]` avec le bon `use`.
|
||||
Éditer `MeProvider.php` / `UserPasswordHasherProcessor.php` : nouveaux namespaces ; `use App\Module\Core\Domain\Entity\User;` (le processor manipule le concret — c'est dans Core, autorisé).
|
||||
Mettre à jour les `provider:`/`processor:` dans l'`#[ApiResource]` de `User` vers les nouveaux FQCN.
|
||||
|
||||
- [ ] **Step 2: Lier l'interface repository au service Doctrine**
|
||||
|
||||
Dans `config/services.yaml`, alias pour l'injection par interface :
|
||||
```yaml
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Basculer les 25 autres consommateurs sur le contrat**
|
||||
|
||||
Pour chaque fichier important `App\Entity\User` (hors Core), remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;` et le type-hint `User` par `UserInterface` (params, retours, propriétés, `@var`, expressions). Cas particuliers :
|
||||
- `src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php` : les signatures `countUnreadByUser(User $user)` etc. → `UserInterface`. Ne pas changer la logique DQL (`n.user = :user` fonctionne avec l'instance).
|
||||
- `src/State/Absence*`, `TaskDocumentProvider`, `src/Service/AbsenceBalanceService`, `src/Security/MailAccessChecker`, `src/EventListener/TaskNotificationListener` (sera retravaillé en Phase D mais peut déjà passer au contrat ici), `src/Controller/*` (7), `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool`, `src/Mcp/Tool/Serializer` : remplacer le type-hint.
|
||||
- `src/DataFixtures/AppFixtures.php` : **garde le concret** `App\Module\Core\Domain\Entity\User` (les fixtures INSTANCIENT `new User()` et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors `src/Module/Core/` mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
|
||||
|
||||
> Liste de contrôle : après cette step, `grep -rn "use App\\\\Entity\\\\User;" src/` ne doit retourner QUE `src/DataFixtures/AppFixtures.php` (qui importe désormais le FQCN Core, donc 0 occurrence de `App\Entity\User`). Viser **0 occurrence de `App\Entity\User`** dans tout `src/`.
|
||||
|
||||
- [ ] **Step 4: Retirer l'alias de compatibilité**
|
||||
|
||||
```bash
|
||||
git rm src/Module/Core/_compat_user_alias.php
|
||||
```
|
||||
Retirer l'entrée `"files": [...]` ajoutée sous `autoload` dans `composer.json` (Task 2), puis :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `grep` de garde (AC 2) + schéma + tests + login**
|
||||
|
||||
```bash
|
||||
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Expected : grep VIDE, schéma valide, « No changes detected », **118 tests verts**. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
|
||||
|
||||
- [ ] **Step 6: php-cs-fixer + commit (Phase C)**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add -A -- src config composer.json composer.lock
|
||||
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
|
||||
```
|
||||
> ⚠️ NE PAS `git add config/reference.php`. Vérifier `git status` avant le commit ; si `reference.php` est listé, l'exclure du `git add` (stager explicitement les fichiers voulus).
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Notifications via `NotifierInterface` (impl Core)
|
||||
|
||||
### Task 5: Déplacer `Notification` dans Core + `Notifier` (impl) + recâbler le listener
|
||||
|
||||
**Files:**
|
||||
- Move: `src/Entity/Notification.php` → `src/Module/Core/Domain/Entity/Notification.php`
|
||||
- Move: `src/Repository/NotificationRepository.php` → `src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php`
|
||||
- Move: `src/State/NotificationProvider.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php`
|
||||
- Create: `src/Module/Core/Infrastructure/Notifier.php` (implements `NotifierInterface`)
|
||||
- Modify: `src/EventListener/TaskNotificationListener.php` (dépend de `NotifierInterface`)
|
||||
- Modify: `config/packages/doctrine.yaml` (le mapping `Core` couvre déjà `Domain/Entity` → Notification incluse automatiquement)
|
||||
- Modify: `tests/` — ajouter `tests/Unit/Module/Core/NotifierTest.php` (ou Functional) si testable unitairement.
|
||||
|
||||
- [ ] **Step 1: Écrire un test du `Notifier`**
|
||||
|
||||
`tests/Functional/Module/Core/NotifierTest.php` (crée une notif et vérifie la persistance) :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NotifierTest extends KernelTestCase
|
||||
{
|
||||
public function testNotifyPersistsANotificationForTheUser(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$notifier = self::getContainer()->get(NotifierInterface::class);
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
self::assertNotNull($user);
|
||||
|
||||
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
|
||||
|
||||
$count = (int) $em->createQuery(
|
||||
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
|
||||
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
|
||||
|
||||
self::assertSame(1, $count);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer, vérifier l'échec** — `NotifierInterface` non instanciable / `Notification` introuvable au nouveau namespace.
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Déplacer `Notification` + repository + provider**
|
||||
|
||||
```bash
|
||||
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
|
||||
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
|
||||
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
|
||||
```
|
||||
- `Notification.php` : `namespace App\Module\Core\Domain\Entity;`, `use App\Shared\Domain\Contract\UserInterface;`, relation `user` → `targetEntity: UserInterface::class` + type `?UserInterface`, `repositoryClass: DoctrineNotificationRepository::class`, conserver `#[ORM\Table(name:'notification')]` + index VERBATIM, ApiResource (provider → nouveau FQCN). **Table/colonnes inchangées.**
|
||||
- `DoctrineNotificationRepository.php` : namespace Core, `use App\Module\Core\Domain\Entity\Notification;`, signatures `UserInterface`.
|
||||
- `NotificationProvider.php` : namespace Core, mêmes dépendances.
|
||||
|
||||
- [ ] **Step 4: Implémenter `Notifier`**
|
||||
|
||||
`src/Module/Core/Infrastructure/Notifier.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Notification;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class Notifier implements NotifierInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function notify(UserInterface $user, string $type, string $title, string $message): void
|
||||
{
|
||||
$notification = new Notification();
|
||||
$notification->setUser($user);
|
||||
$notification->setType($type);
|
||||
$notification->setTitle($title);
|
||||
$notification->setMessage($message);
|
||||
$this->em->persist($notification);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
> ⚠️ Aligner sur les setters réels de `Notification` (la cartographie indique `user`, `type`, `title`, `message`, `isRead` default false, `createdAt`). Si `createdAt` n'est pas auto (prePersist), le poser ici. Si `setUser` attend le concret, accepter `UserInterface` (resolve_target_entities) — vérifier le type du setter.
|
||||
|
||||
- [ ] **Step 5: Recâbler `TaskNotificationListener` sur `NotifierInterface`**
|
||||
|
||||
Lire le listener ; remplacer la création directe de `Notification` (`new Notification()` + persist) par l'injection et l'appel de `NotifierInterface::notify(...)`. **Attention** : le listener tourne sur `onFlush`/`postFlush` — un `flush()` dans `notify()` pendant un `onFlush` est dangereux. Conserver le pattern existant (accumulation en `onFlush`, écriture en `postFlush`). Si `notify()` flush, l'appeler UNIQUEMENT en `postFlush` (jamais pendant `onFlush`). Préserver le comportement exact (mêmes types `task_assigned`/`task_collaborator_added`, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
|
||||
|
||||
> Si l'intrication onFlush/postFlush rend `NotifierInterface` inadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » : `NotifierInterface` doit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
|
||||
|
||||
- [ ] **Step 6: Tests + login + endpoints notifications**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (118 + 1 = 119). Vérifier `doctrine:migrations:diff` → « No changes detected ». Bloc login. Puis curl notifications :
|
||||
```bash
|
||||
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
|
||||
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
|
||||
```
|
||||
Expected : 200 sur les deux.
|
||||
|
||||
- [ ] **Step 7: php-cs-fixer + commit**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add -A -- src config tests
|
||||
git commit -m "feat(core) : move notification into core and expose notifier contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Déclarer `CoreModule` actif
|
||||
|
||||
### Task 6: Enregistrer Core dans `config/modules.php`
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/modules.php`
|
||||
- Modify: `tests/Functional/Shared/ModulesEndpointTest.php` (ou équivalent — adapter l'assertion à la présence de `core`)
|
||||
|
||||
- [ ] **Step 1: Adapter/écrire le test de l'endpoint modules**
|
||||
|
||||
Vérifier le test existant de `/api/modules` (cartographie : `ModulesProvider`/`ModulesResource` créés en #56). Ajouter une assertion :
|
||||
```php
|
||||
public function testCoreModuleIsActive(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
// /api/modules est public (GET) d'après security.yaml
|
||||
$client->request('GET', '/api/modules');
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertContains('core', $data['modules']);
|
||||
}
|
||||
```
|
||||
> Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer `tests/Functional/Shared/ModulesEndpointTest.php`.
|
||||
|
||||
- [ ] **Step 2: Lancer, vérifier l'échec** (modules.php retourne `[]`).
|
||||
|
||||
- [ ] **Step 3: Activer Core**
|
||||
|
||||
`config/modules.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests + curl**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS. Curl :
|
||||
```bash
|
||||
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
|
||||
```bash
|
||||
git add config/modules.php tests/
|
||||
git commit -m "feat(core) : activate core module in modules registry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Layer front `modules/core/`
|
||||
|
||||
### Task 7: Déplacer login / profile / admin users dans `frontend/modules/core/`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`)
|
||||
- Move: `frontend/pages/login.vue` → `frontend/modules/core/pages/login.vue`
|
||||
- Move: `frontend/pages/profile.vue` → `frontend/modules/core/pages/profile.vue`
|
||||
- Move: `frontend/pages/admin/**` (gestion users) → `frontend/modules/core/pages/admin/**`
|
||||
- Move (si pertinent): composants/services liés à l'identité (ex. `frontend/components/user/**`, `frontend/components/admin/**`, `frontend/services/user.ts`) → `frontend/modules/core/{components,services}/**`
|
||||
|
||||
> ⚠️ AVANT de déplacer, LIRE `frontend/pages/` et `frontend/components/` pour identifier précisément les pages/compos d'identité. Le scan `readdirSync('modules/')` (LST-62) ajoute `./modules/core` à `extends` et `modules/core/composables`/`stores` à `imports.dirs`. Les `pages/` d'un layer Nuxt sont fusionnées automatiquement → **les URLs (`/login`, `/profile`, `/admin/...`) restent identiques**. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
|
||||
|
||||
- [ ] **Step 1: Créer le layer + déplacer les pages d'identité**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p modules/core/pages
|
||||
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
|
||||
git mv pages/login.vue modules/core/pages/login.vue
|
||||
git mv pages/profile.vue modules/core/pages/profile.vue
|
||||
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
|
||||
```
|
||||
> Lister `frontend/pages/admin/` d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
|
||||
|
||||
- [ ] **Step 2: Corriger les imports par chemin éventuels**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules`
|
||||
Corriger toute référence cassée (les redirections `navigateTo('/login')` restent valides — c'est une URL, pas un chemin de fichier).
|
||||
|
||||
- [ ] **Step 3: Gate front (cf. LST-62) + smoke**
|
||||
|
||||
Run: `cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile"` → doit être VIDE.
|
||||
Run: `grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null` ou démarrer `make dev-nuxt` et confirmer que `/login`, `/profile` répondent (la fusion des pages du layer est effective).
|
||||
> Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
|
||||
|
||||
- [ ] **Step 4: commit**
|
||||
|
||||
```bash
|
||||
git add -A -- frontend
|
||||
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance check (après toutes les phases)
|
||||
|
||||
- [ ] **AC1** Login/JWT OK via le module : `login http=204`, `/api/me` 200, MCP apiToken 200, `/api/notifications` 200.
|
||||
- [ ] **AC2** `grep -rn "App\\Entity\\User" src/ config/` → **VIDE** (User vit dans `src/Module/Core/Domain/Entity/`, consommé via contrat ; fixtures importent le FQCN Core).
|
||||
- [ ] **AC3** `make test` vert (≈119 tests), `doctrine:schema:validate` OK, `doctrine:migrations:diff` = « No changes detected » (**aucune migration destructive ni même additive**).
|
||||
- [ ] `/api/modules` renvoie `core` ; `CoreModule::isRequired() === true`.
|
||||
- [ ] `resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User`.
|
||||
- [ ] Front : layer `modules/core/` détecté ; `/login`, `/profile` (+ admin users) accessibles aux mêmes URLs ; aucun `Cannot find module`.
|
||||
- [ ] `config/reference.php` jamais committé.
|
||||
|
||||
## Notes pour le ticket suivant (1.2 — RBAC fin)
|
||||
|
||||
`CoreModule::permissions()` est déjà posé (stub). 1.2 ajoutera `Role`/`Permission`, `app:sync-permissions`, `PermissionVoter`, et fera filtrer `SidebarProvider` **par permission** (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat `UserInterface` enrichi est prêt à recevoir `getPermissions()` si besoin.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Explorateur de partage réseau Windows + viewer — Design
|
||||
|
||||
Date : 2026-06-03
|
||||
Statut : design validé (brainstorming), à transformer en plan d'implémentation.
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Donner accès, **depuis Lesstime**, à un partage de fichiers Windows (SMB), avec :
|
||||
|
||||
- un **explorateur de fichiers façon Google Drive / SharePoint** qui parcourt le partage **en direct** (live, pas d'index) ;
|
||||
- un **viewer propre** pour ouvrir les documents (image, PDF, texte) sans quitter l'app ;
|
||||
- une **configuration en admin** (serveur, partage, identifiants) avec un **bouton « Tester la connexion »** et un **interrupteur d'activation**, sur le même modèle que les intégrations existantes (Zimbra, Gitea, BookStack) ;
|
||||
- une **visibilité conditionnelle** : si l'option SMB est **désactivée** dans l'admin, l'entrée « Documents » et la page **n'apparaissent pas** pour les utilisateurs.
|
||||
|
||||
### Hors périmètre (POC)
|
||||
|
||||
- Pas d'index en base, pas de recherche plein texte, pas d'extraction de contenu (pas de Tika).
|
||||
- Pas d'OCR.
|
||||
- Pas d'écriture sur le partage (lecture seule).
|
||||
- Pas de cron / synchronisation. Tout est lu **à la volée** à chaque navigation.
|
||||
|
||||
## 2. Décisions d'architecture
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Accès au partage | **`icewind/smb`** (protocole SMB en PHP), **pas de montage CIFS**. La connexion est configurée dans l'app. |
|
||||
| Configuration | Entité `ShareConfiguration` (1 ligne) saisie en admin, mot de passe chiffré au repos — calquée sur `ZimbraConfiguration`. |
|
||||
| Abstraction | Interface `FileSource` (lister / lire), implémentation `SmbFileSource`. Permet de remplacer la source plus tard sans toucher au front ni aux endpoints. |
|
||||
| API navigation | 2 endpoints live : `browse` (lister un dossier) et `download` (streamer un fichier). |
|
||||
| Front | Explorateur **maison léger** (fil d'Ariane + tableau), cohérent avec `@malio/layer-ui`. Aucune lib de file-manager externe (elFinder/vue-finder écartés : vieux ou hors design system). |
|
||||
| Rendu PDF | **PDF.js via `vue-pdf-embed`** dans le viewer (meilleur rendu qu'un `<iframe>`). Images et texte : rendu natif. |
|
||||
| Sécurité chemin | Validation stricte anti path-traversal : tout chemin demandé doit rester sous la racine configurée. |
|
||||
|
||||
### Schéma
|
||||
|
||||
```
|
||||
//WIN-SRV/Partage
|
||||
│ SMB (icewind/smb, identifiants chiffrés en base)
|
||||
▼
|
||||
Lesstime (Symfony) ──FileSource → SmbFileSource──┐
|
||||
│ │
|
||||
├─ GET /api/share/browse?path=/Compta/2024 → listing live (dossiers + fichiers)
|
||||
├─ GET /api/share/download?path=…/x.pdf → stream du fichier (viewer / download)
|
||||
├─ GET/PUT /api/settings/share → lire / enregistrer la config (admin)
|
||||
└─ POST /api/settings/share/test → tester la connexion (admin)
|
||||
```
|
||||
|
||||
## 3. Backend (Symfony)
|
||||
|
||||
### 3.1 Entité `ShareConfiguration`
|
||||
|
||||
Une seule ligne de config (singleton, comme `ZimbraConfiguration`). Champs :
|
||||
|
||||
- `id`
|
||||
- `host` (string, ex. `WIN-SRV` ou IP)
|
||||
- `shareName` (string, nom du partage SMB, ex. `Documents`)
|
||||
- `basePath` (string nullable, sous-dossier racine optionnel, ex. `/Projets`) — la navigation est confinée à cette racine
|
||||
- `domain` (string nullable, workgroup/domaine, défaut `WORKGROUP`)
|
||||
- `username` (string nullable)
|
||||
- `encryptedPassword` (text nullable) — chiffré, réutilise le mécanisme de chiffrement déjà employé par Zimbra
|
||||
- `enabled` (bool, défaut `false`)
|
||||
- `hasPassword()` helper
|
||||
|
||||
Migration Doctrine dédiée. Repository singleton (`findConfiguration()` renvoie la ligne unique ou en crée une vide), calqué sur `ZimbraConfigurationRepository`.
|
||||
|
||||
### 3.2 Ressources API de configuration (admin)
|
||||
|
||||
Calquées **à l'identique** sur Zimbra :
|
||||
|
||||
- `ShareSettings` (ApiResource) — `Get` + `Put` sur `/api/settings/share`, `security: ROLE_ADMIN`.
|
||||
- Champs lus/écrits : `host`, `shareName`, `basePath`, `domain`, `username`, `enabled`.
|
||||
- `password` : **write-only** (groupe write uniquement).
|
||||
- `hasPassword` : **read-only** (indique si un mot de passe est déjà enregistré).
|
||||
- Provider `ShareSettingsProvider` (lit l'entité → DTO), Processor `ShareSettingsProcessor` (DTO → entité, chiffre le mot de passe si fourni, ne l'écrase pas s'il est vide).
|
||||
- `ShareTestConnection` (ApiResource) — `Post` sur `/api/settings/share/test`, `input: false`, `security: ROLE_ADMIN`.
|
||||
- Renvoie `{ success: bool, message: string|null }`.
|
||||
- Provider `ShareTestConnectionProvider` : tente une connexion SMB + un `dir()` sur la racine ; `success=false` + message d'erreur lisible en cas d'échec.
|
||||
|
||||
### 3.3 Source de fichiers
|
||||
|
||||
```
|
||||
interface FileSource {
|
||||
list(string $relativeDir): FileEntry[] // dossiers d'abord, puis fichiers
|
||||
read(string $relativePath): resource // flux binaire du fichier
|
||||
test(): TestResult // connexion + accès racine
|
||||
}
|
||||
```
|
||||
|
||||
`FileEntry` = `{ name, path, isDir, size, modifiedAt, mimeType }`.
|
||||
|
||||
`SmbFileSource` :
|
||||
|
||||
- construit la connexion à partir de `ShareConfiguration` (déchiffre le mot de passe) via `icewind/smb` ;
|
||||
- préfixe tous les chemins par `basePath` ;
|
||||
- **valide chaque chemin** (`normalize` + rejet de tout chemin qui s'échappe de la racine : pas de `..`, pas de chemin absolu hors racine) → `InvalidPathException` sinon ;
|
||||
- déduit le `mimeType` à partir de l'extension (suffisant pour piloter le viewer ; pas de lecture du contenu pour le listing).
|
||||
|
||||
> **Dépendance infra** : `icewind/smb` requiert le binaire `smbclient` (ou l'extension `libsmbclient`) dans le conteneur PHP. Les deux images sont Debian (`apt-get`), donc une seule ligne suffit, **à appliquer dans les deux Dockerfiles** :
|
||||
> - `infra/dev/Dockerfile` — ajouter `smbclient` à la liste `apt-get install` existante (~ligne 9).
|
||||
> - `infra/prod/Dockerfile` — ajouter `smbclient` à l'`apt-get install` du **stage `production`** (le runtime FPM, ~ligne 41), **pas** au stage de build.
|
||||
>
|
||||
> Conséquence déploiement : l'image prod (`lesstime-app`) doit être **rebuildée et redéployée** pour embarquer `smbclient` ; sans ça, la fonctionnalité marcherait en dev et échouerait en prod. À inscrire comme étape du plan (avec la migration Doctrine de `ShareConfiguration`).
|
||||
|
||||
### 3.4 Endpoints de navigation
|
||||
|
||||
Controllers custom sous `/api/` (pas d'entité Doctrine derrière → controllers, avec `priority: 1` sur la route pour éviter le conflit avec API Platform `{id}`), `security: IS_AUTHENTICATED_FULLY` :
|
||||
|
||||
- `GET /api/share/browse?path=<rel>` → `ShareBrowseController`
|
||||
- renvoie `{ path, breadcrumb[], entries: FileEntry[] }` ;
|
||||
- si config désactivée/incomplète → `409` avec message clair ;
|
||||
- chemin invalide → `400`.
|
||||
- `GET /api/share/download?path=<rel>&disposition=inline|attachment` → `ShareDownloadController`
|
||||
- streame le fichier (`StreamedResponse`) avec le bon `Content-Type` ;
|
||||
- `inline` par défaut (pour le viewer), `attachment` pour le téléchargement ;
|
||||
- fichier absent → `404`.
|
||||
- `GET /api/share/status` → `ShareStatusController`, `security: IS_AUTHENTICATED_FULLY`
|
||||
- renvoie `{ enabled: bool }` — **uniquement le booléen**, aucune donnée de connexion ;
|
||||
- utilisé par le front pour afficher/masquer l'entrée « Documents » et garder la page.
|
||||
|
||||
## 4. Frontend (Nuxt)
|
||||
|
||||
### 4.1 Explorateur — `pages/documents.vue`
|
||||
|
||||
- **Fil d'Ariane** du chemin courant (cliquable pour remonter).
|
||||
- **Tableau** des entrées : dossiers d'abord, puis fichiers ; colonnes nom (icône par type), taille, date de modification.
|
||||
- clic dossier → on descend (met à jour `path`, recharge `browse`) ;
|
||||
- clic fichier → ouvre le viewer.
|
||||
- **Filtre par nom** du dossier courant, **côté client** (live, non-indexé) — filtre simplement la liste déjà chargée.
|
||||
- États : chargement, dossier vide, erreur (config désactivée / connexion KO) avec message.
|
||||
|
||||
### 4.2 Viewer — `components/share/SharedFilePreview.vue`
|
||||
|
||||
Adapté de `TaskDocumentPreview.vue` existant :
|
||||
|
||||
- **Image** : `<img>` sur l'URL `download?disposition=inline`.
|
||||
- **PDF** : **`vue-pdf-embed`** (PDF.js) — rendu, pagination, zoom.
|
||||
- **Texte/markdown/csv/json** : chargement du contenu + `<pre>` (comme l'existant).
|
||||
- **Autre** : carte « fichier » + bouton de téléchargement (`attachment`).
|
||||
- Navigation précédent/suivant dans la liste du dossier courant, fermeture clavier — repris de l'existant.
|
||||
|
||||
### 4.3 Service & config admin
|
||||
|
||||
- `services/share.ts` : `browse(path)`, `getDownloadUrl(path, disposition)` + DTO `FileEntry`.
|
||||
- `services/share-settings.ts` (+ DTO) : `get()`, `update(payload)`, `test()` — calqué sur `services/zimbra.ts`.
|
||||
- `components/admin/AdminShareTab.vue` : calqué sur `Admin ZimbraTab.vue` — champs host / shareName / basePath / domain / username / password + toggle `enabled`, bouton **« Tester la connexion »** (toast succès/échec) et **« Enregistrer »**. Onglet ajouté à la page admin.
|
||||
- **i18n** : nouvelles clés (`sharedFiles.*`, `adminShare.*`) dans `frontend/i18n/locales/`.
|
||||
- **Navigation conditionnelle** : le lien « Documents » du layout n'est affiché **que si** `GET /api/share/status` renvoie `enabled=true` (récupéré via un composable, ex. `useShareStatus`, mis en cache). Le middleware/garde de `pages/documents.vue` redirige vers l'accueil si la fonctionnalité est désactivée (défense en profondeur, en plus du `409` backend).
|
||||
|
||||
### 4.4 Dépendance frontend
|
||||
|
||||
`vue-pdf-embed` (+ `pdfjs-dist`) ajouté au `package.json` du frontend.
|
||||
|
||||
## 5. Flux
|
||||
|
||||
- **Configuration** (admin) : saisie host/partage/identifiants → « Tester » (`POST /settings/share/test`) → « Enregistrer » (`PUT /settings/share`).
|
||||
- **Navigation** (utilisateur) : ouverture `/documents` → `GET /share/browse?path=/` → tableau ; clic dossier → re-`browse` ; clic fichier → viewer → `GET /share/download?...inline`.
|
||||
- **Téléchargement** : bouton → `GET /share/download?...attachment`.
|
||||
|
||||
## 6. Gestion des erreurs
|
||||
|
||||
- **SMB injoignable / identifiants faux** → `browse`/`download` renvoient une erreur ; l'UI affiche un message clair. Le test de connexion renvoie `success=false` + message.
|
||||
- **Config désactivée ou incomplète** → `browse` `409`, UI invite à configurer (admin).
|
||||
- **Path-traversal** (`..`, chemin hors racine) → `400`, jamais d'accès hors `basePath`.
|
||||
- **Fichier supprimé/déplacé entre listing et ouverture** → `download` `404`, message dans le viewer.
|
||||
|
||||
## 7. Sécurité
|
||||
|
||||
- **Lecture seule** : aucune écriture sur le partage.
|
||||
- **Rôles** : navigation/lecture = utilisateur authentifié (`IS_AUTHENTICATED_FULLY`) ; configuration = `ROLE_ADMIN`.
|
||||
- **Mot de passe chiffré au repos** (réutilise le mécanisme Zimbra), jamais renvoyé au front (`hasPassword` seulement).
|
||||
- **Confinement** strict à `basePath` (anti path-traversal).
|
||||
|
||||
## 8. Tests
|
||||
|
||||
- **Unitaire**
|
||||
- `SmbFileSource` : validation/normalisation de chemin, rejet `..` et chemins hors racine (connexion SMB mockée).
|
||||
- Déduction du `mimeType` par extension.
|
||||
- **Fonctionnel**
|
||||
- `GET/PUT /api/settings/share` et `POST /api/settings/share/test` exigent `ROLE_ADMIN` ; le mot de passe n'est jamais exposé en lecture.
|
||||
- `GET /api/share/browse` et `/download` exigent l'authentification ; un chemin `..` est rejeté (`400`).
|
||||
|
||||
## 9. Notes & suites possibles
|
||||
|
||||
- Perf : chaque `browse` = un aller-retour SMB live ; acceptable pour un POC. Gros dossiers = listing potentiellement lent (pas de pagination au POC).
|
||||
- Évolutions naturelles (non incluses) : index + recherche plein texte (Tika), miniatures, multi-partages, restriction par dossier/rôle, mise en cache des listings.
|
||||
```
|
||||
@@ -0,0 +1,126 @@
|
||||
# Notifications sur événements de tâche — Design
|
||||
|
||||
**Date :** 2026-06-15
|
||||
**Ticket lié :** (à créer) — recâblage du système de notifications
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Le système de notifications de Lesstime est aujourd'hui une **coquille vide** : toute la
|
||||
plomberie consommatrice existe encore (entité `Notification`, `NotificationProvider`,
|
||||
`NotificationRepository`, `NotificationUnreadCountController`, `MarkAllReadController`,
|
||||
et côté front `NotificationBell.vue` + `useNotifications.ts` + `services/notifications.ts`
|
||||
qui poll toutes les 2 min), **mais plus aucun producteur ne crée de notification**.
|
||||
|
||||
Cause : le seul producteur était `NotificationService`, déclenché par les `ClientTicket`
|
||||
du portail client. Le commit `2a0b202` (« suppression du portail client ») a retiré
|
||||
`ClientTicket`, `NotificationService` et les processors associés, laissant la cloche
|
||||
interroger `/notifications/unread-count` dans le vide. Le compteur reste donc à 0 et le
|
||||
dropdown est toujours vide.
|
||||
|
||||
> Le travail récent LST-52 (pagination du `NotificationProvider`) est correct mais portait
|
||||
> sur une liste structurellement toujours vide.
|
||||
|
||||
## Objectif
|
||||
|
||||
Rebrancher la **création** de notifications sur des événements **réels** qui existent
|
||||
encore dans l'app : les événements de **tâche**.
|
||||
|
||||
## Périmètre (MVP)
|
||||
|
||||
### Déclencheurs & destinataires
|
||||
|
||||
| Événement | Détection (changeset Doctrine) | Destinataire | Type |
|
||||
|-----------|-------------------------------|--------------|------|
|
||||
| Tâche assignée (création **ou** modif où `assignee` passe à un nouvel user) | `assignee` : `old ≠ new` et `new ≠ null` | le nouvel assigné | `task_assigned` |
|
||||
| Collaborateur ajouté | `insertDiff` sur la collection `collaborators` | chaque user ajouté | `task_collaborator_added` |
|
||||
|
||||
Règles :
|
||||
- **Auto-exclusion** : si le destinataire == l'acteur courant, aucune notification.
|
||||
- Réassignation A→B : seul **B** est notifié (pas de notification « désassigné » — hors scope).
|
||||
- `assignee` passe à `null` : aucune notification.
|
||||
- Si plusieurs personnes deviennent destinataires dans un même flush, chacune reçoit
|
||||
sa notification.
|
||||
|
||||
### Contenu des notifications
|
||||
|
||||
Réutilise l'entité `Notification` existante (`user`, `type`, `title`, `message`,
|
||||
`isRead`, `createdAt`) — **aucune migration**.
|
||||
|
||||
- `task_assigned` → titre « Nouvelle tâche assignée », message `«{titre tâche}» — {nom projet}`.
|
||||
- `task_collaborator_added` → titre « Ajout à une tâche », message `«{titre tâche}» — {nom projet}`.
|
||||
|
||||
### Décisions de comportement
|
||||
|
||||
1. **Pas d'acteur authentifié → pas de notification.** Les deux chemins utilisateurs réels
|
||||
(frontend JWT, MCP token) ont toujours un user authentifié. CLI / fixtures / cron de
|
||||
récurrence n'ont pas d'acteur → aucune notification. Effet de bord positif : `make fixtures`
|
||||
ne génère pas de notifications parasites.
|
||||
2. **Pas de lien cliquable** vers la tâche dans cette itération (l'entité `Notification`
|
||||
n'a pas de champ URL ; la cloche affiche titre + message + date relative). Extension
|
||||
future possible, hors scope MVP.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Approche retenue : listener Doctrine `onFlush` / `postFlush`** (un seul point de vérité
|
||||
qui couvre tous les chemins d'écriture — frontend API Platform, MCP, et tout futur chemin —
|
||||
puisque tous persistent via `EntityManager::flush()`).
|
||||
|
||||
Approches écartées :
|
||||
- *Décorateur de processor API Platform + hooks dans les tools MCP* : logique dupliquée sur
|
||||
plusieurs endroits, risque d'oublier un chemin (c'est exactement ce type d'oubli qui a créé
|
||||
le bug initial).
|
||||
- *Événements de domaine + Symfony Messenger async* : surdimensionné pour 2 événements,
|
||||
ajoute transport + worker (YAGNI).
|
||||
|
||||
### Composant : `App\EventListener\TaskNotificationListener`
|
||||
|
||||
Enregistré via `#[AsDoctrineListener]` sur les événements `onFlush` et `postFlush`.
|
||||
Dépendances injectées : `Symfony\Bundle\SecurityBundle\Security` (acteur courant).
|
||||
|
||||
**`onFlush(OnFlushEventArgs $args)`** — collecte (ne persiste rien encore) :
|
||||
1. `$uow = $em->getUnitOfWork();`
|
||||
2. Acteur : `$actor = $this->security->getUser();` → si `null`, **on sort** (aucune notif).
|
||||
3. Assignations :
|
||||
- `getScheduledEntityInsertions()` : pour chaque `Task` insérée avec `assignee !== null`
|
||||
et `assignee !== actor` → file `(assignee, 'task_assigned', task)`.
|
||||
- `getScheduledEntityUpdates()` : pour chaque `Task`, `getEntityChangeSet($task)` ;
|
||||
si `isset($cs['assignee'])` avec `[$old, $new] = $cs['assignee']`, `$new !== null`
|
||||
et `$new !== actor` → file `(new, 'task_assigned', task)`.
|
||||
4. Collaborateurs :
|
||||
- `getScheduledCollectionUpdates()` : pour chaque `PersistentCollection` dont l'owner est
|
||||
une `Task` et le champ vaut `collaborators`, `getInsertDiff()` donne les users ajoutés ;
|
||||
pour chacun `!== actor` → file `(user, 'task_collaborator_added', task)`.
|
||||
5. Stocke la file dans une propriété privée du listener.
|
||||
|
||||
**`postFlush(PostFlushEventArgs $args)`** — persiste :
|
||||
1. Si la file est vide, retour immédiat.
|
||||
2. Vide la file dans une variable locale puis **réinitialise la propriété** (anti-réentrance).
|
||||
3. Pour chaque entrée, crée une `Notification` (user, type, title, message, createdAt),
|
||||
`persist`.
|
||||
4. `$em->flush()` une seconde fois. Pas de boucle infinie : les `Notification` ne sont pas
|
||||
des `Task`, donc ce second flush ne reschedule aucune assignation/collaboration.
|
||||
|
||||
## Tests (PHPUnit, `make test`)
|
||||
|
||||
Cas couverts :
|
||||
- Assignation d'une tâche à un user (par un autre acteur) → 1 notification `task_assigned`
|
||||
pour cet user.
|
||||
- Auto-assignation (acteur s'assigne la tâche) → **aucune** notification.
|
||||
- Ajout d'un collaborateur → 1 notification `task_collaborator_added` pour cet user.
|
||||
- Réassignation A→B → seul **B** reçoit une notification.
|
||||
- `assignee` passé à `null` → aucune notification.
|
||||
- Pas d'acteur authentifié (contexte CLI) → aucune notification.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Notifications de changement de statut, d'échéance proche, de désassignation.
|
||||
- Lien cliquable / navigation vers la tâche depuis la notification.
|
||||
- Préférences utilisateur (opt-in/opt-out par type), notifications e-mail.
|
||||
- Modification du front (la cloche consomme déjà l'API et s'affichera dès que des
|
||||
notifications existent).
|
||||
|
||||
## Fichiers impactés
|
||||
|
||||
- **Nouveau** : `src/EventListener/TaskNotificationListener.php`
|
||||
- **Nouveau** : tests PHPUnit (`tests/EventListener/` ou emplacement équivalent au projet).
|
||||
- **Aucune** migration, **aucun** changement d'entité, **aucun** changement front.
|
||||
@@ -0,0 +1,192 @@
|
||||
# LST-56 — Socle modular monolith DDD + pilote « Projets/Tâches »
|
||||
|
||||
> Ticket Lesstime **#56** (1/5 — groupe « Refonte / Alignement Starseed »).
|
||||
> Design validé le 2026-06-19. Référence vivante : repo **Starseed** (`.claude/rules/*.md` + implémentation réelle), et `Starseed/doc/architecture-modulaire-malio.md` (vision cible théorique — **non contraignante** là où elle diverge du code réel).
|
||||
|
||||
## 1. Objectif & contraintes
|
||||
|
||||
Poser dans Lesstime l'**infrastructure d'un modular monolith DDD** calquée sur Starseed, et **migrer un premier module pilote** (Projets/Tâches) de bout en bout comme preuve que la mécanique tient sur le cœur métier.
|
||||
|
||||
Contraintes **non négociables** :
|
||||
|
||||
- **Ne rien casser de l'existant.** Migration **strangler progressive** : le code legacy (`src/Entity/…`) et les modules (`src/Module/…`) coexistent ; l'application reste fonctionnelle et `make test` vert à **chaque** étape.
|
||||
- **Prod = Docker, BDD peuplée** → uniquement des migrations **additives et nullable** (aucun `DROP`, aucun `NOT NULL` rétroactif, aucun déplacement de données).
|
||||
- **Profondeur DDD : pragmatique**, alignée sur le **Starseed réel** (pas la doc théorique) : ORM attributs conservés dans les entités Domain, Repository = interface (Domain) + impl Doctrine (Infrastructure), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le cross-module. **Pas de CQRS bus systématique, pas de multi-tenant.**
|
||||
|
||||
### Décisions de cadrage (figées)
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Périmètre #56 | Socle complet + **1 module pilote** migré de bout en bout |
|
||||
| Stratégie | **Strangler progressif** (legacy + modules en parallèle) |
|
||||
| Profondeur DDD | **Pragmatique** (= Starseed réel) |
|
||||
| Module pilote | **Projets/Tâches** (cœur métier) |
|
||||
| Dépendances du pilote (User/Client/Notification) | Restent **legacy**, câblées via **contrats `Shared/Domain/Contract`** + `resolve_target_entities` |
|
||||
| Infra d'audit Starseed | **Différée** → ticket Lesstime dédié (créé séparément) |
|
||||
| Périmètre front #56 | **Câblage shell/shared/middlewares + migration du pilote en layer**, sans relooking (le relooking Malio reste #60) |
|
||||
| Exposition API du pilote | **Garder les `#[ApiResource]` actuels** (étendre seulement les chemins de scan) — zéro régression API |
|
||||
| Tâche → Notification | **Contrat `NotifierInterface`** (impl legacy crée la `Notification`) |
|
||||
| Nom/ID du module | back `ProjectManagement` / front `project-management` / ID `project_management` |
|
||||
|
||||
## 2. Garde-fous Starseed retenus pour #56
|
||||
|
||||
Repris : `declare(strict_types=1)`, `src/Module/<X>/{Domain,Application,Infrastructure}`, `Shared/Domain/Contract` + `resolve_target_entities` (zéro import inter-modules), `config/modules.php` + `config/sidebar.php`, endpoints `/api/modules` + `/api/sidebar` + `/api/version`, `TimestampableBlamableTrait` + subscriber, pagination obligatoire, `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`), front layers auto-détectés + `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts`.
|
||||
|
||||
Reportés (hors #56) : **infra d'audit** (`#[Auditable]`/`#[AuditIgnore]`, table `audit_log`, listener, resource) → ticket dédié. **RBAC fin** (`module.resource.action`) → #57 ; en #56 la sidebar filtre **par module actif** (au plus un gate `ROLE_ADMIN`).
|
||||
|
||||
## 3. Backend — arborescence cible
|
||||
|
||||
```
|
||||
src/Shared/
|
||||
├── Domain/
|
||||
│ ├── Contract/ UserInterface, UserResolverInterface, ClientInterface, NotifierInterface
|
||||
│ ├── Event/ DomainEventInterface
|
||||
│ └── Trait/ TimestampableBlamableTrait
|
||||
├── Infrastructure/
|
||||
│ ├── Doctrine/ TimestampableBlamableSubscriber
|
||||
│ ├── Database/ ColumnCommentsCatalog (helper COMMENT ON COLUMN + 4 colonnes std)
|
||||
│ └── ApiPlatform/
|
||||
│ ├── Resource/ ModulesResource, SidebarResource
|
||||
│ └── State/ ModulesProvider, SidebarProvider
|
||||
│
|
||||
src/Module/ProjectManagement/
|
||||
├── ProjectManagementModule.php ID='project_management', LABEL='Projets', REQUIRED=false, permissions()=[] (stub, RBAC réel #57)
|
||||
├── Domain/
|
||||
│ ├── Entity/ Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort,
|
||||
│ │ TaskPriority, TaskTag, TaskRecurrence, TaskDocument
|
||||
│ └── Repository/ *RepositoryInterface (une interface par agrégat consommé)
|
||||
├── Application/ RecurrenceCalculator/RecurrenceHandler + services task-centric déplacés
|
||||
└── Infrastructure/
|
||||
├── Doctrine/ Doctrine*Repository + Migrations/ (additif Timestampable)
|
||||
├── ApiPlatform/ State/Provider + State/Processor déplacés (TaskNumber, TaskCalendar,
|
||||
│ TaskDocument*, SwitchProjectWorkflow, WorkflowDelete, ActiveTimeEntry resté legacy…)
|
||||
└── Mcp/Tool/ MCP tools Project/, Task/, TaskMeta/, Workflow/ déplacés
|
||||
```
|
||||
|
||||
`src/Entity/` conserve **intacts** : `User`, `Client`, `Notification`, `TimeEntry`, `AbsenceRequest`/`AbsencePolicy`/`AbsenceBalance`, `Mail*`, `Gitea*`/`BookStack*`/`Zimbra*`/`Share*Configuration`. Ces domaines seront modularisés dans des tickets ultérieurs.
|
||||
|
||||
> **Note de découpage** : `TimeEntry` reste legacy en #56 (domaine Time tracking séparé). Le lien `Task ↔ TimeEntry` est porté côté `TimeEntry` (FK nullable vers la table `task`) ; aucune contrainte ne casse car la table `task` ne change pas de nom.
|
||||
|
||||
## 4. Câblage des dépendances (zéro import inter-modules)
|
||||
|
||||
1. Interfaces dans `src/Shared/Domain/Contract/` :
|
||||
- `UserInterface` (id + identifiants nécessaires aux entités du module : assignee, collaborators, createdBy/updatedBy),
|
||||
- `ClientInterface` (id + nom, pour `Project.client`),
|
||||
- `UserResolverInterface` (résoudre un user par id, pour les State/MCP du module),
|
||||
- `NotifierInterface` (créer une notification — impl legacy).
|
||||
2. Les entités du module **type-hintent les interfaces**, jamais `App\Entity\*`.
|
||||
3. `config/packages/doctrine.yaml → orm.resolve_target_entities` :
|
||||
```yaml
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Entity\User
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Entity\Client
|
||||
```
|
||||
4. `App\Entity\User` `implements UserInterface`, `App\Entity\Client` `implements ClientInterface` (legacy modifié à minima, additif).
|
||||
5. Notifications : `App\Module\ProjectManagement\…` appelle `NotifierInterface` ; impl `App\…\LegacyNotifier` (wrappe le `NotificationService` actuel). Le `TaskNotificationListener` est déplacé/adapté pour passer par le contrat.
|
||||
|
||||
## 5. Config backend (toutes additives)
|
||||
|
||||
- **`doctrine.yaml`** — ajouter un mapping module (garder `App → src/Entity`) :
|
||||
```yaml
|
||||
mappings:
|
||||
App: { type: attribute, is_bundle: false, dir: '%kernel.project_dir%/src/Entity', prefix: 'App\Entity', alias: App }
|
||||
ProjectManagement:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
||||
prefix: 'App\Module\ProjectManagement\Domain\Entity'
|
||||
```
|
||||
Les entités déplacées **gardent leur `#[ORM\Table(name: '…')]` actuel** (table inchangée → aucune donnée déplacée). `#[ORM\Entity(repositoryClass: DoctrineXxxRepository::class)]` mis à jour vers la nouvelle classe.
|
||||
- **`doctrine_migrations.yaml`** — ajouter le namespace module (garder `DoctrineMigrations`) :
|
||||
```yaml
|
||||
migrations_paths:
|
||||
DoctrineMigrations: '%kernel.project_dir%/migrations'
|
||||
'App\Module\ProjectManagement\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/ProjectManagement/Infrastructure/Doctrine/Migrations'
|
||||
```
|
||||
> ⚠️ Doctrine Migrations trie par FQCN entre namespaces : le legacy `DoctrineMigrations` (setup initial) passe avant les migrations modulaires sur base vide. Sur la prod déjà migrée, seules les **nouvelles** migrations additives s'appliquent → pas d'impact d'ordre.
|
||||
- **`api_platform.yaml`** — déclarer les chemins de mapping (entités + resources legacy **et** module) pour que les `#[ApiResource]` du pilote restent découverts :
|
||||
```yaml
|
||||
mapping:
|
||||
paths:
|
||||
- '%kernel.project_dir%/src/Entity'
|
||||
- '%kernel.project_dir%/src/ApiResource'
|
||||
- '%kernel.project_dir%/src/Shared/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
||||
```
|
||||
- **`services.yaml`** — mettre à jour les FQCN explicites déplacés : `App\EventListener\TaskDocumentListener`, `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool` → nouveaux namespaces module. Le glob `App\: '../src/'` continue d'autowire les classes déplacées.
|
||||
|
||||
## 6. Garde-fous portés dans #56
|
||||
|
||||
- **TimestampableBlamable** : trait `Shared/Domain/Trait/TimestampableBlamableTrait` (4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` — toutes **nullable**), rempli par `TimestampableBlamableSubscriber` (prePersist/preUpdate). Appliqué aux entités du pilote → **1 migration additive** par table concernée, avec `COMMENT ON COLUMN` via `ColumnCommentsCatalog::addStandardTimestampableBlamableComments()`.
|
||||
- **Pagination** : conserver le standard API Platform actuel (les collections du pilote restent paginées comme aujourd'hui).
|
||||
- **`COMMENT ON COLUMN`** : appliqué sur les colonnes ajoutées par #56 (pas de rétro-commentaire forcé sur le legacy).
|
||||
|
||||
## 7. Endpoints modules / sidebar / version
|
||||
|
||||
- `GET /api/modules` (public) — `ModulesResource` + `ModulesProvider` lisant `config/modules.php` (renvoie `{ modules: ["project_management", …] }`).
|
||||
- `GET /api/sidebar` (auth) — `SidebarResource` + `SidebarProvider` lisant `config/sidebar.php` ; filtrage **par module actif** (item `module` absent de la liste active → masqué + route ajoutée à `disabledRoutes`) ; gate de section optionnel `ROLE_ADMIN`. Le filtrage par **permissions fines** est explicitement reporté à #57.
|
||||
- `GET /api/version` — **déjà présent** (`AppVersion`) ; vérifier le format `{ version }`, ré-aligner si besoin (déplacement optionnel vers `Shared/`).
|
||||
- `config/modules.php` : `return [ ProjectManagementModule::class ];` (Core viendra plus tard ; pas de module REQUIRED bloquant en #56).
|
||||
- `config/sidebar.php` : sections « Projets » / « Mes tâches » avec `module => 'project_management'` ; les entrées des domaines encore legacy (Time tracking, Absences, Mail, Admin…) listées **sans** clé `module` (donc toujours visibles) pour ne rien masquer.
|
||||
|
||||
## 8. Frontend — câblage + pilote en layer (sans relooking)
|
||||
|
||||
```
|
||||
frontend/app/
|
||||
├── layouts/default.vue shell : sidebar (depuis /api/sidebar) + main
|
||||
├── middleware/auth.global.ts protège routes, charge sidebar+modules après login
|
||||
└── middleware/modules.global.ts redirige si route ∈ disabledRoutes
|
||||
frontend/shared/
|
||||
├── composables/ useApi (déplacé), useSidebar, useModules, + existants réutilisés
|
||||
├── stores/ auth, ui, timer (timer reste partagé : Time tracking encore legacy)
|
||||
├── utils/ api.ts (extractHydraMembers/fetchAllHydra), …
|
||||
└── types/
|
||||
frontend/modules/project-management/
|
||||
├── nuxt.config.ts defineNuxtConfig({})
|
||||
├── pages/ my-tasks.vue, projects/index.vue, projects/[id]/* (déplacés tels quels)
|
||||
├── components/ task/*, project/* (déplacés)
|
||||
├── services/ tasks.ts, projects.ts, task-*.ts, workflows.ts (déplacés)
|
||||
└── stores/ (si spécifiques au domaine)
|
||||
```
|
||||
|
||||
- **`nuxt.config.ts`** : auto-détection des layers `modules/*/` (scan `readdirSync` comme Starseed) ajoutés à `extends`, + dirs d'auto-import des composables/stores par layer. `extends: ['@malio/layer-ui']` conservé en tête.
|
||||
- **`useSidebar`/`useModules`** : état singleton, `loadSidebar()`/`loadModules()` appelés dans `auth.global.ts`, `reset*()` au logout.
|
||||
- **`modules.global.ts`** : `isRouteDisabled(to.path)` → `navigateTo('/')`.
|
||||
- **Migration des pages** : déplacement **sans réécriture visuelle** ; les pages des autres domaines (time-tracking, absences, mail, admin, profile…) **restent dans `frontend/pages/`** (legacy) tant que leurs modules ne sont pas migrés. Nuxt fusionne les routes du shell + des layers → cohabitation transparente.
|
||||
|
||||
> Point de vigilance front : vérifier que la cohabitation `frontend/pages/` (legacy) + `frontend/modules/*/pages/` (layer) ne crée pas de collision de routes ; `my-tasks`/`projects` sont déplacés **et retirés** de `frontend/pages/` pour éviter le doublon.
|
||||
|
||||
## 9. Plan strangler (ordre d'exécution — app verte à chaque palier)
|
||||
|
||||
1. **Shared/ + garde-fous** : trait, subscriber, `ColumnCommentsCatalog`. Neutre (rien ne les consomme encore).
|
||||
2. **Endpoints modules/sidebar** + `config/modules.php` + `config/sidebar.php` (toutes entrées legacy sans `module` → rien masqué). Additif.
|
||||
3. **Contrats `Shared/Domain/Contract`** + `resolve_target_entities` + `User`/`Client` `implements …Interface`. Neutre.
|
||||
4. **Déplacement back du module** ProjectManagement (entités → Domain/Entity, repos → Infra/Doctrine + interfaces Domain, State, MCP) + mises à jour `doctrine.yaml`/`api_platform.yaml`/`doctrine_migrations.yaml`/`services.yaml`. **`make test` vert.**
|
||||
5. **Migration additive Timestampable** sur les tables du pilote (+ `COMMENT ON COLUMN`).
|
||||
6. **Front shell** : `app/` + `shared/` + middlewares + auto-détection `nuxt.config.ts`. App encore en pages plates.
|
||||
7. **Déplacement front du pilote** vers `modules/project-management/` (pages/components/services), retrait des doublons de `frontend/pages/`.
|
||||
8. **Vérification bout-en-bout** : commenter `ProjectManagementModule::class` dans `config/modules.php` → `/api/modules` ne le liste plus, `/api/sidebar` masque ses entrées + peuple `disabledRoutes`, le front redirige `/my-tasks`→`/`. Décommenter → tout revient. Documenter le test.
|
||||
|
||||
## 10. Critères d'acceptation (repris du ticket, raffinés)
|
||||
|
||||
- [ ] `src/Shared/` + `src/Module/ProjectManagement/{Domain,Application,Infrastructure}` en place.
|
||||
- [ ] `/api/modules`, `/api/sidebar` fonctionnels ; `/api/version` aligné.
|
||||
- [ ] Aucun import direct `App\Entity\User`/`Client` depuis le module (contrats + `resolve_target_entities`).
|
||||
- [ ] Front : layers `frontend/modules/*/` auto-détectés ; `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts` opérationnels ; pilote migré sans régression visuelle.
|
||||
- [ ] Garde-fous : TimestampableBlamable (migration additive + `COMMENT ON COLUMN`) ; pagination conservée. **Audit explicitement hors périmètre** (ticket dédié).
|
||||
- [ ] `make test` vert ; activation/désactivation du module validée de bout en bout.
|
||||
- [ ] Aucune migration destructive ; prod déployable sans perte.
|
||||
|
||||
## 11. Risques & points de vigilance
|
||||
|
||||
- **Prod peuplée** : seules migrations additives nullable. `created_by`/`updated_by` non backfillés (historique) — conforme Starseed.
|
||||
- **Changement de namespace des entités** : sans impact DB (Doctrine mappe par table). Vérifier qu'aucun code legacy ne référence en dur `App\Entity\Task` etc. → grep + remplacement (le pilote tire Task/Project, consommés par TimeEntry/Mail/BookStack links restés legacy : ces liens passeront par les contrats ou un type-hint relâché).
|
||||
- **Collision de routes front** legacy vs layer (cf. §8).
|
||||
- **MCP tools** (spécificité Lesstime) : déplacés sous `Module/*/Infrastructure/Mcp/` ; confirmer que `McpSchemaGeneratorPass` les redécouvre (scan `src/`).
|
||||
- **`auto_mapping: true`** : valider que l'ajout d'un mapping explicite ne perturbe pas la résolution (sinon désactiver `auto_mapping` et lister explicitement).
|
||||
|
||||
## 12. Suite
|
||||
|
||||
- Ticket **audit** dédié à créer (infra `#[Auditable]` + `audit_log` + listener + resource), prérequis souple de #57.
|
||||
- #57 RBAC fin (permissions `module.resource.action`, sidebar filtrée par permission).
|
||||
- #58 Répertoire (Clients/Prospects), #59 Reporting, #60 Refonte front Malio.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Roadmap — Migration Lesstime → modular monolith DDD (archi Starseed)
|
||||
|
||||
> Plan de migration **complet** validé le 2026-06-19. Référence architecture : repo **Starseed**
|
||||
> (`.claude/rules/*.md` + implémentation réelle). Détail technique du socle : voir
|
||||
> `2026-06-19-lst-56-modular-monolith-design.md`.
|
||||
|
||||
## Principes directeurs
|
||||
|
||||
- **Strangler progressif** : legacy (`src/Entity/…`) et modules (`src/Module/…`) coexistent ; l'app
|
||||
reste fonctionnelle et `make test` vert à **chaque** merge. Aucune migration destructive (prod Docker, BDD peuplée → migrations **additives nullable** uniquement).
|
||||
- **DDD pragmatique** (= Starseed réel) : ORM attrs dans l'entité Domain, Repository interface (Domain)
|
||||
+ impl Doctrine (Infra), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le
|
||||
cross-module. **Pas de CQRS bus, pas de multi-tenant.**
|
||||
- **Tranches verticales** : chaque module de Phase 2 est livré **back + front (layer Malio) + MCP**
|
||||
d'un coup → fonctionnel de bout en bout à son merge. L'ancienne idée d'un « ticket refonte front »
|
||||
global est dissoute : chaque module arrive déjà en Malio ; un ticket de finition harmonise à la fin.
|
||||
- **Ordre par dépendances** : socle → Core (identité/RBAC/audit) → modules métier → transverse/finition.
|
||||
- **Zéro import inter-modules** : interfaces `Shared/Domain/Contract` + `resolve_target_entities`,
|
||||
ou domain events / contrat `NotifierInterface`.
|
||||
|
||||
## Garde-fous Starseed (appliqués à chaque entité migrée)
|
||||
|
||||
`declare(strict_types=1)` · `TimestampableBlamableTrait` (4 colonnes nullable) + subscriber ·
|
||||
pagination obligatoire · `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`) ·
|
||||
`#[Auditable]`/`#[AuditIgnore]` (dès que 1.3 est livré) · front `Malio*` + `usePaginatedList` +
|
||||
`useFormErrors` · RBAC `module.resource.action` (dès 1.2).
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Socle (fondations, ne touche aucun métier)
|
||||
|
||||
### 0.1 · Socle back — infrastructure modulaire *(réécrit depuis #56)*
|
||||
**Dépend de** : —
|
||||
`src/Shared/Domain/Contract/` (UserInterface, UserResolverInterface, ClientInterface, NotifierInterface),
|
||||
`Shared/Domain/Event/DomainEventInterface`, `Shared/Domain/Trait/TimestampableBlamableTrait`,
|
||||
`Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber`,
|
||||
`Shared/Infrastructure/Database/ColumnCommentsCatalog`,
|
||||
`Shared/Infrastructure/ApiPlatform/{Resource,State}` (`ModulesResource`/`ModulesProvider`,
|
||||
`SidebarResource`/`SidebarProvider`), `config/modules.php`, `config/sidebar.php`, `/api/version` aligné.
|
||||
Config additive : mapping Doctrine module prêt, `migrations_paths` modulaire, `api_platform.mapping.paths`.
|
||||
**AC** : `/api/modules` + `/api/sidebar` répondent ; app verte ; aucune migration destructive.
|
||||
|
||||
### 0.2 · Socle front — shell + auto-détection des layers
|
||||
**Dépend de** : 0.1
|
||||
`frontend/app/` (shell `layouts/default.vue`), `frontend/shared/` (`useApi` déplacé, `useSidebar`,
|
||||
`useModules`, stores), middlewares `auth.global.ts` + `modules.global.ts`, auto-détection des layers
|
||||
`modules/*/` dans `nuxt.config.ts`. **Aucune page métier déplacée** (app encore plate).
|
||||
**AC** : sidebar dynamique depuis `/api/sidebar` ; routes désactivées redirigées ; app verte.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Module Core (identité, sécurité, traçabilité — transverse)
|
||||
|
||||
### 1.1 · Core — Identité & Notifications
|
||||
**Dépend de** : 0.1, 0.2
|
||||
Migrer `User` + Auth/JWT dans `src/Module/Core/` (Domain/Entity, Repository interface + Doctrine impl,
|
||||
`MeProvider`, password hasher), `User implements UserInterface`, `resolve_target_entities → Core\User`.
|
||||
`Notification` exposée via `NotifierInterface`. `CoreModule.php` (**REQUIRED=true**). Front : layer
|
||||
`modules/core/` (login, profile, admin users).
|
||||
**AC** : login/JWT OK ; app verte ; aucun import direct `App\Entity\User` hors Core.
|
||||
|
||||
### 1.2 · RBAC fin *(réécrit depuis #57)*
|
||||
**Dépend de** : 1.1
|
||||
`Role`/`Permission`, `permissions()` par module, commande `app:sync-permissions`, `PermissionVoter`,
|
||||
`SidebarProvider` filtrant **par permission** (en plus du module actif), seed RBAC. Front : gestion des
|
||||
rôles + `usePermissions`.
|
||||
**AC** : permissions `module.resource.action` ; sidebar gated par permission.
|
||||
|
||||
### 1.3 · Audit log *(réécrit depuis #61)*
|
||||
**Dépend de** : 1.1
|
||||
`#[Auditable]`/`#[AuditIgnore]` (`Shared/Domain/Attribute`), table `audit_log` (migration additive +
|
||||
`COMMENT ON COLUMN`), `AuditListener`/`AuditLogWriter`/`RequestIdProvider`, `AuditLogResource` +
|
||||
`/api/audit-logs` paginé/filtrable, page front + labels i18n `audit.entity.*`.
|
||||
**AC** : CRUD des entités `#[Auditable]` tracé ; endpoint paginé ; aucune migration destructive.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Modules métier (tranches verticales back + front + MCP, strangler)
|
||||
|
||||
### 2.1 · Module TimeTracking *(premier module — rodage)*
|
||||
**Dépend de** : 1.1
|
||||
Migrer `TimeEntry` → `src/Module/TimeTracking/` (Domain/Entity, repo, `ActiveTimeEntryProvider`,
|
||||
`TimeEntryExportService`/controller, MCP TimeEntry tools), front layer `modules/time-tracking/`
|
||||
(`time-tracking.vue`, components, services, store `timer`). Timestampable additif. **Rode toute la
|
||||
mécanique modulaire à risque quasi nul.**
|
||||
**AC** : time tracking fonctionnel en module ; activation/désactivation testée ; app verte.
|
||||
|
||||
### 2.2 · Module ProjectManagement *(cœur métier — réécrit depuis #56 pilote)*
|
||||
**Dépend de** : 2.1, 1.1
|
||||
`Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence,
|
||||
TaskDocument` → `src/Module/ProjectManagement/` (vertical back + MCP Task/Project/TaskMeta/Workflow +
|
||||
front layer `modules/project-management/`). User/Client via contrats (Client encore legacy jusqu'à 2.4).
|
||||
Notifications via `NotifierInterface`. `#[ApiResource]` conservés (étendre le scan). Timestampable additif.
|
||||
**AC** : cœur en module sans régression API ; app verte.
|
||||
|
||||
### 2.3 · Module Absence
|
||||
**Dépend de** : 1.1
|
||||
`AbsenceRequest/AbsencePolicy/AbsenceBalance` + services (`AbsenceBalanceService`, `AbsenceDayCalculator`,
|
||||
`PublicHolidayProvider`) + controllers (calendar, preview, justificatif) + MCP absence tools →
|
||||
`src/Module/Absence/`, front layer `modules/absence/`.
|
||||
**AC** : module absences complet ; app verte.
|
||||
|
||||
### 2.4 · Module Directory — Clients + Prospects *(réécrit depuis #58)*
|
||||
**Dépend de** : 1.1 (et après 2.2 qui référence Client via contrat)
|
||||
`Client` → `src/Module/Directory/` + nouvelle entité `Prospect`. L'impl de `ClientInterface` migre du
|
||||
legacy vers le module (`resolve_target_entities` mis à jour). Front répertoire (clients + prospects).
|
||||
**AC** : Clients + Prospects en module ; contrats à jour ; app verte.
|
||||
|
||||
### 2.5 · Module Mail
|
||||
**Dépend de** : 1.1, 2.2 (TaskMailLink → Task)
|
||||
`Mail*` + `TaskMailLink` + `MailSyncService` + controllers + settings → `src/Module/Mail/`, front layer.
|
||||
Intègre le WIP `feat/mail-integration`.
|
||||
**AC** : mail en module ; app verte.
|
||||
|
||||
### 2.6 · Module Integration — Gitea / BookStack / Zimbra / Share
|
||||
**Dépend de** : 1.1, 2.2 (liens Task)
|
||||
Configs + services API (`GiteaApiService`, `BookStackApiService`, `CalDavService`, Share) + controllers +
|
||||
liens → `src/Module/Integration/`, front (onglets admin + sections task).
|
||||
**AC** : intégrations en module ; app verte.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Transverse & finition
|
||||
|
||||
### 3.1 · Module Reporting *(réécrit depuis #59)*
|
||||
**Dépend de** : Phase 2 (consomme les modules)
|
||||
Reporting natif transverse (agrège time tracking, tâches, absences) via contrats / API. Module
|
||||
`src/Module/Reporting/` + front.
|
||||
**AC** : rapports natifs ; aucune dépendance directe inter-modules.
|
||||
|
||||
### 3.2 · Module Portail client
|
||||
**Dépend de** : 1.1, 2.2, 2.4
|
||||
Portail client (accès restreint), module `src/Module/ClientPortal/` + front layer + RBAC dédié.
|
||||
**AC** : portail fonctionnel ; gated RBAC.
|
||||
|
||||
### 3.3 · Finition Malio + nettoyage legacy *(réécrit depuis #60)*
|
||||
**Dépend de** : tout
|
||||
Harmonisation visuelle Malio finale, **vidage de `src/Entity/` legacy résiduel**, suppression du mapping
|
||||
Doctrine legacy + des pages plates `frontend/pages/` résiduelles, durcissement `resolve_target_entities`.
|
||||
**AC** : `src/Entity` vide ; 100 % modulaire ; app verte ; aucune route/legacy orpheline.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution recommandé
|
||||
|
||||
`0.1 → 0.2 → 1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3 → 2.4 → 2.5 → 2.6 → 3.1 → 3.2 → 3.3`
|
||||
|
||||
Les tickets 1.2 et 1.3 peuvent se paralléliser après 1.1. Les modules 2.3 (Absence) et 2.4 (Directory)
|
||||
peuvent se paralléliser après 2.2. Mail (2.5) et Integration (2.6) suivent 2.2.
|
||||
|
||||
## Mapping avec les tickets Lesstime existants
|
||||
|
||||
| Ancien | Devient |
|
||||
|--------|---------|
|
||||
| #56 (1/5 Aligner archi) | **0.1 Socle back** (le reste éclaté en 0.2 + 2.2) |
|
||||
| #57 (2/5 RBAC) | **1.2 RBAC fin** |
|
||||
| #58 (3/5 Répertoire) | **2.4 Directory** |
|
||||
| #59 (4/5 Reporting) | **3.1 Reporting** |
|
||||
| #60 (5/5 Front Malio) | **3.3 Finition Malio + nettoyage** (le front se fait par module) |
|
||||
| #61 (Audit) | **1.3 Audit log** |
|
||||
| *(créés)* | 0.2, 1.1, 2.1, 2.2, 2.3, 2.5, 2.6, 3.2 |
|
||||
@@ -38,123 +38,47 @@
|
||||
</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="Tableau de bord"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Gestion de projet -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Gestion de projet
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
label="Projets"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}`"
|
||||
icon="mdi:view-column-outline"
|
||||
label="Kanban"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
exact
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/groups`"
|
||||
icon="mdi:tag-multiple-outline"
|
||||
label="Groupes"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/archives`"
|
||||
icon="mdi:archive-outline"
|
||||
label="Archives"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
icon="mdi:calendar-edit-outline"
|
||||
label="Suivi de temps"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Section : Absences -->
|
||||
<template v-if="isAbsenceSectionVisible">
|
||||
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
|
||||
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Absences
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
</template>
|
||||
<SidebarLink
|
||||
v-if="isEmployee"
|
||||
to="/absences"
|
||||
icon="mdi:umbrella-beach-outline"
|
||||
label="Mes absences"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/team-absences"
|
||||
icon="mdi:calendar-account-outline"
|
||||
label="Absences équipe"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Administration (admin only) -->
|
||||
<template v-if="isAdmin">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Administration
|
||||
{{ section.label }}
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
|
||||
<template v-if="sIndex === 0">
|
||||
<!-- Contextuel projet -->
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
<!-- Feature-flag : Documents -->
|
||||
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<!-- Feature-flag : Mail + badge -->
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
|
||||
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
@@ -212,16 +136,31 @@ const ui = useUiStore()
|
||||
const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
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,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
|
||||
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
||||
const isAbsenceSectionVisible = computed(() => isEmployee.value || isAdmin.value)
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
|
||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
@@ -267,13 +206,17 @@ onMounted(() => {
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
})
|
||||
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
} else {
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
|
||||
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
} else {
|
||||
// Logout / session expirée : purge l'état partagé pour le prochain login.
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
|
||||
if (isRouteDisabled(to.path)) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -19,3 +19,20 @@
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Champs Malio (@malio/layer-ui >= 1.7.5) : depuis cette version, la ligne de
|
||||
* message sous chaque champ est toujours rendue (`reserveMessageSpace` à `true`
|
||||
* par défaut) et réserve ~1rem (16px) même sans erreur/hint, ce qui décale les
|
||||
* formulaires denses. On retire cette réserve et on masque la ligne quand elle
|
||||
* est vide, sans désactiver l'option champ par champ ni perdre l'affichage des
|
||||
* vraies erreurs/hints.
|
||||
*
|
||||
* Hook stable : la ligne de message a un id se terminant par "-describedby".
|
||||
*/
|
||||
[id$="-describedby"] {
|
||||
min-height: 0;
|
||||
}
|
||||
[id$="-describedby"]:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4">
|
||||
<MalioSelect
|
||||
v-model="entityTypeFilter"
|
||||
:options="entityTypeOptions"
|
||||
:label="$t('admin.audit.filterEntityType')"
|
||||
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="actionFilter"
|
||||
:options="actionOptions"
|
||||
:label="$t('admin.audit.filterAction')"
|
||||
:empty-option-label="$t('admin.audit.filterActionAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.audit.empty')"
|
||||
>
|
||||
<template #cell-performedAt="{ item }">
|
||||
{{ formatDate(item.performedAt) }}
|
||||
</template>
|
||||
<template #cell-entityType="{ item }">
|
||||
{{ entityTypeLabel(item.entityType) }}
|
||||
</template>
|
||||
<template #cell-action="{ item }">
|
||||
{{ actionLabel(item.action) }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.previous')"
|
||||
:disabled="page <= 1 || isLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.next')"
|
||||
:disabled="!hasNextPage || isLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
|
||||
import { useAuditLogService } from '~/modules/core/services/audit-logs'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
|
||||
{ key: 'performedBy', label: t('admin.audit.performedBy') },
|
||||
{ key: 'entityType', label: t('admin.audit.entityType') },
|
||||
{ key: 'action', label: t('admin.audit.action') },
|
||||
{ key: 'entityId', label: t('admin.audit.entityId') },
|
||||
])
|
||||
|
||||
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
{ value: 'update', label: t('audit.action.update') },
|
||||
{ value: 'delete', label: t('audit.action.delete') },
|
||||
])
|
||||
|
||||
const auditLogService = useAuditLogService()
|
||||
|
||||
const rows = ref<AuditLogItem[]>([])
|
||||
const entityTypes = ref<string[]>([])
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const isLoading = ref(true)
|
||||
const entityTypeFilter = ref<string | null>(null)
|
||||
const actionFilter = ref<AuditLogAction | null>(null)
|
||||
|
||||
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
|
||||
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
|
||||
)
|
||||
|
||||
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
|
||||
// "next" button accurate even on the last (partial) page.
|
||||
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
|
||||
|
||||
function entityTypeLabel(value: string): string {
|
||||
const key = `audit.entity.${value}`
|
||||
return te(key) ? t(key) : value
|
||||
}
|
||||
|
||||
function actionLabel(action: AuditLogAction): string {
|
||||
return t(`audit.action.${action}`)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await auditLogService.list({
|
||||
page: page.value,
|
||||
entityType: entityTypeFilter.value ?? undefined,
|
||||
action: actionFilter.value ?? undefined,
|
||||
})
|
||||
rows.value = result.items
|
||||
totalItems.value = result.totalItems
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntityTypes() {
|
||||
entityTypes.value = await auditLogService.entityTypes()
|
||||
}
|
||||
|
||||
function goToPage(target: number) {
|
||||
if (target < 1) {
|
||||
return
|
||||
}
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
|
||||
watch([entityTypeFilter, actionFilter], () => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadEntityTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.roles.addRole')"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.roles.empty')"
|
||||
@row-click="openEdit"
|
||||
>
|
||||
<template #cell-isSystem="{ item }">
|
||||
<span
|
||||
v-if="item.isSystem"
|
||||
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ $t('admin.roles.system') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-permissions="{ item }">
|
||||
<span class="text-neutral-600">{{ item.permissions.length }}</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButtonIcon
|
||||
v-if="!item.isSystem"
|
||||
icon="mdi:delete-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-red-500"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<RoleDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
:permissions="permissions"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
import { usePermissionService } from '~/modules/core/services/permissions'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'label', label: t('admin.roles.label'), primary: true },
|
||||
{ key: 'code', label: t('admin.roles.code') },
|
||||
{ key: 'permissions', label: t('admin.roles.permissions') },
|
||||
{ key: 'isSystem', label: '' },
|
||||
])
|
||||
|
||||
const roleService = useRoleService()
|
||||
const permissionService = usePermissionService()
|
||||
|
||||
const items = ref<Role[]>([])
|
||||
const permissions = ref<Permission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<Role | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await roleService.list()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
permissions.value = await permissionService.list()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Role) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await roleService.remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadPermissions()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('adminShare.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.host"
|
||||
:label="$t('adminShare.host')"
|
||||
:placeholder="$t('adminShare.hostPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.shareName"
|
||||
:label="$t('adminShare.shareName')"
|
||||
:placeholder="$t('adminShare.shareNamePlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.basePath"
|
||||
:label="$t('adminShare.basePath')"
|
||||
:placeholder="$t('adminShare.basePathPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.domain"
|
||||
:label="$t('adminShare.domain')"
|
||||
:placeholder="$t('adminShare.domainPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="$t('adminShare.username')"
|
||||
:placeholder="$t('adminShare.usernamePlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('adminShare.password')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('adminShare.passwordConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||
<span class="text-sm">{{ $t('adminShare.enabled') }}</span>
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
:label="$t('adminShare.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('adminShare.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult ? $t('adminShare.testSuccess') : (testMessage ?? $t('adminShare.testFailed')) }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShareSettingsService } from '~/services/share-settings'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||
|
||||
const form = reactive({
|
||||
host: '',
|
||||
shareName: '',
|
||||
basePath: '',
|
||||
domain: '',
|
||||
username: '',
|
||||
password: '',
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const hasPassword = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
const testMessage = ref<string | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.host = settings.host ?? ''
|
||||
form.shareName = settings.shareName ?? ''
|
||||
form.basePath = settings.basePath ?? ''
|
||||
form.domain = settings.domain ?? ''
|
||||
form.username = settings.username ?? ''
|
||||
form.enabled = settings.enabled
|
||||
hasPassword.value = settings.hasPassword
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
host: form.host.trim() || null,
|
||||
shareName: form.shareName.trim() || null,
|
||||
basePath: form.basePath.trim() || null,
|
||||
domain: form.domain.trim() || null,
|
||||
username: form.username.trim() || null,
|
||||
password: form.password || null,
|
||||
enabled: form.enabled,
|
||||
})
|
||||
hasPassword.value = result.hasPassword
|
||||
form.password = ''
|
||||
testResult.value = null
|
||||
testMessage.value = null
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
testMessage.value = null
|
||||
try {
|
||||
const result = await testConnection()
|
||||
testResult.value = result.success
|
||||
testMessage.value = result.message
|
||||
} catch {
|
||||
testResult.value = false
|
||||
testMessage.value = null
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:label="$t('admin.roles.code')"
|
||||
input-class="w-full"
|
||||
:disabled="isEditing"
|
||||
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
|
||||
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
|
||||
@blur="touched.code = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="$t('admin.roles.label')"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('admin.roles.description')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('admin.roles.permissions') }}
|
||||
</label>
|
||||
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('admin.roles.noPermissions') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="group in groupedPermissions"
|
||||
:key="group.module"
|
||||
class="mt-3 rounded-lg border border-neutral-200 p-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
{{ group.module }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.id"
|
||||
class="flex items-start gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="checkbox"
|
||||
:value="perm['@id']"
|
||||
class="mt-0.5 rounded border-neutral-300"
|
||||
/>
|
||||
<span>
|
||||
{{ perm.label }}
|
||||
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role, RoleWrite } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: Role | null
|
||||
permissions: Permission[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
permissions: [] as string[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
label: false,
|
||||
})
|
||||
|
||||
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const byModule = new Map<string, Permission[]>()
|
||||
for (const perm of props.permissions) {
|
||||
const list = byModule.get(perm.module) ?? []
|
||||
list.push(perm)
|
||||
byModule.set(perm.module, list)
|
||||
}
|
||||
return [...byModule.entries()]
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.code = props.item.code
|
||||
form.label = props.item.label
|
||||
form.description = props.item.description ?? ''
|
||||
form.permissions = props.item.permissions
|
||||
.map((p) => p['@id'])
|
||||
.filter((iri): iri is string => !!iri)
|
||||
} else {
|
||||
form.code = ''
|
||||
form.label = ''
|
||||
form.description = ''
|
||||
form.permissions = []
|
||||
}
|
||||
touched.code = false
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useRoleService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.code = true
|
||||
touched.label = true
|
||||
if (!form.label.trim()) {
|
||||
return
|
||||
}
|
||||
if (!isEditing.value && !codeValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (isEditing.value && props.item) {
|
||||
const payload: Partial<RoleWrite> = {
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
const payload: RoleWrite = {
|
||||
code: form.code.trim(),
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@ import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" appear>
|
||||
<div
|
||||
v-if="entry"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
|
||||
@click.self="$emit('close')"
|
||||
@keydown.escape="$emit('close')"
|
||||
@keydown.left="$emit('prev')"
|
||||
@keydown.right="$emit('next')"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<MalioButtonIcon
|
||||
v-if="hasPrev"
|
||||
icon="heroicons:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('prev')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
v-if="hasNext"
|
||||
icon="heroicons:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('next')"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||
<!-- Image preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="inlineUrl"
|
||||
:alt="entry.name"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
|
||||
<!-- PDF preview — iframe pattern, même approche que TaskDocumentPreview -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="inlineUrl"
|
||||
class="h-[85vh] w-[80vw] rounded-lg bg-white"
|
||||
/>
|
||||
|
||||
<!-- Text / Markdown / JSON / XML / CSV / Log preview -->
|
||||
<div
|
||||
v-else-if="isText"
|
||||
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('sharedFiles.download') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-auto p-4">
|
||||
<div v-if="loadingText" class="flex justify-center py-10">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
|
||||
>{{ textContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOCX preview — rendu HTML via docx-preview (lazy) -->
|
||||
<div
|
||||
v-else-if="isDocx"
|
||||
class="flex max-h-[85vh] w-[85vw] max-w-4xl flex-col overflow-hidden rounded-xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('sharedFiles.download') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-auto bg-neutral-100 p-4">
|
||||
<div v-if="loadingOffice" class="flex justify-center py-10">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="officeError" class="py-10 text-center text-sm text-red-600">
|
||||
{{ $t('sharedFiles.previewError') }}
|
||||
</p>
|
||||
<div v-show="!loadingOffice && !officeError" ref="docxContainer" class="mx-auto bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet preview — rendu table via SheetJS (lazy) -->
|
||||
<div
|
||||
v-else-if="isSpreadsheet"
|
||||
class="flex max-h-[85vh] w-[88vw] max-w-5xl flex-col overflow-hidden rounded-xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('sharedFiles.download') }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="sheetNames.length > 1" class="flex gap-1 overflow-x-auto border-b border-neutral-100 px-3 py-2">
|
||||
<button
|
||||
v-for="(name, i) in sheetNames"
|
||||
:key="name"
|
||||
class="whitespace-nowrap rounded px-2 py-1 text-xs"
|
||||
:class="i === activeSheet ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
@click="selectSheet(i)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto p-4">
|
||||
<div v-if="loadingOffice" class="flex justify-center py-10">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="officeError" class="py-10 text-center text-sm text-red-600">
|
||||
{{ $t('sharedFiles.previewError') }}
|
||||
</p>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- HTML généré par SheetJS, valeurs de cellule échappées -->
|
||||
<div v-else class="xlsx-host" v-html="sheetHtml" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic file — download fallback -->
|
||||
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
||||
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ entry.name }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatFileSize(entry.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('sharedFiles.download') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- File name footer (masqué pour les vues qui affichent déjà le nom dans leur en-tête) -->
|
||||
<p v-if="!isText && !isDocx && !isSpreadsheet" class="mt-3 text-sm text-white/70">{{ entry.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: FileEntry | null
|
||||
hasPrev: boolean
|
||||
hasNext: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
const textContent = ref('')
|
||||
const loadingText = ref(false)
|
||||
|
||||
// Office previews (rendus côté client, libs chargées à la demande)
|
||||
const docxContainer = ref<HTMLElement | null>(null)
|
||||
const loadingOffice = ref(false)
|
||||
const officeError = ref(false)
|
||||
const sheetNames = ref<string[]>([])
|
||||
const activeSheet = ref(0)
|
||||
const sheetHtml = ref('')
|
||||
// Workbook SheetJS courant (type laissé libre : la lib est importée dynamiquement)
|
||||
let workbook: { SheetNames: string[]; Sheets: Record<string, unknown> } | null = null
|
||||
|
||||
const { getDownloadUrl } = useShareService()
|
||||
|
||||
const TEXT_RE = /\.(md|markdown|txt|csv|json|xml|log)$/i
|
||||
const DOCX_RE = /\.docx$/i
|
||||
const SHEET_RE = /\.(xlsx|xlsm|xls)$/i
|
||||
|
||||
const inlineUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'inline') : '')
|
||||
const downloadUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'attachment') : '')
|
||||
const isImage = computed(() => props.entry?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.entry?.mimeType === 'application/pdf')
|
||||
const isText = computed(() =>
|
||||
props.entry
|
||||
? (props.entry.mimeType.startsWith('text/') || TEXT_RE.test(props.entry.name))
|
||||
: false
|
||||
)
|
||||
const isDocx = computed(() => props.entry ? DOCX_RE.test(props.entry.name) : false)
|
||||
const isSpreadsheet = computed(() => props.entry ? SHEET_RE.test(props.entry.name) : false)
|
||||
|
||||
async function fetchBlob(): Promise<Blob> {
|
||||
return $fetch<Blob>(downloadUrl.value, {
|
||||
credentials: 'include',
|
||||
responseType: 'blob' as never,
|
||||
})
|
||||
}
|
||||
|
||||
async function renderDocx(blob: Blob) {
|
||||
const [{ renderAsync }, DOMPurify] = await Promise.all([
|
||||
import('docx-preview'),
|
||||
import('dompurify'),
|
||||
])
|
||||
loadingOffice.value = false
|
||||
await nextTick()
|
||||
if (docxContainer.value) {
|
||||
docxContainer.value.innerHTML = ''
|
||||
await renderAsync(blob, docxContainer.value, undefined, { inWrapper: true, ignoreLastRenderedPageBreak: true })
|
||||
// Anti-XSS : neutralise tout script injecté via un .docx piégé, en gardant la mise en forme (style)
|
||||
docxContainer.value.innerHTML = DOMPurify.default.sanitize(docxContainer.value.innerHTML, {
|
||||
ADD_TAGS: ['style'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSpreadsheet(blob: Blob) {
|
||||
const [XLSX, DOMPurify] = await Promise.all([import('xlsx'), import('dompurify')])
|
||||
const buf = await blob.arrayBuffer()
|
||||
workbook = XLSX.read(buf, { type: 'array' }) as typeof workbook
|
||||
sheetNames.value = workbook?.SheetNames ?? []
|
||||
await selectSheet(0, XLSX, DOMPurify)
|
||||
loadingOffice.value = false
|
||||
}
|
||||
|
||||
async function selectSheet(
|
||||
index: number,
|
||||
xlsx?: typeof import('xlsx'),
|
||||
purify?: typeof import('dompurify'),
|
||||
) {
|
||||
if (!workbook) return
|
||||
activeSheet.value = index
|
||||
const XLSX = xlsx ?? (await import('xlsx'))
|
||||
const DOMPurify = purify ?? (await import('dompurify'))
|
||||
const ws = workbook.Sheets[workbook.SheetNames[index]!]
|
||||
const rawHtml = XLSX.utils.sheet_to_html(ws as never, { editable: false })
|
||||
sheetHtml.value = DOMPurify.default.sanitize(rawHtml, { FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'] })
|
||||
}
|
||||
|
||||
function resetOffice() {
|
||||
loadingOffice.value = false
|
||||
officeError.value = false
|
||||
sheetHtml.value = ''
|
||||
sheetNames.value = []
|
||||
activeSheet.value = 0
|
||||
workbook = null
|
||||
}
|
||||
|
||||
watch(() => props.entry, async (entry) => {
|
||||
textContent.value = ''
|
||||
resetOffice()
|
||||
if (!entry) return
|
||||
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
|
||||
if (isText.value) {
|
||||
loadingText.value = true
|
||||
try {
|
||||
textContent.value = await $fetch<string>(inlineUrl.value, {
|
||||
credentials: 'include',
|
||||
responseType: 'text' as never,
|
||||
})
|
||||
} catch {
|
||||
textContent.value = ''
|
||||
} finally {
|
||||
loadingText.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isDocx.value || isSpreadsheet.value) {
|
||||
loadingOffice.value = true
|
||||
try {
|
||||
const blob = await fetchBlob()
|
||||
if (isDocx.value) {
|
||||
await renderDocx(blob)
|
||||
} else {
|
||||
await renderSpreadsheet(blob)
|
||||
}
|
||||
} catch {
|
||||
officeError.value = true
|
||||
loadingOffice.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Rendu des tableurs (HTML généré par SheetJS) */
|
||||
.xlsx-host :deep(table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.xlsx-host :deep(td),
|
||||
.xlsx-host :deep(th) {
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -11,24 +11,37 @@
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
<!-- Thumbnail or icon -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
<div class="relative h-10 w-10 shrink-0">
|
||||
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<!-- Pastille : document lié depuis le partage SMB -->
|
||||
<span
|
||||
v-if="doc.sharePath"
|
||||
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 ring-2 ring-white"
|
||||
:title="$t('taskDocuments.shareLinkBadge')"
|
||||
>
|
||||
<Icon name="heroicons:link" class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
<span v-if="doc.sharePath" class="font-medium text-primary-500">{{ $t('taskDocuments.shareLinkLabel') }}</span>
|
||||
<span v-if="doc.sharePath"> · </span>{{ formatFileSize(doc.size) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="close" />
|
||||
<div class="relative z-10 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.linkShareTitle') }}</h3>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
:aria-label="$t('common.cancel')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-neutral-700"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="flex flex-wrap items-center gap-1 border-b border-neutral-100 px-6 py-2 text-sm text-neutral-500">
|
||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span>/</span>
|
||||
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="min-h-[12rem] flex-1 overflow-auto px-2 py-2">
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="error" class="px-4 py-12 text-center text-sm text-red-600">{{ error }}</p>
|
||||
<p v-else-if="entries.length === 0" class="px-4 py-12 text-center text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
|
||||
<ul v-else class="text-sm">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-neutral-50"
|
||||
:class="{ 'opacity-60': linking }"
|
||||
@click="onEntryClick(entry)"
|
||||
>
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
|
||||
<span class="flex-1 truncate">{{ entry.name }}</span>
|
||||
<span class="shrink-0 text-xs text-neutral-400">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="border-t border-neutral-100 px-6 py-3 text-xs text-neutral-400">{{ $t('taskDocuments.linkShareHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'linked'): void
|
||||
}>()
|
||||
|
||||
const { browse } = useShareService()
|
||||
const { linkShare } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPath = ref('')
|
||||
const breadcrumb = ref<Breadcrumb[]>([])
|
||||
const entries = ref<FileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const linking = ref(false)
|
||||
|
||||
async function load(path: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await browse(path)
|
||||
currentPath.value = result.path
|
||||
breadcrumb.value = result.breadcrumb
|
||||
entries.value = result.entries
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message ?? t('sharedFiles.previewError')
|
||||
entries.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPath(path: string) {
|
||||
load(path)
|
||||
}
|
||||
|
||||
async function onEntryClick(entry: FileEntry) {
|
||||
if (linking.value) return
|
||||
if (entry.isDir) {
|
||||
load(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
linking.value = true
|
||||
try {
|
||||
await linkShare(props.taskId, entry.path)
|
||||
toast.success({ title: '', message: t('taskDocuments.linkShareSuccess') })
|
||||
emit('linked')
|
||||
close()
|
||||
} catch {
|
||||
toast.error({ title: 'Erreur', message: t('taskDocuments.linkShareError') })
|
||||
} finally {
|
||||
linking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function iconForMime(mime: string): string {
|
||||
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
|
||||
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
|
||||
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
|
||||
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
|
||||
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
|
||||
return 'mdi:file-outline'
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
entries.value = []
|
||||
load('')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -184,6 +184,20 @@
|
||||
:task-id="task.id"
|
||||
@uploaded="handleDocumentUploaded"
|
||||
/>
|
||||
<div v-if="isEditing && task && isAdmin && shareEnabled" class="mt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('taskDocuments.linkShareButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click="showShareLinker = true"
|
||||
/>
|
||||
</div>
|
||||
<TaskDocumentShareLinker
|
||||
v-if="isEditing && task && isAdmin"
|
||||
v-model="showShareLinker"
|
||||
:task-id="task.id"
|
||||
@linked="handleDocumentUploaded"
|
||||
/>
|
||||
<TaskDocumentList
|
||||
v-if="isEditing && task"
|
||||
:documents="localDocuments"
|
||||
@@ -869,6 +883,11 @@ function formatMailDate(iso: string | null): string {
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
// Lien vers un fichier du partage SMB (en plus de l'upload classique)
|
||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||
const showShareLinker = ref(false)
|
||||
ensureShareStatus()
|
||||
|
||||
// Sync documents from task prop when modal opens or task changes
|
||||
watch(() => props.task?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useShareService } from '~/services/share'
|
||||
|
||||
export function useShareStatus() {
|
||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||
const { getStatus } = useShareService()
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const status = await getStatus()
|
||||
enabled.value = status.enabled
|
||||
} catch {
|
||||
enabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (enabled.value === null) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled, refresh, ensureLoaded }
|
||||
}
|
||||
@@ -128,7 +128,14 @@
|
||||
"download": "Télécharger",
|
||||
"copy": "Copier",
|
||||
"copied": "Contenu copié !",
|
||||
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
|
||||
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo.",
|
||||
"linkShareButton": "Lier depuis le partage",
|
||||
"linkShareTitle": "Lier un fichier du partage",
|
||||
"linkShareHint": "Cliquez sur un dossier pour naviguer, sur un fichier pour le lier au ticket.",
|
||||
"linkShareSuccess": "Fichier du partage lié au ticket.",
|
||||
"linkShareError": "Impossible de lier ce fichier (type non autorisé ou introuvable).",
|
||||
"shareLinkBadge": "Lien vers le partage",
|
||||
"shareLinkLabel": "Partage"
|
||||
},
|
||||
"tasks": {
|
||||
"created": "Ticket créé avec succès.",
|
||||
@@ -188,6 +195,57 @@
|
||||
"addUser": "Ajouter un utilisateur",
|
||||
"editUser": "Modifier un utilisateur"
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
"title": "Rôles",
|
||||
"addRole": "Ajouter un rôle",
|
||||
"editRole": "Modifier un rôle",
|
||||
"empty": "Aucun rôle trouvé.",
|
||||
"system": "Système",
|
||||
"code": "Code",
|
||||
"codeHint": "Identifiant technique en snake_case (immuable).",
|
||||
"codeImmutable": "Le code ne peut pas être modifié après création.",
|
||||
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
|
||||
"label": "Libellé",
|
||||
"labelRequired": "Le libellé est requis.",
|
||||
"description": "Description",
|
||||
"permissions": "Permissions",
|
||||
"noPermissions": "Aucune permission disponible.",
|
||||
"created": "Rôle créé avec succès.",
|
||||
"updated": "Rôle mis à jour avec succès.",
|
||||
"deleted": "Rôle supprimé avec succès."
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit",
|
||||
"empty": "Aucune entrée d'audit trouvée.",
|
||||
"date": "Date",
|
||||
"performedBy": "Utilisateur",
|
||||
"entityType": "Type d'entité",
|
||||
"action": "Action",
|
||||
"entityId": "Identifiant",
|
||||
"filterEntityType": "Type d'entité",
|
||||
"filterEntityTypeAll": "Tous les types",
|
||||
"filterAction": "Action",
|
||||
"filterActionAll": "Toutes les actions",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"page": "Page {page}"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"entity": {
|
||||
"core": {
|
||||
"User": "Utilisateur",
|
||||
"Role": "Rôle",
|
||||
"Permission": "Permission"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Création",
|
||||
"update": "Modification",
|
||||
"delete": "Suppression"
|
||||
}
|
||||
},
|
||||
"timeEntries": {
|
||||
"created": "Temps enregistré",
|
||||
"updated": "Temps modifié",
|
||||
@@ -289,7 +347,19 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
"myTasks": "Mes tâches",
|
||||
"general": {
|
||||
"section": "Gestion de projet",
|
||||
"dashboard": "Tableau de bord",
|
||||
"myTasks": "Mes tâches",
|
||||
"projects": "Projets",
|
||||
"timeTracking": "Suivi de temps"
|
||||
},
|
||||
"admin": {
|
||||
"section": "Administration",
|
||||
"teamAbsences": "Absences équipe",
|
||||
"administration": "Administration"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
@@ -428,6 +498,43 @@
|
||||
"testFailed": "Connexion échouée"
|
||||
}
|
||||
},
|
||||
"sharedFiles": {
|
||||
"title": "Documents",
|
||||
"root": "Racine",
|
||||
"empty": "Ce dossier est vide.",
|
||||
"noResults": "Aucun document ne correspond à votre recherche.",
|
||||
"searchPlaceholder": "Rechercher dans tout le partage…",
|
||||
"download": "Télécharger",
|
||||
"reload": "Recharger",
|
||||
"previewError": "Aperçu impossible. Téléchargez le fichier pour l'ouvrir.",
|
||||
"colName": "Nom",
|
||||
"colSize": "Taille",
|
||||
"colModified": "Modifié le",
|
||||
"sidebar": {
|
||||
"title": "Documents"
|
||||
}
|
||||
},
|
||||
"adminShare": {
|
||||
"title": "Partage réseau (SMB)",
|
||||
"host": "Serveur",
|
||||
"hostPlaceholder": "ex. WIN-SRV ou 192.168.1.10",
|
||||
"shareName": "Nom du partage",
|
||||
"shareNamePlaceholder": "ex. Documents",
|
||||
"basePath": "Sous-dossier racine (optionnel)",
|
||||
"basePathPlaceholder": "ex. /Projets",
|
||||
"domain": "Domaine / groupe de travail",
|
||||
"domainPlaceholder": "WORKGROUP",
|
||||
"username": "Identifiant",
|
||||
"usernamePlaceholder": "ex. lesstime",
|
||||
"password": "Mot de passe",
|
||||
"passwordConfigured": "Un mot de passe est déjà enregistré.",
|
||||
"enabled": "Activer l'accès au partage",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration enregistrée.",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie.",
|
||||
"testFailed": "Échec de la connexion."
|
||||
},
|
||||
"taskRecurrence": {
|
||||
"created": "Récurrence créée",
|
||||
"updated": "Récurrence mise à jour",
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
function isAdmin(): boolean {
|
||||
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
|
||||
}
|
||||
|
||||
function can(code: string): boolean {
|
||||
if (!auth.user) {
|
||||
return false
|
||||
}
|
||||
if (isAdmin()) {
|
||||
return true
|
||||
}
|
||||
return auth.user.effectivePermissions?.includes(code) ?? false
|
||||
}
|
||||
|
||||
function canAny(codes: string[]): boolean {
|
||||
return codes.some((c) => can(c))
|
||||
}
|
||||
|
||||
function canAll(codes: string[]): boolean {
|
||||
return codes.every((c) => can(c))
|
||||
}
|
||||
|
||||
return { can, canAny, canAll, isAdmin }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export type AuditLogAction = 'create' | 'update' | 'delete'
|
||||
|
||||
export type AuditLogItem = {
|
||||
id: string
|
||||
'@id'?: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
action: AuditLogAction
|
||||
changes: Record<string, unknown>
|
||||
performedBy: string
|
||||
performedAt: string
|
||||
ipAddress: string | null
|
||||
requestId: string | null
|
||||
}
|
||||
|
||||
export type AuditLogQuery = {
|
||||
page?: number
|
||||
entityType?: string
|
||||
action?: AuditLogAction
|
||||
}
|
||||
|
||||
export type AuditLogPage = {
|
||||
items: AuditLogItem[]
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
export type AuditLogEntityTypes = {
|
||||
'@id'?: string
|
||||
entityTypes: string[]
|
||||
}
|
||||
|
||||
export function useAuditLogService() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(params: AuditLogQuery = {}): Promise<AuditLogPage> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (params.page !== undefined) {
|
||||
query.page = params.page
|
||||
}
|
||||
if (params.entityType) {
|
||||
query.entity_type = params.entityType
|
||||
}
|
||||
if (params.action) {
|
||||
query.action = params.action
|
||||
}
|
||||
|
||||
const data = await api.get<HydraCollection<AuditLogItem>>('/audit-logs', query)
|
||||
return {
|
||||
items: extractHydraMembers(data),
|
||||
totalItems: data['hydra:totalItems'] ?? data['totalItems'] ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function entityTypes(): Promise<string[]> {
|
||||
// `/audit-log-entity-types` is a single API Platform item resource
|
||||
// (not a hydra collection): it returns `{ entityTypes: string[] }`.
|
||||
const data = await api.get<AuditLogEntityTypes>('/audit-log-entity-types')
|
||||
return data.entityTypes ?? []
|
||||
}
|
||||
|
||||
return { list, entityTypes }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export type Permission = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
code: string
|
||||
label: string
|
||||
module: string
|
||||
orphan?: boolean
|
||||
}
|
||||
|
||||
export function usePermissionService() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(): Promise<Permission[]> {
|
||||
const data = await api.get<HydraCollection<Permission>>('/permissions')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
return { list }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Permission } from './permissions'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export type Role = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
code: string
|
||||
label: string
|
||||
description?: string | null
|
||||
isSystem: boolean
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
export type RoleWrite = {
|
||||
code?: string
|
||||
label: string
|
||||
description?: string | null
|
||||
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export function useRoleService() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(): Promise<Role[]> {
|
||||
const data = await api.get<HydraCollection<Role>>('/roles')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: RoleWrite): Promise<Role> {
|
||||
return api.post<Role>('/roles', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'admin.roles.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
|
||||
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'admin.roles.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/roles/${id}`, {}, {
|
||||
toastSuccessKey: 'admin.roles.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { list, create, update, remove }
|
||||
}
|
||||
+45
-12
@@ -1,14 +1,32 @@
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
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}`)
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/composables`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
const moduleStoreDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/stores`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
srcDir: '.',
|
||||
css: ['~/assets/css/app.css', '~/assets/css/dark.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',
|
||||
@@ -16,16 +34,35 @@ export default defineNuxtConfig({
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon',
|
||||
],
|
||||
dir: {
|
||||
layouts: 'app/layouts',
|
||||
middleware: 'app/middleware',
|
||||
},
|
||||
imports: {
|
||||
dirs: [
|
||||
'shared/composables',
|
||||
'shared/stores',
|
||||
'shared/utils',
|
||||
'composables',
|
||||
'stores',
|
||||
'utils',
|
||||
...moduleComposableDirs,
|
||||
...moduleStoreDirs,
|
||||
],
|
||||
},
|
||||
pinia: {
|
||||
storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'],
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
}
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
components: [
|
||||
{path: '~/components', pathPrefix: false},
|
||||
{ path: '~/components', pathPrefix: false },
|
||||
],
|
||||
vite: {
|
||||
server: {
|
||||
@@ -56,10 +93,6 @@ export default defineNuxtConfig({
|
||||
{code: 'fr', file: 'fr.json', name: 'Français'}
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
build: {
|
||||
transpile: ['@vuepic/vue-datepicker']
|
||||
}
|
||||
typescript: { strict: true },
|
||||
build: { transpile: ['@vuepic/vue-datepicker'] },
|
||||
})
|
||||
|
||||
Generated
+160
-49
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -15,7 +15,8 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dompurify": "^3.4.9",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -23,7 +24,8 @@
|
||||
"vue": "^3.5.29",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
@@ -82,7 +84,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1044,6 +1045,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
@@ -1053,6 +1055,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"debug": "^4.3.1",
|
||||
@@ -1067,6 +1070,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
},
|
||||
@@ -1079,6 +1083,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
@@ -1091,6 +1096,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
@@ -1100,6 +1106,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"levn": "^0.4.1"
|
||||
@@ -1122,7 +1129,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
@@ -1176,6 +1182,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
@@ -1185,6 +1192,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
@@ -1198,6 +1206,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
},
|
||||
@@ -1211,6 +1220,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
@@ -2210,9 +2220,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.5/layer-ui-1.7.5.tgz",
|
||||
"integrity": "sha512-xryrAYgVgX3eurEWXT/d0p4r/MBYNBB3mBnvV6xVcFhzxW+HfOra8hsVHLvrCtd+m5E1t7PDRzjw1FObkV6fdQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2236,7 +2246,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2485,7 +2494,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2591,7 +2599,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
||||
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2618,7 +2625,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2633,7 +2639,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
@@ -3016,7 +3021,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
||||
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -3089,7 +3093,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
||||
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.27",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3736,7 +3739,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
||||
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.95.0"
|
||||
},
|
||||
@@ -5877,7 +5879,8 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -5889,7 +5892,8 @@
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
@@ -6259,7 +6263,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
@@ -6509,7 +6512,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6549,6 +6551,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6880,7 +6883,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -7074,7 +7076,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7203,7 +7204,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -7345,7 +7345,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -7382,7 +7381,6 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
@@ -7938,7 +7936,8 @@
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
@@ -8068,6 +8067,15 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docx-preview": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
|
||||
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jszip": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -8122,9 +8130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
|
||||
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -8453,6 +8461,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -8483,6 +8492,7 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -8495,6 +8505,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -8507,6 +8518,7 @@
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
@@ -8519,6 +8531,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -8528,6 +8541,7 @@
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
@@ -8545,6 +8559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -8570,6 +8585,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
@@ -8582,6 +8598,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
@@ -8682,7 +8699,8 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -8710,13 +8728,15 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-npm-meta": {
|
||||
"version": "1.4.0",
|
||||
@@ -8761,6 +8781,7 @@
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
@@ -8791,6 +8812,7 @@
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
@@ -8807,6 +8829,7 @@
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
@@ -8819,7 +8842,8 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
@@ -9335,6 +9359,12 @@
|
||||
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/impound": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.2.tgz",
|
||||
@@ -9352,6 +9382,7 @@
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
@@ -9728,19 +9759,22 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -9801,6 +9835,48 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keygrip": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||
@@ -9818,6 +9894,7 @@
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
@@ -10078,6 +10155,7 @@
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
@@ -10086,6 +10164,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -10177,6 +10264,7 @@
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
@@ -10621,7 +10709,8 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
@@ -10857,7 +10946,6 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
||||
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.3.2",
|
||||
"@nuxt/cli": "^3.33.0",
|
||||
@@ -11128,6 +11216,7 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -11185,7 +11274,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -11269,6 +11357,7 @@
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -11284,6 +11373,7 @@
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
@@ -11306,6 +11396,12 @@
|
||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -11326,6 +11422,7 @@
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -11429,7 +11526,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -11546,7 +11642,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -12090,7 +12185,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -12141,6 +12235,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
@@ -12317,6 +12412,7 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -12687,7 +12783,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -12946,6 +13041,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -13476,7 +13577,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -13817,6 +13917,7 @@
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
@@ -13884,7 +13985,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14326,6 +14426,7 @@
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
@@ -14350,7 +14451,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14712,7 +14812,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
@@ -14777,7 +14876,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
@@ -14799,7 +14897,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -14858,6 +14955,7 @@
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14939,6 +15037,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -15026,6 +15136,7 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -19,7 +19,8 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dompurify": "^3.4.9",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -27,7 +28,8 @@
|
||||
"vue": "^3.5.29",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
@@ -27,9 +27,12 @@
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
|
||||
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminShareTab v-if="activeTab === 'share'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||
</div>
|
||||
@@ -40,6 +43,12 @@
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const { can } = usePermissions()
|
||||
const { t } = useI18n()
|
||||
|
||||
const canViewRoles = computed(() => can('core.roles.view'))
|
||||
const canViewAudit = computed(() => can('core.audit_log.view'))
|
||||
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
@@ -47,14 +56,21 @@ const tabs = [
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
|
||||
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'share', label: 'Partage' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
{ key: 'absences', label: 'Absences' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
const visibleTabs = computed(() =>
|
||||
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
|
||||
)
|
||||
|
||||
const activeTab = ref<TabKey>('clients')
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>
|
||||
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span>/</span>
|
||||
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Filtre local + rechargement -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<div class="max-w-sm flex-1">
|
||||
<MalioInputText
|
||||
v-model="filter"
|
||||
:placeholder="$t('sharedFiles.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:arrow-path"
|
||||
:aria-label="$t('sharedFiles.reload')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
:disabled="loading"
|
||||
button-class="text-neutral-500 hover:text-primary-500"
|
||||
@click="reload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- États -->
|
||||
<div v-if="loading || searching" class="mt-10 flex justify-center">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="error || searchError" class="mt-10 text-sm text-red-600">{{ error || searchError }}</p>
|
||||
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">
|
||||
{{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }}
|
||||
</p>
|
||||
|
||||
<!-- Tableau -->
|
||||
<table v-else class="mt-6 w-full text-sm">
|
||||
<thead class="border-b border-neutral-200 text-left text-xs uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th class="py-2">{{ $t('sharedFiles.colName') }}</th>
|
||||
<th class="py-2">{{ $t('sharedFiles.colSize') }}</th>
|
||||
<th class="py-2">{{ $t('sharedFiles.colModified') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in visibleEntries"
|
||||
:key="entry.path"
|
||||
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
|
||||
@click="onEntryClick(entry)"
|
||||
>
|
||||
<td class="flex items-center gap-2 py-2">
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
<span v-if="isSearchMode && parentDir(entry)" class="truncate text-xs text-neutral-400">{{ parentDir(entry) }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 text-neutral-500">{{ entry.isDir ? '—' : formatFileSize(entry.size) }}</td>
|
||||
<td class="py-2 text-neutral-500">{{ formatDate(entry.modifiedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<SharedFilePreview
|
||||
:entry="previewEntry"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex >= 0 && previewIndex < fileEntries.length - 1"
|
||||
@close="previewEntry = null"
|
||||
@prev="stepPreview(-1)"
|
||||
@next="stepPreview(1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
useHead({ title: 'Documents' })
|
||||
|
||||
const { browse, search } = useShareService()
|
||||
const { enabled, ensureLoaded } = useShareStatus()
|
||||
|
||||
const MIN_SEARCH_LENGTH = 2
|
||||
|
||||
const currentPath = ref('')
|
||||
const breadcrumb = ref<Breadcrumb[]>([])
|
||||
const entries = ref<FileEntry[]>([])
|
||||
const filter = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const searchResults = ref<FileEntry[]>([])
|
||||
const searching = ref(false)
|
||||
const searchError = ref<string | null>(null)
|
||||
|
||||
const previewEntry = ref<FileEntry | null>(null)
|
||||
|
||||
// Recherche globale (récursive sur tout le partage) dès 2 caractères ;
|
||||
// en deçà, simple filtre local sur le dossier courant.
|
||||
const isSearchMode = computed(() => filter.value.trim().length >= MIN_SEARCH_LENGTH)
|
||||
|
||||
const visibleEntries = computed(() => {
|
||||
const f = filter.value.trim().toLowerCase()
|
||||
if (!f) return entries.value
|
||||
if (f.length < MIN_SEARCH_LENGTH) {
|
||||
return entries.value.filter((e) => e.name.toLowerCase().includes(f))
|
||||
}
|
||||
return searchResults.value
|
||||
})
|
||||
|
||||
function parentDir(entry: FileEntry): string {
|
||||
const idx = entry.path.lastIndexOf('/')
|
||||
return idx === -1 ? '' : entry.path.slice(0, idx)
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchSeq = 0
|
||||
|
||||
async function runSearch(query: string) {
|
||||
const seq = ++searchSeq
|
||||
searching.value = true
|
||||
searchError.value = null
|
||||
try {
|
||||
const result = await search(query)
|
||||
if (seq !== searchSeq) return // une frappe plus récente a pris le relais
|
||||
searchResults.value = result.entries
|
||||
} catch (e: unknown) {
|
||||
if (seq !== searchSeq) return
|
||||
searchError.value = (e as Error)?.message ?? 'Erreur'
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
if (seq === searchSeq) searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filter, (value) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
|
||||
const q = value.trim()
|
||||
if (q.length < MIN_SEARCH_LENGTH) {
|
||||
searchSeq++ // invalide toute recherche en vol
|
||||
searching.value = false
|
||||
searchError.value = null
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searching.value = true
|
||||
searchTimer = setTimeout(() => runSearch(q), 300)
|
||||
})
|
||||
|
||||
const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir))
|
||||
const previewIndex = computed(() => previewEntry.value ? fileEntries.value.findIndex((e) => e.path === previewEntry.value!.path) : -1)
|
||||
|
||||
async function load(path: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await browse(path)
|
||||
currentPath.value = result.path
|
||||
breadcrumb.value = result.breadcrumb
|
||||
entries.value = result.entries
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message ?? 'Erreur'
|
||||
entries.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPath(path: string) {
|
||||
filter.value = ''
|
||||
load(path)
|
||||
}
|
||||
|
||||
function reload() {
|
||||
load(currentPath.value)
|
||||
}
|
||||
|
||||
function onEntryClick(entry: FileEntry) {
|
||||
if (entry.isDir) {
|
||||
openPath(entry.path)
|
||||
} else {
|
||||
previewEntry.value = entry
|
||||
}
|
||||
}
|
||||
|
||||
function stepPreview(delta: number) {
|
||||
const idx = previewIndex.value + delta
|
||||
if (idx >= 0 && idx < fileEntries.value.length) {
|
||||
previewEntry.value = fileEntries.value[idx] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function iconForMime(mime: string): string {
|
||||
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
|
||||
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
|
||||
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
|
||||
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
|
||||
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
|
||||
return 'mdi:file-outline'
|
||||
}
|
||||
|
||||
function formatDate(ts: number | null): string {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureLoaded()
|
||||
if (enabled.value === false) {
|
||||
await navigateTo('/')
|
||||
return
|
||||
}
|
||||
load('')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
export type FileEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modifiedAt: number | null
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type Breadcrumb = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ShareBrowseResult = {
|
||||
path: string
|
||||
breadcrumb: Breadcrumb[]
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareSearchResult = {
|
||||
query: string
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareSettings = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ShareSettingsWrite = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareTestResult = {
|
||||
success: boolean
|
||||
message: string | null
|
||||
}
|
||||
@@ -5,7 +5,8 @@ export type TaskDocument = {
|
||||
id: number
|
||||
task: string
|
||||
originalName: string
|
||||
fileName: string
|
||||
fileName?: string | null
|
||||
sharePath?: string | null
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
|
||||
@@ -7,6 +7,7 @@ export type UserData = {
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
roles: string[]
|
||||
effectivePermissions?: string[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
// HR / absence management
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { Notification } from './dto/notification'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
import { fetchAllHydra } from '~/utils/api'
|
||||
|
||||
export function useNotificationService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Notification[]> {
|
||||
const data = await api.get<HydraCollection<Notification>>('/notifications')
|
||||
return extractHydraMembers(data)
|
||||
// La ressource /notifications reste paginée côté back (volume non borné) :
|
||||
// on suit toutes les pages pour ne pas tronquer la liste à 30 éléments.
|
||||
return fetchAllHydra<Notification>(page =>
|
||||
api.get<HydraCollection<Notification>>('/notifications', { page }),
|
||||
)
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share'
|
||||
|
||||
export function useShareSettingsService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<ShareSettings> {
|
||||
return api.get<ShareSettings>('/settings/share')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: ShareSettingsWrite): Promise<ShareSettings> {
|
||||
return api.put<ShareSettings>('/settings/share', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'adminShare.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<ShareTestResult> {
|
||||
return api.post<ShareTestResult>('/settings/share/test', {})
|
||||
}
|
||||
|
||||
return { getSettings, saveSettings, testConnection }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share'
|
||||
|
||||
export function useShareService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
async function browse(path: string): Promise<ShareBrowseResult> {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||
return api.get<ShareBrowseResult>(`/share/browse${query}`)
|
||||
}
|
||||
|
||||
async function search(query: string): Promise<ShareSearchResult> {
|
||||
return api.get<ShareSearchResult>(`/share/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
async function getStatus(): Promise<ShareStatus> {
|
||||
return api.get<ShareStatus>('/share/status')
|
||||
}
|
||||
|
||||
function getDownloadUrl(path: string, disposition: 'inline' | 'attachment' = 'inline'): string {
|
||||
const base = config.public.apiBase || '/api'
|
||||
return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
|
||||
}
|
||||
|
||||
return { browse, search, getStatus, getDownloadUrl }
|
||||
}
|
||||
@@ -31,6 +31,15 @@ export function useTaskDocumentService() {
|
||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||
}
|
||||
|
||||
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
|
||||
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task: `/api/tasks/${taskId}`, sharePath }),
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
@@ -48,5 +57,5 @@ export function useTaskDocumentService() {
|
||||
})
|
||||
}
|
||||
|
||||
return { getByTask, upload, remove, getDownloadUrl, getContent }
|
||||
return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules(): Promise<void> {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules(): void {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SidebarSection } from '~/shared/types/sidebar'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
const disabledRoutes = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSidebar() {
|
||||
async function loadSidebar(): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
function isRouteDisabled(path: string): boolean {
|
||||
return disabledRoutes.value.some(
|
||||
(disabled) => path === disabled || path.startsWith(disabled + '/'),
|
||||
)
|
||||
}
|
||||
|
||||
function resetSidebar(): void {
|
||||
sections.value = []
|
||||
disabledRoutes.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type SidebarItem = {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type SidebarSection = {
|
||||
label: string
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
@@ -8,3 +8,60 @@ export type HydraCollection<T> = {
|
||||
export function extractHydraMembers<T>(response: HydraCollection<T>): T[] {
|
||||
return response['hydra:member'] ?? response['member'] ?? []
|
||||
}
|
||||
|
||||
function extractHydraTotal<T>(response: HydraCollection<T>): number | undefined {
|
||||
return response['hydra:totalItems'] ?? response['totalItems']
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère TOUS les éléments d'une collection Hydra paginée en parcourant les pages.
|
||||
*
|
||||
* `extractHydraMembers` ne lit que la première page (30 éléments par défaut côté
|
||||
* API Platform) : toute liste plus longue est tronquée silencieusement. Utiliser
|
||||
* ce helper dès qu'une collection potentiellement > 30 éléments doit être
|
||||
* affichée en entier alors que sa ressource back reste paginée.
|
||||
*
|
||||
* `fetchPage` reçoit le numéro de page (1-indexé) et doit renvoyer la collection
|
||||
* Hydra correspondante (passer `page` en query param de l'appel API).
|
||||
*
|
||||
* @param maxPages garde-fou anti-boucle infinie (par défaut 1000 pages).
|
||||
*/
|
||||
export async function fetchAllHydra<T>(
|
||||
fetchPage: (page: number) => Promise<HydraCollection<T>>,
|
||||
maxPages = 1000,
|
||||
): Promise<T[]> {
|
||||
const first = await fetchPage(1)
|
||||
const all = extractHydraMembers(first)
|
||||
const total = extractHydraTotal(first)
|
||||
const pageSize = all.length
|
||||
|
||||
// 1ʳᵉ page vide → collection vide, rien de plus à récupérer.
|
||||
if (pageSize === 0) {
|
||||
return all
|
||||
}
|
||||
|
||||
let page = 2
|
||||
while (page <= maxPages) {
|
||||
if (total !== undefined) {
|
||||
// Total connu : on s'arrête dès qu'on a tout récupéré.
|
||||
if (all.length >= total) {
|
||||
break
|
||||
}
|
||||
} else if (all.length % pageSize !== 0) {
|
||||
// Total inconnu (provider custom sans `hydra:totalItems`) : la dernière
|
||||
// page récupérée n'était pas pleine → fin de collection. On ne s'arrête
|
||||
// pas en silence sur la 1ʳᵉ page, contrairement à `extractHydraMembers`.
|
||||
break
|
||||
}
|
||||
|
||||
const next = await fetchPage(page)
|
||||
const members = extractHydraMembers(next)
|
||||
if (members.length === 0) {
|
||||
break
|
||||
}
|
||||
all.push(...members)
|
||||
page += 1
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
git \
|
||||
unzip \
|
||||
smbclient \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
intl \
|
||||
zip \
|
||||
|
||||
@@ -40,7 +40,7 @@ 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 \
|
||||
nginx supervisor smbclient \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -73,7 +73,7 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/log /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Add share_configuration table for SMB/Windows share explorer feature.
|
||||
*/
|
||||
final class Version20260603165850 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create share_configuration table (SMB/Windows share explorer)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE share_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, host VARCHAR(255) DEFAULT NULL, share_name VARCHAR(255) DEFAULT NULL, base_path VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY(id))');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE share_configuration');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Ajoute le support des documents de tâche liés au partage SMB :
|
||||
* colonne share_path (chemin relatif sur le partage) et file_name rendu nullable
|
||||
* (un lien SMB n'a pas de fichier sur disque).
|
||||
*/
|
||||
final class Version20260612131431 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'TaskDocument: add share_path column and make file_name nullable (SMB share links)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE task_document ADD share_path VARCHAR(1024) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task_document ALTER file_name DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE task_document ALTER file_name SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE task_document DROP share_path');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* RBAC fin (LST-57) : permission, role and their many-to-many relations with user.
|
||||
* Additive only — no DROP/ALTER on existing tables.
|
||||
*/
|
||||
final class Version20260619145109 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add RBAC permission and role entities with user relations (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('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 NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_E04992AA77153098 ON permission (code)');
|
||||
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
|
||||
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
|
||||
$this->addSql('COMMENT ON COLUMN permission.code IS \'Permission code (module.resource[.sub].action)\'');
|
||||
$this->addSql('COMMENT ON COLUMN permission.label IS \'Human-readable permission label\'');
|
||||
$this->addSql('COMMENT ON COLUMN permission.module IS \'Owning module id (e.g. core)\'');
|
||||
$this->addSql('COMMENT ON COLUMN permission.orphan IS \'True when the permission is no longer declared by any active module\'');
|
||||
|
||||
$this->addSql('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 NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_57698A6A77153098 ON "role" (code)');
|
||||
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
|
||||
$this->addSql('COMMENT ON COLUMN "role".code IS \'Immutable role code (snake_case)\'');
|
||||
$this->addSql('COMMENT ON COLUMN "role".label IS \'Human-readable role label\'');
|
||||
$this->addSql('COMMENT ON COLUMN "role".description IS \'Optional role description\'');
|
||||
$this->addSql('COMMENT ON COLUMN "role".is_system IS \'True for built-in roles that cannot be deleted\'');
|
||||
|
||||
$this->addSql('CREATE TABLE role_permission (role_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (role_id, permission_id))');
|
||||
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
|
||||
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE user_role (user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id))');
|
||||
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE user_permission (user_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (user_id, permission_id))');
|
||||
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
|
||||
|
||||
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$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 role_permission');
|
||||
$this->addSql('DROP TABLE user_role');
|
||||
$this->addSql('DROP TABLE user_permission');
|
||||
$this->addSql('DROP TABLE permission');
|
||||
$this->addSql('DROP TABLE "role"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Audit log (LST-61) : append-only `audit_log` table.
|
||||
*
|
||||
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
|
||||
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
|
||||
* flushes from the Doctrine listener. Columns are lowercase snake_case.
|
||||
* Additive only — no DROP/ALTER on existing tables.
|
||||
*/
|
||||
final class Version20260619185448 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Audit log: create append-only audit_log table + indexes (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_log (
|
||||
id uuid NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL,
|
||||
entity_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(10) NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
performed_by VARCHAR(100) NOT NULL,
|
||||
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
ip_address VARCHAR(45) DEFAULT NULL,
|
||||
request_id VARCHAR(36) DEFAULT NULL,
|
||||
PRIMARY KEY(id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE audit_log');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\State\ShareSettingsProcessor;
|
||||
use App\State\ShareSettingsProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/settings/share',
|
||||
normalizationContext: ['groups' => ['share_settings:read']],
|
||||
provider: ShareSettingsProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/settings/share',
|
||||
denormalizationContext: ['groups' => ['share_settings:write']],
|
||||
normalizationContext: ['groups' => ['share_settings:read']],
|
||||
provider: ShareSettingsProvider::class,
|
||||
processor: ShareSettingsProcessor::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ShareSettings
|
||||
{
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public ?string $host = null;
|
||||
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public ?string $shareName = null;
|
||||
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public ?string $basePath = null;
|
||||
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public ?string $domain = null;
|
||||
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public ?string $username = null;
|
||||
|
||||
#[Groups(['share_settings:write'])]
|
||||
public ?string $password = null;
|
||||
|
||||
#[Groups(['share_settings:read', 'share_settings:write'])]
|
||||
public bool $enabled = false;
|
||||
|
||||
#[Groups(['share_settings:read'])]
|
||||
public bool $hasPassword = false;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\ShareTestConnectionProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/settings/share/test',
|
||||
input: false,
|
||||
normalizationContext: ['groups' => ['share_test:read']],
|
||||
provider: ShareTestConnectionProvider::class,
|
||||
processor: ShareTestConnectionProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ShareTestConnection
|
||||
{
|
||||
#[Groups(['share_test:read'])]
|
||||
public bool $success = false;
|
||||
|
||||
#[Groups(['share_test:read'])]
|
||||
public ?string $message = null;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Command;
|
||||
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use App\Repository\AbsenceBalanceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\AbsenceBalanceService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -37,7 +37,7 @@ use function sprintf;
|
||||
class AccrueLeaveCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly AbsenceBalanceRepository $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -22,7 +22,7 @@ use function sprintf;
|
||||
class GenerateApiTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Absence;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\AbsenceBalanceRepository;
|
||||
use App\Repository\AbsencePolicyRepository;
|
||||
use App\Service\AbsenceBalanceService;
|
||||
use App\Service\AbsenceDayCalculator;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -71,7 +71,7 @@ class AbsencePreviewController extends AbstractController
|
||||
);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof User);
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$available = null;
|
||||
$projectedAvailable = null;
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Entity\Task;
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Entity\TaskMailLink;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\User;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class MarkAllReadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
private readonly DoctrineNotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
/** @var UserInterface $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$this->notificationRepository->markAllReadByUser($user);
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class NotificationUnreadCountController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
private readonly DoctrineNotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
/** @var UserInterface $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$count = $this->notificationRepository->countUnreadByUser($user);
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Share;
|
||||
|
||||
use App\Service\Share\Exception\InvalidPathException;
|
||||
use App\Service\Share\Exception\ShareConnectionException;
|
||||
use App\Service\Share\Exception\ShareNotConfiguredException;
|
||||
use App\Service\Share\FileEntry;
|
||||
use App\Service\Share\FileSource;
|
||||
use App\Service\Share\SharePathResolver;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ShareBrowseController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileSource $fileSource,
|
||||
private readonly SharePathResolver $pathResolver,
|
||||
) {}
|
||||
|
||||
#[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$rawPath = (string) $request->query->get('path', '');
|
||||
|
||||
try {
|
||||
$path = $this->pathResolver->normalizeRelative($rawPath);
|
||||
} catch (InvalidPathException) {
|
||||
return new JsonResponse(['error' => 'Invalid path.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$entries = $this->fileSource->dir($path);
|
||||
} catch (ShareNotConfiguredException) {
|
||||
return new JsonResponse(['error' => 'Share not configured.'], 409);
|
||||
} catch (ShareConnectionException) {
|
||||
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'path' => $path,
|
||||
'breadcrumb' => $this->breadcrumb($path),
|
||||
'entries' => array_map(static fn (FileEntry $e): array => [
|
||||
'name' => $e->name,
|
||||
'path' => $e->path,
|
||||
'isDir' => $e->isDir,
|
||||
'size' => $e->size,
|
||||
'modifiedAt' => $e->modifiedAt,
|
||||
'mimeType' => $e->mimeType,
|
||||
], $entries),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, path: string}>
|
||||
*/
|
||||
private function breadcrumb(string $path): array
|
||||
{
|
||||
if ('' === $path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$crumbs = [];
|
||||
$acc = '';
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
$acc = '' === $acc ? $segment : $acc.'/'.$segment;
|
||||
$crumbs[] = ['name' => $segment, 'path' => $acc];
|
||||
}
|
||||
|
||||
return $crumbs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Share;
|
||||
|
||||
use App\Service\Share\Exception\InvalidPathException;
|
||||
use App\Service\Share\Exception\ShareConnectionException;
|
||||
use App\Service\Share\Exception\ShareNotConfiguredException;
|
||||
use App\Service\Share\FileSource;
|
||||
use App\Service\Share\SharePathResolver;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function is_resource;
|
||||
|
||||
class ShareDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileSource $fileSource,
|
||||
private readonly SharePathResolver $pathResolver,
|
||||
) {}
|
||||
|
||||
#[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$rawPath = (string) $request->query->get('path', '');
|
||||
|
||||
try {
|
||||
$path = $this->pathResolver->normalizeRelative($rawPath);
|
||||
} catch (InvalidPathException) {
|
||||
return new Response('Invalid path.', 400);
|
||||
}
|
||||
|
||||
if ('' === $path) {
|
||||
throw new NotFoundHttpException('No file requested.');
|
||||
}
|
||||
|
||||
try {
|
||||
$stream = $this->fileSource->read($path);
|
||||
} catch (ShareNotConfiguredException) {
|
||||
return new Response('Share not configured.', 409);
|
||||
} catch (ShareConnectionException) {
|
||||
throw new NotFoundHttpException('File not found.');
|
||||
}
|
||||
|
||||
$name = basename($path);
|
||||
$extension = pathinfo($name, PATHINFO_EXTENSION);
|
||||
$mime = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream';
|
||||
|
||||
// Anti-XSS : seuls des types non exécutables sont servis inline (images hors SVG, PDF).
|
||||
// Tout le reste (HTML, SVG, octet-stream, etc.) est forcé en attachment, même si inline est demandé.
|
||||
$inlineSafe = ('image/svg+xml' !== $mime && str_starts_with($mime, 'image/')) || 'application/pdf' === $mime;
|
||||
$wantInline = 'attachment' !== $request->query->get('disposition');
|
||||
$disposition = ($inlineSafe && $wantInline) ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response = new StreamedResponse(function () use ($stream): void {
|
||||
if (is_resource($stream)) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
$response->headers->set('Content-Type', $mime);
|
||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name));
|
||||
// Empêche le navigateur de "deviner" un type exécutable à partir du contenu.
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Share;
|
||||
|
||||
use App\Service\Share\Exception\ShareConnectionException;
|
||||
use App\Service\Share\Exception\ShareNotConfiguredException;
|
||||
use App\Service\Share\FileEntry;
|
||||
use App\Service\Share\FileSource;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ShareSearchController extends AbstractController
|
||||
{
|
||||
/** Longueur minimale du terme de recherche (évite un parcours global trop large). */
|
||||
private const int MIN_QUERY_LENGTH = 2;
|
||||
|
||||
public function __construct(
|
||||
private readonly FileSource $fileSource,
|
||||
) {}
|
||||
|
||||
#[Route('/api/share/search', name: 'share_search', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
|
||||
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
|
||||
return new JsonResponse(['query' => $query, 'entries' => []]);
|
||||
}
|
||||
|
||||
try {
|
||||
$entries = $this->fileSource->search($query);
|
||||
} catch (ShareNotConfiguredException) {
|
||||
return new JsonResponse(['error' => 'Share not configured.'], 409);
|
||||
} catch (ShareConnectionException) {
|
||||
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'query' => $query,
|
||||
'entries' => array_map(static fn (FileEntry $e): array => [
|
||||
'name' => $e->name,
|
||||
'path' => $e->path,
|
||||
'isDir' => $e->isDir,
|
||||
'size' => $e->size,
|
||||
'modifiedAt' => $e->modifiedAt,
|
||||
'mimeType' => $e->mimeType,
|
||||
], $entries),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Share;
|
||||
|
||||
use App\Repository\ShareConfigurationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ShareStatusController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShareConfigurationRepository $configRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]);
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,33 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\TaskDocument;
|
||||
use App\Service\Share\Exception\ShareConnectionException;
|
||||
use App\Service\Share\Exception\ShareNotConfiguredException;
|
||||
use App\Service\Share\FileSource;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function is_resource;
|
||||
|
||||
class TaskDocumentDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly FileSource $fileSource,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(int $id): BinaryFileResponse
|
||||
public function __invoke(int $id): Response
|
||||
{
|
||||
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
|
||||
|
||||
@@ -30,6 +39,20 @@ class TaskDocumentDownloadController extends AbstractController
|
||||
throw new NotFoundHttpException('Document not found.');
|
||||
}
|
||||
|
||||
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
|
||||
|
||||
// Inline for images (except SVG) and PDFs, attachment for everything else.
|
||||
// SVG is always attachment to prevent XSS via embedded JavaScript.
|
||||
$inline = 'image/svg+xml' !== $mimeType && (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType);
|
||||
$disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
return $document->isShareLink()
|
||||
? $this->streamFromShare($document, $mimeType, $disposition)
|
||||
: $this->streamFromDisk($document, $mimeType, $disposition);
|
||||
}
|
||||
|
||||
private function streamFromDisk(TaskDocument $document, string $mimeType, string $disposition): BinaryFileResponse
|
||||
{
|
||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
@@ -37,18 +60,32 @@ class TaskDocumentDownloadController extends AbstractController
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
|
||||
|
||||
// Inline for images and PDFs, attachment for everything else
|
||||
// SVG files are always served as attachment to prevent XSS via embedded JavaScript
|
||||
$disposition = 'image/svg+xml' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_ATTACHMENT
|
||||
: (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT);
|
||||
|
||||
$response->setContentDisposition($disposition, $document->getOriginalName());
|
||||
$response->setContentDisposition($disposition, (string) $document->getOriginalName());
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function streamFromShare(TaskDocument $document, string $mimeType, string $disposition): StreamedResponse
|
||||
{
|
||||
try {
|
||||
$stream = $this->fileSource->read((string) $document->getSharePath());
|
||||
} catch (ShareNotConfiguredException) {
|
||||
throw new NotFoundHttpException('Share not configured.');
|
||||
} catch (ShareConnectionException) {
|
||||
throw new NotFoundHttpException('File not found on the share.');
|
||||
}
|
||||
|
||||
$response = new StreamedResponse(function () use ($stream): void {
|
||||
if (is_resource($stream)) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}
|
||||
});
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, (string) $document->getOriginalName()));
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Service\TimeEntryExportService;
|
||||
use DateTimeImmutable;
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
@@ -18,7 +18,6 @@ use App\Entity\TaskRecurrence;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\User;
|
||||
use App\Entity\Workflow;
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use App\Enum\AbsenceStatus;
|
||||
@@ -26,6 +25,8 @@ use App\Enum\AbsenceType;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Enum\StatusCategory;
|
||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
@@ -36,6 +37,7 @@ class AppFixtures extends Fixture
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RbacSeeder $rbacSeeder,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
@@ -751,5 +753,9 @@ class AppFixtures extends Fixture
|
||||
$manager->persist($pendingMarriage);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
// Seed des rôles système RBAC (admin, user). Idempotent ; aucune matrice
|
||||
// métier attachée (cf. Décision 4 : les modules métier arrivent en 2.x).
|
||||
$this->rbacSeeder->ensureSystemRoles();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Repository\AbsenceBalanceRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\State\AbsenceBalanceProvider;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -45,10 +46,10 @@ class AbsenceBalance
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?User $user = null;
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
@@ -110,12 +111,12 @@ class AbsenceBalance
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Enum\AbsenceStatus;
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\AbsenceRequestRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\State\AbsenceCancelProcessor;
|
||||
use App\State\AbsenceRequestProcessor;
|
||||
use App\State\AbsenceRequestProvider;
|
||||
@@ -73,10 +74,10 @@ class AbsenceRequest
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?User $user = null;
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
@@ -130,10 +131,10 @@ class AbsenceRequest
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?DateTimeImmutable $reviewedAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?User $reviewedBy = null;
|
||||
private ?UserInterface $reviewedBy = null;
|
||||
|
||||
#[Groups(['absence_request:read'])]
|
||||
public function getLabel(): ?string
|
||||
@@ -156,12 +157,12 @@ class AbsenceRequest
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
@@ -312,12 +313,12 @@ class AbsenceRequest
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedBy(): ?User
|
||||
public function getReviewedBy(): ?UserInterface
|
||||
{
|
||||
return $this->reviewedBy;
|
||||
}
|
||||
|
||||
public function setReviewedBy(?User $reviewedBy): static
|
||||
public function setReviewedBy(?UserInterface $reviewedBy): static
|
||||
{
|
||||
$this->reviewedBy = $reviewedBy;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ShareConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ShareConfigurationRepository::class)]
|
||||
class ShareConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $host = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $shareName = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $basePath = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $domain = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedPassword = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $enabled = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHost(): ?string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function setHost(?string $host): static
|
||||
{
|
||||
$this->host = $host;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getShareName(): ?string
|
||||
{
|
||||
return $this->shareName;
|
||||
}
|
||||
|
||||
public function setShareName(?string $shareName): static
|
||||
{
|
||||
$this->shareName = $shareName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBasePath(): ?string
|
||||
{
|
||||
return $this->basePath;
|
||||
}
|
||||
|
||||
public function setBasePath(?string $basePath): static
|
||||
{
|
||||
$this->basePath = $basePath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDomain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function setDomain(?string $domain): static
|
||||
{
|
||||
$this->domain = $domain;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(?string $username): static
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedPassword(): ?string
|
||||
{
|
||||
return $this->encryptedPassword;
|
||||
}
|
||||
|
||||
public function setEncryptedPassword(?string $encryptedPassword): static
|
||||
{
|
||||
$this->encryptedPassword = $encryptedPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return null !== $this->encryptedPassword;
|
||||
}
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this->enabled
|
||||
&& null !== $this->host && '' !== $this->host
|
||||
&& null !== $this->shareName && '' !== $this->shareName;
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\State\TaskCalendarProcessor;
|
||||
use App\State\TaskNumberProcessor;
|
||||
use DateTimeImmutable;
|
||||
@@ -80,13 +81,13 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?TaskPriority $priority = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?User $assignee = null;
|
||||
private ?UserInterface $assignee = null;
|
||||
|
||||
/** @var Collection<int, User> */
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
/** @var Collection<int, UserInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'task_collaborator',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
@@ -239,25 +240,25 @@ class Task
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAssignee(): ?User
|
||||
public function getAssignee(): ?UserInterface
|
||||
{
|
||||
return $this->assignee;
|
||||
}
|
||||
|
||||
public function setAssignee(?User $assignee): static
|
||||
public function setAssignee(?UserInterface $assignee): static
|
||||
{
|
||||
$this->assignee = $assignee;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
/** @return Collection<int, UserInterface> */
|
||||
public function getCollaborators(): Collection
|
||||
{
|
||||
return $this->collaborators;
|
||||
}
|
||||
|
||||
public function addCollaborator(User $user): static
|
||||
public function addCollaborator(UserInterface $user): static
|
||||
{
|
||||
if (!$this->collaborators->contains($user)) {
|
||||
$this->collaborators->add($user);
|
||||
@@ -266,7 +267,7 @@ class Task
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCollaborator(User $user): static
|
||||
public function removeCollaborator(UserInterface $user): static
|
||||
{
|
||||
$this->collaborators->removeElement($user);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\EventListener\TaskDocumentListener;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\State\TaskDocumentProcessor;
|
||||
use App\State\TaskDocumentProvider;
|
||||
use DateTimeImmutable;
|
||||
@@ -53,10 +54,18 @@ class TaskDocument
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $originalName = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $fileName = null;
|
||||
|
||||
/**
|
||||
* Chemin relatif sur le partage SMB lorsque le document est un lien vers un fichier du partage
|
||||
* (au lieu d'un fichier uploadé stocké sur disque). Mutuellement exclusif avec fileName.
|
||||
*/
|
||||
#[ORM\Column(length: 1024, nullable: true)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $sharePath = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $mimeType = null;
|
||||
@@ -69,10 +78,10 @@ class TaskDocument
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?User $uploadedBy = null;
|
||||
private ?UserInterface $uploadedBy = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
@@ -108,13 +117,30 @@ class TaskDocument
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
public function setFileName(string $fileName): static
|
||||
public function setFileName(?string $fileName): static
|
||||
{
|
||||
$this->fileName = $fileName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSharePath(): ?string
|
||||
{
|
||||
return $this->sharePath;
|
||||
}
|
||||
|
||||
public function setSharePath(?string $sharePath): static
|
||||
{
|
||||
$this->sharePath = $sharePath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isShareLink(): bool
|
||||
{
|
||||
return null !== $this->sharePath;
|
||||
}
|
||||
|
||||
public function getMimeType(): ?string
|
||||
{
|
||||
return $this->mimeType;
|
||||
@@ -151,12 +177,12 @@ class TaskDocument
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUploadedBy(): ?User
|
||||
public function getUploadedBy(): ?UserInterface
|
||||
{
|
||||
return $this->uploadedBy;
|
||||
}
|
||||
|
||||
public function setUploadedBy(?User $uploadedBy): static
|
||||
public function setUploadedBy(?UserInterface $uploadedBy): static
|
||||
{
|
||||
$this->uploadedBy = $uploadedBy;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
@@ -29,9 +30,9 @@ class TaskMailLink
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $linkedAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $linkedBy = null;
|
||||
private ?UserInterface $linkedBy = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
@@ -74,12 +75,12 @@ class TaskMailLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLinkedBy(): ?User
|
||||
public function getLinkedBy(): ?UserInterface
|
||||
{
|
||||
return $this->linkedBy;
|
||||
}
|
||||
|
||||
public function setLinkedBy(?User $linkedBy): static
|
||||
public function setLinkedBy(?UserInterface $linkedBy): static
|
||||
{
|
||||
$this->linkedBy = $linkedBy;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user