Modification des pages d'authentification

This commit is contained in:
2026-06-14 21:58:05 +02:00
parent dc28b12c27
commit 9f6363ec20
20 changed files with 2494 additions and 172 deletions
+398
View File
@@ -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",
+2
View File
@@ -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"
+101 -76
View File
@@ -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;
+14 -3
View File
@@ -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 ?? '',
);
+406 -18
View File
@@ -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: `<p>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.</p>
<p>Ce lien est valable <strong>24 heures</strong>.</p>`,
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: `<p>Vous avez modifié votre adresse email. Cliquez ci-dessous pour confirmer cette nouvelle adresse.</p>`,
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: `<p>Bonjour ${prenom},</p>
<p>Cliquez sur le bouton ci-dessous pour vérifier votre adresse email. Ce lien est valable <strong>24 heures</strong>.</p>`,
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: `<p>Bonjour ${prenom},</p>
<p>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 <strong>1 heure</strong>.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email.</p>`,
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: `<p>Bonjour ${prenom},</p>
<p>Voici votre code de vérification à usage unique :</p>
<p style="font-size:32px;font-weight:700;letter-spacing:8px;text-align:center;margin:24px 0;">${code}</p>
<p>Ce code est valable <strong>5 minutes</strong>. Ne le communiquez à personne.</p>`,
}),
});
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); }
});
+16
View File
@@ -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
+1 -1
View File
@@ -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 || '',
};
}