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>
This commit is contained in:
133
memento-note/lib/auth/email-verification.ts
Normal file
133
memento-note/lib/auth/email-verification.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user