Lien de connexion directe permanent dans le récapitulatif de planning
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s

{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 20:01:03 +01:00
co-authored by Claude Opus 5.5
parent 8d6781e6ce
commit 05cdb0276b
14 changed files with 316 additions and 16 deletions
+24 -3
View File
@@ -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, 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 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 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.<id du compte>.<HMAC(JWT_SECRET, id + login_link_version)>`,
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 `PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422 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` - **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body` (COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`, sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est `{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles 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`, 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, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+18
View File
@@ -109,6 +109,24 @@ def update_account(
return 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) @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,
+12
View File
@@ -25,6 +25,8 @@ from app.api.schemas import (
from app.auth.deps import get_current_account 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.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.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.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
@@ -184,6 +186,16 @@ def verify_magic_link(
payload: VerifyRequest, payload: VerifyRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TokenResponse: ) -> 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( token = session.exec(
select(MagicLinkToken).where( select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip()) MagicLinkToken.token_hash == hash_token(payload.token.strip())
+43 -9
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -18,14 +19,15 @@ from app.api.schemas import (
TestEmailResult, TestEmailResult,
) )
from app.auth.deps import require_coordinator 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.config import get_settings
from app.db import get_session from app.db import get_session
from app.email import edition_logo, render_planning from app.email import edition_logo, render_planning
from app.email.html import render_planning_table from app.email.html import render_planning_table
from app.email.sender import build_message, deliver_test, send_messages from app.email.sender import build_message, deliver_test, send_messages
from app.i18n import t 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 from app.validation.engine import day_boundary, festival_day
logger = logging.getLogger("app.notifications") logger = logging.getLogger("app.notifications")
@@ -38,16 +40,48 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str: 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( account = session.exec(
select(Account) select(Account)
.where(Account.edition_id == edition.id) .where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id) .where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first() ).first()
if account is None: if account is None and volunteer.email:
return build_magic_link_url("").split("?")[0] email = volunteer.email.strip()
return build_magic_link_url(issue_magic_link_token(session, account)) 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 # 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"), 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"), last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [], 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( message = build_message(
email, email,
@@ -300,7 +334,7 @@ def send_planning_notification(
) )
recipients.append(volunteer.id) 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( logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email", "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id, edition.id,
+64
View File
@@ -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
@@ -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")
+6
View File
@@ -294,6 +294,12 @@ class Account(TimestampMixin, table=True):
nullable=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 @property
def auth_method(self) -> str: def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link" return "password" if self.password_hash else "magic_link"
+73 -2
View File
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"} _url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "/connexion?token=" in plain_text(outbox[0]) assert "/connexion?token=pl." in plain_text(outbox[0])
assert len(session.exec(select(MagicLinkToken)).all()) == 1 # Lien permanent : rien de stocké, pas de jeton à usage unique.
assert session.exec(select(MagicLinkToken)).all() == []
def test_no_magic_link_token_without_placeholder( 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) text = plain_text(message)
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text 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 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
+8
View File
@@ -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() { export function useDeleteAccount() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
+2
View File
@@ -183,6 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), 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' }),
resetLoginLink: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
}, },
imports: { imports: {
analyze: (editionId, file: File) => { analyze: (editionId, file: File) => {
+3
View File
@@ -1240,6 +1240,9 @@ export function createMockApi(): PlanningApi {
account.updated_at = touch(); account.updated_at = touch();
return delay(account); 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) => { 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);
+3
View File
@@ -111,6 +111,9 @@ export interface PlanningApi {
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */ /** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>; update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** 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<AccountRead>;
}; };
imports: { imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */ /** Phase 1 : lit le fichier, propose un mapping des colonnes. */
+6 -2
View File
@@ -263,7 +263,7 @@
"config.mail.subject": "OBJET", "config.mail.subject": "OBJET",
"config.mail.body": "CORPS DU MESSAGE", "config.mail.body": "CORPS DU MESSAGE",
"config.mail.variables": "VARIABLES DISPONIBLES", "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.send": "ENVOYER LE RÉCAPITULATIF",
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION", "config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
"config.mail.sending": "ENVOI EN COURS…", "config.mail.sending": "ENVOI EN COURS…",
@@ -327,11 +327,15 @@
"config.vol.photo.remove": "Retirer", "config.vol.photo.remove": "Retirer",
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?", "config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
"config.vol.access.title": "Accès de connexion", "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.grant": "Donner un accès de connexion",
"config.vol.access.active": "Accès actif : {email}", "config.vol.access.active": "Accès actif : {email}",
"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.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.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes", "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.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.",
@@ -9,6 +9,7 @@ import {
useCreateAccount, useCreateAccount,
useCreateVolunteer, useCreateVolunteer,
useDeleteAccount, useDeleteAccount,
useResetLoginLink,
useDeleteVolunteer, useDeleteVolunteer,
useExecuteImport, useExecuteImport,
useExportMecenatReceipts, useExportMecenatReceipts,
@@ -112,6 +113,7 @@ function VolunteerAccessSection({
const accounts = useAccounts(); const accounts = useAccounts();
const createAccount = useCreateAccount(); const createAccount = useCreateAccount();
const deleteAccount = useDeleteAccount(); const deleteAccount = useDeleteAccount();
const resetLoginLink = useResetLoginLink();
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId); const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
return ( return (
@@ -157,8 +159,29 @@ function VolunteerAccessSection({
{t('config.vol.access.needEmail')} {t('config.vol.access.needEmail')}
</div> </div>
) : null} ) : null}
{account && account.auth_method === 'magic_link' ? (
<div className="mt-[6px] flex flex-wrap items-center gap-2">
<button
type="button"
disabled={resetLoginLink.isPending}
onClick={() => {
if (window.confirm(t('config.vol.access.resetLinkConfirm'))) {
resetLoginLink.mutate(account.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
>
{t('config.vol.access.resetLink')}
</button>
<HelpTip text={t('config.vol.access.resetLinkHint')} />
{resetLoginLink.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold">{t('config.vol.access.resetLinkDone')}</span>
) : null}
</div>
) : null}
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null} {createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null} {deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
</div> </div>
); );
} }