Lien de connexion directe permanent dans le récapitulatif de planning
{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:
co-authored by
Claude Opus 5.5
parent
8d6781e6ce
commit
05cdb0276b
@@ -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 +
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user