Modification des pages d'authentification
This commit is contained in:
@@ -24,3 +24,27 @@ JWT_EXPIRES_IN=7d
|
|||||||
# Chemins dans le container (ne pas modifier)
|
# Chemins dans le container (ne pas modifier)
|
||||||
DB_PATH=/app/data/crowdlending.db
|
DB_PATH=/app/data/crowdlending.db
|
||||||
UPLOAD_DIR=/app/uploads
|
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
|
||||||
|
|||||||
Generated
+398
@@ -19,6 +19,8 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -499,6 +501,83 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -521,6 +600,30 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -731,6 +834,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/cfb": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
@@ -750,6 +862,17 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@@ -759,6 +882,24 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/concat-stream": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||||
@@ -854,6 +995,15 @@
|
|||||||
"ms": "2.0.0"
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -906,6 +1056,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -947,6 +1103,12 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -1104,6 +1266,19 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1146,6 +1321,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1307,6 +1491,15 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
@@ -1362,6 +1555,18 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -1652,6 +1857,56 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1661,12 +1916,30 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -1723,6 +1996,23 @@
|
|||||||
"once": "^1.3.1"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
@@ -1798,6 +2088,21 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1881,6 +2186,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -2092,6 +2403,32 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
@@ -2223,6 +2560,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/wmf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
@@ -2241,6 +2584,20 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -2277,6 +2634,47 @@
|
|||||||
"node": ">=0.4"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
+101
-76
@@ -939,7 +939,6 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default db;
|
|
||||||
|
|
||||||
// ── Table user_preferences ───────────────────────────────────────────────────
|
// ── Table user_preferences ───────────────────────────────────────────────────
|
||||||
// Stockage générique des préférences UI par utilisateur.
|
// 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');
|
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é.
|
// ── Migration : table de configuration SMTP ──────────────────────────────────
|
||||||
// On recalcule date_souscription + duree_mois et on régénère la simulation.
|
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 userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
const [y, m, d] = isoDate.split('-').map(Number);
|
if (!userCols.includes('email_verified')) {
|
||||||
let nm = m + months;
|
// DEFAULT 1 pour ne pas bloquer les comptes existants
|
||||||
let ny = y;
|
db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1');
|
||||||
while (nm > 12) { nm -= 12; ny++; }
|
console.log('[DB] users.email_verified ajouté');
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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(`
|
const userCols2 = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
CREATE TABLE IF NOT EXISTS smtp_config (
|
if (!userCols2.includes('totp_secret')) {
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT');
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
console.log('[DB] users.totp_secret ajouté');
|
||||||
host TEXT,
|
}
|
||||||
port INTEGER NOT NULL DEFAULT 587,
|
if (!userCols2.includes('totp_enabled')) {
|
||||||
secure INTEGER NOT NULL DEFAULT 0,
|
db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
|
||||||
email TEXT,
|
console.log('[DB] users.totp_enabled ajouté');
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -48,13 +48,24 @@ const router = Router();
|
|||||||
/** Liste tous les utilisateurs */
|
/** Liste tous les utilisateurs */
|
||||||
router.get('/users', (req, res) => {
|
router.get('/users', (req, res) => {
|
||||||
const users = db.prepare(`
|
const users = db.prepare(`
|
||||||
SELECT id, email, display_name, role, created_at
|
SELECT id, email, display_name, role, email_verified, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`).all();
|
`).all();
|
||||||
res.json(users);
|
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 */
|
/** Crée un utilisateur */
|
||||||
const CreateUserSchema = z.object({
|
const CreateUserSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -387,7 +398,7 @@ router.get('/smtp', (req, res) => {
|
|||||||
username: row.username || '',
|
username: row.username || '',
|
||||||
hasPassword: !!(row.password),
|
hasPassword: !!(row.password),
|
||||||
allowUnauth: !!row.allow_unauth,
|
allowUnauth: !!row.allow_unauth,
|
||||||
appName: row.app_name || 'Crowdlending',
|
appName: row.app_name || 'Crowdlending Tracker',
|
||||||
appUrl: row.app_url || '',
|
appUrl: row.app_url || '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -450,7 +461,7 @@ router.put('/smtp', (req, res, next) => {
|
|||||||
body.username ?? null,
|
body.username ?? null,
|
||||||
passwordToStore,
|
passwordToStore,
|
||||||
body.allowUnauth ? 1 : 0,
|
body.allowUnauth ? 1 : 0,
|
||||||
body.appName ?? 'Crowdlending',
|
body.appName ?? 'Crowdlending Tracker',
|
||||||
body.appUrl ?? '',
|
body.appUrl ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+406
-18
@@ -1,9 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
|
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ const LoginSchema = z.object({
|
|||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/register', (req, res, next) => {
|
router.post('/register', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = RegisterSchema.parse(req.body);
|
const body = RegisterSchema.parse(req.body);
|
||||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email);
|
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 isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0;
|
||||||
const role = isFirst ? 'admin' : 'user';
|
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 hash = bcrypt.hashSync(body.password, 10);
|
||||||
const result = db
|
const result = db
|
||||||
.prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
.prepare('INSERT INTO users (email, password_hash, display_name, role, email_verified) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(body.email, hash, body.displayName || null, role);
|
.run(body.email, hash, body.displayName || null, role, autoVerified);
|
||||||
|
|
||||||
const userId = result.lastInsertRowid;
|
const userId = result.lastInsertRowid;
|
||||||
|
|
||||||
@@ -47,36 +54,104 @@ router.post('/register', (req, res, next) => {
|
|||||||
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
||||||
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
).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 });
|
const token = signToken({ sub: userId, email: body.email });
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
token,
|
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); }
|
} 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 {
|
try {
|
||||||
const body = LoginSchema.parse(req.body);
|
const body = LoginSchema2FA.parse(req.body);
|
||||||
const user = db
|
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);
|
.get(body.email);
|
||||||
if (!user) throw new HttpError(401, 'Invalid credentials');
|
if (!user) throw new HttpError(401, 'Invalid credentials');
|
||||||
|
|
||||||
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
||||||
if (!ok) throw new HttpError(401, 'Invalid credentials');
|
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 });
|
const token = signToken({ sub: user.id, email: user.email });
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', requireAuth, (req, res) => {
|
router.get('/me', requireAuth, (req, res) => {
|
||||||
const user = db
|
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);
|
.get(req.user.id);
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
});
|
});
|
||||||
@@ -88,12 +163,12 @@ const UpdateMeSchema = z.object({
|
|||||||
newPassword: z.string().min(8).optional(),
|
newPassword: z.string().min(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me', requireAuth, (req, res, next) => {
|
router.put('/me', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = UpdateMeSchema.parse(req.body);
|
const body = UpdateMeSchema.parse(req.body);
|
||||||
|
|
||||||
const current = db
|
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);
|
.get(req.user.id);
|
||||||
|
|
||||||
let newHash = undefined;
|
let newHash = undefined;
|
||||||
@@ -114,18 +189,331 @@ router.put('/me', requireAuth, (req, res, next) => {
|
|||||||
const newEmail = body.email ?? current.email;
|
const newEmail = body.email ?? current.email;
|
||||||
const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name;
|
const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name;
|
||||||
const newPasswordHash = newHash ?? current.password_hash;
|
const newPasswordHash = newHash ?? current.password_hash;
|
||||||
|
const emailChanged = newEmail !== current.email;
|
||||||
|
const newEmailVerified = emailChanged ? 0 : (current.email_verified ?? 1);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?"
|
"UPDATE users SET email=?, display_name=?, password_hash=?, email_verified=?, updated_at=datetime('now') WHERE id=?"
|
||||||
).run(newEmail, newDisplayName, newPasswordHash, req.user.id);
|
).run(newEmail, newDisplayName, newPasswordHash, newEmailVerified, req.user.id);
|
||||||
|
|
||||||
const token = newEmail !== current.email
|
// Si l'email change, envoyer un email de vérification sur la nouvelle adresse
|
||||||
? signToken({ sub: req.user.id, email: newEmail })
|
if (emailChanged) {
|
||||||
: undefined;
|
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({
|
res.json({
|
||||||
user: { id: req.user.id, email: newEmail, display_name: newDisplayName },
|
user: updatedUser,
|
||||||
...(token ? { token } : {}),
|
...(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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import refSecteursRouter from './routes/ref-secteurs.js';
|
|||||||
import categoriesInvRouter from './routes/categories-inv.js';
|
import categoriesInvRouter from './routes/categories-inv.js';
|
||||||
import secteursInvRouter from './routes/secteurs-inv.js';
|
import secteursInvRouter from './routes/secteurs-inv.js';
|
||||||
import associationsInvRouter from './routes/associations-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 app = express();
|
||||||
const PORT = process.env.PORT || 4000;
|
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() }));
|
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);
|
app.use('/api/auth', authLimiter, authRouter);
|
||||||
|
|
||||||
// All routes below require authentication
|
// All routes below require authentication
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function getSmtpConfig() {
|
|||||||
username: row?.username || process.env.SMTP_USERNAME || '',
|
username: row?.username || process.env.SMTP_USERNAME || '',
|
||||||
password: row?.password || process.env.SMTP_PASSWORD || '',
|
password: row?.password || process.env.SMTP_PASSWORD || '',
|
||||||
allowUnauth: !!(row?.allow_unauth),
|
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 || '',
|
appUrl: row?.app_url || process.env.APP_URL || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { useAuth } from './context/AuthContext.jsx';
|
import { useAuth } from './context/AuthContext.jsx';
|
||||||
import Login from './pages/Login.jsx';
|
import Login from './pages/Login.jsx';
|
||||||
import Register from './pages/Register.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 Layout from './components/Layout.jsx';
|
||||||
import Dashboard from './pages/Dashboard.jsx';
|
import Dashboard from './pages/Dashboard.jsx';
|
||||||
import DepotsRetraits from './pages/DepotsRetraits.jsx';
|
import DepotsRetraits from './pages/DepotsRetraits.jsx';
|
||||||
@@ -36,8 +39,11 @@ function AdminOnly({ children }) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route element={<Protected><Layout /></Protected>}>
|
<Route element={<Protected><Layout /></Protected>}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="plateformes" element={<Plateformes />} />
|
<Route path="plateformes" element={<Plateformes />} />
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ async function handle(res) {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (body && body.error) || res.statusText || 'Request failed';
|
const msg = (body && body.error) || res.statusText || 'Request failed';
|
||||||
const err = new Error(msg);
|
const err = new Error(msg);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
|
err.code = body && body.code;
|
||||||
err.details = body && body.details;
|
err.details = body && body.details;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,32 @@ export function AuthProvider({ children }) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password, deviceToken) => {
|
||||||
const r = await api.post('/auth/login', { email, password });
|
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);
|
localStorage.setItem('cl_token', r.token);
|
||||||
setToken(r.token);
|
setToken(r.token);
|
||||||
setUser(r.user);
|
setUser(r.user);
|
||||||
return 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 register = async (email, password, displayName) => {
|
||||||
const r = await api.post('/auth/register', { 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);
|
localStorage.setItem('cl_token', r.token);
|
||||||
setToken(r.token);
|
setToken(r.token);
|
||||||
setUser(r.user);
|
setUser(r.user);
|
||||||
@@ -54,7 +70,7 @@ export function AuthProvider({ children }) {
|
|||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ token, user, loading, login, register, logout, updateUser, isAdmin }}>
|
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthCtx.Provider>
|
</AuthCtx.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||||
|
|
||||||
|
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||||
|
<div className="login-bg-col" style={{
|
||||||
|
flex: '1 1 50%',
|
||||||
|
display: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/login-bg.jpg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
onError={e => { e.target.style.display = 'none'; }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
objectFit: 'cover', opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 40, left: 40,
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||||
|
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 50%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 24px',
|
||||||
|
background: 'var(--background, #fff)',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Logo + nom */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt={appInfo.appName}
|
||||||
|
width={52} height={52} style={{ borderRadius: 12 }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20, fontWeight: 700,
|
||||||
|
color: 'var(--text)', letterSpacing: '-0.4px',
|
||||||
|
}}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
|
||||||
|
{status === 'sent' ? (
|
||||||
|
/* ── Confirmation envoi ── */
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: '50%',
|
||||||
|
background: 'var(--success-bg, #f0fdf4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{
|
||||||
|
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
|
||||||
|
letterSpacing: '-0.4px', color: 'var(--text)',
|
||||||
|
}}>Email envoyé</h1>
|
||||||
|
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||||
|
Si un compte correspond à <strong>{email}</strong>, vous recevrez
|
||||||
|
un lien de réinitialisation valable <strong>1 heure</strong>.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
display: 'block', width: '100%', padding: '11px 0',
|
||||||
|
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||||
|
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: 'none', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Formulaire ── */
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h1 style={{
|
||||||
|
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
|
||||||
|
letterSpacing: '-0.5px', color: 'var(--text)',
|
||||||
|
}}>
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Saisissez votre email pour recevoir un lien de réinitialisation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderRadius: 8, fontSize: 14,
|
||||||
|
background: 'var(--danger-bg, #fef2f2)',
|
||||||
|
color: 'var(--danger, #dc2626)',
|
||||||
|
border: '1px solid var(--danger-light, #fca5a5)',
|
||||||
|
}}>
|
||||||
|
{errMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Adresse email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email" required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="vous@exemple.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, width: '100%',
|
||||||
|
padding: '11px 0',
|
||||||
|
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none', borderRadius: 8,
|
||||||
|
fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: busy ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Envoi…' : 'Envoyer les instructions'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
marginTop: 24, textAlign: 'center',
|
||||||
|
fontSize: 13, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
Vous vous souvenez ?{' '}
|
||||||
|
<Link to="/login" style={{
|
||||||
|
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||||
|
}}>
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.login-bg-col { display: block !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+378
-35
@@ -1,44 +1,387 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext.jsx';
|
import { useAuth } from '../context/AuthContext.jsx';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
export default function Login() {
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
const { login } = useAuth();
|
const DEVICE_KEY = 'cl_device_token';
|
||||||
const navigate = useNavigate();
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [err, setErr] = useState(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const submit = async (e) => {
|
function fmtCountdown(sec) {
|
||||||
e.preventDefault();
|
const m = Math.floor(sec / 60).toString().padStart(2, '0');
|
||||||
setErr(null); setBusy(true);
|
const s = (sec % 60).toString().padStart(2, '0');
|
||||||
try {
|
return `${m}:${s}`;
|
||||||
await login(email, password);
|
}
|
||||||
navigate('/');
|
|
||||||
} catch (e) {
|
|
||||||
setErr(e.message);
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// ── Colonne image gauche (partagée) ───────────────────────────────────────
|
||||||
|
function BgCol({ appInfo }) {
|
||||||
return (
|
return (
|
||||||
<div className="login-shell">
|
<div className="login-bg-col" style={{
|
||||||
<form className="card login-card" onSubmit={submit}>
|
flex: '1 1 50%', display: 'none',
|
||||||
<h2 style={{ marginTop: 0 }}>Connexion</h2>
|
position: 'relative', overflow: 'hidden', background: '#0d0d0d',
|
||||||
{err && <div className="error">{err}</div>}
|
}}>
|
||||||
<label>Email</label>
|
<img src="/login-bg.jpg" alt="" aria-hidden="true"
|
||||||
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
|
onError={e => { e.target.style.display = 'none'; }}
|
||||||
<div style={{ height: 10 }} />
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
|
||||||
<label>Mot de passe</label>
|
/>
|
||||||
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
|
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ height: 16 }} />
|
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8, flexShrink: 0 }} />}
|
||||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>{appInfo.appName}</span>
|
||||||
{busy ? '…' : 'Se connecter'}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
);
|
||||||
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
|
}
|
||||||
</p>
|
|
||||||
</form>
|
// ── Header logo (colonne droite) ──────────────────────────────────────────
|
||||||
|
function AppHeader({ appInfo }) {
|
||||||
|
return (
|
||||||
|
<div className="login-mobile-header" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 32 }}>
|
||||||
|
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt={appInfo.appName} width={52} height={52} style={{ borderRadius: 12 }} />}
|
||||||
|
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.4px' }}>{appInfo.appName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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' }) => (
|
||||||
|
<button type={type} disabled={disabled} onClick={onClick} style={{
|
||||||
|
width: '100%', padding: '11px 0',
|
||||||
|
background: disabled ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||||
|
color: '#fff', border: 'none', borderRadius: 8,
|
||||||
|
fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}>{children}</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErrBox = ({ msg }) => msg ? (
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)', border: '1px solid var(--danger-light, #fca5a5)' }}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||||
|
<BgCol appInfo={appInfo} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<AppHeader appInfo={appInfo} />
|
||||||
|
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
|
||||||
|
{/* ══ ÉTAPE 1 : Formulaire email/mot de passe ══════════════════ */}
|
||||||
|
{step === 'form' && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-0.5px', color: 'var(--text)' }}>Bienvenue</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Connectez-vous à votre compte</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<ErrBox msg={err} />
|
||||||
|
|
||||||
|
{unverified && (
|
||||||
|
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
|
||||||
|
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{resendDone
|
||||||
|
? <span style={{ color: 'var(--success, #16a34a)', fontWeight: 500 }}>✓ Email renvoyé !</span>
|
||||||
|
: <button type="button" onClick={() => resend()} disabled={resendBusy} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary, #1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
{resendBusy ? 'Envoi…' : "Renvoyer l'email de vérification"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Adresse email</label>
|
||||||
|
<input className="form-input" type="email" required autoComplete="email" placeholder="vous@exemple.com"
|
||||||
|
value={email} onChange={e => setEmail(e.target.value)} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe</label>
|
||||||
|
<Link to="/forgot-password" style={{ fontSize: 13, color: 'var(--text-muted)', textDecoration: 'underline' }}>Mot de passe oublié ?</Link>
|
||||||
|
</div>
|
||||||
|
<input className="form-input" type="password" required autoComplete="current-password" placeholder="••••••••"
|
||||||
|
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BtnPrimary disabled={busy}>{busy ? 'Connexion…' : 'Se connecter'}</BtnPrimary>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Pas encore de compte ?{' '}
|
||||||
|
<Link to="/register" style={{ color: 'var(--text)', fontWeight: 500, textDecoration: 'underline' }}>Créer un compte</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ ÉTAPE 2 : Choix de méthode 2FA ══════════════════════════ */}
|
||||||
|
{step === 'method' && (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg, #eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="5" y="11" width="14" height="10" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 018 0v4" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="12" cy="16" r="1.5" fill="var(--primary,#1e40af)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>Vérification en deux étapes</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Choisissez votre méthode de vérification</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrBox msg={codeErr} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: codeErr ? 16 : 0 }}>
|
||||||
|
<button onClick={() => chooseMethod('totp')} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '14px 16px', borderRadius: 10,
|
||||||
|
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
|
||||||
|
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
|
||||||
|
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Application d'authentification</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Google Authenticator, Authy, etc.</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => chooseMethod('email')} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '14px 16px', borderRadius: 10,
|
||||||
|
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
|
||||||
|
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
|
||||||
|
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Code par email</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Recevoir un code à {email}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => { setStep('form'); setErr(null); }} style={{ marginTop: 24, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
← Retour à la connexion
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ ÉTAPE 3 : Saisie du code ═════════════════════════════════ */}
|
||||||
|
{step === 'code' && (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
||||||
|
{method === 'totp'
|
||||||
|
? <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/><line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
||||||
|
: <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
||||||
|
{method === 'totp' ? 'Code de l\'application' : 'Code par email'}
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
{method === 'totp'
|
||||||
|
? 'Entrez le code à 6 chiffres affiché dans votre application d\'authentification.'
|
||||||
|
: <>Code envoyé à <strong>{email}</strong>.</>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={verifyCode} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<ErrBox msg={codeErr} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Code de vérification</label>
|
||||||
|
{method === 'email' && (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: countdown > 0 ? 'var(--text-muted)' : 'var(--danger,#dc2626)' }}>
|
||||||
|
{countdown > 0 ? fmtCountdown(countdown) : 'Expiré'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text" inputMode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxLength={6} autoComplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
style={{ width: '100%', fontSize: 24, letterSpacing: 8, textAlign: 'center', fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={trustDev} onChange={e => setTrustDev(e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Faire confiance à cet appareil pendant 30 jours
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<BtnPrimary disabled={codeBusy || code.length !== 6}>
|
||||||
|
{codeBusy ? 'Vérification…' : 'Vérifier'}
|
||||||
|
</BtnPrimary>
|
||||||
|
|
||||||
|
{method === 'email' && countdown === 0 && (
|
||||||
|
<button type="button" onClick={resendEmailCode} style={{ background: 'none', border: 'none', color: 'var(--primary,#1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
Renvoyer le code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => { setStep('method'); setCode(''); setCodeErr(null); clearInterval(timerRef.current); }} style={{ marginTop: 20, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
← Changer de méthode
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <p style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 6 }}>✓ Email de vérification renvoyé.</p>;
|
||||||
|
return (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
|
||||||
|
Email non confirmé.{' '}
|
||||||
|
<button type="button" onClick={() => resend()} disabled={busy}
|
||||||
|
style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary)', fontWeight: 600, fontSize: 12, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||||
|
{busy ? 'Envoi…' : 'Renvoyer le lien'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<form onSubmit={submit} style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Après le changement, un email de vérification sera envoyé à la nouvelle adresse.
|
||||||
|
</p>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
{msg && <div className="success-msg">{msg}</div>}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<label className="profile-label">Nouvelle adresse email</label>
|
||||||
|
<input className="profile-input" type="email" required value={newEmail} onChange={e => setNewEmail(e.target.value)} placeholder="nouvelle@email.com" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<label className="profile-label">Mot de passe actuel (confirmation)</label>
|
||||||
|
<input className="profile-input" type="password" required value={pwd} onChange={e => setPwd(e.target.value)} placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy} style={{ fontSize: 13 }}>
|
||||||
|
{busy ? 'Enregistrement…' : 'Confirmer le changement'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => onDone()} style={{ fontSize: 13 }}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mon profil + Préférences ────────────────────────────────── */
|
/* ── Mon profil + Préférences ────────────────────────────────── */
|
||||||
function AccountForm() {
|
function AccountForm() {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
@@ -92,9 +170,10 @@ function AccountForm() {
|
|||||||
const initial = parseName(user?.display_name);
|
const initial = parseName(user?.display_name);
|
||||||
const [prenom, setPrenom] = useState(initial.prenom);
|
const [prenom, setPrenom] = useState(initial.prenom);
|
||||||
const [nom, setNom] = useState(initial.nom);
|
const [nom, setNom] = useState(initial.nom);
|
||||||
const [infoMsg, setInfoMsg] = useState(null);
|
const [infoMsg, setInfoMsg] = useState(null);
|
||||||
const [infoErr, setInfoErr] = useState(null);
|
const [infoErr, setInfoErr] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [changingEmail, setChangingEmail] = useState(false);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setInfoErr(null); setInfoMsg(null); setLoading(true);
|
setInfoErr(null); setInfoMsg(null); setLoading(true);
|
||||||
@@ -138,16 +217,23 @@ function AccountForm() {
|
|||||||
|
|
||||||
<div className="profile-field profile-field-full">
|
<div className="profile-field profile-field-full">
|
||||||
<span className="profile-label">Mon email</span>
|
<span className="profile-label">Mon email</span>
|
||||||
<div className="profile-email-row">
|
<div className="profile-email-row" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<span className="profile-email-value">{user?.email}</span>
|
<span className="profile-email-value">{user?.email}</span>
|
||||||
|
{user?.email_verified
|
||||||
|
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>✓ Vérifié</span>
|
||||||
|
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>Non vérifié</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{!user?.email_verified && <EmailResendBlock email={user?.email} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<button className="profile-manage-btn" type="button" disabled>
|
<button className="profile-manage-btn" type="button" onClick={() => setChangingEmail(v => !v)}>
|
||||||
Gérer mon email
|
{changingEmail ? 'Annuler' : 'Changer mon email'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{changingEmail && <EmailChangeForm onDone={() => setChangingEmail(false)} />}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Préférences ─────────────────────────────────────── */}
|
{/* ── 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 (
|
||||||
|
<div className="card" style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Double authentification (2FA)</h3>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 12,
|
||||||
|
background: enabled ? 'var(--success-bg,#f0fdf4)' : 'var(--surface-2,#f4f4f5)',
|
||||||
|
color: enabled ? 'var(--success,#16a34a)' : 'var(--text-muted)',
|
||||||
|
border: `1px solid ${enabled ? '#bbf7d0' : 'var(--border)'}`,
|
||||||
|
}}>
|
||||||
|
{enabled ? '✓ Activé' : 'Désactivé'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||||
|
Protégez votre compte avec une vérification supplémentaire à chaque connexion.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||||
|
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
|
||||||
|
|
||||||
|
{/* ── État normal ── */}
|
||||||
|
{status === 'idle' && !enabled && (
|
||||||
|
<button className="primary" onClick={() => startSetup()} disabled={busy}>
|
||||||
|
{busy ? 'Chargement…' : 'Activer le 2FA'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === 'idle' && enabled && (
|
||||||
|
<button className="btn btn-outline" style={{ color: 'var(--danger,#dc2626)', borderColor: 'var(--danger,#dc2626)' }}
|
||||||
|
onClick={() => { setStatus('disabling'); setErr(null); setMsg(null); }}>
|
||||||
|
Désactiver le 2FA
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Setup : QR code + vérification ── */}
|
||||||
|
{status === 'setup' && setupData && (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
Scannez ce QR code avec votre application d'authentification (<strong>Google Authenticator</strong>, <strong>Authy</strong>, etc.), puis entrez le code à 6 chiffres pour confirmer.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
|
||||||
|
<div style={{ background: '#fff', padding: 12, borderRadius: 8, border: '1px solid var(--border)', display: 'inline-block' }}>
|
||||||
|
<img src={setupData.qrCode} alt="QR code 2FA" width={160} height={160} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
Vous ne pouvez pas scanner ? Entrez manuellement cette clé dans votre application :
|
||||||
|
</p>
|
||||||
|
<code style={{
|
||||||
|
display: 'block', background: 'var(--surface-2)', padding: '8px 10px',
|
||||||
|
borderRadius: 6, fontSize: 13, letterSpacing: 1, wordBreak: 'break-all',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
{setupData.secret}
|
||||||
|
</code>
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
Émetteur : <strong>{setupData.issuer}</strong> · Compte : <strong>{setupData.email}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={confirmSetup} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', maxWidth: 320 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Code de vérification</label>
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric" maxLength={6} pattern="[0-9]{6}"
|
||||||
|
placeholder="000000" value={code}
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
style={{ fontSize: 20, letterSpacing: 6, textAlign: 'center', fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="primary" type="submit" disabled={busy || code.length !== 6} style={{ flexShrink: 0 }}>
|
||||||
|
{busy ? '…' : 'Confirmer'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" onClick={() => { setStatus('idle'); setSetupData(null); setCode(''); setErr(null); }}
|
||||||
|
style={{ marginTop: 12, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Désactivation : mot de passe ── */}
|
||||||
|
{status === 'disabling' && (
|
||||||
|
<form onSubmit={disable} style={{ maxWidth: 360 }}>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Confirmez votre mot de passe pour désactiver le 2FA.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Mot de passe actuel</label>
|
||||||
|
<input type="password" required value={disablePwd}
|
||||||
|
onChange={e => setDisablePwd(e.target.value)}
|
||||||
|
autoComplete="current-password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<button className="btn" style={{ background: 'var(--danger,#dc2626)', color: '#fff', flexShrink: 0 }}
|
||||||
|
type="submit" disabled={busy || !disablePwd}>
|
||||||
|
{busy ? '…' : 'Désactiver'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => { setStatus('idle'); setErr(null); setDisablePwd(''); }}
|
||||||
|
style={{ marginTop: 10, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
|
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
|
||||||
function SecurityForm() {
|
function SecurityForm() {
|
||||||
const { updateUser } = useAuth();
|
const { updateUser } = useAuth();
|
||||||
@@ -249,6 +492,7 @@ function SecurityForm() {
|
|||||||
export default function MonCompte() {
|
export default function MonCompte() {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const section = new URLSearchParams(search).get('section') || 'profil';
|
const section = new URLSearchParams(search).get('section') || 'profil';
|
||||||
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
|
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
|
||||||
@@ -278,7 +522,7 @@ export default function MonCompte() {
|
|||||||
{/* ── Contenu ─────────────────────────────────────── */}
|
{/* ── Contenu ─────────────────────────────────────── */}
|
||||||
<div className="account-content">
|
<div className="account-content">
|
||||||
{section === 'profil' && <AccountForm />}
|
{section === 'profil' && <AccountForm />}
|
||||||
{section === 'securite' && <SecurityForm />}
|
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /></>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+240
-23
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext.jsx';
|
import { useAuth } from '../context/AuthContext.jsx';
|
||||||
|
|
||||||
@@ -8,40 +8,257 @@ export default function Register() {
|
|||||||
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
|
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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 set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
|
||||||
|
|
||||||
|
const [verifyEmail, setVerifyEmail] = useState(null); // email à vérifier
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null); setBusy(true);
|
setErr(null); setBusy(true);
|
||||||
try {
|
try {
|
||||||
await register(form.email, form.password, form.displayName || undefined);
|
const result = await register(form.email, form.password, form.displayName || undefined);
|
||||||
navigate('/');
|
if (result?.requiresVerification) {
|
||||||
|
setVerifyEmail(result.email);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
} catch (e) { setErr(e.message); }
|
} catch (e) { setErr(e.message); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-shell">
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||||
<form className="card login-card" onSubmit={submit}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
|
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||||
{err && <div className="error">{err}</div>}
|
<div className="login-bg-col" style={{
|
||||||
<label>Nom d'affichage</label>
|
flex: '1 1 50%',
|
||||||
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
|
display: 'none',
|
||||||
<div style={{ height: 10 }} />
|
position: 'relative',
|
||||||
<label>Email</label>
|
overflow: 'hidden',
|
||||||
<input type="email" required value={form.email} onChange={set('email')} />
|
background: '#0d0d0d',
|
||||||
<div style={{ height: 10 }} />
|
}}>
|
||||||
<label>Mot de passe (8 car. min.)</label>
|
<img
|
||||||
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
|
src="/login-bg.jpg"
|
||||||
<div style={{ height: 16 }} />
|
alt=""
|
||||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
aria-hidden="true"
|
||||||
{busy ? '…' : 'Créer le compte'}
|
onError={e => { e.target.style.display = 'none'; }}
|
||||||
</button>
|
style={{
|
||||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
position: 'absolute', inset: 0,
|
||||||
Déjà inscrit ? <Link to="/login">Se connecter</Link>
|
width: '100%', height: '100%',
|
||||||
</p>
|
objectFit: 'cover', opacity: 0.85,
|
||||||
</form>
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 40, left: 40,
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||||
|
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 50%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 24px',
|
||||||
|
background: 'var(--background, #fff)',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Logo + nom */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt={appInfo.appName}
|
||||||
|
width={52} height={52} style={{ borderRadius: 12 }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20, fontWeight: 700,
|
||||||
|
color: 'var(--text)', letterSpacing: '-0.4px',
|
||||||
|
}}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
|
||||||
|
{/* ── Écran vérification email ── */}
|
||||||
|
{verifyEmail ? (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--primary-bg, #eff6ff)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
stroke="var(--primary, #1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
||||||
|
Vérifiez votre email
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '0 0 6px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||||
|
Un email de vérification a été envoyé à
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 24px', fontWeight: 600, color: 'var(--text)', fontSize: 14 }}>
|
||||||
|
{verifyEmail}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
Cliquez sur le lien dans l'email pour activer votre compte. Vérifiez aussi vos spams.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
display: 'block', width: '100%', padding: '11px 0',
|
||||||
|
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||||
|
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: 'none', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
|
||||||
|
{/* En-tête */}
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h1 style={{
|
||||||
|
margin: '0 0 6px',
|
||||||
|
fontSize: 26, fontWeight: 700,
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}>
|
||||||
|
Créer un compte
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Rejoignez {appInfo.appName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire */}
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderRadius: 8, fontSize: 14,
|
||||||
|
background: 'var(--danger-bg, #fef2f2)',
|
||||||
|
color: 'var(--danger, #dc2626)',
|
||||||
|
border: '1px solid var(--danger-light, #fca5a5)',
|
||||||
|
}}>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Nom d'affichage
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder="Olivier"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={set('displayName')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Adresse email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email" required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="vous@exemple.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={set('email')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Mot de passe <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(8 car. min.)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password" required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={set('password')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, width: '100%',
|
||||||
|
padding: '11px 0',
|
||||||
|
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none', borderRadius: 8,
|
||||||
|
fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: busy ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Création…' : 'Créer le compte'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
marginTop: 24, textAlign: 'center',
|
||||||
|
fontSize: 13, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
Déjà inscrit ?{' '}
|
||||||
|
<Link to="/login" style={{
|
||||||
|
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||||
|
}}>
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</>)} {/* fin verifyEmail ternaire */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.login-bg-col { display: block !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||||
|
|
||||||
|
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||||
|
<div className="login-bg-col" style={{
|
||||||
|
flex: '1 1 50%',
|
||||||
|
display: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/login-bg.jpg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
onError={e => { e.target.style.display = 'none'; }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
objectFit: 'cover', opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 40, left: 40,
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||||
|
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 50%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 24px',
|
||||||
|
background: 'var(--background, #fff)',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Logo + nom */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}>
|
||||||
|
{appInfo.iconUrl && (
|
||||||
|
<img src={appInfo.iconUrl} alt={appInfo.appName}
|
||||||
|
width={52} height={52} style={{ borderRadius: 12 }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20, fontWeight: 700,
|
||||||
|
color: 'var(--text)', letterSpacing: '-0.4px',
|
||||||
|
}}>
|
||||||
|
{appInfo.appName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
|
||||||
|
{!token ? (
|
||||||
|
/* ── Lien invalide ── */
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<p style={{ color: 'var(--danger)', marginBottom: 20 }}>
|
||||||
|
Lien de réinitialisation invalide ou manquant.
|
||||||
|
</p>
|
||||||
|
<Link to="/forgot-password" style={{
|
||||||
|
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||||
|
}}>
|
||||||
|
Faire une nouvelle demande
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : status === 'done' ? (
|
||||||
|
/* ── Succès ── */
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: '50%',
|
||||||
|
background: 'var(--success-bg, #f0fdf4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{
|
||||||
|
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
|
||||||
|
letterSpacing: '-0.4px', color: 'var(--text)',
|
||||||
|
}}>Mot de passe mis à jour</h1>
|
||||||
|
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Redirection vers la connexion dans quelques secondes…
|
||||||
|
</p>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
display: 'block', width: '100%', padding: '11px 0',
|
||||||
|
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||||
|
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: 'none', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Formulaire ── */
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h1 style={{
|
||||||
|
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
|
||||||
|
letterSpacing: '-0.5px', color: 'var(--text)',
|
||||||
|
}}>
|
||||||
|
Nouveau mot de passe
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Choisissez un mot de passe d'au moins 8 caractères.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderRadius: 8, fontSize: 14,
|
||||||
|
background: 'var(--danger-bg, #fef2f2)',
|
||||||
|
color: 'var(--danger, #dc2626)',
|
||||||
|
border: '1px solid var(--danger-light, #fca5a5)',
|
||||||
|
}}>
|
||||||
|
{errMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Nouveau mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password" required minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={e => { setPassword(e.target.value); setStatus(null); }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Confirmer le mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password" required minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password2}
|
||||||
|
onChange={e => { setPassword2(e.target.value); setStatus(null); }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, width: '100%',
|
||||||
|
padding: '11px 0',
|
||||||
|
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none', borderRadius: 8,
|
||||||
|
fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: busy ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Enregistrement…' : 'Réinitialiser le mot de passe'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
marginTop: 24, textAlign: 'center',
|
||||||
|
fontSize: 13, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||||
|
}}>
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.login-bg-col { display: block !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||||
|
|
||||||
|
<div className="login-bg-col" style={{
|
||||||
|
flex: '1 1 50%', display: 'none', position: 'relative',
|
||||||
|
overflow: 'hidden', background: '#0d0d0d',
|
||||||
|
}}>
|
||||||
|
<img src="/login-bg.jpg" alt="" aria-hidden="true"
|
||||||
|
onError={e => { e.target.style.display = 'none'; }}
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8 }} />}
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600 }}>{appInfo.appName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 32 }}>
|
||||||
|
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt={appInfo.appName} width={52} height={52} style={{ borderRadius: 12 }} />}
|
||||||
|
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.4px' }}>{appInfo.appName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: '100%', maxWidth: 360, textAlign: 'center' }}>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>Vérification en cours…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--success-bg, #f0fdf4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 10px', fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>
|
||||||
|
Email vérifié !
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||||
|
Votre adresse email a bien été confirmée. Vous pouvez maintenant vous connecter.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
display: 'block', width: '100%', padding: '11px 0',
|
||||||
|
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||||
|
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}>
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--danger-bg, #fef2f2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||||
|
stroke="var(--danger, #dc2626)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
||||||
|
Lien invalide
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||||
|
{errMsg}
|
||||||
|
</p>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
display: 'block', width: '100%', padding: '11px 0',
|
||||||
|
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||||
|
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}>
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ const DEFAULT_STATE = {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
allowUnauth: false,
|
allowUnauth: false,
|
||||||
appName: 'Crowdlending',
|
appName: 'Crowdlending Tracker',
|
||||||
appUrl: '',
|
appUrl: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export default function SmtpSection() {
|
|||||||
username: data.username || '',
|
username: data.username || '',
|
||||||
password: '',
|
password: '',
|
||||||
allowUnauth: !!data.allowUnauth,
|
allowUnauth: !!data.allowUnauth,
|
||||||
appName: data.appName || 'Crowdlending',
|
appName: data.appName || 'Crowdlending Tracker',
|
||||||
appUrl: data.appUrl || '',
|
appUrl: data.appUrl || '',
|
||||||
});
|
});
|
||||||
setHasPassword(!!data.hasPassword);
|
setHasPassword(!!data.hasPassword);
|
||||||
|
|||||||
@@ -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) => {
|
const deleteUser = (u) => {
|
||||||
setConfirmAction({
|
setConfirmAction({
|
||||||
title: 'Supprimer l\'utilisateur',
|
title: 'Supprimer l\'utilisateur',
|
||||||
@@ -66,6 +81,7 @@ export default function UsersSection({ currentUserId }) {
|
|||||||
<th style={{ width: 36 }}>ID</th>
|
<th style={{ width: 36 }}>ID</th>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Email vérifié</th>
|
||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>Créé le</th>
|
<th>Créé le</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -77,10 +93,16 @@ export default function UsersSection({ currentUserId }) {
|
|||||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
||||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
||||||
<td>{u.email}</td>
|
<td>{u.email}</td>
|
||||||
|
<td>
|
||||||
|
{u.email_verified
|
||||||
|
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>✓ Vérifié</span>
|
||||||
|
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td><Badge role={u.role} /></td>
|
<td><Badge role={u.role} /></td>
|
||||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-outline"
|
className="btn btn-sm btn-outline"
|
||||||
onClick={() => toggleRole(u)}
|
onClick={() => toggleRole(u)}
|
||||||
@@ -89,6 +111,11 @@ export default function UsersSection({ currentUserId }) {
|
|||||||
>
|
>
|
||||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
||||||
</button>
|
</button>
|
||||||
|
{!u.email_verified && (
|
||||||
|
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
|
||||||
|
Vérifier email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{u.id !== currentUserId && (
|
{u.id !== currentUserId && (
|
||||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
||||||
Supprimer
|
Supprimer
|
||||||
|
|||||||
Reference in New Issue
Block a user