From 9f6363ec206e5327d8cac901081ce887ac18b04d Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 14 Jun 2026 21:58:05 +0200 Subject: [PATCH] Modification des pages d'authentification --- .env.example | 24 ++ backend/package-lock.json | 398 ++++++++++++++++++++ backend/package.json | 2 + backend/src/db/index.js | 177 +++++---- backend/src/routes/admin.js | 17 +- backend/src/routes/auth.js | 424 +++++++++++++++++++++- backend/src/server.js | 16 + backend/src/utils/mailer.js | 2 +- frontend/public/login-bg.jpg | Bin 0 -> 54510 bytes frontend/src/App.jsx | 10 +- frontend/src/api.js | 3 +- frontend/src/context/AuthContext.jsx | 22 +- frontend/src/pages/ForgotPassword.jsx | 223 ++++++++++++ frontend/src/pages/Login.jsx | 413 +++++++++++++++++++-- frontend/src/pages/MonCompte.jsx | 258 ++++++++++++- frontend/src/pages/Register.jsx | 263 ++++++++++++-- frontend/src/pages/ResetPassword.jsx | 254 +++++++++++++ frontend/src/pages/VerifyEmail.jsx | 127 +++++++ frontend/src/pages/admin/SmtpSection.jsx | 4 +- frontend/src/pages/admin/UsersSection.jsx | 29 +- 20 files changed, 2494 insertions(+), 172 deletions(-) create mode 100644 frontend/public/login-bg.jpg create mode 100644 frontend/src/pages/ForgotPassword.jsx create mode 100644 frontend/src/pages/ResetPassword.jsx create mode 100644 frontend/src/pages/VerifyEmail.jsx diff --git a/.env.example b/.env.example index a7be19c..c407580 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,27 @@ JWT_EXPIRES_IN=7d # Chemins dans le container (ne pas modifier) DB_PATH=/app/data/crowdlending.db UPLOAD_DIR=/app/uploads + +# --- SMTP (optionnel — configurable aussi depuis Administration > SMTP) --- +# La DB est prioritaire sur ces variables. Elles servent de valeurs par défaut +# au premier démarrage ou si aucune config n'a été sauvegardée en base. + +# Activer l'envoi d'emails (0 = désactivé, 1 = activé) +# SMTP_ENABLED=1 + +# Serveur SMTP +# SMTP_HOST=smtp.example.com +# SMTP_PORT=465 +# SMTP_SECURE=1 # 1 = TLS (port 465), 0 = STARTTLS (port 587) + +# Authentification +# SMTP_EMAIL=noreply@example.com +# SMTP_USERNAME=noreply@example.com +# SMTP_PASSWORD=your-smtp-password + +# Autoriser les certificats auto-signés (déconseillé en production) +# SMTP_ALLOW_UNAUTH=0 + +# Identité de la plateforme (utilisée dans les emails et sur la page de connexion) +# APP_NAME=Crowdlending Tracker +# APP_URL=https://votre-domaine.com diff --git a/backend/package-lock.json b/backend/package-lock.json index f0b57b1..4427617 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,8 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.11", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" @@ -499,6 +501,83 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -521,6 +600,30 @@ "node": ">=0.8" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -731,6 +834,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -750,6 +862,17 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -759,6 +882,24 @@ "node": ">=0.8" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -854,6 +995,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -906,6 +1056,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -947,6 +1103,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1104,6 +1266,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1146,6 +1321,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1307,6 +1491,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1362,6 +1555,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1652,6 +1857,56 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1661,12 +1916,30 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1723,6 +1996,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1798,6 +2088,21 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1881,6 +2186,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2092,6 +2403,32 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2223,6 +2560,12 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -2241,6 +2584,20 @@ "node": ">=0.8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2277,6 +2634,47 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index 6ee6883..2c3d3b2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,8 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.11", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 2e5b66d..f6728ef 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -939,7 +939,6 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_s } } -export default db; // ── Table user_preferences ─────────────────────────────────────────────────── // Stockage générique des préférences UI par utilisateur. @@ -1671,85 +1670,111 @@ db.exec(` console.log('[DB] Tables catégories/secteurs plateforme+investissement OK'); } -// ── Migration ponctuelle : correction date_cible aberrantes (>2100) ────────── -// Certains prêts différés importés ont une date_cible avec un siècle erroné. -// On recalcule date_souscription + duree_mois et on régénère la simulation. + +// ── Migration : table de configuration SMTP ────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS smtp_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + secure INTEGER NOT NULL DEFAULT 0, + email TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + allow_unauth INTEGER NOT NULL DEFAULT 0, + app_name TEXT NOT NULL DEFAULT 'Crowdlending Tracker', + app_url TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migration : email_verified sur users ───────────────────────────────────── { - function fixAddMonths(isoDate, months) { - const [y, m, d] = isoDate.split('-').map(Number); - let nm = m + months; - let ny = y; - while (nm > 12) { nm -= 12; ny++; } - const maxDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate(); - const nd = Math.min(d, maxDay); - return `${String(ny).padStart(4,'0')}-${String(nm).padStart(2,'0')}-${String(nd).padStart(2,'0')}`; - } - - const toFix = db.prepare(` - SELECT i.id, i.date_souscription, i.duree_mois, - i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets, - i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois - FROM investissements i - WHERE i.statut IN ('en_cours','en_retard','procedure') - AND i.type_remb = 'differe' - AND i.date_cible > '2100-01-01' - AND i.duree_mois IS NOT NULL - `).all(); - - if (toFix.length > 0) { - const updateDate = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`); - const fixAll = db.transaction(() => { - for (const inv of toFix) { - const newDate = fixAddMonths(inv.date_souscription, inv.duree_mois); - updateDate.run(newDate, inv.id); - generateSimul(db, { ...inv, date_cible: newDate }); - console.log(`[DB] Fix date_cible id=${inv.id} → ${newDate}`); - } - }); - fixAll(); - console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`); + const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!userCols.includes('email_verified')) { + // DEFAULT 1 pour ne pas bloquer les comptes existants + db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1'); + console.log('[DB] users.email_verified ajouté'); } } -// ── Migration : table smtp_config ───────────────────────────────────────── +// ── Migration : table email_verification_tokens ─────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migration : table password_reset_tokens ─────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migrations 2FA ──────────────────────────────────────────────────────────── { - db.exec(` - CREATE TABLE IF NOT EXISTS smtp_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - enabled INTEGER NOT NULL DEFAULT 0, - host TEXT, - port INTEGER NOT NULL DEFAULT 587, - secure INTEGER NOT NULL DEFAULT 0, - email TEXT, - username TEXT, - password TEXT, - allow_unauth INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - `); - - // Seed row unique (id=1) si elle n'existe pas encore - const existing = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get(); - if (!existing) { - // Pré-remplir depuis les variables d'environnement si disponibles - db.prepare(` - INSERT INTO smtp_config (id, enabled, host, port, email, username, password) - VALUES (1, 0, ?, ?, ?, ?, ?) - `).run( - process.env.SMTP_HOST || null, - parseInt(process.env.SMTP_PORT || '587', 10), - process.env.SMTP_EMAIL || null, - process.env.SMTP_USERNAME || null, - process.env.SMTP_PASSWORD || null, - ); + const userCols2 = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!userCols2.includes('totp_secret')) { + db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT'); + console.log('[DB] users.totp_secret ajouté'); + } + if (!userCols2.includes('totp_enabled')) { + db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0'); + console.log('[DB] users.totp_enabled ajouté'); } - - // Ajout des colonnes app_name et app_url si absentes - const smtpCols = db.prepare('PRAGMA table_info(smtp_config)').all().map(c => c.name); - if (!smtpCols.includes('app_name')) - db.exec(`ALTER TABLE smtp_config ADD COLUMN app_name TEXT DEFAULT 'Crowdlending'`); - if (!smtpCols.includes('app_url')) - db.exec(`ALTER TABLE smtp_config ADD COLUMN app_url TEXT DEFAULT ''`); - - console.log('[DB] Table smtp_config OK'); } + +// Sessions temporaires 2FA (entre /login et /2fa/verify) +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_sess_token ON two_fa_sessions(token)'); + +// Codes OTP envoyés par email +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_email_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_email_uid ON two_fa_email_codes(user_id)'); + +// Appareils de confiance (30 jours) +db.exec(` + CREATE TABLE IF NOT EXISTS two_fa_trusted_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_token ON two_fa_trusted_devices(token)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(user_id)'); + +console.log('[DB] Migrations 2FA OK'); + +export default db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0cb929d..2643462 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -48,13 +48,24 @@ const router = Router(); /** Liste tous les utilisateurs */ router.get('/users', (req, res) => { const users = db.prepare(` - SELECT id, email, display_name, role, created_at + SELECT id, email, display_name, role, email_verified, created_at FROM users ORDER BY id ASC `).all(); res.json(users); }); +/** Vérifier manuellement l'email d'un utilisateur */ +router.patch('/users/:id/verify-email', (req, res, next) => { + try { + const targetId = Number(req.params.id); + const r = db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(targetId); + if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(targetId); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + /** Crée un utilisateur */ const CreateUserSchema = z.object({ email: z.string().email(), @@ -387,7 +398,7 @@ router.get('/smtp', (req, res) => { username: row.username || '', hasPassword: !!(row.password), allowUnauth: !!row.allow_unauth, - appName: row.app_name || 'Crowdlending', + appName: row.app_name || 'Crowdlending Tracker', appUrl: row.app_url || '', }); }); @@ -450,7 +461,7 @@ router.put('/smtp', (req, res, next) => { body.username ?? null, passwordToStore, body.allowUnauth ? 1 : 0, - body.appName ?? 'Crowdlending', + body.appName ?? 'Crowdlending Tracker', body.appUrl ?? '', ); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index b9b650f..2fc0534 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,9 +1,11 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; +import crypto from 'node:crypto'; import { z } from 'zod'; import db from '../db/index.js'; import { signToken, requireAuth } from '../middleware/auth.js'; import { HttpError } from '../middleware/errorHandler.js'; +import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js'; const router = Router(); @@ -18,7 +20,7 @@ const LoginSchema = z.object({ password: z.string().min(1), }); -router.post('/register', (req, res, next) => { +router.post('/register', async (req, res, next) => { try { const body = RegisterSchema.parse(req.body); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email); @@ -28,10 +30,15 @@ router.post('/register', (req, res, next) => { const isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0; const role = isFirst ? 'admin' : 'user'; + // Le 1er utilisateur (admin) est auto-vérifié ; les suivants si SMTP désactivé + const cfg = getSmtpConfig(); + const smtpReady = cfg.enabled && cfg.host && cfg.email; + const autoVerified = isFirst || !smtpReady ? 1 : 0; + const hash = bcrypt.hashSync(body.password, 10); const result = db - .prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)') - .run(body.email, hash, body.displayName || null, role); + .prepare('INSERT INTO users (email, password_hash, display_name, role, email_verified) VALUES (?, ?, ?, ?, ?)') + .run(body.email, hash, body.displayName || null, role, autoVerified); const userId = result.lastInsertRowid; @@ -47,36 +54,104 @@ router.post('/register', (req, res, next) => { 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' ).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid); + // Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue + if (!autoVerified) { + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(userId, vToken, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + try { + await sendMail({ + to: body.email, + subject: `Bienvenue sur ${cfg.appName} — Vérifiez votre adresse email`, + html: buildEmailHtml({ + title: `Bienvenue ${prenom || ''} !`, + body: `

