feat: permettre le renvoi du lien d'invitation quand le token Supabase a expiré (24h)

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.
This commit is contained in:
Jalil Arfaoui 2026-03-13 00:33:28 +01:00
parent 9c63aa3ff4
commit b7f381828f
6 changed files with 193 additions and 8 deletions

View file

@ -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<string | null>(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<HTMLFormElement>) => {
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 (
<div className={styles.page}>
@ -46,6 +69,36 @@ export default function AcceptInvitationForm({ tokenHash }: AcceptInvitationForm
)
}
if (resendSuccess) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Nouveau lien envoyé</h1>
<p className={styles.text}>
Un nouveau lien d&apos;invitation vous a é envoyé à <strong>{email}</strong>. Vérifiez
votre boîte de réception.
</p>
</div>
)
}
if (tokenExpired) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Lien expiré</h1>
<p className={styles.text}>
Votre lien d&apos;invitation a expiré. Vous pouvez demander l&apos;envoi d&apos;un nouveau
lien.
</p>
{error && <FormError message={error} />}
<Button variant="primary" onClick={handleResend} disabled={loading}>
{loading ? 'Envoi en cours…' : 'Renvoyer un nouveau lien'}
</Button>
</div>
)
}
return (
<div className={styles.page}>
<h1 className={styles.title}>Bienvenue sur Débats.co</h1>

View file

@ -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<ResendResult> {
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 }
}

View file

@ -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 <AcceptInvitationForm tokenHash={token_hash} />
return <AcceptInvitationForm tokenHash={token_hash} email={email} />
}

View file

@ -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 = <A, E>(effect: Effect.Effect<A, E>) => 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é')
}
})
})

View file

@ -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<Invitation, string> =>
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.',
),
)

View file

@ -14,7 +14,7 @@
</p>
<p style="text-align: center; margin: 32px 0">
<a
href="{{ .SiteURL }}/accepter-invitation?token_hash={{ .TokenHash }}&type=invite"
href="{{ .SiteURL }}/accepter-invitation?token_hash={{ .TokenHash }}&type=invite&email={{ urlquery .Email }}"
style="
background-color: #f21e40;
color: white;