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,
|
||||
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.<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
|
||||
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 +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -111,6 +111,9 @@ export interface PlanningApi {
|
||||
/** 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>;
|
||||
/** 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: {
|
||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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')}
|
||||
</div>
|
||||
) : 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}
|
||||
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
|
||||
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user