feat : ajout du rôle ROLE_BUREAU et hiérarchie de rôles
- Hiérarchie Symfony : ROLE_ADMIN -> ROLE_BUREAU -> ROLE_USER - ROLE_BUREAU autorise : export inventaire bovin, sync EDNOTIF, visibilité des colonnes Prix/kg et Prix total - Front : utility roles.ts qui réplique la hiérarchie + auth store étendu (hasRole, isBureau) - Refactor useBovineColumns : variants withPrices/withoutPrices × inventory/case, gate par isBureau - Inventory page : Export/Rafraîchir conditionnés par isBureau - Ajout du rôle dans la const ROLE pour le form admin user Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,11 @@
|
|||||||
security:
|
security:
|
||||||
|
# Hiérarchie des rôles : ADMIN inclut BUREAU qui inclut USER.
|
||||||
|
# Ajouter un nouveau rôle = ajouter une ligne ici (et son équivalent côté
|
||||||
|
# front dans utils/roles.ts).
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_BUREAU: ROLE_USER
|
||||||
|
ROLE_ADMIN: ROLE_BUREAU
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
App\Entity\User: 'auto'
|
App\Entity\User: 'auto'
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ export interface UseBovineColumnsOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Définition partagée des colonnes des tableaux bovins (inventory + case).
|
* Définition partagée des colonnes des tableaux bovins (inventory + case).
|
||||||
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de
|
* 4 variants : avec/sans colonnes prix × inventory/case.
|
||||||
* pouvoir ajuster les largeurs indépendamment.
|
*
|
||||||
|
* Les colonnes Prix/kg et Prix total sont visibles pour les rôles BUREAU
|
||||||
|
* et ADMIN (BUREAU hérite ses droits price-visibility, ADMIN hérite de BUREAU).
|
||||||
*/
|
*/
|
||||||
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const adminColumnsInventory: BovineColumn[] = [
|
const withPricesInventory: BovineColumn[] = [
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||||
@@ -38,7 +40,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
|||||||
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const userColumnsInventory: BovineColumn[] = [
|
const withoutPricesInventory: BovineColumn[] = [
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
|
||||||
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
|
||||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||||
@@ -50,7 +52,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
|||||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
|
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const adminColumnsCase: BovineColumn[] = [
|
const withPricesCase: BovineColumn[] = [
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||||
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||||
{ key: 'sex', label: 'Sexe', width: '90px' },
|
{ key: 'sex', label: 'Sexe', width: '90px' },
|
||||||
@@ -62,7 +64,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
|||||||
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
|
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const userColumnsCase: BovineColumn[] = [
|
const withoutPricesCase: BovineColumn[] = [
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
|
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
|
||||||
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
|
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
|
||||||
{ key: 'sex', label: 'Sexe', width: '110px' },
|
{ key: 'sex', label: 'Sexe', width: '110px' },
|
||||||
@@ -73,10 +75,13 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const columns = computed<BovineColumn[]>(() => {
|
const columns = computed<BovineColumn[]>(() => {
|
||||||
if (options.variant === 'case') {
|
const isCase = options.variant === 'case'
|
||||||
return auth.isAdmin ? adminColumnsCase : userColumnsCase
|
const seePrice = auth.isBureau
|
||||||
|
|
||||||
|
if (isCase) {
|
||||||
|
return seePrice ? withPricesCase : withoutPricesCase
|
||||||
}
|
}
|
||||||
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
|
return seePrice ? withPricesInventory : withoutPricesInventory
|
||||||
})
|
})
|
||||||
|
|
||||||
return { columns }
|
return { columns }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||||||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||||||
<div
|
<div
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isBureau"
|
||||||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||||
title="Exporter en Excel"
|
title="Exporter en Excel"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isBureau"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="syncing"
|
:disabled="syncing"
|
||||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
|
|||||||
import type {UserData} from '~/services/dto/user-data'
|
import type {UserData} from '~/services/dto/user-data'
|
||||||
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
||||||
import type {UserPayload} from "~/services/dto/user-data";
|
import type {UserPayload} from "~/services/dto/user-data";
|
||||||
import {ROLE} from '~/utils/constants'
|
import {userHasRole} from '~/utils/roles'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -12,7 +12,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
isAuthenticated: (state) => Boolean(state.user),
|
isAuthenticated: (state) => Boolean(state.user),
|
||||||
isAdmin: (state) => Boolean(state.user?.roles?.includes(ROLE[0].value))
|
hasRole: (state) => (role: string): boolean => userHasRole(state.user?.roles, role),
|
||||||
|
isAdmin: (state) => userHasRole(state.user?.roles, 'ROLE_ADMIN'),
|
||||||
|
isBureau: (state) => userHasRole(state.user?.roles, 'ROLE_BUREAU')
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
clearSession() {
|
clearSession() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const MERCHANDISE_TYPE_CODES = {
|
|||||||
|
|
||||||
export const ROLE = [
|
export const ROLE = [
|
||||||
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
|
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
|
||||||
|
{ label: 'Bureau', value: 'ROLE_BUREAU' },
|
||||||
{ label: 'Utilisateur', value: 'ROLE_USER' }
|
{ label: 'Utilisateur', value: 'ROLE_USER' }
|
||||||
]
|
]
|
||||||
export const SUPPLIER_CODE = {
|
export const SUPPLIER_CODE = {
|
||||||
|
|||||||
38
frontend/utils/roles.ts
Normal file
38
frontend/utils/roles.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Hiérarchie des rôles côté front. Doit rester synchronisée avec
|
||||||
|
* `role_hierarchy` dans config/packages/security.yaml côté back.
|
||||||
|
*
|
||||||
|
* Pour ajouter un nouveau rôle :
|
||||||
|
* 1. Ajouter une entrée ici (son rôle parent dans la chaîne)
|
||||||
|
* 2. Ajouter `ROLE_X: ROLE_Y` dans security.yaml côté back
|
||||||
|
* 3. Ajouter le rôle dans `ROLE` (utils/constants.ts) pour le form admin
|
||||||
|
*/
|
||||||
|
export const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||||
|
ROLE_ADMIN: ['ROLE_BUREAU'],
|
||||||
|
ROLE_BUREAU: ['ROLE_USER'],
|
||||||
|
ROLE_USER: []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'ensemble des rôles effectifs en expansant la hiérarchie.
|
||||||
|
* Ex : ['ROLE_ADMIN'] → Set { 'ROLE_ADMIN', 'ROLE_BUREAU', 'ROLE_USER' }.
|
||||||
|
*/
|
||||||
|
export const expandRoles = (roles: string[]): Set<string> => {
|
||||||
|
const expanded = new Set<string>(roles)
|
||||||
|
const visit = (role: string): void => {
|
||||||
|
const parents = ROLE_HIERARCHY[role] ?? []
|
||||||
|
for (const parent of parents) {
|
||||||
|
if (!expanded.has(parent)) {
|
||||||
|
expanded.add(parent)
|
||||||
|
visit(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of roles) visit(r)
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userHasRole = (userRoles: string[] | null | undefined, role: string): boolean => {
|
||||||
|
if (!userRoles || userRoles.length === 0) return false
|
||||||
|
return expandRoles(userRoles).has(role)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use App\State\Bovin\BovineInventoryExportProvider;
|
|||||||
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
|
description: "Retourne un fichier XLSX listant tous les bovins actifs (exitedAt IS NULL) triés par date de naissance croissante, avec colorisation des lignes selon l'âge.",
|
||||||
tags: ['Bovines'],
|
tags: ['Bovines'],
|
||||||
),
|
),
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_BUREAU')",
|
||||||
output: false,
|
output: false,
|
||||||
provider: BovineInventoryExportProvider::class,
|
provider: BovineInventoryExportProvider::class,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use App\State\Bovin\BovineSyncInventoryProcessor;
|
|||||||
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
||||||
tags: ['Bovines'],
|
tags: ['Bovines'],
|
||||||
),
|
),
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_BUREAU')",
|
||||||
input: false,
|
input: false,
|
||||||
output: self::class,
|
output: self::class,
|
||||||
processor: BovineSyncInventoryProcessor::class,
|
processor: BovineSyncInventoryProcessor::class,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class Bovine
|
|||||||
|
|
||||||
#[ORM\Column(type: 'float', nullable: true)]
|
#[ORM\Column(type: 'float', nullable: true)]
|
||||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
|
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||||
private ?float $pricePerKg = null;
|
private ?float $pricePerKg = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
@@ -177,7 +177,7 @@ class Bovine
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
|
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
|
||||||
public function getFinalPrice(): ?float
|
public function getFinalPrice(): ?float
|
||||||
{
|
{
|
||||||
if (null === $this->receivedWeight || null === $this->pricePerKg) {
|
if (null === $this->receivedWeight || null === $this->pricePerKg) {
|
||||||
|
|||||||
Reference in New Issue
Block a user