Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions 3294b0c361 chore: bump version to v0.4.33
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 35s
2026-06-23 15:15:20 +00:00
matthieu 46e23874bd Merge pull request 'fix(rbac) : appliquer les permissions granulaires sur les ressources métier' (#19) from feat/rbac-enforcement into develop
Auto Tag Develop / tag (push) Successful in 12s
Reviewed-on: #19
2026-06-23 15:15:07 +00:00
Matthieu 4a7fd46493 fix(rbac) : add dedicated time-tracking.entries.manage permission
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m32s
La revue de sécurité a relevé que les écritures de TimeEntry (Post/Patch/Delete)
étaient gardées par time-tracking.entries.view : une permission de lecture
accordait l'écriture (confusion lecture/écriture, least-privilege).

- Ajout de la permission time-tracking.entries.manage (catalogue cohérent avec
  les autres modules en view/manage).
- Écritures TimeEntry recâblées sur entries.manage ; self-service conservé
  (object.getUser() == user). Lecture inchangée (entries.view).
2026-06-23 17:10:58 +02:00
Matthieu 5e3607658a refactor(directory) : reduce client/prospect forms to company name
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 4m39s
Les formulaires d'ajout/édition client et prospect ne conservent que le champ
« Nom société ». Les coordonnées (email, téléphone) et les champs prospect
(société, statut, source, notes) sont retirés : ils seront gérés via Contact.
Le statut prospect prend son défaut New à la création ; DTO assouplis, payload
réduit à { name }.
2026-06-23 17:06:04 +02:00
Matthieu 9705b335ef fix(rbac) : enforce granular permissions on business resources
Les ressources métier (ProjectManagement, Directory, TimeTracking) étaient
gardées par is_granted('ROLE_USER')/'ROLE_ADMIN', ignorant les permissions
RBAC granulaires déclarées par les modules : un utilisateur sans permission
voyait quand même projets, tâches, clients, etc.

- PermissionVoter : le regex excluait les tirets, donc project-management.* et
  time-tracking.* n'étaient supportées par aucun voter (refus pour tous, admin
  compris car le bypass ROLE_ADMIN est interne au voter). Ajout du tiret.
- Câblage des permissions *.view (lecture) / *.manage (écriture) sur les 17
  ressources métier. Métadonnées tâches lisibles via projects.view OR tasks.view.
  Directory partagé client/prospect via clients.* OR prospects.*. TimeEntry
  conserve le self-service (object.getUser() == user).
- Sidebar : gating par permission effective des onglets Projets / Mes tâches /
  Suivi du temps (config/sidebar.php).
