diff --git a/CLAUDE.md b/CLAUDE.md index 26cc978..4c5f907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -579,7 +579,26 @@ compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive, pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle -cet endpoint avec `role=VOLUNTEER`. +cet endpoint avec `role=VOLUNTEER`. Seule exception : envoyer un +récapitulatif de planning contenant `{lien_connexion}` vaut aussi « donner +un accès » (voir plus bas). + +*Lien de connexion permanent* — celui du récapitulatif de planning +(`{lien_connexion}`, `app/auth/permanent_link.py`) : connexion directe en +un clic, **réutilisable et sans expiration** (contrairement au lien magique +de la page de connexion, 30 min et usage unique), pour que le bénévole +retrouve son planning depuis n'importe quel email reçu. Rien n'est stocké : +jeton `pl..`, +vérifié par le même `POST .../auth/verify` (même page `/connexion?token=` +côté front). Bénévoles et référents seulement — jamais un compte de la +coordination ni à mot de passe. Révocation : bouton « Renouveler le lien +de connexion » sur la fiche (`POST .../accounts/{id}/reset-login-link`, +COORDINATION, incrémente `Account.login_link_version`, migration `0031`) — +tous les liens déjà envoyés à ce compte cessent de fonctionner ; changer +`JWT_SECRET` les révoque tous. Un bénévole sans compte en reçoit un +(`VOLUNTEER`, à son email) à l'envoi d'un récapitulatif avec +`{lien_connexion}` ; si son email est déjà celui d'un autre compte (couple, +famille), pas de compte possible : lien vers la page de connexion seule. `PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422 @@ -642,8 +661,9 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, - **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning` (COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body` sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`, - `{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est - émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles + `{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du + bénévole, voir « Lien de connexion permanent » plus haut — compte créé + s'il n'en a pas). `volunteer_ids` absent ⇒ tous les bénévoles de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email, recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de @@ -1098,6 +1118,7 @@ Aucune dépendance locale requise hors Docker. | 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider | | 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider | | 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider | +| 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py index 7dabecd..d7c6192 100644 --- a/backend/app/api/accounts.py +++ b/backend/app/api/accounts.py @@ -109,6 +109,24 @@ def update_account( return account +@router.post("/{account_id}/reset-login-link", response_model=AccountRead) +def reset_login_link( + account_id: uuid.UUID, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> Account: + """Révoque le lien de connexion permanent du compte (`app/auth/ + permanent_link.py`) : tous les liens déjà envoyés cessent de + fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex. + email transféré, téléphone perdu.""" + account = owned_or_404(session.get(Account, account_id), edition.id) + account.login_link_version += 1 + 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, diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e09e2dc..f9677e3 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -25,6 +25,8 @@ from app.api.schemas import ( from app.auth.deps import get_current_account from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.passwords import MIN_LENGTH, hash_password, verify_password +from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX +from app.auth.permanent_link import account_from_permanent_token from app.auth.rate_limit import login_limiter, magic_link_limiter from app.auth.tokens import create_access_token, hash_token from app.config import get_settings @@ -184,6 +186,16 @@ def verify_magic_link( payload: VerifyRequest, session: Session = Depends(get_session), ) -> TokenResponse: + raw = payload.token.strip() + if raw.startswith(PERMANENT_PREFIX): + # Lien permanent du récapitulatif de planning : réutilisable, sans + # expiration, révocable (voir `app/auth/permanent_link.py`). + account = account_from_permanent_token(session, raw) + if account is None or account.edition_id != edition_id: + logger.info("Lien permanent refusé (édition %s)", edition_id) + raise _invalid_token() + logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id) + return _issue(account) token = session.exec( select(MagicLinkToken).where( MagicLinkToken.token_hash == hash_token(payload.token.strip()) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 32a832e..458a3fc 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from email.message import EmailMessage from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy import func from sqlmodel import Session, select from app.api.deps import get_edition @@ -18,14 +19,15 @@ from app.api.schemas import ( TestEmailResult, ) from app.auth.deps import require_coordinator -from app.auth.magic_link import build_magic_link_url, issue_magic_link_token +from app.auth.magic_link import build_magic_link_url +from app.auth.permanent_link import permanent_login_token from app.config import get_settings from app.db import get_session from app.email import edition_logo, render_planning from app.email.html import render_planning_table from app.email.sender import build_message, deliver_test, send_messages from app.i18n import t -from app.models import Account, Assignment, DayPart, Edition, Position, Shift, Volunteer +from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer from app.validation.engine import day_boundary, festival_day logger = logging.getLogger("app.notifications") @@ -38,16 +40,48 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}" def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str: - """Lien magique personnel si le bénévole a un compte, sinon l'accueil.""" + """Lien de connexion **permanent** du bénévole (`app/auth/ + permanent_link.py` : connexion directe en un clic, réutilisable, sans + expiration, révocable). Envoyer ce lien vaut « donner un accès de + connexion » : un bénévole qui n'a pas encore de compte en reçoit un + (rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre + compte de l'édition (couple, famille partageant une adresse), pas de + compte possible : simple lien vers la page de connexion.""" account = session.exec( select(Account) .where(Account.edition_id == edition.id) .where(Account.volunteer_id == volunteer.id) - .where(Account.password_hash.is_(None)) ).first() - if account is None: - return build_magic_link_url("").split("?")[0] - return build_magic_link_url(issue_magic_link_token(session, account)) + if account is None and volunteer.email: + email = volunteer.email.strip() + taken = session.exec( + select(Account.id) + .where(Account.edition_id == edition.id) + .where(func.lower(Account.email) == email.lower()) + ).first() + if taken is None: + account = Account( + edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id + ) + session.add(account) + session.flush() + logger.info( + "Compte créé pour le lien de connexion : bénévole %s (compte %s)", + volunteer.id, + account.id, + ) + if ( + account is None + or account.password_hash is not None + or account.role not in (Role.VOLUNTEER, Role.LEAD) + ): + return _login_page_url() + return build_magic_link_url(permanent_login_token(account)) + + +def _login_page_url() -> str: + """Page de connexion, sans jeton.""" + return build_magic_link_url("").split("?")[0] # Repère du tableau des créneaux dans la version HTML (caractère d'usage @@ -222,7 +256,7 @@ def send_test_email( first_name=example.first_name if example else t("email.test.example_first_name"), last_name=example.last_name if example else t("email.test.example_last_name"), shifts=shifts_by_volunteer.get(example.id, []) if example else [], - login_link=build_magic_link_url("").split("?")[0], + login_link=_login_page_url(), ) message = build_message( email, @@ -300,7 +334,7 @@ def send_planning_notification( ) recipients.append(volunteer.id) - session.commit() # persiste les éventuels MagicLinkToken émis + session.commit() # persiste les comptes créés pour les liens de connexion logger.info( "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email", edition.id, diff --git a/backend/app/auth/permanent_link.py b/backend/app/auth/permanent_link.py new file mode 100644 index 0000000..98d87cb --- /dev/null +++ b/backend/app/auth/permanent_link.py @@ -0,0 +1,64 @@ +"""Lien de connexion permanent — celui du récapitulatif de planning +(`{lien_connexion}`, `app/api/notifications.py`). + +Contrairement au lien magique de la page de connexion (`magic_link.py` : +aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans +limite de durée : le bénévole retrouve son planning en un clic depuis +n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature +HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa +`login_link_version`, donc le même lien figure dans tous les emails d'un +bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/ +reset-login-link`) invalide tous les liens déjà envoyés à ce compte ; +changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement, +jamais la coordination ni un compte à mot de passe.""" + +import base64 +import hashlib +import hmac +import uuid + +from sqlmodel import Session + +from app.config import get_settings +from app.models import Account, Role + +# Jamais la coordination, même sans mot de passe : son accès complet ne +# doit pas tenir à un lien qui circule par email. +_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD) + +PREFIX = "pl." + + +def _signature(account_id: uuid.UUID, version: int) -> str: + digest = hmac.new( + get_settings().jwt_secret.encode("utf-8"), + f"permanent-login:{account_id}:{version}".encode(), + hashlib.sha256, + ).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def permanent_login_token(account: Account) -> str: + """Jeton en clair du lien permanent d'un compte (toujours le même tant + que sa version ne change pas).""" + return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}" + + +def account_from_permanent_token(session: Session, token: str) -> Account | None: + """Compte désigné par un jeton permanent valide, `None` sinon (format, + compte inconnu, signature ou version périmée, compte à mot de passe ou + de la coordination).""" + if not token.startswith(PREFIX): + return None + raw_id, _, signature = token[len(PREFIX) :].partition(".") + try: + account_id = uuid.UUID(hex=raw_id) + except ValueError: + return None + account = session.get(Account, account_id) + if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES: + return None + expected = _signature(account.id, account.login_link_version) + if not hmac.compare_digest(signature, expected): + return None + return account diff --git a/backend/app/migrations/versions/0031_account_login_link_version.py b/backend/app/migrations/versions/0031_account_login_link_version.py new file mode 100644 index 0000000..bce9423 --- /dev/null +++ b/backend/app/migrations/versions/0031_account_login_link_version.py @@ -0,0 +1,31 @@ +"""Lien de connexion permanent + +Revision ID: 0031_account_login_link_version +Revises: 0030_assignment_attendance +Create Date: 2026-10-28 + +`account.login_link_version` : version du lien de connexion permanent du +récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter +révoque tous les liens déjà envoyés à ce compte. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0031_account_login_link_version" +down_revision: str | None = "0030_assignment_attendance" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "account", + sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + op.drop_column("account", "login_link_version") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 96a799c..7e30354 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -294,6 +294,12 @@ class Account(TimestampMixin, table=True): nullable=True, ) + # Version du lien de connexion permanent (`app/auth/permanent_link.py`) : + # l'incrémenter révoque tous les liens déjà envoyés à ce compte. + login_link_version: int = Field( + default=0, sa_column=Column(Integer, nullable=False, server_default="0") + ) + @property def auth_method(self) -> str: return "password" if self.password_hash else "magic_link" diff --git a/backend/tests/test_api_notifications.py b/backend/tests/test_api_notifications.py index 4ea1f45..80dd036 100644 --- a/backend/tests/test_api_notifications.py +++ b/backend/tests/test_api_notifications.py @@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present( _url(edition), json={"body": "Connecte-toi : {lien_connexion}"} ) assert resp.status_code == 200 - assert "/connexion?token=" in plain_text(outbox[0]) - assert len(session.exec(select(MagicLinkToken)).all()) == 1 + assert "/connexion?token=pl." in plain_text(outbox[0]) + # Lien permanent : rien de stocké, pas de jeton à usage unique. + assert session.exec(select(MagicLinkToken)).all() == [] def test_no_magic_link_token_without_placeholder( @@ -129,3 +130,73 @@ def test_shifts_placeholder_is_a_table_by_day_with_the_referent( text = plain_text(message) assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text assert "09:00–12:00 · Parking · référent : à désigner" in text + + + +def _link(message) -> str: + import re + + return re.search(r"token=(\S+)", plain_text(message)).group(1) + + +def _verify(client, edition, token): + return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token}) + + +def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox): + ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr") + make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr") + client.post(_url(edition), json={"body": "{lien_connexion}"}) + client.post(_url(edition), json={"body": "{lien_connexion}"}) + first, second = (_link(m) for m in outbox) + # Le même lien dans chaque email, utilisable plusieurs fois. + assert first == second + for _ in range(3): + resp = _verify(client, edition, first) + assert resp.status_code == 200 + assert resp.json()["account"]["volunteer_id"] == str(ana.id) + + +def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox): + ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr") + account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr") + client.post(_url(edition), json={"body": "{lien_connexion}"}) + old = _link(outbox[0]) + + resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link") + assert resp.status_code == 200 + assert _verify(client, edition, old).status_code == 401 + + client.post(_url(edition), json={"body": "{lien_connexion}"}) + new = _link(outbox[1]) + assert new != old + assert _verify(client, edition, new).status_code == 200 + + +def test_sending_the_link_gives_an_account_to_a_volunteer_without_one( + client, edition, session, outbox +): + from app.models import Account + + ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr") + client.post(_url(edition), json={"body": "{lien_connexion}"}) + (account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all() + assert account.role == Role.VOLUNTEER + assert _verify(client, edition, _link(outbox[0])).status_code == 200 + + +def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox): + # Couple sur la même adresse : un seul compte possible pour cet email. + ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr") + make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr") + bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr") + client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"}) + assert "token=" not in plain_text(outbox[0]) + + +def test_forged_or_coordinator_links_are_refused(client, edition, coordinator): + from app.auth.permanent_link import permanent_login_token + + assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401 + assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401 + assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 9c2dc7e..0846ed9 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -351,6 +351,14 @@ export function useUpdateAccount() { }); } +export function useResetLoginLink() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.accounts.resetLoginLink(edition, id), + onSuccess: invalidate.accounts, + }); +} + export function useDeleteAccount() { const invalidate = useInvalidate(); return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 05f6f38..7f94543 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -183,6 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi { request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), remove: (editionId, id) => request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), + resetLoginLink: (editionId, id) => + request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }), }, imports: { analyze: (editionId, file: File) => { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 15e870d..6408362 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -1240,6 +1240,9 @@ export function createMockApi(): PlanningApi { account.updated_at = touch(); return delay(account); }, + // Pas de lien permanent en démo (liens magiques simulés) : rien à + // révoquer, le compte est renvoyé tel quel. + resetLoginLink: (_edition, id) => delay(state.accounts.find((a) => a.id === id) ?? notFound()), remove: (_edition, id) => { state.accounts = state.accounts.filter((a) => a.id !== id); return delay(undefined); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index eed84df..428fcf3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -111,6 +111,9 @@ export interface PlanningApi { /** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */ update(editionId: string, id: string, body: AccountUpdate): Promise; remove(editionId: string, id: string): Promise; + /** Révoque le lien de connexion permanent (récapitulatif de planning) : + * les liens déjà envoyés cessent de fonctionner. */ + resetLoginLink(editionId: string, id: string): Promise; }; imports: { /** Phase 1 : lit le fichier, propose un mapping des colonnes. */ diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index c5ddd5e..046ad09 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -263,7 +263,7 @@ "config.mail.subject": "OBJET", "config.mail.body": "CORPS DU MESSAGE", "config.mail.variables": "VARIABLES DISPONIBLES", - "config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.", + "config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère le lien de connexion directe permanent du bénévole (un clic suffit, sans limite de durée ; crée son accès s'il n'en a pas encore) — révocable depuis sa fiche. {creneaux} insère le tableau de ses créneaux.", "config.mail.send": "ENVOYER LE RÉCAPITULATIF", "config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION", "config.mail.sending": "ENVOI EN COURS…", @@ -327,11 +327,15 @@ "config.vol.photo.remove": "Retirer", "config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?", "config.vol.access.title": "Accès de connexion", - "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.", + "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : il faut un accès donné ici, ou l'envoi d'un récapitulatif de planning avec {lien_connexion} (qui en crée un).", "config.vol.access.grant": "Donner un accès de connexion", "config.vol.access.active": "Accès actif : {email}", "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.resetLink": "Renouveler le lien de connexion", + "config.vol.access.resetLinkHint": "Le lien de connexion des récapitulatifs de planning est permanent. Le renouveler rend inutilisables tous les liens déjà envoyés (email transféré, téléphone perdu…) ; le prochain récapitulatif en portera un nouveau.", + "config.vol.access.resetLinkConfirm": "Rendre inutilisables les liens de connexion déjà envoyés à ce bénévole ?", + "config.vol.access.resetLinkDone": "✓ Anciens liens révoqué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.", diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 8fafa2c..8cbaf2b 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -9,6 +9,7 @@ import { useCreateAccount, useCreateVolunteer, useDeleteAccount, + useResetLoginLink, useDeleteVolunteer, useExecuteImport, useExportMecenatReceipts, @@ -112,6 +113,7 @@ function VolunteerAccessSection({ const accounts = useAccounts(); const createAccount = useCreateAccount(); const deleteAccount = useDeleteAccount(); + const resetLoginLink = useResetLoginLink(); const account = accounts.data?.find((a) => a.volunteer_id === volunteerId); return ( @@ -157,8 +159,29 @@ function VolunteerAccessSection({ {t('config.vol.access.needEmail')} ) : null} + {account && account.auth_method === 'magic_link' ? ( +
+ + + {resetLoginLink.isSuccess ? ( + {t('config.vol.access.resetLinkDone')} + ) : null} +
+ ) : null} {createAccount.isError ? : null} {deleteAccount.isError ? : null} + {resetLoginLink.isError ? : null} ); }