Gestion des référents depuis la fiche bénévole

Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 23:19:56 +02:00
co-authored by Claude Sonnet 5
parent db11d9a3ba
commit 8fcc22115f
14 changed files with 373 additions and 7 deletions
+17 -1
View File
@@ -93,7 +93,15 @@ les tables sauf les tables d'association.
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <> unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
companion_id)` : un bénévole ne peut pas se souhaiter lui-même. companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
- **`Position`** : `name`, `location`, `category` (`PositionCategory`), - **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
section « Référent des postes ») plutôt que depuis le poste : cocher un
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
retirer le poste, sans jamais rétrograder le compte automatiquement.
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at` il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un (peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
@@ -188,6 +196,13 @@ pour ne jamais révéler si un compte existe). Front : bouton « Donner un accè
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`. cet endpoint avec `role=VOLUNTEER`.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
met à niveau le compte d'un bénévole en référent quand on le désigne
référent d'un poste (voir `Position.lead_account_id` plus haut).
### Emails (`app/email/`) ### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒ Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
@@ -495,6 +510,7 @@ Aucune dépendance locale requise hors Docker.
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider | | 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider | | 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider | | 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+25 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import AccountCreate, AccountRead from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.passwords import MIN_LENGTH, hash_password from app.auth.passwords import MIN_LENGTH, hash_password
from app.db import get_session from app.db import get_session
@@ -85,6 +85,30 @@ def list_accounts(
) )
@router.patch("/{account_id}", response_model=AccountRead)
def update_account(
account_id: uuid.UUID,
payload: AccountUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
(lien magique dans les deux cas) : devenir référent d'un poste (voir
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
mot de passe d'un compte COORDINATION."""
account = owned_or_404(session.get(Account, account_id), edition.id)
if payload.role is not None and payload.role != account.role:
if Role.COORDINATOR in (account.role, payload.role):
raise HTTPException(
status_code=422, detail={"code": "account.role_change_not_allowed"}
)
account.role = payload.role
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account( def delete_account(
account_id: uuid.UUID, account_id: uuid.UUID,
+27 -4
View File
@@ -1,8 +1,13 @@
"""CRUD des postes.""" """CRUD des postes.
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
import uuid import uuid
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404
@@ -21,13 +26,28 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session from app.db import get_session
from app.models import Account, Edition, Position from app.models import Account, Edition, Position, Role
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"] prefix="/editions/{edition_id}/positions", tags=["positions"]
) )
def _check_lead_account(
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
) -> None:
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
cette même édition — sinon le poste se retrouve sans référent utilisable
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
if lead_account_id is None:
return
lead = session.get(Account, lead_account_id)
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
raise HTTPException(
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
)
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
def create_position( def create_position(
payload: PositionCreate, payload: PositionCreate,
@@ -35,6 +55,7 @@ def create_position(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Position: ) -> Position:
_check_lead_account(session, edition, payload.lead_account_id)
position = Position(edition_id=edition.id, **payload.model_dump()) position = Position(edition_id=edition.id, **payload.model_dump())
session.add(position) session.add(position)
session.flush() session.flush()
@@ -80,7 +101,9 @@ def update_position(
) -> Position: ) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id) position = owned_or_404(session.get(Position, position_id), edition.id)
ensure_can_write_position(account, position) ensure_can_write_position(account, position)
for field, value in payload.model_dump(exclude_unset=True).items(): changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id"))
for field, value in changes.items():
setattr(position, field, value) setattr(position, field, value)
session.commit() session.commit()
session.refresh(position) session.refresh(position)
+6
View File
@@ -363,6 +363,12 @@ class AccountCreate(ApiModel):
password: str | None = None password: str | None = None
class AccountUpdate(ApiModel):
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
role: Role | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Notifications par email # # Notifications par email #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+2
View File
@@ -10,6 +10,7 @@
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.", "day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.", "artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.", "artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.", "artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
@@ -25,6 +26,7 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.", "account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.", "account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.", "account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
"import.error.empty_file": "Le fichier est vide.", "import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.", "import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.", "import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
+51
View File
@@ -0,0 +1,51 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/accounts{suffix}"
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 200
assert resp.json()["role"] == "LEAD"
assert resp.json()["auth_method"] == "magic_link"
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
account = make_account(Role.LEAD, email="l@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
assert resp.status_code == 200
assert resp.json()["role"] == "VOLUNTEER"
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_unknown_account_is_404(client, edition):
assert (
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
== 404
)
def test_volunteer_role_forbidden(edition, make_account, as_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
api = as_account(volunteer_account)
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
assert resp.status_code == 403
+74
View File
@@ -1,5 +1,7 @@
import uuid import uuid
from app.models import Role
def _url(edition, suffix=""): def _url(edition, suffix=""):
return f"/editions/{edition.id}/positions{suffix}" return f"/editions/{edition.id}/positions{suffix}"
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
assert ( assert (
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404 client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
) )
def test_sets_a_referent(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent@ex.fr")
created = client.post(
_url(edition),
json={"name": "Bar", "category": "BAR"},
)
pid = created.json()["id"]
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] == str(lead.id)
def test_referent_must_have_the_lead_role(client, edition, make_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
import datetime as dt
from app.models import Edition
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
session.add(other_edition)
session.commit()
session.refresh(other_edition)
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
other_lead.edition_id = other_edition.id
session.add(other_lead)
session.commit()
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_unknown_referent_is_422(client, edition):
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_clearing_the_referent_is_allowed(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent2@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] is None
+10
View File
@@ -8,6 +8,7 @@ import {
import { EDITION_ID, api } from './index'; import { EDITION_ID, api } from './index';
import type { import type {
AccountCreate, AccountCreate,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistUpdate, ArtistUpdate,
AssignmentCreate, AssignmentCreate,
@@ -253,6 +254,15 @@ export function useCreateAccount() {
}); });
} }
export function useUpdateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
api.accounts.update(edition, id, body),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() { export function useDeleteAccount() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
+3
View File
@@ -1,5 +1,6 @@
import type { import type {
AccountCreate, AccountCreate,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistUpdate, ArtistUpdate,
AssignmentCreate, AssignmentCreate,
@@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
list: (editionId) => request(`${base(editionId)}/accounts`), list: (editionId) => request(`${base(editionId)}/accounts`),
create: (editionId, body: AccountCreate) => create: (editionId, body: AccountCreate) =>
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }), request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AccountUpdate) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
}, },
+24
View File
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
return resolved; return resolved;
} }
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
* existant — sinon le poste se retrouve sans référent utilisable. */
function checkLeadAccount(leadAccountId: string | null | undefined): void {
if (leadAccountId == null) return;
const lead = state.accounts.find((a) => a.id === leadAccountId);
if (!lead || lead.role !== 'LEAD') {
throw new ApiError(422, 'position.lead_must_be_referent_account');
}
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] { function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day; const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`]; return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi {
state.accounts.push(account); state.accounts.push(account);
return delay(account); return delay(account);
}, },
update: (_edition, id, body) => {
const account = state.accounts.find((a) => a.id === id) ?? notFound();
if (body.role != null && body.role !== account.role) {
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
throw new ApiError(422, 'account.role_change_not_allowed');
}
account.role = body.role;
account.auth_method = 'magic_link';
}
account.updated_at = touch();
return delay(account);
},
remove: (_edition, id) => { remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id); state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined); return delay(undefined);
@@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi {
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()), get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => { create: (_edition, body) => {
checkLeadAccount(body.lead_account_id);
const now = touch(); const now = touch();
const position = { const position = {
id: `p${state.seq++}`, id: `p${state.seq++}`,
@@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi {
}, },
update: (_edition, id, body) => { update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound(); const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
Object.assign(p, { Object.assign(p, {
...('name' in body ? { name: body.name } : {}), ...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}), ...('location' in body ? { location: body.location ?? null } : {}),
+5
View File
@@ -421,6 +421,11 @@ export interface AccountCreate {
password?: string | null; password?: string | null;
} }
export interface AccountUpdate {
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
role?: Role;
}
export interface TokenResponse { export interface TokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
+3
View File
@@ -1,6 +1,7 @@
import type { import type {
AccountCreate, AccountCreate,
AccountRead, AccountRead,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistRead, ArtistRead,
ArtistUpdate, ArtistUpdate,
@@ -73,6 +74,8 @@ export interface PlanningApi {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
list(editionId: string): Promise<AccountRead[]>; list(editionId: string): Promise<AccountRead[]>;
create(editionId: string, body: AccountCreate): Promise<AccountRead>; create(editionId: string, body: AccountCreate): Promise<AccountRead>;
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
}; };
availability: { availability: {
+6
View File
@@ -15,10 +15,12 @@
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
"shift.ends_not_after_starts": "La fin doit être après le début", "shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent de cette édition",
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole", "account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole",
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition", "account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition",
"account.password_required": "Un compte coordination requiert un mot de passe", "account.password_required": "Un compte coordination requiert un mot de passe",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe", "account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi",
"auth.invalid_credentials": "Email ou mot de passe incorrect", "auth.invalid_credentials": "Email ou mot de passe incorrect",
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré", "auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
"role.coordinator": "Coordination", "role.coordinator": "Coordination",
@@ -234,6 +236,10 @@
"config.vol.access.revoke": "Révoquer", "config.vol.access.revoke": "Révoquer",
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?", "config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.", "config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes",
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
"config.vol.referentIsCoordinator": "Ce bénévole a un compte coordination : il a déjà accès à tous les postes, inutile de le désigner référent.",
"config.vol.referentNeedsEmail": "Renseignez un email sur la fiche pour pouvoir désigner ce bénévole référent.",
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
"config.vol.create": "CRÉER LE BÉNÉVOLE", "config.vol.create": "CRÉER LE BÉNÉVOLE",
+120 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
import { import {
useAccounts, useAccounts,
useAnalyzeImport, useAnalyzeImport,
@@ -13,6 +14,8 @@ import {
useExecuteImport, useExecuteImport,
usePositions, usePositions,
useReplaceAvailability, useReplaceAvailability,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer, useUpdateVolunteer,
useVolunteerAvailability, useVolunteerAvailability,
useVolunteers, useVolunteers,
@@ -115,6 +118,9 @@ function VolunteerAccessSection({
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]"> <div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
<span className="min-w-0 truncate font-mono text-[10px]"> <span className="min-w-0 truncate font-mono text-[10px]">
{t('config.vol.access.active', { email: account.email })} {t('config.vol.access.active', { email: account.email })}
<span className="ml-[6px] border-[1.5px] border-ink bg-yellow px-1 py-[1px] font-display text-[8px] font-extrabold uppercase tracking-wide">
{t(roleLabelKey(account.role))}
</span>
</span> </span>
<button <button
type="button" type="button"
@@ -152,6 +158,116 @@ function VolunteerAccessSection({
); );
} }
/**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
* à sa prochaine connexion il verra l'écran référent, pas l'écran bénévole
* (voir `auth/roles.ts:homePath`). Décocher retire juste son nom du poste,
* sans jamais rétrograder son accès automatiquement.
*/
function VolunteerReferentSection({
volunteerId,
email,
}: {
volunteerId: string;
email: string | null;
}) {
const accounts = useAccounts();
const positions = usePositions();
const createAccount = useCreateAccount();
const updateAccount = useUpdateAccount();
const updatePosition = useUpdatePosition();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
const isCoordinator = account?.role === 'COORDINATOR';
const options = useMemo(() => positionOptions(positions.data), [positions.data]);
const referentIds = (positions.data ?? [])
.filter((p) => account && p.lead_account_id === account.id)
.map((p) => p.id);
const ensureLeadAccountId = async (): Promise<string> => {
if (account) {
if (account.role !== 'LEAD') {
await updateAccount.mutateAsync({ id: account.id, body: { role: 'LEAD' } });
}
return account.id;
}
if (!email) throw new ApiError(422, 'config.vol.referentNeedsEmail');
const created = await createAccount.mutateAsync({
email,
role: 'LEAD',
volunteer_id: volunteerId,
});
return created.id;
};
const handleChange = (nextIds: string[]) => {
const added = nextIds.filter((id) => !referentIds.includes(id));
const removed = referentIds.filter((id) => !nextIds.includes(id));
if (added.length === 0 && removed.length === 0) return;
setError(null);
setPending(true);
void (async () => {
try {
let leadAccountId: string | null = null;
if (added.length > 0) leadAccountId = await ensureLeadAccountId();
for (const id of added) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: leadAccountId } });
}
for (const id of removed) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: null } });
}
} catch (err) {
setError(err instanceof ApiError ? t(err.code) : t('error.generic'));
} finally {
setPending(false);
}
})();
};
const label = (
<>
{t('config.vol.field.referentPositions')} <HelpTip text={t('config.vol.referentHint')} />
</>
);
if (isCoordinator) {
return (
<div>
<FieldLabel>{label}</FieldLabel>
<div className="font-mono text-[10px] opacity-60">
{t('config.vol.referentIsCoordinator')}
</div>
</div>
);
}
return (
<div>
<BadgeWishPicker
label={label}
options={options}
selected={referentIds}
onChange={handleChange}
emptyLabel={t('config.vol.noPositions')}
disabled={pending}
/>
{referentIds.length === 0 && !email ? (
<div className="mt-1 font-mono text-[9.5px] opacity-60">
{t('config.vol.access.needEmail')}
</div>
) : null}
{error ? (
<div className="mt-1 border-2 border-ink bg-orange p-[7px] font-display text-[10px] font-bold">
{error}
</div>
) : null}
</div>
);
}
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement /** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
* longue) — options déjà triées par ordre alphabétique par l'appelant. */ * longue) — options déjà triées par ordre alphabétique par l'appelant. */
function WishPicker({ function WishPicker({
@@ -235,12 +351,14 @@ function BadgeWishPicker({
selected, selected,
onChange, onChange,
emptyLabel, emptyLabel,
disabled,
}: { }: {
label: ReactNode; label: ReactNode;
options: WishOption[]; options: WishOption[];
selected: string[]; selected: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
emptyLabel: string; emptyLabel: string;
disabled?: boolean;
}) { }) {
const toggle = (id: string) => const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
@@ -250,7 +368,7 @@ function BadgeWishPicker({
{options.length === 0 ? ( {options.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span> <span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : ( ) : (
<div className="flex flex-wrap gap-[6px]"> <div className={cx('flex flex-wrap gap-[6px]', disabled && 'pointer-events-none opacity-50')}>
{options.map((o) => ( {options.map((o) => (
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}> <Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
{o.hint ? `${o.hint} · ${o.label}` : o.label} {o.hint ? `${o.hint} · ${o.label}` : o.label}
@@ -489,6 +607,7 @@ export function ConfigVolunteers() {
</label> </label>
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} /> <VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
<BadgeWishPicker <BadgeWishPicker
label={t('config.vol.field.artists')} label={t('config.vol.field.artists')}