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,
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 +
+18
View File
@@ -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,
+12
View File
@@ -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())
+42 -8
View File
@@ -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:
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]
return build_magic_link_url(issue_magic_link_token(session, account))
# 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,
+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,
)
# 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"
+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}"}
)
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
+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() {
const invalidate = useInvalidate();
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) }),
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) => {
+3
View 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);
+3
View File
@@ -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. */
+6 -2
View File
@@ -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>
);
}