Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
import prisma from '@/lib/prisma'
|
|
import { sendEmail } from '@/lib/mail'
|
|
import { getSystemConfig } from '@/lib/config'
|
|
import { getEmailTemplate } from '@/lib/email-template'
|
|
|
|
const VERIFY_PREFIX = 'email-verify:'
|
|
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000 // 24h
|
|
|
|
export function generateVerificationToken(): string {
|
|
const array = new Uint8Array(32)
|
|
globalThis.crypto.getRandomValues(array)
|
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
function identifierForEmail(email: string): string {
|
|
return `${VERIFY_PREFIX}${email.toLowerCase()}`
|
|
}
|
|
|
|
export async function createEmailVerificationToken(email: string): Promise<string> {
|
|
const normalized = email.toLowerCase()
|
|
const identifier = identifierForEmail(normalized)
|
|
const token = generateVerificationToken()
|
|
const expires = new Date(Date.now() + TOKEN_TTL_MS)
|
|
|
|
// Replace any pending tokens for this email
|
|
await prisma.verificationToken.deleteMany({ where: { identifier } })
|
|
await prisma.verificationToken.create({
|
|
data: { identifier, token, expires },
|
|
})
|
|
|
|
return token
|
|
}
|
|
|
|
export async function sendVerificationEmail(opts: {
|
|
email: string
|
|
name?: string | null
|
|
locale?: string
|
|
}): Promise<{ success: boolean; error?: string }> {
|
|
const token = await createEmailVerificationToken(opts.email)
|
|
const baseUrl = (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
|
|
const verifyLink = `${baseUrl}/verify-email?token=${token}`
|
|
|
|
const isFr = (opts.locale ?? '').toLowerCase().startsWith('fr')
|
|
const greet = opts.name?.trim()
|
|
? opts.name.trim()
|
|
: isFr
|
|
? 'Bonjour'
|
|
: 'Hi'
|
|
|
|
const title = isFr ? 'Confirmez votre adresse e-mail' : 'Confirm your email address'
|
|
const body = isFr
|
|
? `<p>${greet},</p><p>Merci de vous être inscrit sur Memento. Cliquez sur le bouton ci-dessous pour activer votre compte. Ce lien est valable 24 heures.</p>`
|
|
: `<p>${greet},</p><p>Thanks for signing up for Memento. Click the button below to activate your account. This link is valid for 24 hours.</p>`
|
|
const cta = isFr ? 'Confirmer mon e-mail' : 'Confirm my email'
|
|
const subject = isFr
|
|
? 'Confirmez votre compte Memento'
|
|
: 'Confirm your Memento account'
|
|
|
|
const html = getEmailTemplate(title, body, verifyLink, cta)
|
|
const sysConfig = await getSystemConfig()
|
|
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
|
|
|
return sendEmail({ to: opts.email.toLowerCase(), subject, html }, emailProvider)
|
|
}
|
|
|
|
export async function verifyEmailToken(
|
|
token: string,
|
|
): Promise<{ success: true } | { success: false; error: 'invalid' | 'expired' }> {
|
|
if (!token) return { success: false, error: 'invalid' }
|
|
|
|
const record = await prisma.verificationToken.findFirst({
|
|
where: { token },
|
|
})
|
|
|
|
if (!record || !record.identifier.startsWith(VERIFY_PREFIX)) {
|
|
return { success: false, error: 'invalid' }
|
|
}
|
|
|
|
if (record.expires < new Date()) {
|
|
await prisma.verificationToken.deleteMany({
|
|
where: { identifier: record.identifier },
|
|
})
|
|
return { success: false, error: 'expired' }
|
|
}
|
|
|
|
const email = record.identifier.slice(VERIFY_PREFIX.length)
|
|
const user = await prisma.user.findUnique({ where: { email } })
|
|
if (!user) {
|
|
return { success: false, error: 'invalid' }
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { emailVerified: new Date() },
|
|
}),
|
|
prisma.verificationToken.deleteMany({
|
|
where: { identifier: record.identifier },
|
|
}),
|
|
])
|
|
|
|
return { success: true }
|
|
}
|
|
|
|
/**
|
|
* Resend verification for an unverified password account.
|
|
* Always returns success to avoid email enumeration when the address is unknown.
|
|
*/
|
|
export async function resendVerificationEmail(
|
|
email: string,
|
|
locale?: string,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const normalized = email.toLowerCase().trim()
|
|
if (!normalized) return { error: 'missing_email', success: false }
|
|
|
|
const user = await prisma.user.findUnique({ where: { email: normalized } })
|
|
if (!user || !user.password) {
|
|
return { success: true }
|
|
}
|
|
if (user.emailVerified) {
|
|
return { success: true }
|
|
}
|
|
|
|
const result = await sendVerificationEmail({
|
|
email: user.email,
|
|
name: user.name,
|
|
locale,
|
|
})
|
|
if (!result.success) {
|
|
return { success: false, error: 'send_failed' }
|
|
}
|
|
return { success: true }
|
|
}
|