- Test fonctionnel ProjectAccessControlTest (0 perm -> 403, view -> 200,
  view ne donne pas l'écriture -> 403).
2026-06-23 17:05:33 +02:00
gitea-actions 903030afbc chore: bump version to v0.4.32
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m58s
2026-06-23 14:25:40 +00:00
matthieu 961b7f56b4 Merge pull request 'Directory : save contacts/adresses au clic, denormalizer interface & seed RBAC au déploiement' (#18) from feat/directory-detail-save into develop
Auto Tag Develop / tag (push) Successful in 7s
Reviewed-on: #18
2026-06-23 14:25:33 +00:00
Matthieu 8e00c5f5a8 fix(deploy) : seed RBAC system roles during deployment
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m8s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m37s
deploy.sh only synced the permission catalog; the system roles (admin, user)
were never seeded, leaving the admin Roles page empty after a fresh deploy.
Add app:seed-rbac (idempotent) to the deploy script, refresh the embedded
script in deployment-docker.md, document the RBAC post-deploy step (with the
manual fix for an already-deployed prod), and note it in CLAUDE.md.
2026-06-23 16:11:58 +02:00
Matthieu f2d945b0c3 fix(directory) : persist contacts/addresses on explicit save instead of on blur
Hold contact/address block edits in memory and persist them via explicit
saveContacts/saveAddresses on click (with saving guards), matching the task
forms. Keep immediate deletion. Minor restyle of blocks and action buttons.
2026-06-23 16:04:02 +02:00
Matthieu 610e99eeb9 fix(api) : denormalize interface-typed relation collections
Add ContractRelationDenormalizer to resolve IRIs for collections typed
against an interface (Contract), fixing POST/PATCH 400 errors. Cover it
with InterfaceCollectionDenormalizationTest.
2026-06-23 16:03:32 +02:00
37 changed files with 593 additions and 284 deletions
+6
View File
@@ -126,6 +126,12 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN)
+3 -3
View File
@@ -23,9 +23,9 @@ return [
'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', 'module' => 'project-management'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.31'
app.version: '0.4.33'
+31 -1
View File
@@ -128,6 +128,12 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -294,7 +300,31 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
---
## RBAC : roles & permissions (post-deploiement)
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
les peuplent, integrees au `deploy.sh` :
| Commande | Effet |
|----------|-------|
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
```bash
cd /var/www/lesstime
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
```
---
@@ -6,21 +6,11 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom"
label="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<div class="mt-6 flex justify-end">
<MalioButton
@@ -58,28 +48,16 @@ const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.client) {
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
}
form.name = props.client?.name ?? ''
touched.name = false
touched.email = false
}
})
@@ -93,8 +71,6 @@ async function handleSubmit() {
try {
const payload: ClientWrite = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
}
if (isEditing.value && props.client) {
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-6 pt-6">
<!-- Formulaire d'ajout / édition -->
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
class="col-span-2"
:label="$t('directory.reports.fields.subject')"
@@ -50,8 +50,8 @@
</p>
</div>
<div v-if="isAdmin" class="flex gap-2">
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
<MalioButtonIcon icon="mdi:pencil-outline" variant="ghost" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:delete-outline" variant="ghost" :aria-label="$t('common.delete')" @click="remove(report.id)" />
</div>
</div>
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
@@ -1,13 +1,13 @@
<template>
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -1,13 +1,13 @@
<template>
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -6,41 +6,11 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prospects.fields.name')"
label="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.company"
:label="$t('prospects.fields.company')"
input-class="w-full"
/>
<MalioInputText
v-model="form.email"
:label="$t('prospects.fields.email')"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
:label="$t('prospects.fields.phone')"
input-class="w-full"
/>
<MalioSelect
v-model="form.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="form.source"
:label="$t('prospects.fields.source')"
input-class="w-full"
/>
<MalioInputTextArea
v-model="form.notes"
:label="$t('prospects.fields.notes')"
/>
<div class="mt-6 flex items-center justify-between gap-2">
<MalioButton
@@ -69,7 +39,7 @@
</template>
<script setup lang="ts">
import type { Prospect, ProspectStatus, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import type { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
const props = defineProps<{
@@ -82,8 +52,6 @@ const emit = defineEmits<{
(e: 'saved'): void
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
@@ -93,30 +61,8 @@ const isEditing = computed(() => !!props.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false)
const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const form = reactive<{
name: string
company: string
email: string
phone: string
status: ProspectStatus
source: string
notes: string
}>({
const form = reactive({
name: '',
company: '',
email: '',
phone: '',
status: 'new',
source: '',
notes: '',
})
const touched = reactive({
@@ -125,23 +71,7 @@ const touched = reactive({
watch(() => props.modelValue, (open) => {
if (open) {
if (props.prospect) {
form.name = props.prospect.name ?? ''
form.company = props.prospect.company ?? ''
form.email = props.prospect.email ?? ''
form.phone = props.prospect.phone ?? ''
form.status = props.prospect.status ?? 'new'
form.source = props.prospect.source ?? ''
form.notes = props.prospect.notes ?? ''
} else {
form.name = ''
form.company = ''
form.email = ''
form.phone = ''
form.status = 'new'
form.source = ''
form.notes = ''
}
form.name = props.prospect?.name ?? ''
touched.name = false
}
})
@@ -156,12 +86,6 @@ async function handleSubmit() {
try {
const payload: ProspectWrite = {
name: form.name.trim(),
company: form.company.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
status: form.status,
source: form.source.trim() || null,
notes: form.notes.trim() || null,
}
if (isEditing.value && props.prospect) {
@@ -6,10 +6,12 @@ import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string }
/**
* Logique partagée des fiches détail Client/Prospect : gestion des blocs
* répétables Contact et Adresse (chargement, ajout, édition par bloc avec
* persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
* (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService()
@@ -17,6 +19,8 @@ export function useDirectoryDetail(owner: Owner) {
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact {
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
@@ -25,54 +29,75 @@ export function useDirectoryDetail(owner: Owner) {
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
}
async function onContactInput(index: number, value: Contact): Promise<void> {
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
contacts.value[index] = value
await persistContact(index)
}
async function persistContact(index: number): Promise<void> {
const c = contacts.value[index]
if (!c) return
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
const created = await contactService.create(payload)
contacts.value[index] = created
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
}
function addContact(): void {
contacts.value.push(emptyContact())
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
// simplement retirée de la liste.
async function removeContact(index: number): Promise<void> {
const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id)
contacts.value.splice(index, 1)
}
async function onAddressInput(index: number, value: Address): Promise<void> {
addresses.value[index] = value
await persistAddress(index)
}
async function persistAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (!a) return
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
const created = await addressService.create(payload)
addresses.value[index] = created
}
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
async function removeAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id)
addresses.value.splice(index, 1)
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
if (savingContacts.value) return
savingContacts.value = true
try {
for (let i = 0; i < contacts.value.length; i++) {
const c = contacts.value[i]
if (!c) continue
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
contacts.value[i] = await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
contacts.value[i] = await contactService.create(payload)
}
}
} finally {
savingContacts.value = false
}
}
async function saveAddresses(): Promise<void> {
if (savingAddresses.value) return
savingAddresses.value = true
try {
for (let i = 0; i < addresses.value.length; i++) {
const a = addresses.value[i]
if (!a) continue
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
addresses.value[i] = await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
addresses.value[i] = await addressService.create(payload)
}
}
} finally {
savingAddresses.value = false
}
}
async function load(): Promise<void> {
contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner)
@@ -81,12 +106,16 @@ export function useDirectoryDetail(owner: Owner) {
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
}
}
@@ -19,13 +19,22 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
@@ -40,13 +49,22 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
@@ -76,12 +94,16 @@ const clientService = useClientService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
@@ -19,13 +19,22 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
@@ -40,13 +49,22 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
@@ -76,12 +94,16 @@ const prospectService = useProspectService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
@@ -8,6 +8,6 @@ export type Client = {
export type ClientWrite = {
name: string
email: string | null
phone: string | null
email?: string | null
phone?: string | null
}
@@ -19,10 +19,10 @@ export type Prospect = {
export type ProspectWrite = {
name: string
company: string | null
email: string | null
phone: string | null
status: ProspectStatus
source: string | null
notes: string | null
company?: string | null
email?: string | null
phone?: string | null
status?: ProspectStatus
source?: string | null
notes?: string | null
}
+3
View File
@@ -27,6 +27,9 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
@@ -14,7 +14,9 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
final class PermissionVoter extends Voter
{
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
// Les codes de permission sont au format module.resource.action où chaque
// segment peut contenir des tirets (ex. project-management, time-tracking).
private const string PATTERN = '/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
@@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
],
normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']],
@@ -25,11 +25,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view')"),
new Get(security: "is_granted('directory.clients.view')"),
new Post(security: "is_granted('directory.clients.manage')"),
new Patch(security: "is_granted('directory.clients.manage')"),
new Delete(security: "is_granted('directory.clients.manage')"),
],
normalizationContext: ['groups' => ['client:read']],
denormalizationContext: ['groups' => ['client:write']],
@@ -26,11 +26,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
],
normalizationContext: ['groups' => ['commercial_report:read']],
denormalizationContext: ['groups' => ['commercial_report:write']],
@@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
],
normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']],
@@ -27,14 +27,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.prospects.view')"),
new Post(security: "is_granted('directory.prospects.manage')"),
new Patch(security: "is_granted('directory.prospects.manage')"),
new Delete(security: "is_granted('directory.prospects.manage')"),
new Post(
uriTemplate: '/prospects/{id}/convert',
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('directory.prospects.manage')",
processor: ConvertProspectProcessor::class,
),
],
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')",
processor: ReportDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
],
normalizationContext: ['groups' => ['report_document:read']],
denormalizationContext: ['groups' => ['report_document:write']],
@@ -30,18 +30,18 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view')"),
new Get(security: "is_granted('project-management.projects.view')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('project-management.projects.manage')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],
),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage')"),
new Delete(security: "is_granted('project-management.projects.manage')"),
new Post(
uriTemplate: '/projects/{id}/switch-workflow',
uriVariables: ['id' => new Link(fromClass: Project::class)],
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('project-management.projects.manage')",
input: false,
output: SwitchWorkflowOutput::class,
normalizationContext: ['groups' => ['switch_workflow:read']],
@@ -33,11 +33,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.tasks.manage')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class),
],
normalizationContext: ['groups' => ['task:read']],
denormalizationContext: ['groups' => ['task:write']],
@@ -21,14 +21,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('project-management.tasks.manage')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_effort:read']],
denormalizationContext: ['groups' => ['task_effort:write']],
@@ -19,11 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_group:read']],
denormalizationContext: ['groups' => ['task_group:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_priority:read']],
denormalizationContext: ['groups' => ['task_priority:write']],
@@ -20,11 +20,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_recurrence:read']],
denormalizationContext: ['groups' => ['task_recurrence:write']],
@@ -18,11 +18,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_status:read']],
denormalizationContext: ['groups' => ['task_status:write']],
@@ -17,11 +17,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
],
normalizationContext: ['groups' => ['task_tag:read']],
denormalizationContext: ['groups' => ['task_tag:write']],
@@ -21,11 +21,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
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')"),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')", processor: WorkflowDeleteProcessor::class),
],
normalizationContext: ['groups' => ['workflow:read']],
denormalizationContext: ['groups' => ['workflow:write']],
@@ -31,13 +31,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(security: "is_granted('time-tracking.entries.view')"),
new GetCollection(
name: 'time_entries_range',
uriTemplate: '/time_entries/range',
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
security: "is_granted('time-tracking.entries.view')",
),
new GetCollection(
name: 'active_time_entry',
@@ -45,12 +45,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
provider: ActiveTimeEntryProvider::class,
description: 'Get the active timer for the current user',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
security: "is_granted('time-tracking.entries.view')",
),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Get(security: "is_granted('time-tracking.entries.view')"),
new Post(security: "is_granted('time-tracking.entries.manage')"),
new Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
new Delete(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
],
normalizationContext: ['groups' => ['time_entry:read']],
denormalizationContext: ['groups' => ['time_entry:write']],
@@ -26,15 +26,13 @@ final class TimeTrackingModule implements ModuleInterface
/**
* Permissions RBAC fin du Module TimeTracking (2.1).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
['code' => 'time-tracking.entries.manage', 'label' => 'Gérer les saisies de temps'],
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
];
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Throwable;
use function array_key_exists;
use function is_string;
/**
* Modular monolith: cross-module relations are typed with a Shared\Domain\Contract
* interface (e.g. UserInterface, TaskTagInterface) instead of the concrete entity,
* to keep modules decoupled. Doctrine maps those back to the concrete entity through
* resolve_target_entities.
*
* API Platform denormalizes *single* interface relations fine (the concrete class is
* derived from the IRI), but blows up on *collections*: the collection value type stays
* the interface, which is not a registered API resource, so no normalizer supports it
* and the request fails with NotNormalizableValueException.
*
* This denormalizer bridges that gap for every contract interface, reusing Doctrine's
* resolve_target_entities mapping (no per-entity config):
* - a string value is an IRI -> resolved through the IriConverter
* - an array value is an embedded object -> denormalized into the concrete entity
*/
final class ContractRelationDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
private const CONTRACT_NAMESPACE = 'App\Shared\Domain\Contract\\';
/** @var array<string, ?class-string> */
private array $resolved = [];
public function __construct(
private readonly IriConverterInterface $iriConverter,
private readonly EntityManagerInterface $entityManager,
) {}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return null !== $this->concreteClassFor($type);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?object
{
$concrete = $this->concreteClassFor($type);
if (null === $concrete) {
return null;
}
if (is_string($data)) {
return $this->iriConverter->getResourceFromIri($data, $context);
}
// Embedded object payload: denormalize into the resolved concrete entity.
return $this->denormalizer->denormalize($data, $concrete, $format, $context);
}
public function getSupportedTypes(?string $format): array
{
// Support depends on the runtime-resolved Doctrine mapping, so it cannot be
// statically cached by the serializer.
return ['object' => false];
}
/**
* @return ?class-string the concrete entity a contract interface resolves to, or null
*/
private function concreteClassFor(string $type): ?string
{
if (array_key_exists($type, $this->resolved)) {
return $this->resolved[$type];
}
if (!str_starts_with($type, self::CONTRACT_NAMESPACE) || !interface_exists($type)) {
return $this->resolved[$type] = null;
}
try {
$name = $this->entityManager->getClassMetadata($type)->getName();
} catch (Throwable) {
// Not a Doctrine-mapped (resolve_target_entities) interface.
return $this->resolved[$type] = null;
}
return $this->resolved[$type] = ($name !== $type ? $name : null);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que les ressources métier sont bien gardées par les permissions RBAC
* granulaires et non plus par le simple ROLE_USER.
*
* @internal
*/
final class ProjectAccessControlTest extends WebTestCase
{
public function testAuthenticatedUserWithoutPermissionIsForbidden(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->createPlainUser($em, 'proj-noperm-'.uniqid());
$em->flush();
$client->loginUser($user);
$client->request('GET', '/api/projects');
self::assertResponseStatusCodeSame(403);
}
public function testUserWithViewPermissionCanListProjects(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']);
self::assertInstanceOf(Permission::class, $permission, 'Le catalogue de permissions doit contenir project-management.projects.view (lancer app:sync-permissions).');
$user = $this->createPlainUser($em, 'proj-view-'.uniqid());
$user->addDirectPermission($permission);
$em->flush();
$client->loginUser($user);
$client->request('GET', '/api/projects');
self::assertResponseIsSuccessful();
}
public function testViewPermissionDoesNotGrantWrite(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']);
self::assertInstanceOf(Permission::class, $permission);
$user = $this->createPlainUser($em, 'proj-noWrite-'.uniqid());
$user->addDirectPermission($permission);
$em->flush();
$client->loginUser($user);
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode(['name' => 'Should be denied']));
self::assertResponseStatusCodeSame(403);
}
private function createPlainUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Shared;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Regression: cross-module to-many relations are typed with a Shared contract
* interface (TaskTagInterface[], UserInterface[]). API Platform cannot
* denormalize a collection whose value type is an interface (no resource
* normalizer supports it), so every POST/PATCH carrying such a collection
* blew up with NotNormalizableValueException.
*
* @internal
*/
final class InterfaceCollectionDenormalizationTest extends WebTestCase
{
protected function tearDown(): void
{
$conn = self::getContainer()->get(EntityManagerInterface::class)->getConnection();
$conn->executeStatement("DELETE FROM time_entry_task_type WHERE time_entry_id IN (SELECT id FROM time_entry WHERE title = 'iface-denorm-te')");
$conn->executeStatement("DELETE FROM time_entry WHERE title = 'iface-denorm-te'");
$conn->executeStatement("DELETE FROM task_collaborator WHERE task_id IN (SELECT id FROM task WHERE title = 'iface-denorm-task')");
$conn->executeStatement("DELETE FROM task WHERE title = 'iface-denorm-task'");
parent::tearDown();
}
public function testPostTimeEntryWithInterfaceTypedTags(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$userId = $this->adminId($em);
$tagId = $this->aTaskTagId($em);
$client->request('POST', '/api/time_entries', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], content: json_encode([
'title' => 'iface-denorm-te',
'startedAt' => '2026-06-22T10:00:00+02:00',
'stoppedAt' => '2026-06-22T11:00:00+02:00',
'user' => '/api/users/'.$userId,
'tags' => ['/api/task_tags/'.$tagId],
]));
self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: '');
$data = json_decode($client->getResponse()->getContent(), true);
self::assertCount(1, $data['tags'] ?? []);
}
public function testPostTaskWithInterfaceTypedCollaborators(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$userId = $this->adminId($em);
$projectId = $this->aProjectId($em);
$client->request('POST', '/api/tasks', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], content: json_encode([
'title' => 'iface-denorm-task',
'project' => '/api/projects/'.$projectId,
'collaborators' => ['/api/users/'.$userId],
]));
self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: '');
$data = json_decode($client->getResponse()->getContent(), true);
self::assertCount(1, $data['collaborators'] ?? []);
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function adminId(EntityManagerInterface $em): int
{
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
private function aTaskTagId(EntityManagerInterface $em): int
{
$tag = $em->getRepository(TaskTag::class)->findOneBy([]);
if (null === $tag) {
$tag = new TaskTag();
$tag->setLabel('iface-denorm-tag');
$em->persist($tag);
$em->flush();
}
return $tag->getId();
}
private function aProjectId(EntityManagerInterface $em): int
{
$project = $em->getRepository(Project::class)->findOneBy([]);
self::assertInstanceOf(Project::class, $project);
return $project->getId();
}
}