Compare commits

...

2 Commits

Author SHA1 Message Date
gitea-actions
e208bcd893 chore: bump version to v0.0.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m29s
2026-04-28 11:52:26 +00:00
3fe0bbf71e feat: modification de la gestion des rôles + ajout rôle d'un bureau (!52)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-28 11:52:18 +00:00
13 changed files with 100 additions and 18 deletions

View File

@@ -1,4 +1,11 @@
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
password_hashers:
App\Entity\User: 'auto'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.92'
app.version: '0.0.93'

View File

@@ -18,13 +18,15 @@ export interface UseBovineColumnsOptions {
/**
* Définition partagée des colonnes des tableaux bovins (inventory + case).
* Variants distincts pour chaque écran et chaque rôle (admin/user) afin de
* pouvoir ajuster les largeurs indépendamment.
* 4 variants : avec/sans colonnes prix × inventory/case.
*
* 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 = {}) => {
const auth = useAuthStore()
const adminColumnsInventory: BovineColumn[] = [
const withPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -38,7 +40,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '80px' }
]
const userColumnsInventory: BovineColumn[] = [
const withoutPricesInventory: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '80px' },
{ key: 'workNumber', label: 'N° Travail', width: '60px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
@@ -50,7 +52,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' }
]
const adminColumnsCase: BovineColumn[] = [
const withPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '90px' },
@@ -62,7 +64,7 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
{ key: 'finalPrice', label: 'Prix total', width: '105px' }
]
const userColumnsCase: BovineColumn[] = [
const withoutPricesCase: BovineColumn[] = [
{ key: 'nationalNumber', label: 'N° National', width: '130px' },
{ key: 'workNumber', label: 'N° Travail', width: '100px' },
{ key: 'sex', label: 'Sexe', width: '110px' },
@@ -73,10 +75,13 @@ export const useBovineColumns = (options: UseBovineColumnsOptions = {}) => {
]
const columns = computed<BovineColumn[]>(() => {
if (options.variant === 'case') {
return auth.isAdmin ? adminColumnsCase : userColumnsCase
const isCase = options.variant === 'case'
const seePrice = auth.isBureau
if (isCase) {
return seePrice ? withPricesCase : withoutPricesCase
}
return auth.isAdmin ? adminColumnsInventory : userColumnsInventory
return seePrice ? withPricesInventory : withoutPricesInventory
})
return { columns }

View File

@@ -0,0 +1,27 @@
import { useAuthStore } from '~/stores/auth'
/**
* Garde-fou global : empêche les utilisateurs non-admin d'accéder aux pages
* sous /admin/*. Renvoie vers la home pour les utilisateurs authentifiés
* non-admin, et vers /login pour les non authentifiés.
*
* L'API back rejette de toute façon les actions admin avec un 403, mais ce
* middleware évite l'affichage des pages vides / en erreur quand un user
* tape directement l'URL /admin/...
*/
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/admin')) {
return
}
const auth = useAuthStore()
await auth.ensureSession()
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
if (!auth.isAdmin) {
return navigateTo('/')
}
})

View File

@@ -13,7 +13,7 @@
<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>
<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="exporting ? 'cursor-not-allowed opacity-60' : ''"
title="Exporter en Excel"
@@ -23,7 +23,7 @@
</div>
</div>
<button
v-if="auth.isAdmin"
v-if="auth.isBureau"
type="button"
: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"

View File

@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
import type {UserData} from '~/services/dto/user-data'
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
import type {UserPayload} from "~/services/dto/user-data";
import {ROLE} from '~/utils/constants'
import {userHasRole} from '~/utils/roles'
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -12,7 +12,9 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
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: {
clearSession() {

View File

@@ -10,6 +10,7 @@ export const MERCHANDISE_TYPE_CODES = {
export const ROLE = [
{ label: 'Administrateur', value: 'ROLE_ADMIN' },
{ label: 'Bureau', value: 'ROLE_BUREAU' },
{ label: 'Utilisateur', value: 'ROLE_USER' }
]
export const SUPPLIER_CODE = {

38
frontend/utils/roles.ts Normal file
View 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)
}

View File

@@ -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.",
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: BovineInventoryExportProvider::class,
),

View File

@@ -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.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_BUREAU')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,

View File

@@ -81,7 +81,7 @@ class Bovine
#[ORM\Column(type: 'float', nullable: true)]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
private ?float $pricePerKg = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
@@ -177,7 +177,7 @@ class Bovine
}
#[Groups(['bovine:read', 'building_case:read'])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[ApiProperty(security: "is_granted('ROLE_BUREAU')")]
public function getFinalPrice(): ?float
{
if (null === $this->receivedWeight || null === $this->pricePerKg) {

View File

@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/receptions/weigh',

View File

@@ -61,6 +61,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
),
new Delete(
requirements: ['id' => '\d+'],
security: "is_granted('ROLE_ADMIN')",
),
new Get(
uriTemplate: '/shipments/weigh',