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:
parent
9c63aa3ff4
commit
b7f381828f
6 changed files with 193 additions and 8 deletions
|
|
@ -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'invitation vous a été 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'invitation a expiré. Vous pouvez demander l'envoi d'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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
78
domain/use-cases/resend-invitation.test.ts
Normal file
78
domain/use-cases/resend-invitation.test.ts
Normal 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é')
|
||||
}
|
||||
})
|
||||
})
|
||||
25
domain/use-cases/resend-invitation.ts
Normal file
25
domain/use-cases/resend-invitation.ts
Normal 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.',
|
||||
),
|
||||
)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue