37 KiB
Tickets correctifs — PR feat/audit-log (4e passe)
Issus de la review du 2026-04-23 (
REVIEW.md). Chaque ticket est autonome : pourquoi, quoi faire, fichiers concernes. Commence par les P0. Un commit par ticket (messagefix(T-XXX) : description).
P0 — Urgents (securite avant mise en prod)
T-001 — Fermer /api/docs en production
Pourquoi : la doc Swagger expose aujourd'hui publiquement la liste de tous les endpoints, les permissions RBAC demandees, les filtres disponibles, les DTOs. Pour un outil interne c'est inutile et ca donne a un attaquant la carte complete de la surface d'attaque sans meme avoir a se connecter.
A faire :
- Ouvrir
config/packages/security.yaml. - Supprimer la ligne qui ouvre
/api/docs:
# AVANT
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
# APRES (on supprime simplement la 2e ligne)
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
Comme /api/docs tombe desormais dans le dernier pattern (^/api), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.
- Recharger :
make cache-clearpuismake restart. - Tester :
curl -i https://coltura.malio-dev.fr/api/docsdoit retourner401 Unauthorized(avant :200).
Fichiers : config/packages/security.yaml
T-002 — Ajouter les en-tetes de securite HTTP de base en prod
Pourquoi : sans X-Frame-Options, quelqu'un peut integrer Coltura dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Coltura). Sans X-Content-Type-Options: nosniff, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
A faire :
- Ouvrir
infra/prod/nginx-proxy.conf(c'est le proxy expose au public). - Ajouter juste apres
server_name coltura.malio-dev.fr;:
# En-tetes de securite applicables a toutes les reponses
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Explication :
X-Frame-Options: DENY: personne ne peut mettre Coltura dans une iframe.X-Content-Type-Options: nosniff: le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.Referrer-Policy: strict-origin-when-cross-origin: limite ce que Coltura envoie comme Referer a des sites externes (evite de leaker/admin/users/42a un site tiers).always: envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).
- Recharger Nginx :
docker restart nginx-coltura(ou celui qui fait office de proxy public). - Verifier :
curl -I https://coltura.malio-dev.fr/doit afficher ces trois en-tetes.
Note : si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).
Fichiers : infra/prod/nginx-proxy.conf
T-003 — Interdire l'indexation par les moteurs de recherche
Pourquoi : frontend/public/robots.txt autorise actuellement Google et compagnie a indexer toutes les pages. Pour un CRM interne, pas besoin que les URLs de pages admin, de fiches clients, etc. remontent dans les resultats de recherche. Meme si le contenu est protege par login, la simple enumeration des URLs publiques est un leak.
A faire :
- Ouvrir
frontend/public/robots.txt. - Remplacer le contenu par :
User-Agent: *
Disallow: /
Fichiers : frontend/public/robots.txt
P1 — Importants (bugs silencieux + qualite)
T-004 — Valider/typer le filtre performed_at sur le journal d'audit
Pourquoi : quand un utilisateur envoie ?performed_at[after]=pasunedate, PostgreSQL n'arrive pas a caster la chaine en timestamp et leve une erreur. L'API retourne un 500 (erreur serveur) au lieu du 400 propre (erreur client) qu'elle devrait. En plus, les logs d'erreur se remplissent inutilement.
A faire :
- Ouvrir
src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php. - En haut du fichier, ajouter les imports suivants si absents :
use Doctrine\DBAL\Types\Types;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
- Dans
applyFilters(), remplacer les blocsperformed_at_afteretperformed_at_before:
// AVANT
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')
->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')
->setParameter('performed_at_before', $filters['performed_at_before']);
}
// APRES
if (isset($filters['performed_at_after'])) {
try {
$after = new \DateTimeImmutable($filters['performed_at_after']);
} catch (\Throwable) {
throw new BadRequestHttpException('performed_at[after] doit etre une date ISO 8601 valide.');
}
$qb->andWhere('performed_at >= :performed_at_after')
->setParameter('performed_at_after', $after, Types::DATETIMETZ_IMMUTABLE);
}
if (isset($filters['performed_at_before'])) {
try {
$before = new \DateTimeImmutable($filters['performed_at_before']);
} catch (\Throwable) {
throw new BadRequestHttpException('performed_at[before] doit etre une date ISO 8601 valide.');
}
$qb->andWhere('performed_at <= :performed_at_before')
->setParameter('performed_at_before', $before, Types::DATETIMETZ_IMMUTABLE);
}
- Tester :
curl -i -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_at%5Bafter%5D=pasunedate'doit retourner400 Bad Request, plus500. - Tester aussi avec une date valide :
curl ... 'performed_at%5Bafter%5D=2026-01-01T00:00:00Z'doit retourner200avec les bons resultats.
Fichiers : src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php
T-005 — Ajouter la clause ESCAPE sur le filtre ILIKE performed_by
Pourquoi : le filtre "recherche contient" sur le nom d'utilisateur utilise ILIKE. Quand un user cherche admin_backup, le _ est un caractere special LIKE qui matche "n'importe quel caractere". Le code essaie d'echapper avec \_ mais il faut dire a PostgreSQL que \ est l'echappement via la clause ESCAPE. Sans ca, sur certaines configs PG, \_ devient deux caracteres distincts et l'echappement est cassé.
Concretement : chercher admin_backup peut faire remonter aussi adminXbackup, admin1backup, etc.
A faire :
- Ouvrir
src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php. - Dans
applyFilters(), modifier le blocperformed_by:
// AVANT (lignes ~171-180)
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere('performed_by ILIKE :performed_by')
->setParameter('performed_by', '%'.$escaped.'%')
;
}
// APRES
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` pour qu'ils soient traites comme caracteres
// litteraux. La clause ESCAPE est EXPLICITE : en SQL standard, LIKE n'a
// pas d'echappement par defaut, il faut le declarer.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\\\'")
->setParameter('performed_by', '%'.$escaped.'%')
;
}
Note : les 4 \ en PHP donnent 2 \ dans la string SQL, qui devient 1 \ apres parsing PostgreSQL. C'est la chaine d'echappement correcte.
- Tester en psql : creer une entree avec
performed_by = 'admin_backup', puis :
curl -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_by=admin_backup'
# → doit retourner UNIQUEMENT admin_backup, pas adminXbackup ou autres
Fichiers : src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php
T-006 — Refuser explicitement les appelants non-User dans SiteAwareInjectionProcessor
Pourquoi : le processor qui injecte/valide le site lors de la creation d'une entite site-aware verifie $user instanceof User. Si jamais l'appelant n'est pas exactement une instance de cette classe (ex: futur provider d'auth, session systeme), la condition est fausse et la validation est silencieusement sautee — l'utilisateur pourrait creer une entite dans un site auquel il n'appartient pas. Aujourd'hui le risque est nul car il n'y a qu'un seul provider, mais le pattern "pas le bon type = on laisse passer" est fragile.
A faire :
- Ouvrir
src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php. - Modifier la partie validation cross-site (ligne 64-75) :
// AVANT
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
$explicitSite = $data->getSite();
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
);
}
}
// APRES
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
// Pas de User authentifie = rejet explicite. On ne laisse pas passer
// une ecriture site-scoped si on ne peut pas valider le proprietaire.
if (!$user instanceof User) {
throw new AccessDeniedHttpException(
'Utilisateur non reconnu pour la validation de site.',
);
}
$explicitSite = $data->getSite();
if ($explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.',
);
}
}
- Relancer la suite back :
make test. Les tests existants surSiteAwareInjectionProcessordoivent encore passer (l'acceptation du happy path est inchangee).
Fichiers : src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php
T-007 — Entourer isHandlingUnauthorized d'un try/finally
Pourquoi : dans useApi, un flag singleton bloque les appels concurrents a navigateTo('/login') quand une 401 survient. Si navigateTo echoue (middleware qui throw, plugin qui throw, etc.), le flag reste sur true indefiniment et toutes les 401 suivantes sont ignorees silencieusement. L'utilisateur doit hard-reload pour s'en sortir.
A faire :
- Ouvrir
frontend/shared/composables/useApi.ts. - Dans le handler
onResponseError, modifier le bloc 401 (lignes 124-131) :
// AVANT
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
auth.clearSession()
await navigateTo('/login')
isHandlingUnauthorized = false
}
}
// APRES
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
try {
auth.clearSession()
await navigateTo('/login')
} finally {
// Garantir la remise a false meme si navigateTo throw : sinon
// les 401 suivantes seraient silencieusement ignorees jusqu'a
// un hard reload.
isHandlingUnauthorized = false
}
}
}
- Pas de test automatise trivial a ecrire (depend de navigateTo) — test manuel : se connecter, laisser le token JWT expirer (ou le supprimer manuellement dans le cookie), declencher deux appels quasi-simultanes et verifier la redirection
/login.
Fichiers : frontend/shared/composables/useApi.ts
T-008 — Capper la pagination sur Permission, Role, Site + nettoyer itemsPerPage: 999
Pourquoi : plusieurs composants (drawers RBAC, page admin sites) appellent l'API avec itemsPerPage: 999. Or, paginationClientItemsPerPage n'est pas active sur ces ressources, donc le 999 est silencieusement ignore et on recoit toujours 30 elements. Aujourd'hui les catalogues sont petits, ca marche par chance. Demain si on passe a 35 permissions, les drawers tronqueront silencieusement — bug invisible.
Parallelement, si on active la pagination client sans plafond, ?itemsPerPage=99999 devient une requete qui sort tout en une fois — DoS bas effort.
La solution la plus simple : desactiver la pagination pour ces catalogues (ils sont exhaustifs par nature) et retirer les itemsPerPage: 999 cote front.
A faire :
- Backend — Ajouter
paginationEnabled: falsesur lesGetCollectionde ces trois ressources.
src/Module/Core/Domain/Entity/Permission.php (vers ligne 28-31) :
// APRES
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
paginationEnabled: false,
),
src/Module/Core/Domain/Entity/Role.php (trouver le new GetCollection et ajouter la meme ligne).
src/Module/Sites/Domain/Entity/Site.php (meme chose sur le GetCollection) :
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
paginationEnabled: false,
),
Attention : pour Site, la page admin va recuperer TOUS les sites. Tant qu'on reste sous quelques dizaines c'est OK. Si la taille explose plus tard, remettre une pagination avec un plafond explicite (paginationClientItemsPerPage: true, paginationMaximumItemsPerPage: 200).
- Frontend — Retirer les
itemsPerPage: 999.
frontend/modules/core/components/UserRbacDrawer.vue:235-236 :
// AVANT
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }),
// APRES
api.get<{ member: Permission[] }>('/permissions', { orphan: false }, { toast: true }),
api.get<{ member: Site[] }>('/sites', {}, { toast: true }),
frontend/modules/core/components/RoleDrawer.vue:149 :
// AVANT
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 999 },
{ toast: true },
)
// APRES
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ orphan: false },
{ toast: true },
)
frontend/modules/sites/pages/admin/sites.vue:117 :
// AVANT
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
// APRES
const data = await api.get<{ member: Site[] }>(
'/sites',
{},
{ toast: false },
)
-
Tester : ouvrir le
UserRbacDrawer, verifier que toutes les permissions et tous les sites s'affichent. Idem surRoleDraweret la pageadmin/sites. -
Relancer
make test(PHP) etmake nuxt-test(front) pour verifier la non-regression.
Fichiers :
src/Module/Core/Domain/Entity/Permission.phpsrc/Module/Core/Domain/Entity/Role.phpsrc/Module/Sites/Domain/Entity/Site.phpfrontend/modules/core/components/UserRbacDrawer.vuefrontend/modules/core/components/RoleDrawer.vuefrontend/modules/sites/pages/admin/sites.vue
T-009 — Proteger JSON.stringify dans AuditLogDetail.vue
Pourquoi : la fonction formatValue serialise des valeurs venant de l'API avec JSON.stringify. Si un jour la valeur contient un objet avec une reference circulaire (ex: refacto ORM qui serialise une entite), stringify leve une erreur qui casse tout le rendu du drawer. Ajouter un try/catch defensif evite de devoir debugger ca en prod.
A faire :
- Ouvrir
frontend/shared/components/audit/AuditLogDetail.vue. - Reperer la fonction
formatValue(ou equivalent qui appelleJSON.stringify). - L'entourer d'un try/catch :
// AVANT (approximativement)
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
// APRES
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return '[valeur non serialisable]'
}
}
return String(value)
}
Fichiers : frontend/shared/components/audit/AuditLogDetail.vue
P2 — Qualite et conventions
T-010 — Remplacer <button> brut par MalioButton dans AuditTimeline
Pourquoi : la regle projet (.claude/rules/frontend.md) impose MalioButton pour tous les boutons. Un seul bouton HTML brut subsiste dans la PR — celui de "Voir plus" dans la timeline.
A faire :
- Ouvrir
frontend/shared/components/audit/AuditTimeline.vue, vers ligne 80. - Remplacer :
<!-- AVANT -->
<button
type="button"
class="mt-3 text-sm text-blue-600 hover:text-blue-800"
@click="loadMore"
>
{{ t('audit.timeline.load_more') }}
</button>
<!-- APRES -->
<MalioButton
type="secondary"
size="sm"
:label="t('audit.timeline.load_more')"
class="mt-3"
@click="loadMore"
/>
Si MalioButton n'a pas de variant adapte au rendu "lien inline" actuel, garder le <button> mais ajouter un TODO explicite :
<!-- TODO(malio-ui) : pas de variant 'link-inline' dispo dans @malio/layer-ui 1.4.2 -->
<button type="button" ...>
- Lancer
make dev-nuxtet verifier visuellement que le bouton s'affiche et fonctionne.
Fichiers : frontend/shared/components/audit/AuditTimeline.vue
T-011 — Deplacer sidebar.core.sites sous sidebar.sites.*
Pourquoi : la convention .claude/rules/naming.md impose les cles i18n sidebar sous le namespace du module owner (sidebar.<module>.*). L'item "Sites" a 'module' => 'sites' dans sidebar.php mais sa cle est sidebar.core.sites — incoherent, et si on desactive le module sites, il faudra chasser cette cle sous core.
A faire :
- Ouvrir
frontend/i18n/locales/fr.json, trouver la sectionsidebar:
// AVANT
"sidebar": {
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
"sites": "Sites",
"audit_log": "Journal d'audit"
}
}
// APRES
"sidebar": {
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
"audit_log": "Journal d'audit"
},
"sites": {
"admin": "Sites"
}
}
- Ouvrir
config/sidebar.php, mettre a jour le label de l'item Sites :
// AVANT
[
'label' => 'sidebar.core.sites',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
// APRES
[
'label' => 'sidebar.sites.admin',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
make cache-clear+ relancermake dev-nuxt, verifier que la sidebar affiche toujours "Sites".
Fichiers :
frontend/i18n/locales/fr.jsonconfig/sidebar.php
T-012 — Rendre UserPasswordHasherProcessor et MeProvider final
Pourquoi : convention implicite de la PR : tous les services sont final readonly. Deux classes echappent a la regle sans raison. Les garder ouvertes a l'heritage invite un futur bug par sous-classe qui oublierait d'appeler parent::process().
A faire :
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php:16:
// AVANT
class UserPasswordHasherProcessor implements ProcessorInterface
// APRES
final class UserPasswordHasherProcessor implements ProcessorInterface
src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php:14:
// AVANT
class MeProvider implements ProviderInterface
// APRES
final class MeProvider implements ProviderInterface
- Lancer
make test— doit passer sans regression.
Fichiers :
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.phpsrc/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php
T-013 — Extraire debounce dans frontend/shared/utils/
Pourquoi : la fonction debounce est definie inline dans audit-log.vue. Si une autre page en ajoute une, on aura du code duplique. frontend/shared/utils/ existe deja (ex: color.ts), c'est l'endroit naturel pour ce genre d'util.
A faire :
- Creer
frontend/shared/utils/debounce.ts:
/**
* Cree une version debounced de `fn` : chaque appel reset un timer et
* l'execution reelle attend `delay` ms apres la derniere invocation.
* Utile pour eviter de spam une recherche a chaque touche.
*/
export function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout> | null = null
return ((...args: Parameters<T>) => {
if (null !== timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
- Creer
frontend/shared/utils/__tests__/debounce.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { debounce } from '../debounce'
describe('debounce', () => {
it('attend delay ms avant d\'appeler fn une seule fois', async () => {
vi.useFakeTimers()
const fn = vi.fn()
const debounced = debounce(fn, 100)
debounced()
debounced()
debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
-
Dans
frontend/modules/core/pages/admin/audit-log.vue, supprimer la definition locale (lignes 306-312) et laisser l'auto-import Nuxt faire son travail (les utils defrontend/shared/utils/sont scannes parnuxt.config.tsdansimports.dirs). -
make nuxt-testdoit passer.
Fichiers :
frontend/shared/utils/debounce.ts(nouveau)frontend/shared/utils/__tests__/debounce.test.ts(nouveau)frontend/modules/core/pages/admin/audit-log.vue
T-014 — Ajouter les paliers month et year a relativeDate
Pourquoi : la fonction qui affiche "il y a X minutes/heures/jours/semaines" plafonne a la semaine. Une entree d'audit vieille d'un an apparait comme "il y a 52 semaines" — peu lisible.
A faire :
- Ouvrir
frontend/shared/components/audit/AuditTimeline.vue, vers ligne 171-182. - Etendre la fonction
relativeDate:
// AVANT
function relativeDate(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
const fmt = rtf.value
if (absSec < 60) return fmt.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
if (absSec < 3600) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
}
// APRES
function relativeDate(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const diffSec = Math.round(diffMs / 1000)
const absSec = Math.abs(diffSec)
const sign = -Math.sign(diffSec)
const fmt = rtf.value
if (absSec < 60) return fmt.format(sign * absSec, 'second')
if (absSec < 3600) return fmt.format(sign * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return fmt.format(sign * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return fmt.format(sign * Math.round(absSec / 86400), 'day')
if (absSec < 2592000) return fmt.format(sign * Math.round(absSec / 604800), 'week') // < ~30j
if (absSec < 31536000) return fmt.format(sign * Math.round(absSec / 2592000), 'month') // < ~365j
return fmt.format(sign * Math.round(absSec / 31536000), 'year')
}
make dev-nuxt, ouvrir un drawer avec un vieux log (ou modifier une date pour tester).
Fichiers : frontend/shared/components/audit/AuditTimeline.vue
T-015 — Traduire entityType dans le drawer de detail d'audit
Pourquoi : le titre du drawer affiche core.User #42 brut. La spec prevoit un lookup i18n pour afficher un nom lisible (ex: "Utilisateur #42"). Les cles sont deja prevues (audit.entity.user existe), il suffit d'en ajouter et de les utiliser.
A faire :
- Completer
frontend/i18n/locales/fr.jsonavec les cles manquantes :
"audit": {
...
"entity": {
"core_user": "Utilisateur",
"core_role": "Rôle",
"core_permission": "Permission",
"sites_site": "Site"
},
...
}
(remplace l'ancienne cle audit.entity.user qui n'etait que pour User)
- Dans
frontend/modules/core/pages/admin/audit-log.vue, ajouter une fonction helper :
const { t, te } = useI18n()
function formatEntityType(type: string): string {
// Convertit "core.User" -> "core_user" pour matcher la cle i18n
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
return te(key) ? t(key) : type
}
- Modifier le template du drawer (ligne 138) :
<!-- AVANT -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
</h3>
<!-- APRES -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
</h3>
- Tester : ouvrir un drawer sur un audit de User, Role, Site — verifier l'affichage.
Fichiers :
frontend/i18n/locales/fr.jsonfrontend/modules/core/pages/admin/audit-log.vue
T-016 — Logger un warning si UserRbacProcessor recoit un body JSON invalide
Pourquoi : le processor parse $request->getContent() pour savoir quelles cles sont absentes du payload PATCH et restaurer les collections. Si le JSON est invalide (rare mais possible), la restauration est silencieusement sautee — les collections RBAC du user peuvent etre ecrasees par des arrays vides. Impossible a debugger sans log.
A faire :
- Ouvrir
src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php. - S'assurer que le logger est injecte dans le constructeur (l'ajouter s'il manque) :
use Psr\Log\LoggerInterface;
public function __construct(
// ... autres dependances
private readonly LoggerInterface $logger,
) {}
- Modifier la methode (vers ligne 241-248) :
// AVANT
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return;
}
// APRES
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
$this->logger->warning(
'UserRbacProcessor : body JSON invalide, restoreAbsentCollections saute. '
.'Les collections RBAC peuvent etre ecrasees silencieusement.',
['user_id' => $data->getId()],
);
return;
}
- Lancer
make test.
Fichiers : src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php
P3 — Polish et dette technique
T-017 — Mettre a jour CHANGELOG.md
Pourquoi : le changelog est toujours a ## [0.0.0] avec un contenu pre-PR. Sans changelog a jour, c'est dur pour un futur dev (ou pour soi-meme dans 6 mois) de savoir ce qui a change.
A faire :
- Remplacer le contenu actuel de
CHANGELOG.mdpar (adapter la version au bump effectif) :
# Changelog
Liste des evolutions du projet Coltura.
## [0.1.34] - 2026-04-XX
### Added
- Systeme d'audit log (table append-only, API read-only, page admin, timeline)
- Module Sites (multi-tenant, scope automatique, drawer admin)
- Systeme RBAC complet (Role, Permission, UserRbacDrawer, AdminHeadcountGuard)
- Suite E2E Playwright (6 personas, matrice RBAC sidebar)
- Tests PHPUnit pour Sites et Audit
### Changed
- Arborescence frontend : `shared/` + `modules/*/` en layers Nuxt
- Migrations d'init au namespace racine (bug tri FQCN Doctrine 3.x)
### Fixed
- UserRbacProcessor : restauration des collections absentes du payload PATCH
- Drawer utilisateurs : chargement RBAC via `/api/users/{id}/rbac`
- Audit UI : guard stale-data sur fetch concurrent
## [0.0.0]
... contenu initial ...
Fichiers : CHANGELOG.md
T-018 — Supprimer id hardcode de AuditLogEntityTypesResource
Pourquoi : la propriete $id = 'entity-types' n'est pas utilisee par le provider. C'est du bruit dans le DTO qui peut confondre un futur lecteur.
A faire :
-
Ouvrir
src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php. -
Supprimer la ligne
public readonly string $id = 'entity-types';(ligne 31). -
S'assurer que
AuditLogEntityTypesResourcen'a plus besoin de cet$idnulle part. Si API Platform leve une erreur pour l'absence d'identifier, remettre la propriete mais avec un commentaire explicite expliquant qu'elle est requise par API Platform pour les ressources singleton. -
make testpour valider.
Fichiers : src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php
T-019 — Conditionner loadSidebar() apres switch de site
Pourquoi : apres chaque switch de site, useCurrentSite recharge la sidebar — mais la sidebar de Coltura ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
A faire :
- Ouvrir
frontend/modules/sites/composables/useCurrentSite.ts(vers ligne 94). - Deux options :
Option A — Supprimer le rechargement (preferee si la sidebar ne depend actuellement d'aucun site) :
// AVANT
await loadSidebar()
// APRES
// Aucun item de sidebar ne depend du site courant aujourd'hui. Si un
// module futur expose des items site-scoped, reintroduire ce reload
// (et le tester).
Option B — Garder le reload avec commentaire explicite :
// Reload defensif : prepare le cas ou /api/sidebar devient site-scoped
// (ex: items RH ou comptabilite par site). Cout : 1 RTT par switch.
await loadSidebar()
- Si option A : tester en switchant de site, verifier qu'il n'y a pas de lag ni de flicker.
Fichiers : frontend/modules/sites/composables/useCurrentSite.ts
T-020 — Supprimer l'alias SiteNotAuthorizedException
Pourquoi : la classe App\Module\Sites\Domain\Exception\SiteNotAuthorizedException est un alias final vide qui etend celle de Shared\Domain\Exception\. Elle est dette technique non planifiee — si quelqu'un la referencait dans un nouveau code, on aurait deux versions flottantes.
A faire :
- Chercher les usages :
grep -rn 'App\\Module\\Sites\\Domain\\Exception\\SiteNotAuthorizedException' src/ tests/
-
Remplacer chaque usage par l'import de
Shared\Domain\Exception\SiteNotAuthorizedException. -
Supprimer le fichier
src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php. -
make testpour verifier.
Fichiers :
src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php(a supprimer)- Tous les fichiers qui l'importent (grep)
T-021 — Reduire le couplage Core → Sites (PHPDoc User, fixtures, SeedE2ECommand)
Pourquoi : la regle projet interdit l'import direct d'un module vers un autre. Dans cette PR, User.php, AppFixtures.php et SeedE2ECommand.php importent respectivement Site, SiteRepositoryInterface, SitesFixtures. C'est documente comme "intentionnel" mais ca viole la regle, donc a documenter plus officiellement ou corriger.
A faire : (ticket dette technique — pas urgent, mais a prevoir)
-
Pour
User.php:23— remplacer les PHPDoc@var Collection<int, Site>par@var Collection<int, SiteInterface>(l'interface existe deja dansShared/Domain/Contract/). -
Pour
AppFixtures.phpetSeedE2ECommand.php— extraire une interfaceSiteFixturesInterfacedansShared/si le couplage est vraiment genant. Sinon, documenter l'exception dansCLAUDE.mdavec une phrase du type "Les fixtures et seeds peuvent importer des repositories de modules metiers — c'est accepte car ils vivent hors domaine." -
Pas de correction de code urgente. Ouvrir un TODO/issue interne.
Fichiers :
src/Module/Core/Domain/Entity/User.phpsrc/Module/Core/Infrastructure/DataFixtures/AppFixtures.phpsrc/Module/Core/Infrastructure/Console/SeedE2ECommand.php.claude/rules/architecture.md(possibles maj)
T-022 — Offrir un bouton "Reessayer" sur les drawers en erreur
Pourquoi : si UserRbacDrawer, RoleDrawer ou SiteDrawer echoue son fetch initial (403, 500, reseau), l'utilisateur doit fermer et rouvrir le drawer pour relancer. Un bouton "Reessayer" dans l'etat loadFailed ameliore l'UX.
A faire : (non bloquant)
- Dans chaque drawer, ajouter dans le template quand
loadFailed.value === true:
<div v-if="loadFailed" class="flex flex-col items-center gap-3 p-4">
<p class="text-sm text-red-600">{{ t('errors.http.get') }}</p>
<MalioButton
type="secondary"
size="sm"
:label="t('common.retry')"
@click="loadData(props.user?.id)"
/>
</div>
-
Ajouter
"retry": "Reessayer"souscommondansfr.json. -
Tester : couper reseau, ouvrir drawer, verifier bouton — remettre reseau, cliquer, verifier rechargement.
Fichiers :
frontend/modules/core/components/UserRbacDrawer.vuefrontend/modules/core/components/RoleDrawer.vuefrontend/modules/sites/components/SiteDrawer.vuefrontend/i18n/locales/fr.json
T-023 — Garder contre double execution onMounted dans logout.vue
Pourquoi : si la page logout est visitee deux fois rapidement, auth.logout() est appele deux fois. Lexik est idempotent cote backend donc sans danger, mais deux toasts d'erreur peuvent apparaitre si le reseau flappe. Un simple guard evite ca.
A faire :
- Ouvrir
frontend/modules/core/pages/logout.vue. - Ajouter un guard module-level :
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
let logoutInProgress = false
onMounted(async () => {
if (logoutInProgress) return
logoutInProgress = true
try {
await auth.logout()
} finally {
resetSidebar()
resetModules()
resetCurrentSite()
resetAuditLog()
await navigateTo('/login')
}
})
</script>
Fichiers : frontend/modules/core/pages/logout.vue
Resume
| Priorite | Tickets | Estimation |
|---|---|---|
| P0 | T-001 a T-003 | ~45 min total (3 tickets de config/fichier simple) |
| P1 | T-004 a T-009 | ~3h (fixes logique + tests + verif) |
| P2 | T-010 a T-016 | ~2h30 (conventions + refacto legers) |
| P3 | T-017 a T-023 | ~1h30 (polish + dette) |
| Total | 23 tickets | ~7h30 |
Commence par T-001 — c'est le plus rapide ET le plus impactant (simple suppression d'une ligne). Apres les P0, les P1 sont prioritaires : T-007 (flag singleton), T-008 (pagination) et T-004 (crash 500) sont ceux qui peuvent casser en production. Pour chaque ticket, fais un commit dedie :
fix(T-XXX) : description courte. Les regles de commit du projet s'appliquent : francais, type + scope + espaces autour du:, pas de mention d'outil d'assistance.