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
+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>
);
}