Modification des pages d'authentification

This commit is contained in:
2026-06-14 21:58:05 +02:00
parent dc28b12c27
commit 9f6363ec20
20 changed files with 2494 additions and 172 deletions
+24
View File
@@ -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
+398
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -939,7 +939,6 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_s
} }
} }
export default db;
// ── Table user_preferences ─────────────────────────────────────────────────── // ── 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;
+14 -3
View File
@@ -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
View File
@@ -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); }
}); });
+16
View File
@@ -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
+1 -1
View File
@@ -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

+8 -2
View File
@@ -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
View File
@@ -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;
} }
+19 -3
View File
@@ -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>
); );
+223
View File
@@ -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
View File
@@ -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>
); );
} }
+251 -7
View File
@@ -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
View File
@@ -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>
); );
} }
+254
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+2 -2
View File
@@ -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);
+28 -1
View File
@@ -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