Votre compte a été créé avec succès. Pour commencer à utiliser ${cfg.appName}, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.

+

Ce lien est valable 24 heures.

`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + } catch (mailErr) { + console.error('[auth] Échec envoi email bienvenue:', mailErr.message); + // Ne pas faire échouer l'inscription si le mail échoue + } + + return res.status(201).json({ requiresVerification: true, email: body.email }); + } + const token = signToken({ sub: userId, email: body.email }); res.status(201).json({ token, - user: { id: userId, email: body.email, displayName: body.displayName || null, role }, + user: { id: userId, email: body.email, displayName: body.displayName || null, role, email_verified: 1 }, }); } catch (e) { next(e); } }); -router.post('/login', (req, res, next) => { +const LoginSchema2FA = LoginSchema.extend({ + deviceToken: z.string().optional(), +}); + +router.post('/login', async (req, res, next) => { try { - const body = LoginSchema.parse(req.body); + const body = LoginSchema2FA.parse(req.body); const user = db - .prepare('SELECT id, email, password_hash, display_name, role FROM users WHERE email = ?') + .prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled FROM users WHERE email = ?') .get(body.email); if (!user) throw new HttpError(401, 'Invalid credentials'); const ok = bcrypt.compareSync(body.password, user.password_hash); if (!ok) throw new HttpError(401, 'Invalid credentials'); + if (!user.email_verified) { + return res.status(403).json({ + error: 'Veuillez vérifier votre adresse email avant de vous connecter.', + code: 'EMAIL_NOT_VERIFIED', + email: user.email, + }); + } + + // ── 2FA ────────────────────────────────────────────────────────────── + if (user.totp_enabled) { + // Vérifier si l'appareil est déjà de confiance + if (body.deviceToken) { + const dev = db.prepare( + 'SELECT id FROM two_fa_trusted_devices WHERE token = ? AND user_id = ? AND expires_at > datetime(\'now\')' + ).get(body.deviceToken, user.id); + if (dev) { + // Appareil de confiance — émettre le JWT directement + const token = signToken({ sub: user.id, email: user.email }); + return res.json({ + token, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 }, + }); + } + } + + // Créer une session 2FA temporaire (5 minutes) + const sessionToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_sessions (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, sessionToken, expiresAt); + + return res.json({ + requires2FA: true, + sessionToken, + email: user.email, + }); + } + const token = signToken({ sub: user.id, email: user.email }); res.json({ token, - user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role }, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 0 }, }); } catch (e) { next(e); } }); router.get('/me', requireAuth, (req, res) => { const user = db - .prepare('SELECT id, email, display_name, role FROM users WHERE id = ?') + .prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id = ?') .get(req.user.id); res.json({ user }); }); @@ -88,12 +163,12 @@ const UpdateMeSchema = z.object({ newPassword: z.string().min(8).optional(), }); -router.put('/me', requireAuth, (req, res, next) => { +router.put('/me', requireAuth, async (req, res, next) => { try { const body = UpdateMeSchema.parse(req.body); const current = db - .prepare('SELECT id, email, password_hash, display_name FROM users WHERE id = ?') + .prepare('SELECT id, email, password_hash, display_name, email_verified FROM users WHERE id = ?') .get(req.user.id); let newHash = undefined; @@ -114,18 +189,331 @@ router.put('/me', requireAuth, (req, res, next) => { const newEmail = body.email ?? current.email; const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name; const newPasswordHash = newHash ?? current.password_hash; + const emailChanged = newEmail !== current.email; + const newEmailVerified = emailChanged ? 0 : (current.email_verified ?? 1); db.prepare( - "UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?" - ).run(newEmail, newDisplayName, newPasswordHash, req.user.id); + "UPDATE users SET email=?, display_name=?, password_hash=?, email_verified=?, updated_at=datetime('now') WHERE id=?" + ).run(newEmail, newDisplayName, newPasswordHash, newEmailVerified, req.user.id); - const token = newEmail !== current.email - ? signToken({ sub: req.user.id, email: newEmail }) - : undefined; + // Si l'email change, envoyer un email de vérification sur la nouvelle adresse + if (emailChanged) { + const cfg = getSmtpConfig(); + if (cfg.enabled && cfg.host) { + try { + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(req.user.id); + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(req.user.id, vToken, expiresAt); + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + await sendMail({ + to: newEmail, + subject: `Vérifiez votre nouvelle adresse email — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Vérification de votre nouvel email', + body: `

