Compare commits
3 Commits
5e7e7b69e9
...
a556760872
| Author | SHA1 | Date | |
|---|---|---|---|
| a556760872 | |||
| e3968e6dbf | |||
| e0f862b361 |
@@ -41,4 +41,13 @@ model User {
|
||||
encryptedPrivateKey String?
|
||||
ActionToken ActionToken[]
|
||||
AuthToken AuthToken[]
|
||||
UserPreference UserPreference?
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id
|
||||
userId String @unique
|
||||
language String @default("fr")
|
||||
theme String @default("light")
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,15 @@ export class AppError extends Error {
|
||||
|
||||
// Erreurs prédéfinies
|
||||
export const Errors = {
|
||||
|
||||
// registration errors
|
||||
EMAIL_TAKEN: new AppError('EMAIL_TAKEN', 409, 'Cette adresse email est déjà utilisée.'),
|
||||
PASSWORD_TOO_WEAK: new AppError('PASSWORD_TOO_WEAK', 400, 'Le mot de passe doit contenir au moins 8 caractères.'),
|
||||
INVALID_CREDENTIALS: new AppError('INVALID_CREDENTIALS', 401, 'Email ou mot de passe incorrect.'),
|
||||
VALIDATION_ERROR: (message: string) => new AppError('VALIDATION_ERROR', 400, message),
|
||||
|
||||
//Action token errors
|
||||
INVALID_TOKEN: new AppError('INVALID_TOKEN', 400, 'Invalid or already used token'),
|
||||
TOKEN_EXPIRED: new AppError('TOKEN_EXPIRED', 400, 'Token has expired'),
|
||||
ALREADY_CONFIRMED: new AppError('ALREADY_CONFIRMED', 400, 'User is already confirmed'),
|
||||
}
|
||||
@@ -9,10 +9,17 @@ export default async function authRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/auth/register', async (request, reply) => {
|
||||
const body = RegisterSchema.parse(request.body) // Zod throw → handler global
|
||||
|
||||
const lang = request.headers['accept-language']
|
||||
?.split(',')[0]
|
||||
.split('-')[0] as 'fr' | 'en'
|
||||
|
||||
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
|
||||
|
||||
const { user, authToken } = await registerUser(
|
||||
fastify.prisma,
|
||||
fastify.mailer,
|
||||
body
|
||||
body,
|
||||
validLang
|
||||
)
|
||||
|
||||
reply.setCookie('authToken', authToken, {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { Errors } from '../errors/AppError'
|
||||
import { confirmEmail } from '../services/user.service'
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/users', async (request, reply) => {
|
||||
@@ -16,4 +18,15 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
|
||||
return users
|
||||
})
|
||||
|
||||
fastify.get('/user/confirm', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
|
||||
if (!token) {
|
||||
throw Errors.INVALID_TOKEN
|
||||
}
|
||||
|
||||
const result = await confirmEmail(fastify.prisma, token)
|
||||
return reply.status(200).send(result)
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { Errors } from '../errors/AppError.js'
|
||||
export async function registerUser(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
input: RegisterInput
|
||||
input: RegisterInput,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
) {
|
||||
// 1. Vérif email unique
|
||||
const existing = await prisma.user.findUnique({
|
||||
@@ -22,7 +23,7 @@ export async function registerUser(
|
||||
// 2. Hash du mot de passe
|
||||
const passwordHash = await argon2.hash(input.password)
|
||||
|
||||
// 3. Création de l'user
|
||||
// 3. Création du user
|
||||
const displayName = input.email.split('@')[0]
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -53,8 +54,8 @@ export async function registerUser(
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Envoi du mail
|
||||
await sendConfirmationMail(mailer, user.email, confirmToken)
|
||||
// 5. Envoi du mail avec la langue
|
||||
await sendConfirmationMail(mailer, user.email, confirmToken, lang)
|
||||
|
||||
// 6. AuthToken pour la persistance
|
||||
const authToken = generateToken()
|
||||
@@ -66,6 +67,16 @@ export async function registerUser(
|
||||
expiresAt: generateAuthTokenExpiry(),
|
||||
},
|
||||
})
|
||||
|
||||
// 7 Création des préférences avec la langue détectée
|
||||
await prisma.userPreference.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
language: lang,
|
||||
theme: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
return { user, authToken }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import { Transporter } from 'nodemailer'
|
||||
|
||||
type Lang = 'fr' | 'en'
|
||||
|
||||
const templates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Merci de confirmer votre e-mail',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Bienvenue sur le gestionnaire de listes !</p>
|
||||
<p>
|
||||
Pour pouvoir utiliser le site, vous devez confirmer votre adresse mail sous 7 jours en cliquant sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Please confirm your email address',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Welcome to the list manager!</p>
|
||||
<p>
|
||||
To start using the site, please confirm your email address within 7 days by clicking
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendConfirmationMail(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
token: string
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.APP_URL}/auth/confirm-email?token=${token}`
|
||||
const url = `${process.env.FRONT_URL}/${lang}/confirm?token=${token}`
|
||||
const { subject, html } = templates[lang](url)
|
||||
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject: 'Confirmez votre adresse mail',
|
||||
html: `
|
||||
<p>Merci de vous être inscrit !</p>
|
||||
<p>Cliquez sur ce lien pour confirmer votre adresse mail (valable 24h) :</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
37
src/services/user.service.ts
Normal file
37
src/services/user.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
|
||||
export async function confirmEmail(prisma: PrismaClient, token: string) {
|
||||
const actionToken = await prisma.actionToken.findUnique({
|
||||
where: { token },
|
||||
})
|
||||
|
||||
if (!actionToken || actionToken.type !== 'email-confirm' || actionToken.used) {
|
||||
throw Errors.INVALID_TOKEN
|
||||
}
|
||||
|
||||
if (actionToken.expiresAt < new Date()) {
|
||||
throw Errors.TOKEN_EXPIRED
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { isConfirmed: true },
|
||||
})
|
||||
|
||||
if (user?.isConfirmed) {
|
||||
throw Errors.ALREADY_CONFIRMED
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: { isConfirmed: true },
|
||||
}),
|
||||
prisma.actionToken.delete({
|
||||
where: { id: actionToken.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
Reference in New Issue
Block a user