Compare commits
4 Commits
845ec655d1
...
user_manag
| Author | SHA1 | Date | |
|---|---|---|---|
| 1957473835 | |||
| 53d2db737e | |||
| 770c80c739 | |||
| 204b72bb94 |
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# Environment variables declared in this file are NOT automatically loaded by Prisma.
|
||||
# Please add `import "dotenv/config";` to your `prisma.config.ts` file, or use the Prisma CLI with Bun
|
||||
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
# The following `prisma+postgres` URL is similar to the URL produced by running a local Prisma Postgres
|
||||
# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
||||
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
|
||||
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||
|
||||
# Envois de mails
|
||||
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT=587
|
||||
SMTP_USER="your@email.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
MAIL_FROM="no-reply@example.com"
|
||||
|
||||
APP_URL=BACK_OFFICE_SERVER_URL
|
||||
|
||||
FRONT_URL=YOUR_FRONT_URL #is used for links in emails
|
||||
|
||||
#Secrets
|
||||
JWT_SECRET="your-jwt-secret"
|
||||
|
||||
|
||||
181
README.md
Normal file
181
README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 🗂️ Fastify User Management Boilerplate
|
||||
|
||||
A production-ready back-office boilerplate for **user account management**, built with Fastify, Prisma, Zod, and PostgreSQL. Designed to be forked and extended as the backend foundation of any application requiring authentication and account lifecycle management.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Authentication** – JWT-based auth with cookie support and token versioning (revocation on password change)
|
||||
- **Registration & Login** – Secure password hashing with Argon2
|
||||
- **Google OAuth** – Login or register via Google access token, with automatic account merge detection
|
||||
- **Password recovery** – Forgot password flow with time-limited action tokens
|
||||
- **Password change** – Authenticated password update with session revocation
|
||||
- **Email change** – Two-step flow with a 15-minute confirmation window and a 24-hour rollback period
|
||||
- **Account deletion** – Soft-delete with a 7-day cancellation window
|
||||
- **Input validation** – Zod schemas with shared password rules
|
||||
- **Email notifications** – Nodemailer integration (confirmation, recovery, alerts)
|
||||
- **Scheduled tasks** – node-cron for background jobs (e.g. purging expired accounts)
|
||||
- **Security-first error codes** – Intentionally vague error responses to prevent user enumeration
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | [Fastify](https://fastify.dev/) v5 |
|
||||
| ORM | [Prisma](https://www.prisma.io/) v7 |
|
||||
| Database | PostgreSQL (via `pg` + `@prisma/adapter-pg`) |
|
||||
| Validation | [Zod](https://zod.dev/) v4 |
|
||||
| Auth | `@fastify/jwt` + `@fastify/cookie` |
|
||||
| Password hashing | [Argon2](https://github.com/ranisalt/node-argon2) |
|
||||
| Email | [Nodemailer](https://nodemailer.com/) |
|
||||
| Scheduled jobs | [node-cron](https://github.com/node-cron/node-cron) |
|
||||
| Runtime | Node.js + [tsx](https://github.com/privatenumber/tsx) |
|
||||
| Language | TypeScript |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.ts # Fastify app factory & plugin registration
|
||||
├── server.ts # Entry point
|
||||
├── routes/ # Route definitions (declared before services)
|
||||
├── services/ # Business logic
|
||||
├── schemas/ # Zod validation schemas (incl. shared.schema.ts)
|
||||
├── plugins/ # Fastify plugins (JWT, cookies, DB, etc.)
|
||||
├── middleware/ # Auth guards and request hooks
|
||||
├── errors/ # Custom error classes and error codes
|
||||
├── types/ # TypeScript type definitions
|
||||
└── generated/ # Prisma generated client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 20
|
||||
- PostgreSQL database
|
||||
- An SMTP server (or service like Mailtrap / Resend for dev)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/your-repo.git
|
||||
cd your-repo
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
Rename `.env.example` to `.env` and fill in the values:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||
|
||||
# Email
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT=587
|
||||
SMTP_USER="your@email.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
MAIL_FROM="no-reply@example.com"
|
||||
|
||||
# URLs
|
||||
APP_URL=BACK_OFFICE_SERVER_URL
|
||||
FRONT_URL=YOUR_FRONT_URL # Used for links in emails
|
||||
|
||||
# Secrets
|
||||
JWT_SECRET="your-jwt-secret"
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
### Run (development)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Auth Flow Overview
|
||||
|
||||
- On login, a **JWT is issued** (stored in an HTTP-only cookie) containing the user's ID and a `tokenVersion`.
|
||||
- On **password change**, `tokenVersion` is incremented, invalidating all previously issued tokens.
|
||||
- Action tokens (password reset, email confirmation, etc.) are **single-use** and deleted on consumption.
|
||||
- The `lang` field on requests is derived from the `Accept-Language` header (defaults to `fr`).
|
||||
|
||||
### Google OAuth
|
||||
|
||||
The Google flow accepts an `access_token` obtained by the client after the Google sign-in, and exchanges it for user info via the [Google UserInfo API](https://www.googleapis.com/oauth2/v3/userinfo). Three cases are handled:
|
||||
|
||||
| Situation | Behaviour |
|
||||
|---|---|
|
||||
| Email exists, no `googleId` | Returns `mergeRequired: true` — the client must prompt the user to link their accounts |
|
||||
| Existing Google account | Logs the user in directly (checks for pending deletion) |
|
||||
| New user | Creates the account with `googleId`, picture and display name from Google; sends a confirmation email if the address is not already verified by Google |
|
||||
|
||||
User preferences (`language`, `theme`) are automatically created on first Google sign-in.
|
||||
|
||||
---
|
||||
|
||||
## 🛣️ API Routes
|
||||
|
||||
### Auth — `/auth`
|
||||
|
||||
| Method | Route | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/auth/register` | — | Create a new account |
|
||||
| `POST` | `/auth/login` | — | Login and receive a JWT cookie |
|
||||
| `POST` | `/auth/logout` | ✅ | Invalidate the session cookie |
|
||||
| `POST` | `/auth/google` | — | Login or register via Google access token |
|
||||
|
||||
### User — `/user`
|
||||
|
||||
| Method | Route | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/users` | — | List all users |
|
||||
| `GET` | `/user/confirm?token=` | — | Confirm email address |
|
||||
| `PATCH` | `/user/display-name` | ✅ | Update display name |
|
||||
| `POST` | `/user/pwd-recovery-request` | — | Request a password reset email |
|
||||
| `POST` | `/user/pwd-recovery` | — | Reset password via token |
|
||||
| `PATCH` | `/user/pwd-change` | ✅ | Change password (re-issues JWT cookie) |
|
||||
| `POST` | `/user/email-change-request` | ✅ | Request an email change (sends confirmation to new address) |
|
||||
| `GET` | `/user/email-change-confirm?token=` | — | Confirm the new email address |
|
||||
| `GET` | `/user/email-change-rollback?token=` | — | Rollback to previous email (24h window) |
|
||||
| `DELETE` | `/user/account` | ✅ | Schedule account deletion (7-day window) |
|
||||
| `GET` | `/user/delete-cancel?token=` | — | Cancel scheduled account deletion |
|
||||
|
||||
> ✅ = requires a valid `authToken` cookie
|
||||
|
||||
---
|
||||
|
||||
## 📋 Available Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server with tsx
|
||||
npm run build # Compile TypeScript to dist/
|
||||
npm run start # Run compiled server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
Copyright (c) 2026 Raffi (& Claude ai)
|
||||
|
||||
Permission is hereby granted to use, copy, modify, and distribute this software
|
||||
for non-commercial purposes only, with attribution.
|
||||
|
||||
Commercial use of any kind is strictly prohibited without prior written permission
|
||||
from the author.
|
||||
@@ -21,11 +21,11 @@ model ActionToken {
|
||||
model User {
|
||||
id String @id
|
||||
email String @unique
|
||||
passwordHash String
|
||||
passwordHash String?
|
||||
displayName String
|
||||
tokenVersion Int @default(0)
|
||||
isConfirmed Boolean @default(false)
|
||||
isGoogleUser Boolean @default(false)
|
||||
googleId String? @unique
|
||||
avatar String
|
||||
publicKey String?
|
||||
encryptedPrivateKey String?
|
||||
|
||||
@@ -18,6 +18,9 @@ export const Errors = {
|
||||
INVALID_CREDENTIALS: new AppError('INVALID_CREDENTIALS', 401, 'Email ou mot de passe incorrect.'),
|
||||
VALIDATION_ERROR: (message: string) => new AppError('VALIDATION_ERROR', 400, message),
|
||||
|
||||
// Google auth errors
|
||||
INVALID_GOOGLE_TOKEN: new AppError('INVALID_GOOGLE_TOKEN', 401, 'Google token invalide ou expiré.'),
|
||||
|
||||
//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'),
|
||||
@@ -26,7 +29,7 @@ export const Errors = {
|
||||
// Email change errors
|
||||
EMAIL_ALREADY_YOURS: new AppError('EMAIL_ALREADY_YOURS', 409, 'This email is already yours'),
|
||||
EMAIL_ALREADY_USED: new AppError('EMAIL_ALREADY_USED', 409, 'This email is already in use'),
|
||||
//acount deletion errors
|
||||
// Account deletion errors
|
||||
ACCOUNT_PENDING_DELETION: new AppError('ACCOUNT_PENDING_DELETION', 403, 'Account is scheduled for deletion. Check your emails to cancel.'),
|
||||
ACCOUNT_ALREADY_PENDING_DELETION: new AppError('ACCOUNT_ALREADY_PENDING_DELETION', 409, 'Account is already scheduled for deletion.'),
|
||||
//Auth errors
|
||||
|
||||
@@ -1,50 +1,67 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { RegisterSchema, LoginSchema } from '../schemas/auth.schema.js'
|
||||
import { registerUser, loginUser, logoutUser } from '../services/auth.service.js'
|
||||
import { googleLoginOrRegister } from '../services/google.service.js'
|
||||
import { GoogleAuthSchema } from '../schemas/google.schema.js'
|
||||
import { signAuthToken } from '../services/authToken.service.js'
|
||||
import { verifyAuth } from '../middleware/verifyAuth.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
|
||||
export default async function authRoutes(fastify: FastifyInstance) {
|
||||
|
||||
fastify.post('/auth/register', async (request, reply) => {
|
||||
const body = RegisterSchema.parse(request.body)
|
||||
const lang = request.headers['accept-language']
|
||||
?.split(',')[0]
|
||||
.split('-')[0] as 'fr' | 'en'
|
||||
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
|
||||
|
||||
const { user } = await registerUser(fastify.prisma, fastify.mailer, body, validLang)
|
||||
|
||||
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: 0 })
|
||||
|
||||
reply.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return reply.status(201).send({ user })
|
||||
})
|
||||
|
||||
fastify.post('/auth/login', async (request, reply) => {
|
||||
const body = LoginSchema.parse(request.body)
|
||||
const { user } = await loginUser(fastify.prisma, body)
|
||||
|
||||
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion })
|
||||
|
||||
reply.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return reply.status(200).send({ user })
|
||||
})
|
||||
|
||||
fastify.post('/auth/logout', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
await logoutUser(fastify.prisma, request.user.userId)
|
||||
reply.clearCookie('authToken', { path: '/' })
|
||||
return reply.status(200).send({ message: 'Déconnecté avec succès' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.post('/auth/google', async (request, reply) => {
|
||||
const { access_token } = GoogleAuthSchema.parse(request.body)
|
||||
const lang = request.headers['accept-language']
|
||||
?.split(',')[0]
|
||||
.split('-')[0] as 'fr' | 'en'
|
||||
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
|
||||
const { user } = await googleLoginOrRegister(fastify.prisma, fastify.mailer, access_token, validLang)
|
||||
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion })
|
||||
reply.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/',
|
||||
})
|
||||
return reply.status(200).send({ user })
|
||||
})
|
||||
|
||||
}
|
||||
@@ -10,21 +10,7 @@ import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../servic
|
||||
import { scheduleAccountDeletion, cancelAccountDeletion } from '../services/accountDeletion.service.js'
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/users', async (request, reply) => {
|
||||
const users = await fastify.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
isConfirmed: true,
|
||||
isGoogleUser: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return users
|
||||
})
|
||||
|
||||
fastify.get('/user/confirm', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
@@ -55,7 +41,7 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
await passwordRecoveryRequest(fastify.prisma, fastify.mailer, email, validLang)
|
||||
|
||||
return reply.status(200).send({
|
||||
message: 'Si cet email est enregistré, vous recevrez un lien de réinitialisation.',
|
||||
success: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +50,7 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
if (!token || !newPassword) throw Errors.VALIDATION_ERROR
|
||||
await confirmPasswordRecovery(fastify.prisma, token, newPassword)
|
||||
|
||||
return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.patch('/user/pwd-change', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
@@ -80,7 +66,7 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.post('/user/email-change-request', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
@@ -92,7 +78,7 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
|
||||
await emailChangeRequest(fastify.prisma, fastify.mailer, request.user.userId, newEmail, validLang)
|
||||
|
||||
return reply.status(200).send({ message: 'Un email de confirmation a été envoyé à votre nouvelle adresse.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/email-change-confirm', async (request, reply) => {
|
||||
@@ -101,7 +87,7 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
|
||||
await emailChangeConfirm(fastify.prisma, token)
|
||||
|
||||
return reply.status(200).send({ message: 'Email mis à jour avec succès.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/email-change-rollback', async (request, reply) => {
|
||||
@@ -110,12 +96,12 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
|
||||
await emailRollback(fastify.prisma, token)
|
||||
|
||||
return reply.status(200).send({ message: 'Email restauré avec succès.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.delete('/user/account', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
await scheduleAccountDeletion(fastify.prisma, fastify.mailer, request.user.userId)
|
||||
return reply.status(200).send({ message: 'Votre compte sera supprimé dans 7 jours.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/delete-cancel', async (request, reply) => {
|
||||
@@ -123,6 +109,6 @@ export default async function userRoutes(fastify: FastifyInstance) {
|
||||
if (!token) throw Errors.INVALID_TOKEN
|
||||
|
||||
await cancelAccountDeletion(fastify.prisma, token)
|
||||
return reply.status(200).send({ message: 'Suppression annulée, votre compte est restauré.' })
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
}
|
||||
@@ -14,4 +14,3 @@ export const LoginSchema = z.object({
|
||||
})
|
||||
|
||||
export type LoginInput = z.infer<typeof LoginSchema>
|
||||
|
||||
|
||||
7
src/schemas/google.schema.ts
Normal file
7
src/schemas/google.schema.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const GoogleAuthSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
})
|
||||
|
||||
export type GoogleAuthInput = z.infer<typeof GoogleAuthSchema>
|
||||
@@ -56,6 +56,7 @@ export async function registerUser(
|
||||
export async function loginUser(prisma: PrismaClient, input: LoginInput) {
|
||||
const user = await prisma.user.findUnique({ where: { email: input.email } })
|
||||
if (!user) throw Errors.INVALID_CREDENTIALS
|
||||
if (!user.passwordHash) throw Errors.INVALID_CREDENTIALS
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, input.password)
|
||||
if (!valid) throw Errors.INVALID_CREDENTIALS
|
||||
|
||||
112
src/services/google.service.ts
Normal file
112
src/services/google.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Transporter } from 'nodemailer'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { generateGravatarUrl } from './avatar.service.js'
|
||||
import { sendConfirmationMail } from './mailing.service.js'
|
||||
import { createActionToken } from './actionToken.service.js'
|
||||
|
||||
interface GoogleUserInfo {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
picture: string
|
||||
email_verified: boolean
|
||||
}
|
||||
|
||||
async function verifyGoogleToken(accessToken: string): Promise<GoogleUserInfo> {
|
||||
const res = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!res.ok) throw Errors.INVALID_GOOGLE_TOKEN
|
||||
|
||||
return res.json() as Promise<GoogleUserInfo>
|
||||
}
|
||||
|
||||
export async function googleLoginOrRegister(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
accessToken: string,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
) {
|
||||
const googleUser = await verifyGoogleToken(accessToken)
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ googleId: googleUser.sub },
|
||||
{ email: googleUser.email },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Email en DB mais compte classique => merge requis, on ne fait rien
|
||||
if (existing && !existing.googleId) {
|
||||
return { mergeRequired: true as const }
|
||||
}
|
||||
|
||||
// Compte google existant
|
||||
if (existing && existing.googleId) {
|
||||
if (existing.scheduledDeletionAt) throw Errors.ACCOUNT_PENDING_DELETION
|
||||
|
||||
return {
|
||||
mergeRequired: false as const,
|
||||
user: {
|
||||
id: existing.id,
|
||||
email: existing.email,
|
||||
displayName: existing.displayName,
|
||||
avatar: existing.avatar,
|
||||
isConfirmed: existing.isConfirmed,
|
||||
createdAt: existing.createdAt,
|
||||
tokenVersion: existing.tokenVersion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Nouveau user Google
|
||||
const isConfirmed = googleUser.email_verified
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
email: googleUser.email,
|
||||
googleId: googleUser.sub,
|
||||
displayName: googleUser.name,
|
||||
avatar: googleUser.picture,
|
||||
passwordHash: null,
|
||||
isConfirmed,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
isConfirmed: true,
|
||||
createdAt: true,
|
||||
tokenVersion: true,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.userPreference.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId: newUser.id,
|
||||
language: lang,
|
||||
theme: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
if (!isConfirmed) {
|
||||
const confirmToken = await createActionToken(prisma, newUser.id, 'email-confirm', 1440)
|
||||
await sendConfirmationMail(mailer, newUser.email, confirmToken, lang)
|
||||
}
|
||||
|
||||
return {
|
||||
mergeRequired: false as const,
|
||||
user: {
|
||||
...newUser,
|
||||
tokenVersion: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export async function passwordRecoveryRequest(
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
|
||||
if (!user) return // Réponse neutre
|
||||
if (user.googleId) return
|
||||
|
||||
const token = await createActionToken(prisma, user.id, 'password-change', 60)
|
||||
|
||||
@@ -50,7 +51,8 @@ export async function changePassword(
|
||||
})
|
||||
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
|
||||
if (!user.passwordHash) throw Errors.UNAUTHORIZED
|
||||
|
||||
const isValid = await argon2.verify(user.passwordHash, oldPassword)
|
||||
if (!isValid) throw Errors.INVALID_CREDENTIALS
|
||||
|
||||
|
||||
Reference in New Issue
Block a user