Vous avez modifié votre adresse email. Cliquez ci-dessous pour confirmer cette nouvelle adresse.

`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + } catch (_) { /* Ne pas bloquer la réponse si SMTP échoue */ } + } + } + + const newToken = emailChanged ? signToken({ sub: req.user.id, email: newEmail }) : undefined; + const updatedUser = db.prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id=?').get(req.user.id); res.json({ - user: { id: req.user.id, email: newEmail, display_name: newDisplayName }, - ...(token ? { token } : {}), + user: updatedUser, + ...(newToken ? { token: newToken } : {}), + ...(emailChanged ? { requiresVerification: true } : {}), + }); + } catch (e) { next(e); } +}); + +// ── Vérification d'adresse email ────────────────────────────────────────── +router.get('/verify-email', (req, res, next) => { + try { + const token = req.query.token; + if (!token) throw new HttpError(400, 'Token manquant.'); + + const row = db.prepare( + `SELECT evt.*, u.id AS uid FROM email_verification_tokens evt + JOIN users u ON u.id = evt.user_id + WHERE evt.token = ? AND evt.used = 0` + ).get(token); + + if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.'); + if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Demandez un nouvel email de vérification.'); + + db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(row.uid); + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE id=?').run(row.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Renvoi de l'email de vérification ───────────────────────────────────── +router.post('/resend-verification', async (req, res, next) => { + try { + const { email } = z.object({ email: z.string().email() }).parse(req.body); + const user = db.prepare('SELECT id, email, display_name, email_verified FROM users WHERE email=?').get(email); + + // Répondre OK même si l'email n'existe pas + if (!user || user.email_verified) return res.json({ ok: true }); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer l'email."); + + // Invalider les anciens tokens + db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(user.id); + + const vToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, vToken, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + const prenom = (user.display_name || user.email).split(' ')[0]; + + await sendMail({ + to: user.email, + subject: `Vérifiez votre adresse email — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Confirmez votre adresse email', + body: `

Bonjour ${prenom},

+

Cliquez sur le bouton ci-dessous pour vérifier votre adresse email. Ce lien est valable 24 heures.

`, + ctaLabel: 'Vérifier mon adresse email', + ctaUrl: `${appUrl}/verify-email?token=${vToken}`, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Mot de passe oublié ─────────────────────────────────────────────────── +router.post('/forgot-password', async (req, res, next) => { + try { + const { email } = z.object({ email: z.string().email() }).parse(req.body); + const user = db.prepare('SELECT id, email, display_name FROM users WHERE email = ?').get(email); + + // Toujours répondre OK pour ne pas divulguer si l'email existe + if (!user) return res.json({ ok: true }); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, 'La réinitialisation par email n\'est pas disponible (SMTP désactivé).'); + + // Invalider les anciens tokens non utilisés + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ? AND used = 0').run(user.id); + + // Générer un token sécurisé (1h de validité) + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)').run(user.id, token, expiresAt); + + const appUrl = cfg.appUrl?.replace(/\/$/, '') || ''; + const resetUrl = `${appUrl}/reset-password?token=${token}`; + const prenom = (user.display_name || user.email).split(' ')[0]; + + await sendMail({ + to: user.email, + subject: `Réinitialisation de votre mot de passe — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Réinitialisation du mot de passe', + body: `

Bonjour ${prenom},

+

Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en définir un nouveau. Ce lien est valable 1 heure.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email.

