Compare commits
20 Commits
70ca3fc5de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1957473835 | |||
| 53d2db737e | |||
| 770c80c739 | |||
| 204b72bb94 | |||
| 845ec655d1 | |||
| 2bcae68eed | |||
| ea88c684ed | |||
| 94f1099083 | |||
| 886dfa4f24 | |||
| 9e247a3bfc | |||
| 2f9c7d4f53 | |||
| 915f9e864e | |||
| a556760872 | |||
| e3968e6dbf | |||
| e0f862b361 | |||
| 5e7e7b69e9 | |||
| 928fb55dea | |||
| 915813d681 | |||
| ba68ef4695 | |||
| fd688f2ff3 |
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.
|
||||
321
package-lock.json
generated
321
package-lock.json
generated
@@ -9,14 +9,22 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.20.0"
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
@@ -75,6 +83,12 @@
|
||||
"@electric-sql/pglite": "0.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
@@ -538,6 +552,26 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.0",
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -589,6 +623,29 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
|
||||
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/error": "^4.2.0",
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fast-jwt": "^6.0.2",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"steed": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
@@ -648,6 +705,24 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -1027,6 +1102,23 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -1088,6 +1180,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argon2": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
|
||||
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phc/format": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1140,6 +1260,12 @@
|
||||
"better-result": "bin/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -1238,11 +1364,27 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -1272,9 +1414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz",
|
||||
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1317,6 +1459,15 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||
@@ -1459,6 +1610,21 @@
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-jwt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
|
||||
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"asn1.js": "^5.4.1",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"mnemonist": "^0.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-querystring": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
|
||||
@@ -1484,6 +1650,18 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastfall": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
|
||||
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.8.4",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||
@@ -1533,6 +1711,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastparallel": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
||||
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4",
|
||||
"xtend": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -1542,6 +1730,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fastseries": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
|
||||
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.0",
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||
@@ -1691,6 +1889,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||
@@ -1711,7 +1915,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
@@ -1809,6 +2012,21 @@
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
@@ -1843,6 +2061,24 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
@@ -1850,6 +2086,26 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||
@@ -1875,6 +2131,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
@@ -1895,7 +2157,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2337,6 +2598,26 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
|
||||
@@ -2372,7 +2653,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -2427,7 +2707,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -2440,7 +2719,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2501,6 +2779,19 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/steed": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastfall": "^1.5.0",
|
||||
"fastparallel": "^2.2.0",
|
||||
"fastq": "^1.3.0",
|
||||
"fastseries": "^1.7.0",
|
||||
"reusify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
@@ -2591,7 +2882,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -2622,6 +2912,15 @@
|
||||
"grammex": "^3.1.11",
|
||||
"graphmatch": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -16,14 +16,22 @@
|
||||
"author": "Raffi",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.20.0"
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
|
||||
@@ -13,32 +13,36 @@ model ActionToken {
|
||||
token String @unique
|
||||
type String
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AuthToken {
|
||||
id String @id
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
revoked Boolean @default(false)
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
email String @unique
|
||||
passwordHash String
|
||||
id String @id
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
displayName String
|
||||
isConfirmed Boolean @default(false)
|
||||
isGoogleUser Boolean @default(false)
|
||||
tokenVersion Int @default(0)
|
||||
isConfirmed Boolean @default(false)
|
||||
googleId String? @unique
|
||||
avatar String
|
||||
createdAt DateTime @default(now())
|
||||
publicKey String?
|
||||
encryptedPrivateKey String?
|
||||
emailSwap String?
|
||||
emailSwapAt DateTime?
|
||||
scheduledDeletionAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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)
|
||||
}
|
||||
|
||||
17
src/app.ts
17
src/app.ts
@@ -1,11 +1,26 @@
|
||||
import Fastify from 'fastify'
|
||||
import prismaPlugin from './plugins/prisma'
|
||||
import cookie from '@fastify/cookie'
|
||||
import jwt from '@fastify/jwt'
|
||||
import prismaPlugin from './plugins/prisma.js'
|
||||
import mailerPlugin from './plugins/mailer.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import userRoutes from './routes/users.js'
|
||||
import errorHandler from './plugins/errorHandler.js'
|
||||
import cronJobs from './plugins/cronJobs.js'
|
||||
|
||||
export default function buildApp() {
|
||||
const app = Fastify({ logger: true })
|
||||
|
||||
app.register(errorHandler)
|
||||
app.register(cookie)
|
||||
app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
})
|
||||
app.register(prismaPlugin)
|
||||
app.register(mailerPlugin)
|
||||
app.register(cronJobs)
|
||||
|
||||
app.register(authRoutes, { prefix: '/api' })
|
||||
app.register(userRoutes, { prefix: '/api' })
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }))
|
||||
|
||||
38
src/errors/AppError.ts
Normal file
38
src/errors/AppError.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly statusCode: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
|
||||
// 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'),
|
||||
// Email confirmation errors
|
||||
ALREADY_CONFIRMED: new AppError('ALREADY_CONFIRMED', 400, 'User is already confirmed'),
|
||||
// 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'),
|
||||
// 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
|
||||
UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'),
|
||||
USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'),
|
||||
}
|
||||
19
src/middleware/verifyAuth.ts
Normal file
19
src/middleware/verifyAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { verifyAuthToken } from '../services/authToken.service.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
|
||||
export async function verifyAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||
const token = request.cookies['authToken']
|
||||
console.log('token reçu:', token)
|
||||
|
||||
if (!token) throw Errors.UNAUTHORIZED
|
||||
|
||||
try {
|
||||
const payload = await verifyAuthToken(request.server, request.server.prisma, token)
|
||||
console.log('payload:', payload)
|
||||
request.user = payload
|
||||
} catch (e) {
|
||||
console.log('erreur:', e)
|
||||
throw Errors.UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
26
src/plugins/cronJobs.ts
Normal file
26
src/plugins/cronJobs.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import cron from 'node-cron'
|
||||
|
||||
export default fp(async (fastify: FastifyInstance) => {
|
||||
// Cleaning all nights at 2am
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
fastify.log.info('Cron: démarrage du nettoyage nocturne')
|
||||
|
||||
// Suppression des comptes dont la date de suppression est dépassée
|
||||
const deletedAccounts = await fastify.prisma.user.deleteMany({
|
||||
where: {
|
||||
scheduledDeletionAt: { lte: new Date() },
|
||||
},
|
||||
})
|
||||
fastify.log.info(`Cron: ${deletedAccounts.count} compte(s) supprimé(s)`)
|
||||
|
||||
// Nettoyage des tokens expirés
|
||||
const deletedTokens = await fastify.prisma.actionToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
})
|
||||
fastify.log.info(`Cron: ${deletedTokens.count} token(s) expiré(s) supprimé(s)`)
|
||||
})
|
||||
})
|
||||
34
src/plugins/errorHandler.ts
Normal file
34
src/plugins/errorHandler.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { ZodError } from 'zod'
|
||||
import { AppError } from '../errors/AppError.js'
|
||||
|
||||
export default fp(async (fastify: FastifyInstance) => {
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
// Erreur Zod
|
||||
if (error instanceof ZodError) {
|
||||
return reply.status(400).send({
|
||||
error: 'VALIDATION_ERROR',
|
||||
details: error.issues.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Erreur métier
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
// Erreur inconnue
|
||||
fastify.log.error(error)
|
||||
return reply.status(500).send({
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Une erreur interne est survenue.',
|
||||
})
|
||||
})
|
||||
})
|
||||
22
src/plugins/mailer.ts
Normal file
22
src/plugins/mailer.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import nodemailer, { Transporter } from 'nodemailer'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
mailer: Transporter
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (fastify: FastifyInstance) => {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
})
|
||||
|
||||
fastify.decorate('mailer', transporter)
|
||||
})
|
||||
67
src/routes/auth.ts
Normal file
67
src/routes/auth.ts
Normal file
@@ -0,0 +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({ 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 })
|
||||
})
|
||||
|
||||
}
|
||||
@@ -1,19 +1,114 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { Errors } from '../errors/AppError'
|
||||
import { confirmEmail } from '../services/user.service'
|
||||
import { verifyAuth } from '../middleware/verifyAuth'
|
||||
import { UpdateDisplayNameSchema, ChangePasswordSchema, PasswordRecoveryRequestSchema, ConfirmPasswordRecoverySchema } from '../schemas/user.schema.js'
|
||||
import { updateDisplayName } from '../services/user.service.js'
|
||||
import { passwordRecoveryRequest, confirmPasswordRecovery, changePassword } from '../services/passwordManagement.service.js'
|
||||
import { EmailChangeRequestSchema } from '../schemas/user.schema.js'
|
||||
import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../services/emailChange.service.js'
|
||||
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,
|
||||
},
|
||||
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
fastify.patch('/user/display-name', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
const body = UpdateDisplayNameSchema.parse(request.body)
|
||||
const user = await updateDisplayName(fastify.prisma, request.user.userId, body)
|
||||
return reply.status(200).send({ user })
|
||||
})
|
||||
|
||||
fastify.post('/user/pwd-recovery-request', async (request, reply) => {
|
||||
const { email } = PasswordRecoveryRequestSchema.parse(request.body)
|
||||
if (!email) throw Errors.VALIDATION_ERROR
|
||||
|
||||
const lang = request.headers['accept-language']
|
||||
?.split(',')[0]
|
||||
.split('-')[0] as 'fr' | 'en'
|
||||
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
|
||||
|
||||
await passwordRecoveryRequest(fastify.prisma, fastify.mailer, email, validLang)
|
||||
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
})
|
||||
})
|
||||
|
||||
fastify.post('/user/pwd-recovery', async (request, reply) => {
|
||||
const { token, newPassword } = ConfirmPasswordRecoverySchema.parse(request.body)
|
||||
if (!token || !newPassword) throw Errors.VALIDATION_ERROR
|
||||
await confirmPasswordRecovery(fastify.prisma, token, newPassword)
|
||||
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.patch('/user/pwd-change', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
const { oldPassword, newPassword } = ChangePasswordSchema.parse(request.body)
|
||||
if (!oldPassword || !newPassword) throw Errors.VALIDATION_ERROR
|
||||
|
||||
const newToken = await changePassword(fastify.prisma, fastify, request.user.userId, oldPassword, newPassword)
|
||||
|
||||
reply.setCookie('authToken', newToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return users
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.post('/user/email-change-request', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
const { newEmail } = EmailChangeRequestSchema.parse(request.body)
|
||||
const lang = request.headers['accept-language']
|
||||
?.split(',')[0]
|
||||
.split('-')[0] as 'fr' | 'en'
|
||||
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
|
||||
|
||||
await emailChangeRequest(fastify.prisma, fastify.mailer, request.user.userId, newEmail, validLang)
|
||||
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/email-change-confirm', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
if (!token) throw Errors.INVALID_TOKEN
|
||||
|
||||
await emailChangeConfirm(fastify.prisma, token)
|
||||
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/email-change-rollback', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
if (!token) throw Errors.INVALID_TOKEN
|
||||
|
||||
await emailRollback(fastify.prisma, token)
|
||||
|
||||
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({ success : true })
|
||||
})
|
||||
|
||||
fastify.get('/user/delete-cancel', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
if (!token) throw Errors.INVALID_TOKEN
|
||||
|
||||
await cancelAccountDeletion(fastify.prisma, token)
|
||||
return reply.status(200).send({ success : true })
|
||||
})
|
||||
}
|
||||
16
src/schemas/auth.schema.ts
Normal file
16
src/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { passwordSchema } from './shared.schema.js'
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
email: z.email({ error: 'Adresse email invalide.' }),
|
||||
password: passwordSchema
|
||||
})
|
||||
|
||||
export type RegisterInput = z.infer<typeof RegisterSchema>
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.email({ error: 'Adresse email invalide.' }),
|
||||
password: z.string().min(1, { error: 'Le mot de passe est requis.' }),
|
||||
})
|
||||
|
||||
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>
|
||||
9
src/schemas/shared.schema.ts
Normal file
9
src/schemas/shared.schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.regex(/^.{8,22}$/, { error: 'Le mot de passe doit contenir entre 8 et 22 caractères.' })
|
||||
.regex(/[^A-Za-z0-9]/, { error: 'Le mot de passe doit contenir au moins un caractère spécial.' })
|
||||
.regex(/[A-Z]/, { error: 'Le mot de passe doit contenir au moins une majuscule.' })
|
||||
.regex(/[a-z]/, { error: 'Le mot de passe doit contenir au moins une minuscule.' })
|
||||
.regex(/\d/, { error: 'Le mot de passe doit contenir au moins un chiffre.' })
|
||||
34
src/schemas/user.schema.ts
Normal file
34
src/schemas/user.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
import { passwordSchema } from './shared.schema'
|
||||
|
||||
export const UpdateDisplayNameSchema = z.object({
|
||||
displayName: z.string().min(2).max(32),
|
||||
})
|
||||
|
||||
export type UpdateDisplayNameInput = z.infer<typeof UpdateDisplayNameSchema>
|
||||
|
||||
export const ChangePasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1, { error: 'L\'ancien mot de passe est requis.' }),
|
||||
newPassword: passwordSchema,
|
||||
})
|
||||
|
||||
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>
|
||||
|
||||
export const PasswordRecoveryRequestSchema = z.object({
|
||||
email: z.email({ error: 'Adresse email invalide.' }),
|
||||
})
|
||||
|
||||
export type PasswordRecoveryRequestInput = z.infer<typeof PasswordRecoveryRequestSchema>
|
||||
|
||||
export const ConfirmPasswordRecoverySchema = z.object({
|
||||
token: z.string().min(1, { error: 'Le token est requis.' }),
|
||||
newPassword: passwordSchema,
|
||||
})
|
||||
|
||||
export type ConfirmPasswordRecoveryInput = z.infer<typeof ConfirmPasswordRecoverySchema>
|
||||
|
||||
export const EmailChangeRequestSchema = z.object({
|
||||
newEmail: z.email({ error: 'Adresse email invalide.' }),
|
||||
})
|
||||
|
||||
export type EmailChangeRequestInput = z.infer<typeof EmailChangeRequestSchema>
|
||||
46
src/services/accountDeletion.service.ts
Normal file
46
src/services/accountDeletion.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Transporter } from 'nodemailer'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { createActionToken, consumeActionToken } from './actionToken.service.js'
|
||||
import { sendAccountDeletionWarning } from './mailing.service.js'
|
||||
|
||||
export async function scheduleAccountDeletion(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
userId: string,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<void> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
if (user.scheduledDeletionAt) throw Errors.ACCOUNT_ALREADY_PENDING_DELETION
|
||||
|
||||
const scheduledDeletionAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { scheduledDeletionAt },
|
||||
})
|
||||
|
||||
const cancelToken = await createActionToken(prisma, userId, 'delete-cancel', 7 * 24 * 60)
|
||||
|
||||
await sendAccountDeletionWarning(mailer, user.email, user.displayName, cancelToken, lang)
|
||||
}
|
||||
|
||||
export async function cancelAccountDeletion(
|
||||
prisma: PrismaClient,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const actionToken = await consumeActionToken(prisma, token, 'delete-cancel')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { scheduledDeletionAt: true },
|
||||
})
|
||||
|
||||
if (!user?.scheduledDeletionAt) throw Errors.INVALID_TOKEN
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: { scheduledDeletionAt: null },
|
||||
})
|
||||
}
|
||||
49
src/services/actionToken.service.ts
Normal file
49
src/services/actionToken.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from 'crypto'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
|
||||
function generateToken(length = 32): string {
|
||||
return crypto.randomBytes(length).toString('hex')
|
||||
}
|
||||
|
||||
function generateActionTokenExpiry(minutes = 1440): Date {
|
||||
const date = new Date()
|
||||
date.setMinutes(date.getMinutes() + minutes)
|
||||
return date
|
||||
}
|
||||
|
||||
export async function createActionToken(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
type: string,
|
||||
expiryMinutes = 1440
|
||||
): Promise<string> {
|
||||
const token = generateToken()
|
||||
await prisma.actionToken.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
expiresAt: generateActionTokenExpiry(expiryMinutes),
|
||||
},
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
export async function consumeActionToken(
|
||||
prisma: PrismaClient,
|
||||
token: string,
|
||||
type: string
|
||||
) {
|
||||
const actionToken = await prisma.actionToken.findUnique({ where: { token } })
|
||||
|
||||
if (!actionToken || actionToken.type !== type) throw Errors.INVALID_TOKEN
|
||||
if (actionToken.expiresAt < new Date()) throw Errors.INVALID_TOKEN
|
||||
|
||||
await prisma.actionToken.delete({
|
||||
where: { token },
|
||||
})
|
||||
|
||||
return actionToken
|
||||
}
|
||||
83
src/services/auth.service.ts
Normal file
83
src/services/auth.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import argon2 from 'argon2'
|
||||
import crypto from 'crypto'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Transporter } from 'nodemailer'
|
||||
import { RegisterInput, LoginInput } from '../schemas/auth.schema.js'
|
||||
import { sendConfirmationMail } from './mailing.service.js'
|
||||
import { createActionToken } from './actionToken.service.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { generateGravatarUrl } from './avatar.service.js'
|
||||
|
||||
export async function registerUser(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
input: RegisterInput,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
) {
|
||||
const existing = await prisma.user.findUnique({ where: { email: input.email } })
|
||||
if (existing) throw Errors.EMAIL_TAKEN
|
||||
|
||||
const passwordHash = await argon2.hash(input.password)
|
||||
const displayName = input.email.split('@')[0]
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName,
|
||||
avatar: generateGravatarUrl(input.email),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatar:true,
|
||||
isConfirmed: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const confirmToken = await createActionToken(prisma, user.id, 'email-confirm', 1440)
|
||||
await sendConfirmationMail(mailer, user.email, confirmToken, lang)
|
||||
|
||||
await prisma.userPreference.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
language: lang,
|
||||
theme: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
return { user }
|
||||
}
|
||||
|
||||
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
|
||||
if (user.scheduledDeletionAt) throw Errors.ACCOUNT_PENDING_DELETION
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
isConfirmed: user.isConfirmed,
|
||||
createdAt: user.createdAt,
|
||||
tokenVersion: user.tokenVersion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutUser(prisma: PrismaClient, userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
30
src/services/authToken.service.ts
Normal file
30
src/services/authToken.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string
|
||||
tokenVersion: number
|
||||
}
|
||||
|
||||
export function signAuthToken(fastify: FastifyInstance, payload: JwtPayload): string {
|
||||
return fastify.jwt.sign(payload, { expiresIn: '7d' })
|
||||
}
|
||||
|
||||
export async function verifyAuthToken(
|
||||
fastify: FastifyInstance,
|
||||
prisma: PrismaClient,
|
||||
token: string
|
||||
): Promise<JwtPayload> {
|
||||
const payload = fastify.jwt.verify<JwtPayload>(token)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { tokenVersion: true },
|
||||
})
|
||||
|
||||
if (!user || user.tokenVersion !== payload.tokenVersion) {
|
||||
throw new Error('TOKEN_REVOKED')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
10
src/services/avatar.service.ts
Normal file
10
src/services/avatar.service.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function generateGravatarUrl(email: string, size = 200): string {
|
||||
const hash = crypto
|
||||
.createHash('md5')
|
||||
.update(email.trim().toLowerCase())
|
||||
.digest('hex')
|
||||
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`
|
||||
}
|
||||
102
src/services/emailChange.service.ts
Normal file
102
src/services/emailChange.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Transporter } from 'nodemailer'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { createActionToken, consumeActionToken } from './actionToken.service.js'
|
||||
import { sendEmailChangeConfirmation, sendEmailChangeWarning } from './mailing.service.js'
|
||||
|
||||
export async function emailChangeRequest(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
userId: string,
|
||||
newEmail: string,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<void> {
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
|
||||
if (newEmail === user.email) throw Errors.EMAIL_ALREADY_YOURS
|
||||
|
||||
const emailTaken = await prisma.user.findUnique({ where: { email: newEmail } })
|
||||
if (emailTaken) throw Errors.EMAIL_ALREADY_USED
|
||||
|
||||
// On stocke le nouvel email dans emailSwap, valable 15 min
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { emailSwap: newEmail, emailSwapAt: new Date() },
|
||||
})
|
||||
|
||||
// Token de confirmation pour la nouvelle boite (15 min)
|
||||
const confirmToken = await createActionToken(prisma, userId, 'email-change-confirm', 15)
|
||||
|
||||
// Token de rollback pour l'ancienne boite (24h)
|
||||
const rollbackToken = await createActionToken(prisma, userId, 'email-change-rollback', 1440)
|
||||
|
||||
// Mail d'avertissement à l'ancienne adresse
|
||||
await sendEmailChangeWarning(mailer, user.email, user.displayName, rollbackToken, lang)
|
||||
|
||||
// Mail de confirmation à la nouvelle adresse
|
||||
await sendEmailChangeConfirmation(mailer, newEmail, user.displayName, confirmToken, lang)
|
||||
}
|
||||
|
||||
export async function emailChangeConfirm(
|
||||
prisma: PrismaClient,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const actionToken = await consumeActionToken(prisma, token, 'email-change-confirm')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { emailSwap: true, emailSwapAt: true },
|
||||
})
|
||||
|
||||
if (!user?.emailSwap || !user.emailSwapAt) throw Errors.INVALID_TOKEN
|
||||
|
||||
// Vérification fenêtre 15 min
|
||||
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000)
|
||||
if (user.emailSwapAt < fifteenMinutesAgo) throw Errors.TOKEN_EXPIRED
|
||||
|
||||
// Permutation : le nouvel email devient l'email principal
|
||||
// l'ancien email va dans emailSwap pour le rollback (24h)
|
||||
const currentEmail = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { email: true },
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: {
|
||||
email: user.emailSwap,
|
||||
emailSwap: currentEmail!.email,
|
||||
emailSwapAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function emailRollback(
|
||||
prisma: PrismaClient,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const actionToken = await consumeActionToken(prisma, token, 'email-change-rollback')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { emailSwap: true, emailSwapAt: true },
|
||||
})
|
||||
|
||||
if (!user?.emailSwap || !user.emailSwapAt) throw Errors.INVALID_TOKEN
|
||||
|
||||
// Vérification fenêtre 24h
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
if (user.emailSwapAt < twentyFourHoursAgo) throw Errors.TOKEN_EXPIRED
|
||||
|
||||
// On remet l'ancien email en place et on nettoie
|
||||
await prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: {
|
||||
email: user.emailSwap,
|
||||
emailSwap: null,
|
||||
emailSwapAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
250
src/services/mailing.service.ts
Normal file
250
src/services/mailing.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
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,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
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,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
const passwordChangeTemplates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Réinitialisation de votre mot de passe',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Vous avez demandé une réinitialisation de votre mot de passe.</p>
|
||||
<p>
|
||||
Cliquez sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>
|
||||
pour choisir un nouveau mot de passe. Ce lien expire dans 60 minutes.
|
||||
</p>
|
||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Reset your password',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>You requested a password reset.</p>
|
||||
<p>
|
||||
Click
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>
|
||||
to choose a new password. This link expires in 60 minutes.
|
||||
</p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendPasswordChangeRequestEmail(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.FRONT_URL}/${lang}/reset-pwd?token=${token}`
|
||||
const { subject, html } = passwordChangeTemplates[lang](url)
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
const emailChangeWarningTemplates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Demande de changement d\'adresse email',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Une demande de changement d'adresse email a été effectuée sur votre compte.</p>
|
||||
<p>
|
||||
Si vous n'êtes pas à l'origine de cette demande, annulez-la immédiatement en cliquant sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
Ce lien est valable 24 heures.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Email address change request',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>A request to change your email address was made on your account.</p>
|
||||
<p>
|
||||
If you did not request this, cancel it immediately by clicking
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
This link is valid for 24 hours.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
const emailChangeConfirmationTemplates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Confirmez votre nouvelle adresse email',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Pour finaliser le changement de votre adresse email, cliquez sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
Ce lien expire dans 15 minutes.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Confirm your new email address',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>To complete your email address change, click
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
This link expires in 15 minutes.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendEmailChangeWarning(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
displayName: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.FRONT_URL}/${lang}/email-rollback?token=${token}`
|
||||
const { subject, html } = emailChangeWarningTemplates[lang](url)
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendEmailChangeConfirmation(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
displayName: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.FRONT_URL}/${lang}/email-change-confirm?token=${token}`
|
||||
const { subject, html } = emailChangeConfirmationTemplates[lang](url)
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
const accountDeletionTemplates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Suppression de votre compte',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Votre compte a été programmé pour être supprimé dans 7 jours.</p>
|
||||
<p>
|
||||
Si vous souhaitez annuler cette suppression, cliquez sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
Ce lien est valable 7 jours.
|
||||
</p>
|
||||
<p>Sans action de votre part, votre compte sera définitivement supprimé.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Account deletion scheduled',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Your account has been scheduled for deletion in 7 days.</p>
|
||||
<p>
|
||||
If you wish to cancel this deletion, click
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
This link is valid for 7 days.
|
||||
</p>
|
||||
<p>If you do nothing, your account will be permanently deleted.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionWarning(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
displayName: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.FRONT_URL}/${lang}/delete-cancel?token=${token}`
|
||||
const { subject, html } = accountDeletionTemplates[lang](url)
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
68
src/services/passwordManagement.service.ts
Normal file
68
src/services/passwordManagement.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import argon2 from 'argon2'
|
||||
import { Transporter } from 'nodemailer'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { createActionToken, consumeActionToken } from './actionToken.service.js'
|
||||
import { sendPasswordChangeRequestEmail } from './mailing.service.js'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { signAuthToken } from './authToken.service.js'
|
||||
|
||||
export async function passwordRecoveryRequest(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<void> {
|
||||
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)
|
||||
|
||||
await sendPasswordChangeRequestEmail(mailer, user.email, token, lang)
|
||||
}
|
||||
|
||||
export async function confirmPasswordRecovery(
|
||||
prisma: PrismaClient,
|
||||
token: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
const actionToken = await consumeActionToken(prisma, token, 'password-change')
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: { passwordHash },
|
||||
})
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
prisma: PrismaClient,
|
||||
fastify: FastifyInstance,
|
||||
userId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<string> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true, tokenVersion: true },
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword)
|
||||
const newTokenVersion = user.tokenVersion + 1
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, tokenVersion: newTokenVersion },
|
||||
})
|
||||
|
||||
return signAuthToken(fastify, { userId, tokenVersion: newTokenVersion })
|
||||
}
|
||||
60
src/services/user.service.ts
Normal file
60
src/services/user.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { Errors } from '../errors/AppError.js'
|
||||
import { UpdateDisplayNameInput } from '../schemas/user.schema.js'
|
||||
|
||||
export async function confirmEmail(prisma: PrismaClient, token: string) {
|
||||
const actionToken = await prisma.actionToken.findUnique({
|
||||
where: { token },
|
||||
})
|
||||
|
||||
if (!actionToken || actionToken.type !== 'email-confirm') {
|
||||
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 }
|
||||
}
|
||||
|
||||
export async function updateDisplayName(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
input: UpdateDisplayNameInput
|
||||
) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
|
||||
return await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { displayName: input.displayName },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
isConfirmed: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
20
src/types/fastify.d.ts
vendored
20
src/types/fastify.d.ts
vendored
@@ -4,4 +4,24 @@ declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
user: {
|
||||
userId: string
|
||||
tokenVersion: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
userId: string
|
||||
tokenVersion: number
|
||||
}
|
||||
user: {
|
||||
userId: string
|
||||
tokenVersion: number
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user