From b7f381828ff6e5971384a05daf9c7854a1a57492 Mon Sep 17 00:00:00 2001 From: Jalil Arfaoui Date: Fri, 13 Mar 2026 00:33:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20permettre=20le=20renvoi=20du=20lien=20d?= =?UTF-8?q?'invitation=20quand=20le=20token=20Supabase=20a=20expir=C3=A9?= =?UTF-8?q?=20(24h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le token OTP Supabase expire après 24h (max autorisé) mais l'invitation applicative reste valide 7 jours. Quand le lien est expiré, l'invité peut maintenant demander un nouveau lien en un clic au lieu d'être bloqué. L'email est inclus dans l'URL d'invitation (urlquery-encodé) pour éviter de le redemander. --- .../AcceptInvitationForm.tsx | 57 +++++++++++++- app/accepter-invitation/actions.ts | 33 +++++++- app/accepter-invitation/page.tsx | 6 +- domain/use-cases/resend-invitation.test.ts | 78 +++++++++++++++++++ domain/use-cases/resend-invitation.ts | 25 ++++++ supabase/templates/invite.html | 2 +- 6 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 domain/use-cases/resend-invitation.test.ts create mode 100644 domain/use-cases/resend-invitation.ts diff --git a/app/accepter-invitation/AcceptInvitationForm.tsx b/app/accepter-invitation/AcceptInvitationForm.tsx index 99781db..8d2907f 100644 --- a/app/accepter-invitation/AcceptInvitationForm.tsx +++ b/app/accepter-invitation/AcceptInvitationForm.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, type FormEvent } from 'react' -import { acceptInvitation } from './actions' +import { acceptInvitation, resendInvitationLink } from './actions' import TextField from '../../components/ui/TextField' import Button from '../../components/ui/Button' import FormError from '../../components/ui/FormError' @@ -9,12 +9,15 @@ import styles from './AcceptInvitationForm.module.css' interface AcceptInvitationFormProps { tokenHash: string + email?: string } -export default function AcceptInvitationForm({ tokenHash }: AcceptInvitationFormProps) { +export default function AcceptInvitationForm({ tokenHash, email }: AcceptInvitationFormProps) { const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [success, setSuccess] = useState(false) + const [tokenExpired, setTokenExpired] = useState(false) + const [resendSuccess, setResendSuccess] = useState(false) const handleSubmit = async (e: FormEvent) => { e.preventDefault() @@ -25,6 +28,9 @@ export default function AcceptInvitationForm({ tokenHash }: AcceptInvitationForm const result = await acceptInvitation(tokenHash, formData) if (!result.success) { + if (result.tokenExpired && email) { + setTokenExpired(true) + } setError(result.error) setLoading(false) return @@ -35,6 +41,23 @@ export default function AcceptInvitationForm({ tokenHash }: AcceptInvitationForm window.location.href = '/' } + const handleResend = async () => { + if (!email) return + setError(null) + setLoading(true) + + const result = await resendInvitationLink(email) + + if (!result.success) { + setError(result.error) + setLoading(false) + return + } + + setResendSuccess(true) + setLoading(false) + } + if (success) { return (
@@ -46,6 +69,36 @@ export default function AcceptInvitationForm({ tokenHash }: AcceptInvitationForm ) } + if (resendSuccess) { + return ( +
+

Nouveau lien envoyé

+

+ Un nouveau lien d'invitation vous a été envoyé à {email}. Vérifiez + votre boîte de réception. +

+
+ ) + } + + if (tokenExpired) { + return ( +
+

Lien expiré

+

+ Votre lien d'invitation a expiré. Vous pouvez demander l'envoi d'un nouveau + lien. +

+ + {error && } + + +
+ ) + } + return (

Bienvenue sur Débats.co

diff --git a/app/accepter-invitation/actions.ts b/app/accepter-invitation/actions.ts index 7472cba..06cec0c 100644 --- a/app/accepter-invitation/actions.ts +++ b/app/accepter-invitation/actions.ts @@ -1,15 +1,18 @@ 'use server' import * as Sentry from '@sentry/nextjs' -import { Either } from 'effect' +import { Effect, Either } from 'effect' import { createSSRSupabaseClient } from '../../infra/supabase/ssr' import { createAdminSupabaseClient } from '../../infra/supabase/admin' import { createInvitationRepository } from '../../infra/database/invitation-repository-supabase' import { createReputationRepository } from '../../infra/database/reputation-repository-supabase' import { createContributorRepository } from '../../infra/database/contributor-repository-supabase' import { acceptInvitationUseCase } from '../../domain/use-cases/accept-invitation' +import { validateResendInvitation } from '../../domain/use-cases/resend-invitation' -type AcceptInvitationResult = { success: true } | { success: false; error: string } +type AcceptInvitationResult = + | { success: true } + | { success: false; error: string; tokenExpired?: boolean } export async function acceptInvitation( tokenHash: string, @@ -38,6 +41,7 @@ export async function acceptInvitation( success: false, error: 'Le lien d\u2019invitation a expiré ou est invalide. Demandez une nouvelle invitation.', + tokenExpired: true, } } @@ -88,3 +92,28 @@ export async function acceptInvitation( return { success: true } } + +type ResendResult = { success: true } | { success: false; error: string } + +export async function resendInvitationLink(email: string): Promise { + const admin = createAdminSupabaseClient() + const invitationRepo = createInvitationRepository(admin) + + const result = await Effect.runPromise( + validateResendInvitation({ email, invitationRepo }).pipe( + Effect.flatMap((invitation) => + Effect.promise(() => + admin.auth.admin.inviteUserByEmail(email, { data: { name: invitation.inviteeName } }), + ), + ), + Effect.flatMap(({ error }) => + error + ? Effect.fail(`Erreur lors du renvoi de l'invitation : ${error.message}`) + : Effect.void, + ), + Effect.either, + ), + ) + + return Either.isLeft(result) ? { success: false, error: result.left } : { success: true } +} diff --git a/app/accepter-invitation/page.tsx b/app/accepter-invitation/page.tsx index ad2a0c8..1558dd3 100644 --- a/app/accepter-invitation/page.tsx +++ b/app/accepter-invitation/page.tsx @@ -8,9 +8,9 @@ export const metadata = { export default async function AcceptInvitationPage({ searchParams, }: { - searchParams: Promise<{ token_hash?: string; type?: string }> + searchParams: Promise<{ token_hash?: string; type?: string; email?: string }> }) { - const { token_hash, type } = await searchParams + const { token_hash, type, email } = await searchParams if (!token_hash || type !== 'invite') { return ( @@ -21,5 +21,5 @@ export default async function AcceptInvitationPage({ ) } - return + return } diff --git a/domain/use-cases/resend-invitation.test.ts b/domain/use-cases/resend-invitation.test.ts new file mode 100644 index 0000000..87e9636 --- /dev/null +++ b/domain/use-cases/resend-invitation.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { Effect, Either } from 'effect' +import { validateResendInvitation } from './resend-invitation' +import { Invitation, createInvitation } from '../entities/invitation' + +const baseInvitation = createInvitation({ + inviterId: 'inviter-uuid', + inviteeEmail: 'invitee@example.com', + inviteeName: 'Jean Dupont', +}) + +const fakeInvitationRepo = { + create: (invitation: Invitation) => Effect.succeed(invitation), + deleteById: () => Effect.succeed(undefined as void), + findPendingByEmail: () => Effect.succeed(null as Invitation | null), + findPendingByInviter: () => Effect.succeed([] as Invitation[]), + acceptByEmail: () => Effect.succeed(undefined as void), +} + +const run = (effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.either)) + +describe('validateResendInvitation', () => { + it('should fail when no pending invitation exists for the email', async () => { + const result = await run( + validateResendInvitation({ + email: 'unknown@example.com', + invitationRepo: fakeInvitationRepo, + }), + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toContain('invitation') + } + }) + + it('should return the invitation when it exists and is not expired', async () => { + const result = await run( + validateResendInvitation({ + email: 'invitee@example.com', + invitationRepo: { + ...fakeInvitationRepo, + findPendingByEmail: () => Effect.succeed(baseInvitation as Invitation | null), + }, + }), + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.inviteeName).toBe('Jean Dupont') + expect(result.right.inviteeEmail).toBe('invitee@example.com') + } + }) + + it('should fail when invitation is expired (>7 days)', async () => { + const eightDaysAgo = new Date() + eightDaysAgo.setDate(eightDaysAgo.getDate() - 8) + const expiredInvitation: Invitation = { + ...baseInvitation, + createdAt: eightDaysAgo, + } + + const result = await run( + validateResendInvitation({ + email: 'invitee@example.com', + invitationRepo: { + ...fakeInvitationRepo, + findPendingByEmail: () => Effect.succeed(expiredInvitation as Invitation | null), + }, + }), + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toContain('expiré') + } + }) +}) diff --git a/domain/use-cases/resend-invitation.ts b/domain/use-cases/resend-invitation.ts new file mode 100644 index 0000000..3699a0b --- /dev/null +++ b/domain/use-cases/resend-invitation.ts @@ -0,0 +1,25 @@ +import { Effect } from 'effect' +import { InvitationRepository } from '../repositories/invitation-repository' +import { isExpired, Invitation } from '../entities/invitation' + +type ValidateResendParams = { + email: string + invitationRepo: InvitationRepository +} + +export const validateResendInvitation = ( + params: ValidateResendParams, +): Effect.Effect => + params.invitationRepo.findPendingByEmail(params.email).pipe( + Effect.mapError(() => 'Erreur lors de la recherche de l\u2019invitation.'), + Effect.flatMap((invitation) => + invitation === null + ? Effect.fail('Aucune invitation en attente pour cette adresse e-mail.') + : Effect.succeed(invitation), + ), + Effect.filterOrFail( + (invitation) => !isExpired(invitation), + () => + 'L\u2019invitation a expiré. Demandez à la personne qui vous a invité·e de vous réinviter.', + ), + ) diff --git a/supabase/templates/invite.html b/supabase/templates/invite.html index 6e5d1a0..9d61cf7 100644 --- a/supabase/templates/invite.html +++ b/supabase/templates/invite.html @@ -14,7 +14,7 @@