`, + ctaLabel: 'Réinitialiser mon mot de passe', + ctaUrl: resetUrl, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Réinitialisation du mot de passe ────────────────────────────────────── +router.post('/reset-password', async (req, res, next) => { + try { + const { token, password } = z.object({ + token: z.string().min(1), + password: z.string().min(8), + }).parse(req.body); + + const row = db.prepare( + `SELECT prt.*, u.id AS uid FROM password_reset_tokens prt + JOIN users u ON u.id = prt.user_id + WHERE prt.token = ? AND prt.used = 0` + ).get(token); + + if (!row) throw new HttpError(400, 'Lien invalide ou déjà utilisé.'); + if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'Ce lien a expiré. Faites une nouvelle demande.'); + + const hash = bcrypt.hashSync(password, 10); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, row.uid); + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE id = ?').run(row.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + + +// ════════════════════════════════════════════════════════════════════════════ +// ── 2FA — Configuration TOTP ───────────────────────────────────────────── +// ════════════════════════════════════════════════════════════════════════════ + +// GET /2fa/setup — génère un secret TOTP + QR code pour l'utilisateur +router.get('/2fa/setup', requireAuth, async (req, res, next) => { + try { + const { authenticator } = await import('otplib'); + const QRCode = (await import('qrcode')).default; + + const cfg = getSmtpConfig(); + const issuer = cfg.appName || 'Crowdlending Tracker'; + const user = db.prepare('SELECT id, email, totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id); + + // Générer un nouveau secret (ou réutiliser si setup pas encore confirmé) + const secret = (user.totp_enabled ? null : user.totp_secret) || authenticator.generateSecret(); + + if (!user.totp_enabled) { + db.prepare("UPDATE users SET totp_secret=? WHERE id=?").run(secret, req.user.id); + } + + const uri = authenticator.keyuri(user.email, issuer, secret); + const qrCode = await QRCode.toDataURL(uri); + + res.json({ secret, qrCode, issuer, email: user.email, totp_enabled: !!user.totp_enabled }); + } catch (e) { next(e); } +}); + +// POST /2fa/confirm-setup — vérifie le code TOTP et active le 2FA +router.post('/2fa/confirm-setup', requireAuth, async (req, res, next) => { + try { + const { code } = z.object({ code: z.string().length(6) }).parse(req.body); + const { authenticator } = await import('otplib'); + + const user = db.prepare('SELECT totp_secret, totp_enabled FROM users WHERE id=?').get(req.user.id); + if (!user.totp_secret) throw new HttpError(400, 'Lancez d\'abord la configuration 2FA.'); + if (user.totp_enabled) throw new HttpError(400, 'Le 2FA est déjà activé.'); + + authenticator.options = { window: 1 }; + const valid = authenticator.verify({ token: code, secret: user.totp_secret }); + if (!valid) throw new HttpError(400, 'Code invalide. Réessayez.'); + + db.prepare("UPDATE users SET totp_enabled=1 WHERE id=?").run(req.user.id); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// POST /2fa/disable — désactive le 2FA (mot de passe requis) +router.post('/2fa/disable', requireAuth, async (req, res, next) => { + try { + const { password } = z.object({ password: z.string().min(1) }).parse(req.body); + + const user = db.prepare('SELECT password_hash, totp_enabled FROM users WHERE id=?').get(req.user.id); + if (!user.totp_enabled) throw new HttpError(400, 'Le 2FA n\'est pas activé.'); + + const ok = bcrypt.compareSync(password, user.password_hash); + if (!ok) throw new HttpError(401, 'Mot de passe incorrect.'); + + db.prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=?").run(req.user.id); + // Supprimer tous les appareils de confiance + db.prepare('DELETE FROM two_fa_trusted_devices WHERE user_id=?').run(req.user.id); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ════════════════════════════════════════════════════════════════════════════ +// ── 2FA — Vérification lors de la connexion ────────────────────────────── +// ════════════════════════════════════════════════════════════════════════════ + +// POST /2fa/send-email-code — envoie un OTP par email (flow 2FA) +router.post('/2fa/send-email-code', async (req, res, next) => { + try { + const { sessionToken } = z.object({ sessionToken: z.string().min(1) }).parse(req.body); + + const sess = db.prepare( + "SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')" + ).get(sessionToken); + if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.'); + + const user = db.prepare('SELECT id, email, display_name FROM users WHERE id=?').get(sess.user_id); + + // Invalider les anciens codes + db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE user_id=? AND used=0').run(user.id); + + const code = String(Math.floor(100000 + Math.random() * 900000)); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_email_codes (user_id, code, expires_at) VALUES (?,?,?)').run(user.id, code, expiresAt); + + const cfg = getSmtpConfig(); + if (!cfg.enabled) throw new HttpError(503, "SMTP désactivé — impossible d'envoyer le code."); + + const prenom = (user.display_name || user.email).split(' ')[0]; + await sendMail({ + to: user.email, + subject: `${code} — Votre code de connexion`, + html: buildEmailHtml({ + title: 'Code de vérification', + body: `

Bonjour ${prenom},

+

Voici votre code de vérification à usage unique :

+

${code}

+

Ce code est valable 5 minutes. Ne le communiquez à personne.

