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:
co-authored by
Claude Sonnet 5
parent
db11d9a3ba
commit
8fcc22115f
@@ -93,7 +93,15 @@ les tables sauf les tables d'association.
|
||||
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
|
||||
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
|
||||
- **`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
|
||||
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
|
||||
@@ -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
|
||||
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/`)
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
| 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
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
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.passwords import MIN_LENGTH, hash_password
|
||||
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)
|
||||
def delete_account(
|
||||
account_id: uuid.UUID,
|
||||
|
||||
@@ -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
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
@@ -21,13 +26,28 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Edition, Position
|
||||
from app.models import Account, Edition, Position, Role
|
||||
|
||||
router = APIRouter(
|
||||
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)
|
||||
def create_position(
|
||||
payload: PositionCreate,
|
||||
@@ -35,6 +55,7 @@ def create_position(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Position:
|
||||
_check_lead_account(session, edition, payload.lead_account_id)
|
||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||
session.add(position)
|
||||
session.flush()
|
||||
@@ -80,7 +101,9 @@ def update_position(
|
||||
) -> Position:
|
||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
||||
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)
|
||||
session.commit()
|
||||
session.refresh(position)
|
||||
|
||||
@@ -363,6 +363,12 @@ class AccountCreate(ApiModel):
|
||||
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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"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.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.duplicate": "Un artiste porte déjà ce nom à cette date dans l'é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.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.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.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/positions{suffix}"
|
||||
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
|
||||
assert (
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { EDITION_ID, api } from './index';
|
||||
import type {
|
||||
AccountCreate,
|
||||
AccountUpdate,
|
||||
ArtistCreate,
|
||||
ArtistUpdate,
|
||||
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() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AccountCreate,
|
||||
AccountUpdate,
|
||||
ArtistCreate,
|
||||
ArtistUpdate,
|
||||
AssignmentCreate,
|
||||
@@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||
create: (editionId, body: AccountCreate) =>
|
||||
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) =>
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
|
||||
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
||||
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] {
|
||||
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
||||
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
||||
@@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi {
|
||||
state.accounts.push(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) => {
|
||||
state.accounts = state.accounts.filter((a) => a.id !== id);
|
||||
return delay(undefined);
|
||||
@@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi {
|
||||
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
|
||||
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
|
||||
create: (_edition, body) => {
|
||||
checkLeadAccount(body.lead_account_id);
|
||||
const now = touch();
|
||||
const position = {
|
||||
id: `p${state.seq++}`,
|
||||
@@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi {
|
||||
},
|
||||
update: (_edition, id, body) => {
|
||||
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
||||
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
||||
Object.assign(p, {
|
||||
...('name' in body ? { name: body.name } : {}),
|
||||
...('location' in body ? { location: body.location ?? null } : {}),
|
||||
|
||||
@@ -421,6 +421,11 @@ export interface AccountCreate {
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
export interface AccountUpdate {
|
||||
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AccountCreate,
|
||||
AccountRead,
|
||||
AccountUpdate,
|
||||
ArtistCreate,
|
||||
ArtistRead,
|
||||
ArtistUpdate,
|
||||
@@ -73,6 +74,8 @@ export interface PlanningApi {
|
||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||
list(editionId: string): 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>;
|
||||
};
|
||||
availability: {
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
"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.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.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_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_token": "Ce lien de connexion est invalide ou expiré",
|
||||
"role.coordinator": "Coordination",
|
||||
@@ -234,6 +236,10 @@
|
||||
"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.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.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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import { roleLabelKey } from '@/auth/roles';
|
||||
import {
|
||||
useAccounts,
|
||||
useAnalyzeImport,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
useExecuteImport,
|
||||
usePositions,
|
||||
useReplaceAvailability,
|
||||
useUpdateAccount,
|
||||
useUpdatePosition,
|
||||
useUpdateVolunteer,
|
||||
useVolunteerAvailability,
|
||||
useVolunteers,
|
||||
@@ -115,6 +118,9 @@ function VolunteerAccessSection({
|
||||
<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]">
|
||||
{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>
|
||||
<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
|
||||
* longue) — options déjà triées par ordre alphabétique par l'appelant. */
|
||||
function WishPicker({
|
||||
@@ -235,12 +351,14 @@ function BadgeWishPicker({
|
||||
selected,
|
||||
onChange,
|
||||
emptyLabel,
|
||||
disabled,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
options: WishOption[];
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
emptyLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toggle = (id: string) =>
|
||||
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
||||
@@ -250,7 +368,7 @@ function BadgeWishPicker({
|
||||
{options.length === 0 ? (
|
||||
<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) => (
|
||||
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
|
||||
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
||||
@@ -489,6 +607,7 @@ export function ConfigVolunteers() {
|
||||
</label>
|
||||
|
||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||
|
||||
<BadgeWishPicker
|
||||
label={t('config.vol.field.artists')}
|
||||
|
||||
Reference in New Issue
Block a user