Files
Momento/memento-note/lib/auth/email-verification.ts
Antigravity 80ccc1f6de
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
feat: dashboard Second Brain, essai 7 jours et vérification e-mail
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>
2026-08-30 07:19:36 +00:00

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 }
}