`, + }), + }); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// POST /2fa/verify — vérifie le code (TOTP ou email) et émet le JWT final +router.post('/2fa/verify', async (req, res, next) => { + try { + const { sessionToken, code, method, trustDevice } = z.object({ + sessionToken: z.string().min(1), + code: z.string().min(6).max(6), + method: z.enum(['totp', 'email']), + trustDevice: z.boolean().optional(), + }).parse(req.body); + + const sess = db.prepare( + "SELECT * FROM two_fa_sessions WHERE token=? AND used=0 AND expires_at > datetime('now')" + ).get(sessionToken); + if (!sess) throw new HttpError(401, 'Session expirée. Reconnectez-vous.'); + + const user = db.prepare('SELECT id, email, display_name, role, totp_secret, totp_enabled FROM users WHERE id=?').get(sess.user_id); + + if (method === 'totp') { + const { authenticator } = await import('otplib'); + authenticator.options = { window: 1 }; + const valid = authenticator.verify({ token: code, secret: user.totp_secret }); + if (!valid) throw new HttpError(400, 'Code invalide.'); + } else { + // Email OTP + const row = db.prepare( + "SELECT id FROM two_fa_email_codes WHERE user_id=? AND code=? AND used=0 AND expires_at > datetime('now')" + ).get(user.id, code); + if (!row) throw new HttpError(400, 'Code invalide ou expiré.'); + db.prepare('UPDATE two_fa_email_codes SET used=1 WHERE id=?').run(row.id); + } + + // Invalider la session 2FA + db.prepare('UPDATE two_fa_sessions SET used=1 WHERE id=?').run(sess.id); + + // Appareil de confiance (30 jours) + let deviceToken = null; + if (trustDevice) { + deviceToken = crypto.randomBytes(32).toString('hex'); + const devExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare('INSERT INTO two_fa_trusted_devices (user_id, token, expires_at) VALUES (?,?,?)').run(user.id, deviceToken, devExpires); + } + + const token = signToken({ sub: user.id, email: user.email }); + res.json({ + token, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, email_verified: 1, totp_enabled: 1 }, + ...(deviceToken ? { deviceToken } : {}), }); } catch (e) { next(e); } }); diff --git a/backend/src/server.js b/backend/src/server.js index 7e2875f..4fa3e0c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -41,6 +41,8 @@ import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; import secteursInvRouter from './routes/secteurs-inv.js'; import associationsInvRouter from './routes/associations-inv.js'; +import db from './db/index.js'; +import { getSmtpConfig } from './utils/mailer.js'; const app = express(); const PORT = process.env.PORT || 4000; @@ -69,6 +71,20 @@ const authLimiter = rateLimit({ app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOString() })); +// Informations publiques de la plateforme (utilisées sur la page de login) +app.get('/api/app-info', (_, res) => { + try { + const cfg = getSmtpConfig(); + const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); + res.json({ + appName: cfg.appName || 'Crowdlending Tracker', + iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, + }); + } catch { + res.json({ appName: 'Crowdlending Tracker', iconUrl: null }); + } +}); + app.use('/api/auth', authLimiter, authRouter); // All routes below require authentication diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index 6e0a5bc..a741f84 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -25,7 +25,7 @@ export function getSmtpConfig() { username: row?.username || process.env.SMTP_USERNAME || '', password: row?.password || process.env.SMTP_PASSWORD || '', allowUnauth: !!(row?.allow_unauth), - appName: row?.app_name || process.env.APP_NAME || 'Crowdlending', + appName: row?.app_name || process.env.APP_NAME || 'Crowdlending Tracker', appUrl: row?.app_url || process.env.APP_URL || '', }; } diff --git a/frontend/public/login-bg.jpg b/frontend/public/login-bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..67fb441f962765513a5418df25233e545cc0bf7e GIT binary patch literal 54510 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 81a726b..a783421 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from './context/AuthContext.jsx'; import Login from './pages/Login.jsx'; import Register from './pages/Register.jsx'; +import ForgotPassword from './pages/ForgotPassword.jsx'; +import ResetPassword from './pages/ResetPassword.jsx'; +import VerifyEmail from './pages/VerifyEmail.jsx'; import Layout from './components/Layout.jsx'; import Dashboard from './pages/Dashboard.jsx'; import DepotsRetraits from './pages/DepotsRetraits.jsx'; @@ -36,8 +39,11 @@ function AdminOnly({ children }) { export default function App() { return ( - } /> - } /> + } /> + } /> + } /> + } /> + } /> }> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index a97f965..c2a716b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -19,7 +19,8 @@ async function handle(res) { if (!res.ok) { const msg = (body && body.error) || res.statusText || 'Request failed'; const err = new Error(msg); - err.status = res.status; + err.status = res.status; + err.code = body && body.code; err.details = body && body.details; throw err; } diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 5d0cba5..55a4866 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -17,16 +17,32 @@ export function AuthProvider({ children }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); - const login = async (email, password) => { - const r = await api.post('/auth/login', { email, password }); + const login = async (email, password, deviceToken) => { + const body = { email, password, ...(deviceToken ? { deviceToken } : {}) }; + const r = await api.post('/auth/login', body); + if (r.requires2FA) { + // Authentification incomplète — retourne les infos pour la page Login + return { requires2FA: true, sessionToken: r.sessionToken, email: r.email }; + } localStorage.setItem('cl_token', r.token); setToken(r.token); setUser(r.user); return r.user; }; + // Appelé par Login après validation du code 2FA + const completeLogin = (token, userData) => { + localStorage.setItem('cl_token', token); + setToken(token); + setUser(userData); + }; + const register = async (email, password, displayName) => { const r = await api.post('/auth/register', { email, password, displayName }); + if (r.requiresVerification) { + // Compte créé mais email non vérifié — pas de connexion automatique + return { requiresVerification: true, email: r.email }; + } localStorage.setItem('cl_token', r.token); setToken(r.token); setUser(r.user); @@ -54,7 +70,7 @@ export function AuthProvider({ children }) { const isAdmin = user?.role === 'admin'; return ( - + {children} ); diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..e38db6e --- /dev/null +++ b/frontend/src/pages/ForgotPassword.jsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +export default function ForgotPassword() { + const [email, setEmail] = useState(''); + const [status, setStatus] = useState(null); // null | 'sent' | 'error' + const [errMsg, setErrMsg] = useState(''); + const [busy, setBusy] = useState(false); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + + useEffect(() => { + fetch('/api/app-info') + .then(r => r.json()) + .then(d => setAppInfo(d)) + .catch(() => {}); + }, []); + + const submit = async (e) => { + e.preventDefault(); + setErrMsg(''); setBusy(true); + try { + const res = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + const data = await res.json(); + if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); } + else setStatus('sent'); + } catch { + setErrMsg('Impossible de joindre le serveur.'); setStatus('error'); + } finally { setBusy(false); } + }; + + return ( +
+ + {/* ── Colonne gauche — image ───────────────────────────── */} +
+ { e.target.style.display = 'none'; }} + style={{ + position: 'absolute', inset: 0, + width: '100%', height: '100%', + objectFit: 'cover', opacity: 0.85, + }} + /> +
+ {appInfo.iconUrl && ( + + )} + + {appInfo.appName} + +
+
+ + {/* ── Colonne droite — formulaire ──────────────────────── */} +
+ + {/* Logo + nom */} +
+ {appInfo.iconUrl && ( + {appInfo.appName} + )} + + {appInfo.appName} + +
+ +
+ + {status === 'sent' ? ( + /* ── Confirmation envoi ── */ +
+
+ + + +
+

Email envoyé

+

+ Si un compte correspond à {email}, vous recevrez + un lien de réinitialisation valable 1 heure. +

+ + Retour à la connexion + +
+ ) : ( + /* ── Formulaire ── */ + <> +
+

+ Mot de passe oublié ? +

+

+ Saisissez votre email pour recevoir un lien de réinitialisation. +

+
+ +
+ + {status === 'error' && ( +
+ {errMsg} +
+ )} + +
+ + setEmail(e.target.value)} + style={{ width: '100%' }} + /> +
+ + + +
+ +

+ Vous vous souvenez ?{' '} + + Se connecter + +

+ + )} + +
+
+ + +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 4707e1d..c2fb4e2 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,44 +1,387 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.jsx'; +import { api } from '../api.js'; -export default function Login() { - const { login } = useAuth(); - const navigate = useNavigate(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [err, setErr] = useState(null); - const [busy, setBusy] = useState(false); +// ── Helpers ──────────────────────────────────────────────────────────────── +const DEVICE_KEY = 'cl_device_token'; - const submit = async (e) => { - e.preventDefault(); - setErr(null); setBusy(true); - try { - await login(email, password); - navigate('/'); - } catch (e) { - setErr(e.message); - } finally { setBusy(false); } - }; +function fmtCountdown(sec) { + const m = Math.floor(sec / 60).toString().padStart(2, '0'); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +} +// ── Colonne image gauche (partagée) ─────────────────────────────────────── +function BgCol({ appInfo }) { return ( -
-
-

Connexion

- {err &&
{err}
} - - setEmail(e.target.value)} /> -
- - setPassword(e.target.value)} /> -
- -

- Pas encore de compte ? Créer un compte -

- +
+ { e.target.style.display = 'none'; }} + style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} + /> +
+ {appInfo.iconUrl && } + {appInfo.appName} +
+
+ ); +} + +// ── Header logo (colonne droite) ────────────────────────────────────────── +function AppHeader({ appInfo }) { + return ( +
+ {appInfo.iconUrl && {appInfo.appName}} + {appInfo.appName} +
+ ); +} + +// ── Composant principal ──────────────────────────────────────────────────── +export default function Login() { + const { login, completeLogin } = useAuth(); + const navigate = useNavigate(); + + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + + // Étape : 'form' | 'method' | 'code' + const [step, setStep] = useState('form'); + const [sessionToken, setSessionToken] = useState(null); + const [method, setMethod] = useState(null); // 'totp' | 'email' + + // Formulaire login + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [trustDev, setTrustDev] = useState(false); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [unverified, setUnverified] = useState(false); + const [resendBusy, setResendBusy] = useState(false); + const [resendDone, setResendDone] = useState(false); + + // Code 2FA + const [code, setCode] = useState(''); + const [codeErr, setCodeErr] = useState(null); + const [codeBusy, setCodeBusy] = useState(false); + const [countdown, setCountdown] = useState(300); // 5 min pour email + const timerRef = useRef(null); + + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {}); + }, []); + + // Countdown pour OTP email + useEffect(() => { + if (step === 'code' && method === 'email') { + setCountdown(300); + timerRef.current = setInterval(() => { + setCountdown(c => { + if (c <= 1) { clearInterval(timerRef.current); return 0; } + return c - 1; + }); + }, 1000); + } + return () => clearInterval(timerRef.current); + }, [step, method]); + + // ── Soumission du formulaire ────────────────────────────────────────────── + const submit = async (e) => { + e.preventDefault(); + setErr(null); setUnverified(false); setResendDone(false); setBusy(true); + try { + const deviceToken = localStorage.getItem(DEVICE_KEY) || undefined; + const result = await login(email, password, deviceToken); + if (result?.requires2FA) { + setSessionToken(result.sessionToken); + setStep('method'); + } else { + navigate('/'); + } + } catch (e) { + if (e.code === 'EMAIL_NOT_VERIFIED') { + setUnverified(true); + } else { + setErr(e.message || 'Identifiants incorrects.'); + } + } finally { setBusy(false); } + }; + + const resend = async () => { + setResendBusy(true); + try { + await fetch('/api/auth/resend-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + setResendDone(true); + } catch (_) {} + finally { setResendBusy(false); } + }; + + // ── Choix de méthode 2FA ────────────────────────────────────────────────── + const chooseMethod = async (m) => { + setMethod(m); + setCodeErr(null); + setCode(''); + if (m === 'email') { + try { + await api.post('/auth/2fa/send-email-code', { sessionToken }); + } catch (e) { + setCodeErr(e.message); + return; + } + } + setStep('code'); + }; + + // ── Vérification du code ────────────────────────────────────────────────── + const verifyCode = async (e) => { + e.preventDefault(); + setCodeErr(null); setCodeBusy(true); + try { + const result = await api.post('/auth/2fa/verify', { + sessionToken, code, method, trustDevice: trustDev, + }); + if (result.deviceToken) { + localStorage.setItem(DEVICE_KEY, result.deviceToken); + } + completeLogin(result.token, result.user); + navigate('/'); + } catch (e) { + setCodeErr(e.message || 'Code invalide.'); + } finally { setCodeBusy(false); } + }; + + const resendEmailCode = async () => { + setCodeErr(null); + try { + await api.post('/auth/2fa/send-email-code', { sessionToken }); + setCountdown(300); + timerRef.current = setInterval(() => { + setCountdown(c => { if (c <= 1) { clearInterval(timerRef.current); return 0; } return c - 1; }); + }, 1000); + } catch (e) { setCodeErr(e.message); } + }; + + // ── Rendu ───────────────────────────────────────────────────────────────── + const BtnPrimary = ({ children, disabled, onClick, type = 'submit' }) => ( + + ); + + const ErrBox = ({ msg }) => msg ? ( +
+ {msg} +
+ ) : null; + + return ( +
+ + +
+ + +
+ + {/* ══ ÉTAPE 1 : Formulaire email/mot de passe ══════════════════ */} + {step === 'form' && ( + <> +
+

Bienvenue

+

Connectez-vous à votre compte

+
+ +
+ + + {unverified && ( +
+ Email non vérifié. Vérifiez votre boîte mail et cliquez sur le lien reçu. +
+ {resendDone + ? ✓ Email renvoyé ! + : + } +
+
+ )} + +
+ + setEmail(e.target.value)} style={{ width: '100%' }} /> +
+ +
+
+ + Mot de passe oublié ? +
+ setPassword(e.target.value)} style={{ width: '100%' }} /> +
+ + {busy ? 'Connexion…' : 'Se connecter'} + + +

+ Pas encore de compte ?{' '} + Créer un compte +

+ + )} + + {/* ══ ÉTAPE 2 : Choix de méthode 2FA ══════════════════════════ */} + {step === 'method' && ( + <> +
+
+ + + + + +
+

Vérification en deux étapes

+

Choisissez votre méthode de vérification

+
+ + + +
+ + + +
+ + + + )} + + {/* ══ ÉTAPE 3 : Saisie du code ═════════════════════════════════ */} + {step === 'code' && ( + <> +
+
+ {method === 'totp' + ? + : + } +
+

+ {method === 'totp' ? 'Code de l\'application' : 'Code par email'} +

+

+ {method === 'totp' + ? 'Entrez le code à 6 chiffres affiché dans votre application d\'authentification.' + : <>Code envoyé à {email}. + } +

+
+ +
+ + +
+
+ + {method === 'email' && ( + 0 ? 'var(--text-muted)' : 'var(--danger,#dc2626)' }}> + {countdown > 0 ? fmtCountdown(countdown) : 'Expiré'} + + )} +
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + style={{ width: '100%', fontSize: 24, letterSpacing: 8, textAlign: 'center', fontWeight: 700 }} + /> +
+ + + + + {codeBusy ? 'Vérification…' : 'Vérifier'} + + + {method === 'email' && countdown === 0 && ( + + )} + + + + + )} + +
+
+ +
); } diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index 4b7c262..317e6db 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -76,6 +76,84 @@ function ProfileSelect({ label, options, value, onChange }) { ); } +/* ── Bloc renvoi email de vérification ───────────────────────── */ +function EmailResendBlock({ email }) { + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + + const resend = async () => { + setBusy(true); + try { + await fetch('/api/auth/resend-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + setDone(true); + } catch (_) {} + finally { setBusy(false); } + }; + + if (done) return

✓ Email de vérification renvoyé.

; + return ( +

+ Email non confirmé.{' '} + +

+ ); +} + +/* ── Changement d'email ───────────────────────────────────────── */ +function EmailChangeForm({ onDone }) { + const { updateUser } = useAuth(); + const [newEmail, setNewEmail] = useState(''); + const [pwd, setPwd] = useState(''); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setErr(null); setMsg(null); setBusy(true); + try { + const r = await updateUser({ email: newEmail, currentPassword: pwd }); + if (r?.requiresVerification) { + setMsg(`Un email de vérification a été envoyé à ${newEmail}. Validez-le pour activer cette adresse.`); + } else { + setMsg('Email mis à jour.'); setTimeout(onDone, 1500); + } + } catch (e) { setErr(e.message); } + finally { setBusy(false); } + }; + + return ( +
+

+ Après le changement, un email de vérification sera envoyé à la nouvelle adresse. +

+ {err &&
{err}
} + {msg &&
{msg}
} +
+ + setNewEmail(e.target.value)} placeholder="nouvelle@email.com" /> +
+
+ + setPwd(e.target.value)} placeholder="••••••••" /> +
+
+ + +
+
+ ); +} + /* ── Mon profil + Préférences ────────────────────────────────── */ function AccountForm() { const { user, updateUser } = useAuth(); @@ -92,9 +170,10 @@ function AccountForm() { const initial = parseName(user?.display_name); const [prenom, setPrenom] = useState(initial.prenom); const [nom, setNom] = useState(initial.nom); - const [infoMsg, setInfoMsg] = useState(null); - const [infoErr, setInfoErr] = useState(null); - const [loading, setLoading] = useState(false); + const [infoMsg, setInfoMsg] = useState(null); + const [infoErr, setInfoErr] = useState(null); + const [loading, setLoading] = useState(false); + const [changingEmail, setChangingEmail] = useState(false); const save = async () => { setInfoErr(null); setInfoMsg(null); setLoading(true); @@ -138,16 +217,23 @@ function AccountForm() {
Mon email -
+
{user?.email} + {user?.email_verified + ? ✓ Vérifié + : Non vérifié + }
+ {!user?.email_verified && }
-
+ + {changingEmail && setChangingEmail(false)} />} {/* ── Préférences ─────────────────────────────────────── */} @@ -164,6 +250,163 @@ function AccountForm() { ); } + +/* ── 2FA — Configuration double authentification ───────────────── */ +function TwoFASection({ user }) { + const [status, setStatus] = useState('idle'); // idle | setup | confirming | disabling + const [setupData, setSetupData] = useState(null); // { secret, qrCode, email } + const [code, setCode] = useState(''); + const [disablePwd, setDisablePwd] = useState(''); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [enabled, setEnabled] = useState(!!user?.totp_enabled); + + const startSetup = async () => { + setErr(null); setMsg(null); setBusy(true); + try { + const data = await api.get('/auth/2fa/setup'); + setSetupData(data); + setStatus('setup'); + } catch (e) { setErr(e.message); } + finally { setBusy(false); } + }; + + const confirmSetup = async (e) => { + e.preventDefault(); setErr(null); setBusy(true); + try { + await api.post('/auth/2fa/confirm-setup', { code }); + setEnabled(true); + setStatus('idle'); + setSetupData(null); + setCode(''); + setMsg('Le 2FA est maintenant activé sur votre compte.'); + } catch (e) { setErr(e.message); } + finally { setBusy(false); } + }; + + const disable = async (e) => { + e.preventDefault(); setErr(null); setBusy(true); + try { + await api.post('/auth/2fa/disable', { password: disablePwd }); + setEnabled(false); + setStatus('idle'); + setDisablePwd(''); + setMsg('Le 2FA a été désactivé.'); + localStorage.removeItem('cl_device_token'); + } catch (e) { setErr(e.message); } + finally { setBusy(false); } + }; + + return ( +
+
+

Double authentification (2FA)

+ + {enabled ? '✓ Activé' : 'Désactivé'} + +
+

+ Protégez votre compte avec une vérification supplémentaire à chaque connexion. +

+ + {err &&
{err}
} + {msg &&
{msg}
} + + {/* ── État normal ── */} + {status === 'idle' && !enabled && ( + + )} + {status === 'idle' && enabled && ( + + )} + + {/* ── Setup : QR code + vérification ── */} + {status === 'setup' && setupData && ( +
+

+ Scannez ce QR code avec votre application d'authentification (Google Authenticator, Authy, etc.), puis entrez le code à 6 chiffres pour confirmer. +

+
+
+ QR code 2FA +
+
+

+ Vous ne pouvez pas scanner ? Entrez manuellement cette clé dans votre application : +

+ + {setupData.secret} + +

+ Émetteur : {setupData.issuer} · Compte : {setupData.email} +

+
+
+
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + autoComplete="one-time-code" + style={{ fontSize: 20, letterSpacing: 6, textAlign: 'center', fontWeight: 700 }} + /> +
+ +
+ +
+ )} + + {/* ── Désactivation : mot de passe ── */} + {status === 'disabling' && ( +
+

+ Confirmez votre mot de passe pour désactiver le 2FA. +

+
+
+ + setDisablePwd(e.target.value)} + autoComplete="current-password" placeholder="••••••••" /> +
+ +
+ +
+ )} +
+ ); +} + /* ── Sécurité — Mot de passe ─────────────────────────────────── */ function SecurityForm() { const { updateUser } = useAuth(); @@ -249,6 +492,7 @@ function SecurityForm() { export default function MonCompte() { const { search } = useLocation(); const navigate = useNavigate(); + const { user } = useAuth(); const section = new URLSearchParams(search).get('section') || 'profil'; const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true }); @@ -278,7 +522,7 @@ export default function MonCompte() { {/* ── Contenu ─────────────────────────────────────── */}
{section === 'profil' && } - {section === 'securite' && } + {section === 'securite' && <>}
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 3834585..3974260 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.jsx'; @@ -8,40 +8,257 @@ export default function Register() { const [form, setForm] = useState({ email: '', password: '', displayName: '' }); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + + useEffect(() => { + fetch('/api/app-info') + .then(r => r.json()) + .then(d => setAppInfo(d)) + .catch(() => {}); + }, []); const set = (k) => (e) => setForm({ ...form, [k]: e.target.value }); + const [verifyEmail, setVerifyEmail] = useState(null); // email à vérifier + const submit = async (e) => { e.preventDefault(); setErr(null); setBusy(true); try { - await register(form.email, form.password, form.displayName || undefined); - navigate('/'); + const result = await register(form.email, form.password, form.displayName || undefined); + if (result?.requiresVerification) { + setVerifyEmail(result.email); + } else { + navigate('/'); + } } catch (e) { setErr(e.message); } finally { setBusy(false); } }; return ( -
-
-

Créer un compte

- {err &&
{err}
} - - -
- - -
- - -
- -

- Déjà inscrit ? Se connecter -

- +
+ + {/* ── Colonne gauche — image ───────────────────────────── */} +
+ { e.target.style.display = 'none'; }} + style={{ + position: 'absolute', inset: 0, + width: '100%', height: '100%', + objectFit: 'cover', opacity: 0.85, + }} + /> +
+ {appInfo.iconUrl && ( + + )} + + {appInfo.appName} + +
+
+ + {/* ── Colonne droite — formulaire ──────────────────────── */} +
+ + {/* Logo + nom */} +
+ {appInfo.iconUrl && ( + {appInfo.appName} + )} + + {appInfo.appName} + +
+ +
+ + {/* ── Écran vérification email ── */} + {verifyEmail ? ( +
+
+ + + +
+

+ Vérifiez votre email +

+

+ Un email de vérification a été envoyé à +

+

+ {verifyEmail} +

+

+ Cliquez sur le lien dans l'email pour activer votre compte. Vérifiez aussi vos spams. +

+ + Retour à la connexion + +
+ ) : ( + <> + + {/* En-tête */} +
+

+ Créer un compte +

+

+ Rejoignez {appInfo.appName} +

+
+ + {/* Formulaire */} +
+ + {err && ( +
+ {err} +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ +

+ Déjà inscrit ?{' '} + + Se connecter + +

+ + )} {/* fin verifyEmail ternaire */} +
+
+ +
); } diff --git a/frontend/src/pages/ResetPassword.jsx b/frontend/src/pages/ResetPassword.jsx new file mode 100644 index 0000000..9c21bb4 --- /dev/null +++ b/frontend/src/pages/ResetPassword.jsx @@ -0,0 +1,254 @@ +import { useState, useEffect } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; + +export default function ResetPassword() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const token = params.get('token') || ''; + + const [password, setPassword] = useState(''); + const [password2, setPassword2] = useState(''); + const [status, setStatus] = useState(null); // null | 'done' | 'error' + const [errMsg, setErrMsg] = useState(''); + const [busy, setBusy] = useState(false); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + + useEffect(() => { + fetch('/api/app-info') + .then(r => r.json()) + .then(d => setAppInfo(d)) + .catch(() => {}); + }, []); + + const submit = async (e) => { + e.preventDefault(); + if (password !== password2) { setErrMsg('Les mots de passe ne correspondent pas.'); setStatus('error'); return; } + setErrMsg(''); setBusy(true); + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }); + const data = await res.json(); + if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); } + else { setStatus('done'); setTimeout(() => navigate('/login'), 3000); } + } catch { + setErrMsg('Impossible de joindre le serveur.'); setStatus('error'); + } finally { setBusy(false); } + }; + + return ( +
+ + {/* ── Colonne gauche — image ───────────────────────────── */} +
+ { e.target.style.display = 'none'; }} + style={{ + position: 'absolute', inset: 0, + width: '100%', height: '100%', + objectFit: 'cover', opacity: 0.85, + }} + /> +
+ {appInfo.iconUrl && ( + + )} + + {appInfo.appName} + +
+
+ + {/* ── Colonne droite — formulaire ──────────────────────── */} +
+ + {/* Logo + nom */} +
+ {appInfo.iconUrl && ( + {appInfo.appName} + )} + + {appInfo.appName} + +
+ +
+ + {!token ? ( + /* ── Lien invalide ── */ +
+

+ Lien de réinitialisation invalide ou manquant. +

+ + Faire une nouvelle demande + +
+ ) : status === 'done' ? ( + /* ── Succès ── */ +
+
+ + + +
+

Mot de passe mis à jour

+

+ Redirection vers la connexion dans quelques secondes… +

+ + Se connecter + +
+ ) : ( + /* ── Formulaire ── */ + <> +
+

+ Nouveau mot de passe +

+

+ Choisissez un mot de passe d'au moins 8 caractères. +

+
+ +
+ + {status === 'error' && ( +
+ {errMsg} +
+ )} + +
+ + { setPassword(e.target.value); setStatus(null); }} + style={{ width: '100%' }} + /> +
+ +
+ + { setPassword2(e.target.value); setStatus(null); }} + style={{ width: '100%' }} + /> +
+ + + +
+ +

+ + Retour à la connexion + +

+ + )} + +
+
+ + +
+ ); +} diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx new file mode 100644 index 0000000..033fdf4 --- /dev/null +++ b/frontend/src/pages/VerifyEmail.jsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; + +export default function VerifyEmail() { + const [params] = useSearchParams(); + const token = params.get('token') || ''; + const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error' + const [errMsg, setErrMsg] = useState(''); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {}); + }, []); + + useEffect(() => { + if (!token) { setStatus('error'); setErrMsg('Lien de vérification invalide ou manquant.'); return; } + fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`) + .then(async r => { + const data = await r.json(); + if (!r.ok) { setErrMsg(data.error || 'Lien invalide.'); setStatus('error'); } + else setStatus('ok'); + }) + .catch(() => { setErrMsg('Impossible de joindre le serveur.'); setStatus('error'); }); + }, [token]); + + return ( +
+ +
+ { e.target.style.display = 'none'; }} + style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} + /> +
+ {appInfo.iconUrl && } + {appInfo.appName} +
+
+ +
+
+ {appInfo.iconUrl && {appInfo.appName}} + {appInfo.appName} +
+ +
+ + {status === 'loading' && ( +

Vérification en cours…

+ )} + + {status === 'ok' && ( + <> +
+ + + +
+

+ Email vérifié ! +

+

+ Votre adresse email a bien été confirmée. Vous pouvez maintenant vous connecter. +

+ + Se connecter + + + )} + + {status === 'error' && ( + <> +
+ + + +
+

+ Lien invalide +

+

+ {errMsg} +

+ + Retour à la connexion + + + )} + +
+
+ + +
+ ); +} diff --git a/frontend/src/pages/admin/SmtpSection.jsx b/frontend/src/pages/admin/SmtpSection.jsx index b541396..cafbeab 100644 --- a/frontend/src/pages/admin/SmtpSection.jsx +++ b/frontend/src/pages/admin/SmtpSection.jsx @@ -77,7 +77,7 @@ const DEFAULT_STATE = { username: '', password: '', allowUnauth: false, - appName: 'Crowdlending', + appName: 'Crowdlending Tracker', appUrl: '', }; @@ -109,7 +109,7 @@ export default function SmtpSection() { username: data.username || '', password: '', allowUnauth: !!data.allowUnauth, - appName: data.appName || 'Crowdlending', + appName: data.appName || 'Crowdlending Tracker', appUrl: data.appUrl || '', }); setHasPassword(!!data.hasPassword); diff --git a/frontend/src/pages/admin/UsersSection.jsx b/frontend/src/pages/admin/UsersSection.jsx index 52abbb4..28570d1 100644 --- a/frontend/src/pages/admin/UsersSection.jsx +++ b/frontend/src/pages/admin/UsersSection.jsx @@ -36,6 +36,21 @@ export default function UsersSection({ currentUserId }) { }); }; + const verifyEmail = (u) => { + setConfirmAction({ + title: 'Vérifier l\'email manuellement', + message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`, + confirmLabel: 'Confirmer', + onConfirm: async () => { + try { + await api.patch(`/admin/users/${u.id}/verify-email`, {}); + load(); + } catch (e) { setErr('Erreur : ' + e.message); } + finally { setConfirmAction(null); } + }, + }); + }; + const deleteUser = (u) => { setConfirmAction({ title: 'Supprimer l\'utilisateur', @@ -66,6 +81,7 @@ export default function UsersSection({ currentUserId }) { ID Nom Email + Email vérifié Rôle Créé le Actions @@ -77,10 +93,16 @@ export default function UsersSection({ currentUserId }) { {u.id} {u.display_name || } {u.email} + + {u.email_verified + ? ✓ Vérifié + : En attente + } + {fmt(u.created_at)} -
+
+ {!u.email_verified && ( + + )} {u.id !== currentUserId && (