Добавлена поддержка двухфакторной аутентификации (2FA) в систему. Реализованы API для настройки, включения, отключения и проверки 2FA, а также обновлены компоненты для обработки 2FA на страницах входа и профиля. Обновлены типы данных для поддержки новых функций и улучшено взаимодействие с пользователем через уведомления.
This commit is contained in:
parent
83abe3f919
commit
c4327d31d1
279
backend/package-lock.json
generated
279
backend/package-lock.json
generated
@ -29,6 +29,8 @@
|
||||
"multer": "^2.0.0-rc.4",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pngjs": "^7.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"speakeasy": "^2.0.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1199,11 +1201,25 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"optional": true,
|
||||
"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/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@ -1248,6 +1264,12 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/base32.js": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@ -1449,6 +1471,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@ -1490,6 +1521,17 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"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/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
@ -1580,6 +1622,24 @@
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"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/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@ -1702,6 +1762,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@ -1746,6 +1815,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
@ -1778,8 +1853,7 @@
|
||||
"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==",
|
||||
"optional": true
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
@ -2018,6 +2092,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@ -2109,6 +2196,15 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@ -2431,7 +2527,6 @@
|
||||
"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==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -2474,6 +2569,18 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"optional": true
|
||||
},
|
||||
"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/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
@ -2989,6 +3096,33 @@
|
||||
"wrappy": "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-map": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||
@ -3004,6 +3138,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@ -3012,6 +3155,15 @@
|
||||
"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-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@ -3121,6 +3273,32 @@
|
||||
"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/qrcode/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/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@ -3219,6 +3397,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"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/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
@ -3332,8 +3525,7 @@
|
||||
"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==",
|
||||
"optional": true
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
@ -3549,6 +3741,18 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlite3": {
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
|
||||
@ -3612,7 +3816,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@ -3626,7 +3829,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@ -3863,6 +4065,12 @@
|
||||
"node": ">= 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/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
@ -3872,6 +4080,20 @@
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@ -3885,10 +4107,51 @@
|
||||
"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/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@
|
||||
"multer": "^2.0.0-rc.4",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pngjs": "^7.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"speakeasy": "^2.0.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -14,6 +14,8 @@ const multer = require("multer");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const speakeasy = require("speakeasy");
|
||||
const QRCode = require("qrcode");
|
||||
const { encrypt, decrypt } = require("./utils/encryption");
|
||||
|
||||
const app = express();
|
||||
@ -541,6 +543,70 @@ function runMigrations() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем существование колонок для 2FA
|
||||
const hasTwoFactorEnabled = columns.some(
|
||||
(col) => col.name === "two_factor_enabled"
|
||||
);
|
||||
if (!hasTwoFactorEnabled) {
|
||||
db.run(
|
||||
"ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER DEFAULT 0",
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error(
|
||||
"Ошибка добавления колонки two_factor_enabled:",
|
||||
err.message
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Колонка two_factor_enabled добавлена в таблицу users"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const hasTwoFactorSecret = columns.some(
|
||||
(col) => col.name === "two_factor_secret"
|
||||
);
|
||||
if (!hasTwoFactorSecret) {
|
||||
db.run(
|
||||
"ALTER TABLE users ADD COLUMN two_factor_secret TEXT",
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error(
|
||||
"Ошибка добавления колонки two_factor_secret:",
|
||||
err.message
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Колонка two_factor_secret добавлена в таблицу users"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const hasTwoFactorBackupCodes = columns.some(
|
||||
(col) => col.name === "two_factor_backup_codes"
|
||||
);
|
||||
if (!hasTwoFactorBackupCodes) {
|
||||
db.run(
|
||||
"ALTER TABLE users ADD COLUMN two_factor_backup_codes TEXT",
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error(
|
||||
"Ошибка добавления колонки two_factor_backup_codes:",
|
||||
err.message
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Колонка two_factor_backup_codes добавлена в таблицу users"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
||||
@ -747,7 +813,23 @@ app.post("/api/login", async (req, res) => {
|
||||
return res.status(401).json({ error: "Неверный логин или пароль" });
|
||||
}
|
||||
|
||||
// Успешный вход
|
||||
// Проверяем, включена ли 2FA
|
||||
if (user.two_factor_enabled === 1) {
|
||||
// Создаем промежуточную сессию для проверки 2FA
|
||||
req.session.twoFactorPending = true;
|
||||
req.session.twoFactorUsername = username;
|
||||
req.session.twoFactorUserId = user.id;
|
||||
|
||||
// НЕ создаем полную сессию, только промежуточную
|
||||
res.json({
|
||||
success: true,
|
||||
requires2FA: true,
|
||||
message: "Требуется код двухфакторной аутентификации",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Если 2FA не включена, создаем полную сессию как обычно
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.authenticated = true;
|
||||
@ -797,7 +879,7 @@ app.get("/api/user", requireApiAuth, (req, res) => {
|
||||
}
|
||||
|
||||
const sql =
|
||||
"SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled FROM users WHERE id = ?";
|
||||
"SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled, two_factor_enabled FROM users WHERE id = ?";
|
||||
db.get(sql, [req.session.userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения данных пользователя:", err.message);
|
||||
@ -812,6 +894,27 @@ app.get("/api/user", requireApiAuth, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// API для получения статуса 2FA
|
||||
app.get("/api/user/2fa/status", requireApiAuth, (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
const sql = "SELECT two_factor_enabled FROM users WHERE id = ?";
|
||||
db.get(sql, [userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения статуса 2FA:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "Пользователь не найден" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
twoFactorEnabled: user.two_factor_enabled === 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Страница с заметками (требует аутентификации)
|
||||
app.get("/notes", requireAuth, (req, res) => {
|
||||
// Получаем цвет пользователя для предотвращения FOUC
|
||||
@ -2412,6 +2515,318 @@ app.put("/api/user/ai-settings", requireApiAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// API для получения настроек 2FA (генерация секрета и QR-кода)
|
||||
app.get("/api/user/2fa/setup", requireApiAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
try {
|
||||
// Получаем информацию о пользователе
|
||||
const getUserSql = "SELECT username FROM users WHERE id = ?";
|
||||
db.get(getUserSql, [userId], async (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения пользователя:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "Пользователь не найден" });
|
||||
}
|
||||
|
||||
// Генерируем секретный ключ
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `NoteJS (${user.username})`,
|
||||
issuer: "NoteJS",
|
||||
length: 32,
|
||||
});
|
||||
|
||||
// Генерируем QR-код
|
||||
try {
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
secret: secret.base32, // Возвращаем base32 секрет для ручного ввода
|
||||
qrCode: qrCodeUrl, // Возвращаем QR-код как data URL
|
||||
otpauthUrl: secret.otpauth_url,
|
||||
});
|
||||
} catch (qrError) {
|
||||
console.error("Ошибка генерации QR-кода:", qrError);
|
||||
return res.status(500).json({ error: "Ошибка генерации QR-кода" });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка настройки 2FA:", error);
|
||||
res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
});
|
||||
|
||||
// API для включения 2FA
|
||||
app.post("/api/user/2fa/enable", requireApiAuth, async (req, res) => {
|
||||
const { secret, token } = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (!secret || !token) {
|
||||
return res.status(400).json({ error: "Секрет и код обязательны" });
|
||||
}
|
||||
|
||||
// Проверяем токен
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: secret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2, // Разрешаем небольшое отклонение по времени
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(400).json({ error: "Неверный код подтверждения" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Генерируем резервные коды (10 штук)
|
||||
const backupCodes = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
backupCodes.push(
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Хешируем резервные коды
|
||||
const hashedBackupCodes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10))
|
||||
);
|
||||
|
||||
// Шифруем секрет перед сохранением
|
||||
const encryptedSecret = encrypt(secret);
|
||||
|
||||
// Сохраняем в БД
|
||||
const updateSql =
|
||||
"UPDATE users SET two_factor_enabled = 1, two_factor_secret = ?, two_factor_backup_codes = ? WHERE id = ?";
|
||||
db.run(
|
||||
updateSql,
|
||||
[encryptedSecret, JSON.stringify(hashedBackupCodes), userId],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error("Ошибка включения 2FA:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
// Логируем включение 2FA
|
||||
logAction(userId, "profile_update", "Включена двухфакторная аутентификация");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "2FA успешно включена",
|
||||
backupCodes: backupCodes, // Возвращаем незахешированные коды один раз
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Ошибка включения 2FA:", error);
|
||||
res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
});
|
||||
|
||||
// API для отключения 2FA
|
||||
app.post("/api/user/2fa/disable", requireApiAuth, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Пароль обязателен" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем пароль
|
||||
const getUserSql = "SELECT password FROM users WHERE id = ?";
|
||||
db.get(getUserSql, [userId], async (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения пользователя:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "Пользователь не найден" });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: "Неверный пароль" });
|
||||
}
|
||||
|
||||
// Отключаем 2FA
|
||||
const updateSql =
|
||||
"UPDATE users SET two_factor_enabled = 0, two_factor_secret = NULL, two_factor_backup_codes = NULL WHERE id = ?";
|
||||
db.run(updateSql, [userId], function (err) {
|
||||
if (err) {
|
||||
console.error("Ошибка отключения 2FA:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
// Логируем отключение 2FA
|
||||
logAction(userId, "profile_update", "Отключена двухфакторная аутентификация");
|
||||
|
||||
res.json({ success: true, message: "2FA успешно отключена" });
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка отключения 2FA:", error);
|
||||
res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
});
|
||||
|
||||
// API для проверки 2FA кода при входе
|
||||
app.post("/api/user/2fa/verify", async (req, res) => {
|
||||
const { username, token } = req.body;
|
||||
|
||||
if (!username || !token) {
|
||||
return res.status(400).json({ error: "Логин и код обязательны" });
|
||||
}
|
||||
|
||||
// Проверяем, есть ли промежуточная сессия для этого пользователя
|
||||
if (!req.session.twoFactorPending || req.session.twoFactorUsername !== username) {
|
||||
return res.status(400).json({ error: "Сессия не найдена. Начните вход заново" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем пользователя
|
||||
const getUserSql =
|
||||
"SELECT id, username, two_factor_secret, two_factor_backup_codes FROM users WHERE username = ? AND two_factor_enabled = 1";
|
||||
db.get(getUserSql, [username], async (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка проверки 2FA:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (!user || !user.two_factor_secret) {
|
||||
return res.status(400).json({ error: "2FA не настроена для этого пользователя" });
|
||||
}
|
||||
|
||||
// Расшифровываем секрет
|
||||
const secret = decrypt(user.two_factor_secret);
|
||||
|
||||
// Проверяем TOTP код
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: secret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
let backupCodeUsed = false;
|
||||
|
||||
// Если TOTP не прошел, проверяем резервные коды
|
||||
if (!verified && user.two_factor_backup_codes) {
|
||||
try {
|
||||
const backupCodes = JSON.parse(user.two_factor_backup_codes);
|
||||
let codeIndex = -1;
|
||||
|
||||
// Проверяем каждый резервный код
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const match = await bcrypt.compare(token, backupCodes[i]);
|
||||
if (match) {
|
||||
codeIndex = i;
|
||||
backupCodeUsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Если резервный код использован, удаляем его
|
||||
if (codeIndex !== -1) {
|
||||
backupCodes.splice(codeIndex, 1);
|
||||
const updateSql =
|
||||
"UPDATE users SET two_factor_backup_codes = ? WHERE id = ?";
|
||||
db.run(updateSql, [JSON.stringify(backupCodes), user.id], (err) => {
|
||||
if (err) {
|
||||
console.error("Ошибка обновления резервных кодов:", err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("Ошибка парсинга резервных кодов:", parseError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified && !backupCodeUsed) {
|
||||
return res.status(401).json({ error: "Неверный код" });
|
||||
}
|
||||
|
||||
// Создаем полную сессию
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.authenticated = true;
|
||||
delete req.session.twoFactorPending;
|
||||
delete req.session.twoFactorUsername;
|
||||
|
||||
// Логируем вход
|
||||
logAction(user.id, "login", `Вход в систему${backupCodeUsed ? " (резервный код)" : ""}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Вход успешен",
|
||||
backupCodeUsed: backupCodeUsed,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка проверки 2FA:", error);
|
||||
res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
});
|
||||
|
||||
// API для генерации новых резервных кодов
|
||||
app.post("/api/user/2fa/backup-codes", requireApiAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
try {
|
||||
// Проверяем, что 2FA включена
|
||||
const getUserSql =
|
||||
"SELECT two_factor_enabled FROM users WHERE id = ?";
|
||||
db.get(getUserSql, [userId], async (err, user) => {
|
||||
if (err) {
|
||||
console.error("Ошибка получения пользователя:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
if (!user || !user.two_factor_enabled) {
|
||||
return res.status(400).json({ error: "2FA не включена" });
|
||||
}
|
||||
|
||||
// Генерируем новые резервные коды
|
||||
const backupCodes = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
backupCodes.push(
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Хешируем резервные коды
|
||||
const hashedBackupCodes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10))
|
||||
);
|
||||
|
||||
// Сохраняем в БД
|
||||
const updateSql =
|
||||
"UPDATE users SET two_factor_backup_codes = ? WHERE id = ?";
|
||||
db.run(updateSql, [JSON.stringify(hashedBackupCodes), userId], function (err) {
|
||||
if (err) {
|
||||
console.error("Ошибка генерации резервных кодов:", err.message);
|
||||
return res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
|
||||
// Логируем генерацию резервных кодов
|
||||
logAction(userId, "profile_update", "Сгенерированы новые резервные коды 2FA");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
backupCodes: backupCodes,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Ошибка генерации резервных кодов:", error);
|
||||
res.status(500).json({ error: "Ошибка сервера" });
|
||||
}
|
||||
});
|
||||
|
||||
// API для улучшения текста через AI
|
||||
app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||
const { text } = req.body;
|
||||
|
||||
@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "/index.html",
|
||||
"revision": "0.5s0rolua70o"
|
||||
"revision": "0.dlpr89s9d7"
|
||||
}], {
|
||||
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ import axiosClient from "./axiosClient";
|
||||
import { AuthResponse } from "../types/user";
|
||||
|
||||
export const authApi = {
|
||||
login: async (username: string, password: string) => {
|
||||
login: async (username: string, password: string): Promise<AuthResponse & { requires2FA?: boolean; message?: string }> => {
|
||||
const { data } = await axiosClient.post("/login", { username, password });
|
||||
return data;
|
||||
},
|
||||
|
||||
@ -40,6 +40,8 @@ axiosClient.interceptors.response.use(
|
||||
"/register", // Страница регистрации
|
||||
"/notes/archived/all", // Удаление всех архивных заметок
|
||||
"/user/delete-account", // Удаление аккаунта
|
||||
"/user/2fa/verify", // Проверка 2FA кода
|
||||
"/user/2fa/disable", // Отключение 2FA
|
||||
];
|
||||
|
||||
// URL, где 401 не должен обрабатываться как ошибка сессии
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import axiosClient from "./axiosClient";
|
||||
import { User, AiSettings } from "../types/user";
|
||||
import {
|
||||
User,
|
||||
AiSettings,
|
||||
TwoFactorSetup,
|
||||
TwoFactorStatus,
|
||||
TwoFactorEnableResponse,
|
||||
TwoFactorDisableResponse,
|
||||
TwoFactorVerifyResponse,
|
||||
} from "../types/user";
|
||||
|
||||
export const userApi = {
|
||||
getProfile: async (): Promise<User> => {
|
||||
@ -51,4 +59,52 @@ export const userApi = {
|
||||
const { data } = await axiosClient.put("/user/ai-settings", settings);
|
||||
return data;
|
||||
},
|
||||
|
||||
// 2FA методы
|
||||
get2FAStatus: async (): Promise<TwoFactorStatus> => {
|
||||
const { data } = await axiosClient.get<TwoFactorStatus>("/user/2fa/status");
|
||||
return data;
|
||||
},
|
||||
|
||||
setup2FA: async (): Promise<TwoFactorSetup> => {
|
||||
const { data } = await axiosClient.get<TwoFactorSetup>("/user/2fa/setup");
|
||||
return data;
|
||||
},
|
||||
|
||||
enable2FA: async (
|
||||
secret: string,
|
||||
token: string
|
||||
): Promise<TwoFactorEnableResponse> => {
|
||||
const { data } = await axiosClient.post<TwoFactorEnableResponse>(
|
||||
"/user/2fa/enable",
|
||||
{ secret, token }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
disable2FA: async (password: string): Promise<TwoFactorDisableResponse> => {
|
||||
const { data } = await axiosClient.post<TwoFactorDisableResponse>(
|
||||
"/user/2fa/disable",
|
||||
{ password }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
verify2FA: async (
|
||||
username: string,
|
||||
token: string
|
||||
): Promise<TwoFactorVerifyResponse> => {
|
||||
const { data } = await axiosClient.post<TwoFactorVerifyResponse>(
|
||||
"/user/2fa/verify",
|
||||
{ username, token }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
generateBackupCodes: async (): Promise<{ success: boolean; backupCodes: string[] }> => {
|
||||
const { data } = await axiosClient.post<{ success: boolean; backupCodes: string[] }>(
|
||||
"/user/2fa/backup-codes"
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ interface ModalProps {
|
||||
title: string;
|
||||
message: string | ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
cancelText?: string | null;
|
||||
confirmType?: "primary" | "danger";
|
||||
}
|
||||
|
||||
@ -18,9 +18,15 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
title,
|
||||
message,
|
||||
confirmText = "OK",
|
||||
cancelText = "Отмена",
|
||||
cancelText,
|
||||
confirmType = "primary",
|
||||
}) => {
|
||||
// Если cancelText не передан или пустая строка, скрываем кнопку отмены
|
||||
// Если передан null или пустая строка, не показываем кнопку
|
||||
// Если не передан вообще (undefined), используем дефолтное значение "Отмена"
|
||||
const shouldShowCancel = cancelText !== null && cancelText !== "";
|
||||
const displayCancelText = cancelText === undefined ? "Отмена" : cancelText;
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@ -54,9 +60,11 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
{shouldShowCancel && (
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
{cancelText}
|
||||
{displayCancelText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
446
src/components/twoFactor/TwoFactorSetup.tsx
Normal file
446
src/components/twoFactor/TwoFactorSetup.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { userApi } from "../../api/userApi";
|
||||
import { useNotification } from "../../hooks/useNotification";
|
||||
import { Modal } from "../common/Modal";
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
twoFactorEnabled: boolean;
|
||||
onStatusChange: () => void;
|
||||
}
|
||||
|
||||
export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
twoFactorEnabled,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { showNotification } = useNotification();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [setupData, setSetupData] = useState<{
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
} | null>(null);
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [disablePassword, setDisablePassword] = useState("");
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [showBackupCodesModal, setShowBackupCodesModal] = useState(false);
|
||||
const [isGeneratingBackupCodes, setIsGeneratingBackupCodes] = useState(false);
|
||||
|
||||
const handleSetup = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await userApi.setup2FA();
|
||||
setSetupData({
|
||||
secret: data.secret,
|
||||
qrCode: data.qrCode,
|
||||
});
|
||||
setVerificationCode("");
|
||||
} catch (error: any) {
|
||||
console.error("Ошибка настройки 2FA:", error);
|
||||
showNotification(
|
||||
error.response?.data?.error || "Ошибка настройки 2FA",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (!setupData || !verificationCode.trim()) {
|
||||
showNotification("Введите код подтверждения", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verificationCode.trim().length !== 6) {
|
||||
showNotification("Код должен содержать 6 цифр", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await userApi.enable2FA(setupData.secret, verificationCode.trim());
|
||||
setBackupCodes(response.backupCodes);
|
||||
setShowBackupCodesModal(true);
|
||||
setSetupData(null);
|
||||
setVerificationCode("");
|
||||
onStatusChange();
|
||||
showNotification("2FA успешно включена", "success");
|
||||
} catch (error: any) {
|
||||
console.error("Ошибка включения 2FA:", error);
|
||||
showNotification(
|
||||
error.response?.data?.error || "Ошибка включения 2FA",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!disablePassword.trim()) {
|
||||
showNotification("Введите пароль", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
await userApi.disable2FA(disablePassword);
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword("");
|
||||
onStatusChange();
|
||||
showNotification("2FA успешно отключена", "success");
|
||||
} catch (error: any) {
|
||||
console.error("Ошибка отключения 2FA:", error);
|
||||
showNotification(
|
||||
error.response?.data?.error || "Ошибка отключения 2FA",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateBackupCodes = async () => {
|
||||
setIsGeneratingBackupCodes(true);
|
||||
try {
|
||||
const response = await userApi.generateBackupCodes();
|
||||
setBackupCodes(response.backupCodes);
|
||||
setShowBackupCodesModal(true);
|
||||
showNotification("Резервные коды успешно сгенерированы", "success");
|
||||
} catch (error: any) {
|
||||
console.error("Ошибка генерации резервных кодов:", error);
|
||||
showNotification(
|
||||
error.response?.data?.error || "Ошибка генерации резервных кодов",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingBackupCodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
showNotification("Скопировано в буфер обмена", "success");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Двухфакторная аутентификация</h3>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}>
|
||||
Двухфакторная аутентификация добавляет дополнительный уровень
|
||||
безопасности к вашему аккаунту. Используйте приложение-аутентификатор
|
||||
(Google Authenticator, Microsoft Authenticator и т.д.) для генерации
|
||||
кодов.
|
||||
</p>
|
||||
|
||||
{!twoFactorEnabled ? (
|
||||
<div>
|
||||
{!setupData ? (
|
||||
<div>
|
||||
<button className="btnSave" onClick={handleSetup} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Icon icon="mdi:loading" /> Настройка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mdi:shield-lock" /> Включить 2FA
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ marginTop: 0 }}>Шаг 1: Отсканируйте QR-код</h4>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||
Откройте приложение-аутентификатор на вашем телефоне и
|
||||
отсканируйте этот QR-код:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={setupData.qrCode}
|
||||
alt="QR Code"
|
||||
style={{
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "10px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "10px" }}>
|
||||
Или введите секретный ключ вручную:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<code
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px",
|
||||
backgroundColor: "var(--bg-color)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{setupData.secret}
|
||||
</code>
|
||||
<button
|
||||
className="btnSave"
|
||||
onClick={() => copyToClipboard(setupData.secret)}
|
||||
style={{ padding: "10px 15px" }}
|
||||
title="Копировать"
|
||||
>
|
||||
<Icon icon="mdi:content-copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ marginTop: 0 }}>Шаг 2: Введите код подтверждения</h4>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||
Введите 6-значный код из приложения-аутентификатора:
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="000000"
|
||||
value={verificationCode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setVerificationCode(value);
|
||||
}}
|
||||
maxLength={6}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "24px",
|
||||
letterSpacing: "8px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
className="btnSave"
|
||||
onClick={handleEnable}
|
||||
disabled={isLoading || verificationCode.length !== 6}
|
||||
>
|
||||
{isLoading ? "Включение..." : "Включить 2FA"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => {
|
||||
setSetupData(null);
|
||||
setVerificationCode("");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid #28a745",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:shield-check" style={{ fontSize: "24px", color: "#28a745" }} />
|
||||
<span style={{ fontWeight: "600", color: "#28a745" }}>
|
||||
2FA включена
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||
Двухфакторная аутентификация активна для вашего аккаунта.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
className="btnSave"
|
||||
onClick={handleGenerateBackupCodes}
|
||||
disabled={isGeneratingBackupCodes}
|
||||
>
|
||||
{isGeneratingBackupCodes ? (
|
||||
<>
|
||||
<Icon icon="mdi:loading" /> Генерация...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mdi:key-variant" /> Сгенерировать резервные коды
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => setShowDisableModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:shield-off" /> Отключить 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно для отключения 2FA */}
|
||||
<Modal
|
||||
isOpen={showDisableModal}
|
||||
onClose={() => {
|
||||
setShowDisableModal(false);
|
||||
setDisablePassword("");
|
||||
}}
|
||||
onConfirm={handleDisable}
|
||||
title="Отключение двухфакторной аутентификации"
|
||||
message={
|
||||
<>
|
||||
<p style={{ marginBottom: "15px" }}>
|
||||
Вы уверены, что хотите отключить двухфакторную аутентификацию? Это
|
||||
снизит безопасность вашего аккаунта.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="disablePassword" style={{ marginBottom: "5px", display: "block" }}>
|
||||
Введите пароль для подтверждения:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="disablePassword"
|
||||
className="form-input"
|
||||
placeholder="Пароль"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter" && !isDisabling && disablePassword.trim()) {
|
||||
handleDisable();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
confirmText={isDisabling ? "Отключение..." : "Отключить"}
|
||||
cancelText="Отмена"
|
||||
confirmType="danger"
|
||||
/>
|
||||
|
||||
{/* Модальное окно для показа резервных кодов */}
|
||||
<Modal
|
||||
isOpen={showBackupCodesModal}
|
||||
onClose={() => setShowBackupCodesModal(false)}
|
||||
onConfirm={() => setShowBackupCodesModal(false)}
|
||||
title="Резервные коды"
|
||||
message={
|
||||
<>
|
||||
<p
|
||||
style={{
|
||||
color: "#dc3545",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
⚠️ ВАЖНО: Сохраните эти коды в безопасном месте!
|
||||
</p>
|
||||
<p style={{ marginBottom: "15px" }}>
|
||||
Если вы потеряете доступ к приложению-аутентификатору, вы сможете
|
||||
использовать эти резервные коды для входа. Каждый код можно
|
||||
использовать только один раз.
|
||||
</p>
|
||||
{backupCodes && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--bg-color)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
{backupCodes.map((code, index) => (
|
||||
<code
|
||||
key={index}
|
||||
style={{
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontFamily: "monospace",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: "12px", color: "#666" }}>
|
||||
Совет: Распечатайте эти коды или сохраните их в менеджере
|
||||
паролей.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmText="Я сохранил коды"
|
||||
cancelText={null}
|
||||
confirmType="primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import { useNavigate, useSearchParams, Link } from "react-router-dom";
|
||||
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||
import { setAuth } from "../store/slices/authSlice";
|
||||
import { authApi } from "../api/authApi";
|
||||
import { userApi } from "../api/userApi";
|
||||
import { useNotification } from "../hooks/useNotification";
|
||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||
import { Icon } from "@iconify/react";
|
||||
@ -11,7 +12,9 @@ import { dbManager } from "../utils/indexedDB";
|
||||
const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [requires2FA, setRequires2FA] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { showNotification } = useNotification();
|
||||
@ -48,13 +51,68 @@ const LoginPage: React.FC = () => {
|
||||
console.log("Login response:", data);
|
||||
|
||||
if (data.success) {
|
||||
// Проверяем, требуется ли 2FA
|
||||
if (data.requires2FA) {
|
||||
setRequires2FA(true);
|
||||
showNotification("Введите код двухфакторной аутентификации", "info");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если 2FA не требуется, продолжаем как обычно
|
||||
await completeLogin();
|
||||
} else {
|
||||
showNotification(data.error || "Ошибка входа", "error");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Login error details:", error);
|
||||
console.error("Error response:", error.response);
|
||||
console.error("Error response data:", error.response?.data);
|
||||
|
||||
let errorMsg = "Ошибка соединения с сервером";
|
||||
|
||||
if (error.response) {
|
||||
// Сервер ответил, но с ошибкой (например, 401 для неверного пароля)
|
||||
const responseData = error.response.data;
|
||||
console.log("Response data:", responseData);
|
||||
|
||||
// Если это ошибка авторизации (401), показываем сообщение от сервера
|
||||
if (error.response.status === 401) {
|
||||
errorMsg = responseData?.error || "Неверный логин или пароль";
|
||||
console.log("401 error message:", errorMsg);
|
||||
} else {
|
||||
// Для других ошибок показываем общее сообщение
|
||||
errorMsg = responseData?.error || `Ошибка ${error.response.status}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Запрос был отправлен, но ответа нет
|
||||
console.error("No response from server");
|
||||
errorMsg =
|
||||
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3001";
|
||||
} else {
|
||||
// Ошибка при настройке запроса
|
||||
console.error("Request setup error:", error.message);
|
||||
errorMsg = error.message || "Ошибка соединения с сервером";
|
||||
}
|
||||
|
||||
console.log("Showing error notification:", errorMsg);
|
||||
showNotification(errorMsg, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeLogin = async () => {
|
||||
try {
|
||||
// Получаем информацию о пользователе
|
||||
const authStatus = await authApi.checkStatus();
|
||||
const newUserId = authStatus.userId!;
|
||||
|
||||
// Если пользователь изменился, очищаем IndexedDB
|
||||
if (currentUserId && currentUserId !== newUserId) {
|
||||
console.log(`[Login] User changed from ${currentUserId} to ${newUserId}, clearing IndexedDB`);
|
||||
console.log(
|
||||
`[Login] User changed from ${currentUserId} to ${newUserId}, clearing IndexedDB`
|
||||
);
|
||||
await dbManager.clearAll();
|
||||
}
|
||||
|
||||
@ -66,35 +124,49 @@ const LoginPage: React.FC = () => {
|
||||
);
|
||||
showNotification("Успешный вход!", "success");
|
||||
navigate("/notes");
|
||||
} catch (error) {
|
||||
console.error("Ошибка завершения входа:", error);
|
||||
showNotification("Ошибка завершения входа", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FAVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!twoFactorCode.trim()) {
|
||||
showNotification("Введите код двухфакторной аутентификации", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await userApi.verify2FA(username, twoFactorCode.trim());
|
||||
|
||||
if (data.success) {
|
||||
if (data.backupCodeUsed) {
|
||||
showNotification("Вход выполнен с использованием резервного кода", "info");
|
||||
}
|
||||
await completeLogin();
|
||||
} else {
|
||||
showNotification(data.error || "Ошибка входа", "error");
|
||||
showNotification(data.message || "Неверный код", "error");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Login error details:", error);
|
||||
console.error("Error response:", error.response);
|
||||
console.error("Error message:", error.message);
|
||||
|
||||
let errorMsg = "Ошибка соединения с сервером";
|
||||
|
||||
if (error.response) {
|
||||
// Сервер ответил, но с ошибкой
|
||||
errorMsg =
|
||||
error.response.data?.error || `Ошибка ${error.response.status}`;
|
||||
} else if (error.request) {
|
||||
// Запрос был отправлен, но ответа нет
|
||||
errorMsg =
|
||||
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3000";
|
||||
} else {
|
||||
// Ошибка при настройке запроса
|
||||
errorMsg = error.message || "Ошибка соединения с сервером";
|
||||
}
|
||||
|
||||
console.error("Ошибка проверки 2FA:", error);
|
||||
const errorMsg =
|
||||
error.response?.data?.error || "Ошибка проверки кода";
|
||||
showNotification(errorMsg, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setRequires2FA(false);
|
||||
setTwoFactorCode("");
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header>
|
||||
@ -112,6 +184,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</header>
|
||||
<div className="login-form">
|
||||
{!requires2FA ? (
|
||||
<form id="loginForm" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Логин:</label>
|
||||
@ -123,6 +196,7 @@ const LoginPage: React.FC = () => {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="Введите ваш логин"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@ -135,15 +209,94 @@ const LoginPage: React.FC = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Введите пароль"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btnSave" disabled={isLoading}>
|
||||
{isLoading ? "Вход..." : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form id="2faForm" onSubmit={handle2FAVerify}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:shield-lock"
|
||||
style={{ fontSize: "24px", color: "var(--accent-color)" }}
|
||||
/>
|
||||
<h3 style={{ margin: 0 }}>Двухфакторная аутентификация</h3>
|
||||
</div>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}>
|
||||
Введите 6-значный код из приложения-аутентификатора или
|
||||
резервный код:
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="twoFactorCode">Код:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="twoFactorCode"
|
||||
name="twoFactorCode"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => {
|
||||
// Разрешаем цифры и заглавные буквы (для резервных кодов)
|
||||
const value = e.target.value
|
||||
.toUpperCase() // Преобразуем в заглавные буквы
|
||||
.replace(/[^A-Z0-9]/g, "") // Удаляем все кроме букв и цифр
|
||||
.slice(0, 10); // Ограничиваем длину
|
||||
setTwoFactorCode(value);
|
||||
}}
|
||||
required
|
||||
placeholder="000000 или резервный код"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "20px",
|
||||
letterSpacing: "4px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btnSave"
|
||||
disabled={isLoading || !twoFactorCode.trim()}
|
||||
>
|
||||
{isLoading ? "Проверка..." : "Продолжить"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={handleBack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{!requires2FA && (
|
||||
<p className="auth-link">
|
||||
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -12,6 +12,7 @@ import { useNotification } from "../hooks/useNotification";
|
||||
import { Modal } from "../components/common/Modal";
|
||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||
import { dbManager } from "../utils/indexedDB";
|
||||
import { TwoFactorSetup } from "../components/twoFactor/TwoFactorSetup";
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -33,12 +34,34 @@ const ProfilePage: React.FC = () => {
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 2FA settings
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
const [isLoading2FA, setIsLoading2FA] = useState(false);
|
||||
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
load2FAStatus();
|
||||
}, []);
|
||||
|
||||
const load2FAStatus = async () => {
|
||||
setIsLoading2FA(true);
|
||||
try {
|
||||
const status = await userApi.get2FAStatus();
|
||||
setTwoFactorEnabled(status.twoFactorEnabled);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки статуса 2FA:", error);
|
||||
} finally {
|
||||
setIsLoading2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FAStatusChange = async () => {
|
||||
await load2FAStatus();
|
||||
await loadProfile();
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const userData = await userApi.getProfile();
|
||||
@ -395,6 +418,18 @@ const ProfilePage: React.FC = () => {
|
||||
|
||||
<hr className="separator" />
|
||||
|
||||
<h3>Безопасность</h3>
|
||||
{isLoading2FA ? (
|
||||
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
|
||||
) : (
|
||||
<TwoFactorSetup
|
||||
twoFactorEnabled={twoFactorEnabled}
|
||||
onStatusChange={handle2FAStatusChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<hr className="separator" />
|
||||
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
|
||||
@ -6,12 +6,14 @@ export interface User {
|
||||
show_edit_date?: number;
|
||||
colored_icons?: number;
|
||||
floating_toolbar_enabled?: number;
|
||||
two_factor_enabled?: number;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
authenticated: boolean;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
requires2FA?: boolean;
|
||||
}
|
||||
|
||||
export interface AiSettings {
|
||||
@ -20,3 +22,31 @@ export interface AiSettings {
|
||||
openai_model: string;
|
||||
ai_enabled: 0 | 1;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetup {
|
||||
success: boolean;
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
otpauthUrl: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorStatus {
|
||||
twoFactorEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorEnableResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface TwoFactorDisableResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorVerifyResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
backupCodeUsed?: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user