feat: Implement Multi-Factor Authentication (MFA) flow in LoginView and add MFA settings page
- Added MFA verification modal to handle MFA during login. - Integrated MFA checks in login and OAuth login processes. - Created MFASettings component to manage MFA setup, enabling, disabling, and backup codes. - Updated ProfileSettings to include timezone selection. - Enhanced CalendarView to display assignee names with avatars. - Added RichTextEditor enhancements for image uploads based on user account type. - Minor adjustments to ChoreEdit and ChoreView components for better UX.
This commit is contained in:
293
package-lock.json
generated
293
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"capacitor-plugin-safe-area": "^4.0.0",
|
||||
"chrono-node": "^2.7.7",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -40,9 +42,13 @@
|
||||
"moment": "^2.30.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"quilljs-markdown": "^1.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar": "^5.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-easy-crop": "^5.4.2",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"reactjs-social-login": "^2.6.3",
|
||||
@@ -4871,7 +4877,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5472,6 +5477,15 @@
|
||||
"integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/browser-image-compression": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uzip": "0.20201231.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
||||
@@ -5614,7 +5628,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6154,6 +6167,17 @@
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.42.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz",
|
||||
"integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.42.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz",
|
||||
@@ -6488,7 +6512,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6770,6 +6793,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"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/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
@@ -6956,7 +6985,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
@@ -7761,7 +7789,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
@@ -8224,7 +8251,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -9093,7 +9119,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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9843,12 +9868,31 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.ismatch": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz",
|
||||
@@ -10534,6 +10578,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -10784,7 +10834,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -10803,6 +10852,12 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -10837,7 +10892,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10974,6 +11028,15 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"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/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -11375,6 +11438,141 @@
|
||||
"teleport": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"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/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -11406,6 +11604,51 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quill/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quilljs-markdown": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/quilljs-markdown/-/quilljs-markdown-1.2.0.tgz",
|
||||
"integrity": "sha512-/Fqm0d7QF+n3dvFGZDosq5W4kBloD4QR6qDzv6ATFAmShDYRtnijP0cODmG+bk+2P+233wivbragV+6DNzePJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.23.5",
|
||||
"regenerator-runtime": "^0.13.9"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -11495,6 +11738,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.4.2.tgz",
|
||||
"integrity": "sha512-V+GQUTkNWD8gK0mbZQfwTvcDxyCB4GS0cM36is8dAcvnsHY7DMEDP2D5IqHju55TOiCHwElJPVOYDgiu8BEiHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
|
||||
@@ -11862,6 +12119,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp-to-ast": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
||||
@@ -12224,7 +12487,6 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -12243,7 +12505,6 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requireindex": {
|
||||
@@ -12593,7 +12854,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
@@ -13115,7 +13375,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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -13263,7 +13522,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -14251,6 +14509,12 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uzip": {
|
||||
"version": "0.20201231.0",
|
||||
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
||||
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
@@ -14520,7 +14784,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.102",
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
@@ -36,6 +36,7 @@
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
@@ -55,6 +56,7 @@
|
||||
"moment": "^2.30.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"quilljs-markdown": "^1.2.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -434,6 +434,63 @@ const ResetPassword = email => {
|
||||
})
|
||||
}
|
||||
|
||||
// MFA Related Functions
|
||||
const GetMFAStatus = () => {
|
||||
return Fetch(`/users/mfa/status`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const SetupMFA = () => {
|
||||
return Fetch(`/users/mfa/setup`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const ConfirmMFA = (secret, code, backupCodes) => {
|
||||
return Fetch(`/users/mfa/confirm`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
code,
|
||||
backupCodes,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const DisableMFA = code => {
|
||||
return Fetch(`/users/mfa/disable`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
const RegenerateBackupCodes = code => {
|
||||
return Fetch(`/users/mfa/regenerate-backup-codes`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
const VerifyMFA = (sessionToken, code) => {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
return fetch(`${baseURL}/auth/mfa/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionToken,
|
||||
code,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const UpdateDueDate = (id, dueDate) => {
|
||||
return Fetch(`/chores/${id}/dueDate`, {
|
||||
method: 'PUT',
|
||||
@@ -498,6 +555,7 @@ export {
|
||||
CancelSubscription,
|
||||
ChangePassword,
|
||||
CompleteSubTask,
|
||||
ConfirmMFA,
|
||||
CreateChore,
|
||||
CreateLabel,
|
||||
CreateLongLiveToken,
|
||||
@@ -508,6 +566,7 @@ export {
|
||||
DeleteLabel,
|
||||
DeleteLongLiveToken,
|
||||
DeleteThing,
|
||||
DisableMFA,
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
GetArchivedChores,
|
||||
@@ -520,6 +579,7 @@ export {
|
||||
GetCircleMemberRequests,
|
||||
GetLabels,
|
||||
GetLongLiveTokens,
|
||||
GetMFAStatus,
|
||||
GetResource,
|
||||
GetStorageUsage,
|
||||
GetSubscriptionSession,
|
||||
@@ -534,9 +594,11 @@ export {
|
||||
PutWebhookURL,
|
||||
RedeemPoints,
|
||||
RefreshToken,
|
||||
RegenerateBackupCodes,
|
||||
ResetPassword,
|
||||
SaveChore,
|
||||
SaveThing,
|
||||
SetupMFA,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateChoreAssignee,
|
||||
@@ -550,6 +612,7 @@ export {
|
||||
UpdatePassword,
|
||||
UpdateThingState,
|
||||
UpdateUserDetails,
|
||||
VerifyMFA,
|
||||
createChore,
|
||||
login,
|
||||
signUp,
|
||||
|
||||
@@ -25,12 +25,15 @@ import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { GetUserProfile, login } from '../../utils/Fetcher'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const LoginView = () => {
|
||||
const { userProfile, setUserProfile } = React.useContext(UserContext)
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [error, setError] = React.useState(null)
|
||||
const [mfaModalOpen, setMfaModalOpen] = React.useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = React.useState('')
|
||||
const { data: resource } = useResource()
|
||||
const Navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
@@ -52,10 +55,17 @@ const LoginView = () => {
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
// Check if MFA is required
|
||||
if (data.mfaRequired) {
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Normal login without MFA
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
// console.log('redirectUrl', redirectUrl)
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
@@ -100,6 +110,14 @@ const LoginView = () => {
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
// Check if MFA is required for OAuth login
|
||||
if (data.mfaRequired) {
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Normal OAuth login without MFA
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
|
||||
@@ -112,7 +130,7 @@ const LoginView = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
return response.json().then(error => {
|
||||
return response.json().then(() => {
|
||||
setError("Couldn't log in with Google, please try again")
|
||||
})
|
||||
})
|
||||
@@ -132,6 +150,31 @@ const LoginView = () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFASuccess = data => {
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/my/chores')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMFAError = errorMessage => {
|
||||
setError(errorMessage)
|
||||
}
|
||||
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
}
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
Navigate('/forgot-password')
|
||||
}
|
||||
@@ -434,6 +477,14 @@ const LoginView = () => {
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={handleMFAError}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
160
src/views/Authorization/MFAVerificationModal.jsx
Normal file
160
src/views/Authorization/MFAVerificationModal.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
open,
|
||||
onClose,
|
||||
sessionToken,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [isBackupCode, setIsBackupCode] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await VerifyMFA(sessionToken, verificationCode)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
onSuccess(data)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(
|
||||
errorData.message || 'Invalid verification code. Please try again.',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to verify code. Please try again.')
|
||||
console.error('MFA verification error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setVerificationCode('')
|
||||
setIsBackupCode(false)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleKeyPress = e => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleVerify()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose}>
|
||||
<ModalDialog size='sm' sx={{ maxWidth: 400 }}>
|
||||
<ModalClose />
|
||||
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography level='h4' sx={{ mb: 1 }}>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
||||
Enter the verification code from your authenticator app
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder={
|
||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
||||
}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: isBackupCode ? 50 : 6,
|
||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||
},
|
||||
}}
|
||||
startDecorator={<Smartphone />}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert color='danger' size='sm'>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
loading={loading}
|
||||
onClick={handleVerify}
|
||||
disabled={!verificationCode.trim()}
|
||||
size='lg'
|
||||
>
|
||||
Verify & Sign In
|
||||
</Button>
|
||||
|
||||
<Box className='text-center'>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsBackupCode(!isBackupCode)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
}}
|
||||
sx={{ fontSize: 'sm' }}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: "Can't access your authenticator? Use a backup code"}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' size='sm'>
|
||||
<Typography level='body-xs'>
|
||||
Having trouble? Make sure your authenticator app is synced and try
|
||||
again. Each backup code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MFAVerificationModal
|
||||
@@ -54,6 +54,7 @@ const ASSIGN_STRATEGIES = [
|
||||
'least_completed',
|
||||
'keep_last_assigned',
|
||||
'random_except_last_assigned',
|
||||
'round_robin',
|
||||
]
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
|
||||
@@ -601,7 +601,6 @@ const ChoreView = () => {
|
||||
}
|
||||
setNote(e.target.value)
|
||||
}}
|
||||
size='md'
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
|
||||
553
src/views/Settings/MFASettings.jsx
Normal file
553
src/views/Settings/MFASettings.jsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import { CheckCircle, Key, Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import QRCode from 'qrcode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ConfirmMFA,
|
||||
DisableMFA,
|
||||
GetMFAStatus,
|
||||
RegenerateBackupCodes,
|
||||
SetupMFA,
|
||||
} from '../../utils/Fetcher'
|
||||
|
||||
const MFASettings = () => {
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false)
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false)
|
||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false)
|
||||
const [setupData, setSetupData] = useState(null)
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [disableCode, setDisableCode] = useState('')
|
||||
const [backupCodes, setBackupCodes] = useState([])
|
||||
const [setupStep, setSetupStep] = useState(1) // 1: QR Code, 2: Verification, 3: Backup Codes
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchMFAStatus()
|
||||
}, [])
|
||||
|
||||
const fetchMFAStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await GetMFAStatus()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setMfaEnabled(data.mfaEnabled)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching MFA status:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateQRCode = async url => {
|
||||
try {
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
})
|
||||
setQrCodeDataUrl(qrCodeDataUrl)
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error)
|
||||
setError('Failed to generate QR code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupMFA = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await SetupMFA()
|
||||
|
||||
console.log('MFA Setup Response Status:', response.status)
|
||||
console.log('MFA Setup Response:', response)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('MFA Setup Data:', data)
|
||||
|
||||
// Check for either qrCode (base64 image) or qrCodeUrl (TOTP URL)
|
||||
if (!data.qrCodeUrl || !data.backupCodes || !data.secret) {
|
||||
console.error('Missing required MFA data:', {
|
||||
hasQrCode: !!data.qrCode,
|
||||
hasQrCodeUrl: !!data.qrCodeUrl,
|
||||
hasSecret: !!data.secret,
|
||||
})
|
||||
setError('Invalid response from server. Missing QR code or secret.')
|
||||
return
|
||||
}
|
||||
if (data.backupCodes) {
|
||||
console.log('Backup Codes:', data.backupCodes)
|
||||
|
||||
setBackupCodes(data.backupCodes)
|
||||
}
|
||||
// If we have a qrCodeUrl, generate the QR code image
|
||||
if (data.qrCodeUrl) {
|
||||
await generateQRCode(data.qrCodeUrl)
|
||||
}
|
||||
|
||||
setSetupData(data)
|
||||
setSetupModalOpen(true)
|
||||
setSetupStep(1)
|
||||
} else {
|
||||
// Handle different error status codes
|
||||
if (response.status === 404) {
|
||||
setError(
|
||||
'MFA setup endpoint not found. This feature may not be available yet.',
|
||||
)
|
||||
} else if (response.status === 401) {
|
||||
setError('Unauthorized. Please login again.')
|
||||
} else if (response.status === 500) {
|
||||
setError('Server error. Please try again later.')
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
setError(
|
||||
errorData.message ||
|
||||
`Failed to setup MFA (${response.status}). Please try again.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up MFA:', error)
|
||||
setError('Network error. Please check your connection and try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmMFA = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await ConfirmMFA(
|
||||
setupData.secret,
|
||||
verificationCode,
|
||||
setupData.backupCodes,
|
||||
)
|
||||
if (response.ok) {
|
||||
setSetupStep(3)
|
||||
setMfaEnabled(true)
|
||||
setSuccess('MFA has been successfully enabled!')
|
||||
} else {
|
||||
setError('Invalid verification code. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to confirm MFA. Please try again.')
|
||||
console.error('Error confirming MFA:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisableMFA = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await DisableMFA(disableCode)
|
||||
if (response.ok) {
|
||||
setMfaEnabled(false)
|
||||
setDisableModalOpen(false)
|
||||
setDisableCode('')
|
||||
setSuccess('MFA has been disabled successfully!')
|
||||
} else {
|
||||
setError('Invalid verification code. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to disable MFA. Please try again.')
|
||||
console.error('Error disabling MFA:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await RegenerateBackupCodes()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setBackupCodes(data.backupCodes)
|
||||
setBackupCodesModalOpen(true)
|
||||
setSuccess('New backup codes have been generated!')
|
||||
} else {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
console.error('Error regenerating backup codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeSetupModal = () => {
|
||||
setSetupModalOpen(false)
|
||||
setSetupStep(1)
|
||||
setVerificationCode('')
|
||||
setSetupData(null)
|
||||
setQrCodeDataUrl('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const closeDisableModal = () => {
|
||||
setDisableModalOpen(false)
|
||||
setDisableCode('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box className='flex justify-center py-4'>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Typography level='h3'>Multi-Factor Authentication</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Add an extra layer of security to your account with multi-factor
|
||||
authentication (MFA). When enabled, you'll need to provide a
|
||||
verification code from your authenticator app in addition to your
|
||||
password when signing in.
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert color='success' onClose={() => setSuccess('')}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color='danger' onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box className='flex items-center justify-between'>
|
||||
<Box className='flex items-center gap-3'>
|
||||
<Security color='primary' />
|
||||
<Box>
|
||||
<Typography level='title-md'>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{mfaEnabled
|
||||
? 'Your account is protected with 2FA'
|
||||
: 'Secure your account with an authenticator app'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className='flex items-center gap-2'>
|
||||
{mfaEnabled ? (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
onClick={() => setDisableModalOpen(true)}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button color='primary' variant='solid' onClick={handleSetupMFA}>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{mfaEnabled && (
|
||||
<Card variant='outlined'>
|
||||
<Box className='flex items-center justify-between'>
|
||||
<Box className='flex items-center gap-3'>
|
||||
<Key color='primary' />
|
||||
<Box>
|
||||
<Typography level='title-md'>Backup Codes</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Generate new backup codes in case you lose access to your
|
||||
authenticator
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
color='neutral'
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
>
|
||||
Generate New Codes
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Setup MFA Modal */}
|
||||
<Modal open={setupModalOpen} onClose={closeSetupModal}>
|
||||
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Set up Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
{setupStep === 1 && setupData && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 1:</strong> Scan the QR code below with your
|
||||
authenticator app (Google Authenticator, Authy, etc.)
|
||||
</Typography>
|
||||
|
||||
<Box className='flex justify-center rounded bg-white p-4'>
|
||||
{qrCodeDataUrl || setupData.qrCode ? (
|
||||
<img
|
||||
src={
|
||||
qrCodeDataUrl ||
|
||||
`data:image/png;base64,${setupData.qrCode}`
|
||||
}
|
||||
alt='MFA QR Code'
|
||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<Alert color='danger'>
|
||||
QR code could not be generated. Please try again or use the
|
||||
manual entry key below.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' variant='soft'>
|
||||
<Typography level='body-sm'>
|
||||
<strong>Manual entry key:</strong> {setupData.secret}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setSetupStep(2)}
|
||||
startDecorator={<Smartphone />}
|
||||
>
|
||||
I've added the account to my app
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{setupStep === 2 && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 2:</strong> Enter the 6-digit verification code
|
||||
from your authenticator app
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
value={verificationCode}
|
||||
size='lg'
|
||||
// send on enter:
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && verificationCode.length === 6) {
|
||||
handleConfirmMFA()
|
||||
}
|
||||
}}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2em',
|
||||
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 6,
|
||||
pattern: '[0-9]*',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={() => setSetupStep(1)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={handleConfirmMFA}
|
||||
disabled={verificationCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{setupStep === 3 && (
|
||||
<Stack spacing={3}>
|
||||
<Box className='text-center'>
|
||||
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography level='h4' color='success'>
|
||||
MFA Successfully Enabled!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert color='warning'>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
Save these backup codes in a safe place
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
You can use these codes to access your account if you lose
|
||||
your authenticator device. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Card variant='outlined' sx={{ p: 2 }}>
|
||||
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
||||
{backupCodes?.map((code, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
level='body-sm'
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{code}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button color='primary' onClick={closeSetupModal}>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
<Modal open={disableModalOpen} onClose={closeDisableModal}>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Disable Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Disabling MFA will make your account less secure. Are you sure
|
||||
you want to continue?
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography level='body-md'>
|
||||
Enter a verification code from your authenticator app to confirm:
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
value={disableCode}
|
||||
size='lg'
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && disableCode.length === 6) {
|
||||
handleDisableMFA()
|
||||
}
|
||||
}}
|
||||
onChange={e => setDisableCode(e.target.value)}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2em',
|
||||
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && verificationCode.length === 6) {
|
||||
handleDisableMFA()
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 6,
|
||||
pattern: '[0-9]*',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={closeDisableModal}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleDisableMFA}
|
||||
disabled={disableCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Disable MFA
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
<Modal
|
||||
open={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
New Backup Codes
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Your previous backup codes are now invalid. Save these new codes
|
||||
in a safe place. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Card variant='outlined' sx={{ p: 2 }}>
|
||||
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
||||
{backupCodes?.map((code, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
level='body-sm'
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{code}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setBackupCodesModalOpen(false)}
|
||||
>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MFASettings
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Input,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
@@ -12,6 +14,7 @@ import ModalDialog from '@mui/joy/ModalDialog'
|
||||
import { useContext, useRef, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { UpdateUserDetails } from '../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getCroppedImg } from '../../utils/imageCropUtils'
|
||||
import { UploadFile } from '../../utils/TokenManager'
|
||||
@@ -19,8 +22,12 @@ import { UploadFile } from '../../utils/TokenManager'
|
||||
const ProfileSettings = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [timezone, setTimezone] = useState(
|
||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
@@ -33,6 +40,9 @@ const ProfileSettings = () => {
|
||||
const [showCropper, setShowCropper] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
|
||||
// Get available timezones
|
||||
const timezones = Intl.supportedValuesOf('timeZone')
|
||||
|
||||
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}
|
||||
@@ -84,13 +94,31 @@ const ProfileSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setUserProfile({ ...userProfile, displayName })
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile updated!',
|
||||
color: 'success',
|
||||
})
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const userDetails = { displayName, timezone }
|
||||
const response = await UpdateUserDetails(userDetails)
|
||||
|
||||
if (response.ok) {
|
||||
setUserProfile({ ...userProfile, displayName, timezone })
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile updated successfully!',
|
||||
color: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to update profile')
|
||||
}
|
||||
} catch (err) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to update profile.',
|
||||
color: 'danger',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to resolve photoURL with baseURL if needed
|
||||
@@ -199,7 +227,7 @@ const ProfileSettings = () => {
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
{/* <Box sx={{ maxWidth: 400 }}>
|
||||
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Display Name
|
||||
</Typography>
|
||||
@@ -209,15 +237,51 @@ const ProfileSettings = () => {
|
||||
placeholder='Enter your display name'
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Timezone
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
value={timezone}
|
||||
onChange={(e, newValue) => setTimezone(newValue)}
|
||||
options={timezones}
|
||||
getOptionLabel={tz => {
|
||||
const formattedTimezone = tz.replace(/_/g, ' ')
|
||||
const currentTime = new Date().toLocaleString('en-US', {
|
||||
timeZone: tz,
|
||||
timeStyle: 'short',
|
||||
})
|
||||
return `${formattedTimezone} (${currentTime})`
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
if (!inputValue) return options
|
||||
|
||||
const searchTerms = inputValue.toLowerCase().split(/\s+/)
|
||||
return options.filter(tz => {
|
||||
const timezoneLower = tz.toLowerCase()
|
||||
const timezoneParts = tz.toLowerCase().split(/[/_]/)
|
||||
|
||||
return searchTerms.every(
|
||||
term =>
|
||||
timezoneLower.includes(term) ||
|
||||
timezoneParts.some(part => part.includes(term)),
|
||||
)
|
||||
})
|
||||
}}
|
||||
placeholder='Select your timezone'
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant='soft'
|
||||
color='primary'
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
sx={{ width: 120 }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box> */}
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
color={snackbar.color}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||
import APITokenSettings from './APITokenSettings'
|
||||
import MFASettings from './MFASettings'
|
||||
import NotificationSetting from './NotificationSetting'
|
||||
import ProfileSettings from './ProfileSettings'
|
||||
import StorageSettings from './StorageSettings'
|
||||
@@ -589,6 +590,7 @@ const Settings = () => {
|
||||
)}
|
||||
</div>
|
||||
<NotificationSetting />
|
||||
<MFASettings />
|
||||
<APITokenSettings />
|
||||
<StorageSettings />
|
||||
<div className='grid gap-4 py-4'>
|
||||
|
||||
@@ -14,16 +14,19 @@ import {
|
||||
import { FormControl } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useAllUsers } from '../../queries/UserQueries'
|
||||
import useDebounce from '../../utils/Debounce'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
|
||||
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseLabels,
|
||||
parsePriority,
|
||||
parseRepeatV2,
|
||||
} from './CustomParsers'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
@@ -31,13 +34,12 @@ import SubTasks from './SubTask'
|
||||
|
||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: allUsers, isLoading: isAllUserLoading } = useAllUsers()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const createChoreMutation = useCreateChore()
|
||||
|
||||
const { userProfile } = useContext(UserContext)
|
||||
const navigate = useNavigate()
|
||||
const [taskText, setTaskText] = useState('')
|
||||
const debounceParsing = useDebounce(taskText, 30)
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
|
||||
@@ -46,7 +48,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
const [assignedTo, setAssignedTo] = useState(userProfile?.id)
|
||||
const [assignees, setAssignees] = useState([])
|
||||
const [labelsV2, setLabelsV2] = useState([])
|
||||
const [frequency, setFrequency] = useState(null)
|
||||
@@ -71,13 +72,231 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
const renderHighlightedSentence = useCallback(
|
||||
(
|
||||
sentence,
|
||||
repeatHighlight,
|
||||
priorityHighlight,
|
||||
labelsHighlight,
|
||||
dueDateHighlight,
|
||||
) => {
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let plainText = ''
|
||||
|
||||
// Combine all highlight ranges and sort them by their start index
|
||||
const allHighlights = []
|
||||
if (repeatHighlight) {
|
||||
repeatHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'repeat', priority: 40 }),
|
||||
)
|
||||
}
|
||||
if (priorityHighlight) {
|
||||
priorityHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'priority', priority: 30 }),
|
||||
)
|
||||
}
|
||||
if (labelsHighlight) {
|
||||
labelsHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'label', priority: 20 }),
|
||||
)
|
||||
}
|
||||
if (dueDateHighlight) {
|
||||
allHighlights.push({
|
||||
...dueDateHighlight,
|
||||
type: 'dueDate',
|
||||
priority: 10,
|
||||
})
|
||||
}
|
||||
|
||||
allHighlights.sort((a, b) => a.start - b.start)
|
||||
const resolvedHighlights = []
|
||||
for (let i = 0; i < allHighlights.length; i++) {
|
||||
const current = allHighlights[i]
|
||||
const previous = resolvedHighlights[resolvedHighlights.length - 1]
|
||||
|
||||
if (previous && current.start < previous.end) {
|
||||
if (current.priority > previous.priority) {
|
||||
resolvedHighlights.pop()
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
} else {
|
||||
// No overlap, add the current highlight
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlight of resolvedHighlights) {
|
||||
// Add the text before the highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
parts.push(textBefore)
|
||||
plainText += textBefore
|
||||
}
|
||||
|
||||
// Determine the class name based on the highlight type
|
||||
let className = ''
|
||||
switch (highlight.type) {
|
||||
case 'repeat':
|
||||
className = 'highlight-repeat'
|
||||
break
|
||||
case 'priority':
|
||||
className = 'highlight-priority'
|
||||
break
|
||||
case 'label':
|
||||
className = 'highlight-label'
|
||||
break
|
||||
case 'dueDate':
|
||||
className = 'highlight-date'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Add the highlighted span
|
||||
const highlightedText = sentence.substring(
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
)
|
||||
parts.push(
|
||||
<span
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
style={{
|
||||
// text underline:
|
||||
textDecoration: 'underline',
|
||||
// textDecorationColor: 'red',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
>
|
||||
{highlightedText}
|
||||
</span>,
|
||||
)
|
||||
|
||||
// Update the last index to the end of the current highlight
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add any remaining text after the last highlight
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
parts.push(remainingText)
|
||||
plainText += remainingText
|
||||
}
|
||||
|
||||
return {
|
||||
parts,
|
||||
plainText,
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
let cleanedSentence = sentence
|
||||
const priority = parsePriority(sentence)
|
||||
if (priority.result) setPriority(priority.result)
|
||||
cleanedSentence = priority.cleanedSentence
|
||||
const labels = parseLabels(sentence, userLabels)
|
||||
if (labels.result) {
|
||||
cleanedSentence = labels.cleanedSentence
|
||||
setLabelsV2(labels.result)
|
||||
}
|
||||
|
||||
const repeat = parseRepeatV2(sentence)
|
||||
if (repeat.result) {
|
||||
setFrequency(repeat.result)
|
||||
setFrequencyHumanReadable(repeat.name)
|
||||
cleanedSentence = repeat.cleanedSentence
|
||||
}
|
||||
// Parse assignees using circle members
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
userId: member.userId,
|
||||
username:
|
||||
member.username ||
|
||||
member.displayName?.toLowerCase().replace(/\s+/g, ''),
|
||||
displayName: member.displayName,
|
||||
name: member.displayName,
|
||||
id: member.userId,
|
||||
}))
|
||||
|
||||
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
|
||||
if (assigneesResult.result) {
|
||||
cleanedSentence = assigneesResult.cleanedSentence
|
||||
setAssignees(assigneesResult.result)
|
||||
} else {
|
||||
// Default to current user if no assignees parsed
|
||||
setAssignees([
|
||||
{
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
displayName: userProfile.displayName,
|
||||
name: userProfile.displayName,
|
||||
id: userProfile.id,
|
||||
},
|
||||
])
|
||||
}
|
||||
const parsedDueDate = chrono.parse(sentence, new Date(), {
|
||||
forwardDate: true,
|
||||
})
|
||||
if (parsedDueDate[0]?.index > -1) {
|
||||
setDueDate(
|
||||
moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '')
|
||||
}
|
||||
|
||||
if (repeat.result) {
|
||||
// if repeat has result the cleaned sentence will remove the date related info which mean
|
||||
// we need to reparse the date again to get the correct due date:
|
||||
const parsedDueDate = chrono.parse(sentence, new Date(), {
|
||||
forwardDate: true,
|
||||
})
|
||||
if (parsedDueDate[0]?.index > -1) {
|
||||
setDueDate(
|
||||
moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setTaskText(sentence)
|
||||
setTaskTitle(cleanedSentence.trim())
|
||||
const { parts, plainText } = renderHighlightedSentence(
|
||||
sentence,
|
||||
repeat.highlight,
|
||||
priority.highlight,
|
||||
labels.highlight,
|
||||
parsedDueDate && parsedDueDate[0]
|
||||
? {
|
||||
start: parsedDueDate[0].index,
|
||||
end: parsedDueDate[0].index + parsedDueDate[0].text.length,
|
||||
text: parsedDueDate[0].text,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
|
||||
setRenderedParts(parts)
|
||||
setTaskTitle(plainText)
|
||||
},
|
||||
[circleMembers, userLabels, userProfile, renderHighlightedSentence],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen || userLabelsLoading || isAllUserLoading) {
|
||||
if (!isModalOpen || userLabelsLoading || isCircleMembersLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
processText(taskText)
|
||||
}, [taskText, userLabelsLoading, isAllUserLoading])
|
||||
}, [
|
||||
taskText,
|
||||
userLabelsLoading,
|
||||
isCircleMembersLoading,
|
||||
isModalOpen,
|
||||
processText,
|
||||
])
|
||||
|
||||
const handleEnterPressed = () => {
|
||||
createChore()
|
||||
@@ -96,6 +315,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setSubTasks(null)
|
||||
setHasSubTasks(false)
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -104,197 +324,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setTaskText('')
|
||||
}
|
||||
|
||||
const handleTextChange = e => {
|
||||
if (!e.target.value) {
|
||||
setTaskText('')
|
||||
setDueDate(null)
|
||||
setFrequency(null)
|
||||
setFrequencyHumanReadable(null)
|
||||
setPriority(0)
|
||||
return
|
||||
}
|
||||
setTaskText(e.target.value)
|
||||
}
|
||||
const processText = sentence => {
|
||||
let cleanedSentence = sentence
|
||||
const priority = parsePriority(sentence)
|
||||
if (priority.result) setPriority(priority.result)
|
||||
cleanedSentence = priority.cleanedSentence
|
||||
const labels = parseLabels(sentence, userLabels)
|
||||
if (labels.result) {
|
||||
cleanedSentence = labels.cleanedSentence
|
||||
setLabelsV2(labels.result)
|
||||
}
|
||||
|
||||
const repeat = parseRepeatV2(sentence)
|
||||
if (repeat.result) {
|
||||
setFrequency(repeat.result)
|
||||
setFrequencyHumanReadable(repeat.name)
|
||||
cleanedSentence = repeat.cleanedSentence
|
||||
}
|
||||
// const assignees = parseAssignees(sentence)
|
||||
// if (assignees.result) {
|
||||
// cleanedSentence = assignees.cleanedSentence
|
||||
// set
|
||||
// }
|
||||
const parsedDueDate = chrono.parse(sentence, new Date(), {
|
||||
forwardDate: true,
|
||||
})
|
||||
if (parsedDueDate[0]?.index > -1) {
|
||||
setDueDate(
|
||||
moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '')
|
||||
}
|
||||
|
||||
if (repeat.result) {
|
||||
// if repeat has result the cleaned sentence will remove the date related info which mean
|
||||
// we need to reparse the date again to get the correct due date:
|
||||
const parsedDueDate = chrono.parse(sentence, new Date(), {
|
||||
forwardDate: true,
|
||||
})
|
||||
if (parsedDueDate[0]?.index > -1) {
|
||||
setDueDate(
|
||||
moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setTaskText(sentence)
|
||||
setTaskTitle(cleanedSentence.trim())
|
||||
const { parts, plainText } = renderHighlightedSentence(
|
||||
sentence,
|
||||
repeat.highlight,
|
||||
priority.highlight,
|
||||
labels.highlight,
|
||||
parsedDueDate && parsedDueDate[0]
|
||||
? {
|
||||
start: parsedDueDate[0].index,
|
||||
end: parsedDueDate[0].index + parsedDueDate[0].text.length,
|
||||
text: parsedDueDate[0].text,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
|
||||
setRenderedParts(parts)
|
||||
setTaskTitle(plainText)
|
||||
}
|
||||
const renderHighlightedSentence = (
|
||||
sentence,
|
||||
repeatHighlight,
|
||||
priorityHighlight,
|
||||
labelsHighlight,
|
||||
dueDateHighlight,
|
||||
) => {
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let plainText = ''
|
||||
|
||||
// Combine all highlight ranges and sort them by their start index
|
||||
const allHighlights = []
|
||||
if (repeatHighlight) {
|
||||
repeatHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'repeat', priority: 40 }),
|
||||
)
|
||||
}
|
||||
if (priorityHighlight) {
|
||||
priorityHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'priority', priority: 30 }),
|
||||
)
|
||||
}
|
||||
if (labelsHighlight) {
|
||||
labelsHighlight.forEach(h =>
|
||||
allHighlights.push({ ...h, type: 'label', priority: 20 }),
|
||||
)
|
||||
}
|
||||
if (dueDateHighlight) {
|
||||
allHighlights.push({ ...dueDateHighlight, type: 'dueDate', priority: 10 })
|
||||
}
|
||||
|
||||
allHighlights.sort((a, b) => a.start - b.start)
|
||||
const resolvedHighlights = []
|
||||
for (let i = 0; i < allHighlights.length; i++) {
|
||||
const current = allHighlights[i]
|
||||
const previous = resolvedHighlights[resolvedHighlights.length - 1]
|
||||
|
||||
if (previous && current.start < previous.end) {
|
||||
if (current.priority > previous.priority) {
|
||||
resolvedHighlights.pop()
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
} else {
|
||||
// No overlap, add the current highlight
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlight of resolvedHighlights) {
|
||||
// Add the text before the highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
parts.push(textBefore)
|
||||
plainText += textBefore
|
||||
}
|
||||
|
||||
// Determine the class name based on the highlight type
|
||||
let className = ''
|
||||
switch (highlight.type) {
|
||||
case 'repeat':
|
||||
className = 'highlight-repeat'
|
||||
break
|
||||
case 'priority':
|
||||
className = 'highlight-priority'
|
||||
break
|
||||
case 'label':
|
||||
className = 'highlight-label'
|
||||
break
|
||||
case 'dueDate':
|
||||
className = 'highlight-date'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Add the highlighted span
|
||||
const highlightedText = sentence.substring(highlight.start, highlight.end)
|
||||
parts.push(
|
||||
<span
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
style={{
|
||||
// text underline:
|
||||
textDecoration: 'underline',
|
||||
// textDecorationColor: 'red',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
>
|
||||
{highlightedText}
|
||||
</span>,
|
||||
)
|
||||
|
||||
// Update the last index to the end of the current highlight
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add any remaining text after the last highlight
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
parts.push(remainingText)
|
||||
plainText += remainingText
|
||||
}
|
||||
|
||||
return {
|
||||
parts,
|
||||
plainText,
|
||||
}
|
||||
}
|
||||
const createChore = () => {
|
||||
const chore = {
|
||||
name: taskTitle,
|
||||
assignees: [{ userId: userProfile.id }],
|
||||
assignees: assignees.map(assignee => ({ userId: assignee.userId })),
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
assignedTo: userProfile.id,
|
||||
assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id,
|
||||
assignStrategy: 'random',
|
||||
isRolling: false,
|
||||
notification: false,
|
||||
@@ -346,7 +381,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (userLabelsLoading || isAllUserLoading) {
|
||||
if (userLabelsLoading || isCircleMembersLoading) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -373,7 +408,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This feature lets you create a task simply by typing a
|
||||
sentence. It attempt parses the sentence to identify the
|
||||
task's due date, priority, and frequency.
|
||||
task's due date, priority, and frequency.
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
@@ -438,14 +473,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
// value: 'userId',
|
||||
// display: 'displayName',
|
||||
// options: allUsers ? allUsers : [],
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: userProfile.id, name: userProfile.displayName },
|
||||
],
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: circleMembers?.res || [],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -561,11 +591,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Assignee</Typography>
|
||||
<Select value={'0'} disabled>
|
||||
<Option value='0'>Me</Option>
|
||||
{/* <Option value='1'>Other</Option> */}
|
||||
</Select>
|
||||
<Typography level='body-sm'>Assignees</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index) => (
|
||||
<Chip
|
||||
key={assignee.userId || index}
|
||||
variant='soft'
|
||||
size='sm'
|
||||
color='primary'
|
||||
>
|
||||
{assignee.displayName || assignee.username}
|
||||
</Chip>
|
||||
))
|
||||
) : (
|
||||
<Chip variant='soft' size='sm' color='neutral'>
|
||||
{userProfile.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Frequency</Typography>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { CalendarMonth } from '@mui/icons-material'
|
||||
import { Box, Chip, Grid, Typography } from '@mui/joy'
|
||||
import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import React, { useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import 'react-calendar/dist/Calendar.css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import './Calendar.css'
|
||||
|
||||
@@ -20,6 +21,19 @@ const CalendarView = ({ chores }) => {
|
||||
const [selectedDate, setSeletedDate] = useState(null)
|
||||
const Navigate = useNavigate()
|
||||
|
||||
// Fetch circle members data to get assignee names
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const circleMembers = circleMembersData?.res || []
|
||||
|
||||
// Helper function to get assignee display name
|
||||
const getAssigneeName = assignedTo => {
|
||||
if (assignedTo === userProfile.id) {
|
||||
return userProfile.displayName
|
||||
}
|
||||
const assignee = circleMembers.find(member => member.userId === assignedTo)
|
||||
return assignee ? `${assignee.displayName}` : 'Assigned to other'
|
||||
}
|
||||
|
||||
const tileContent = ({ date, view }) => {
|
||||
if (view === 'month') {
|
||||
const dayChores = chores.filter(chore => {
|
||||
@@ -109,34 +123,60 @@ const CalendarView = ({ chores }) => {
|
||||
/>
|
||||
{!selectedDate && (
|
||||
<Grid container ml={-3} mt={1}>
|
||||
{[
|
||||
{ name: 'Assigned to me', color: TASK_COLOR.ASSIGNED_TO_ME },
|
||||
{ name: 'Assigned to other', color: TASK_COLOR.ASSIGNED_TO_OTHER },
|
||||
].map((item, index) => (
|
||||
<Grid
|
||||
key={index}
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{/* Show legend with current user first, then other circle members who have assignments */}
|
||||
{(() => {
|
||||
const assignedUserIds = new Set(
|
||||
chores.map(chore => chore.assignedTo).filter(Boolean),
|
||||
)
|
||||
const legendItems = []
|
||||
|
||||
// Add current user if they have assignments
|
||||
if (assignedUserIds.has(userProfile.id)) {
|
||||
legendItems.push({
|
||||
name: 'Assigned to me',
|
||||
color: TASK_COLOR.ASSIGNED_TO_ME,
|
||||
})
|
||||
}
|
||||
|
||||
// Add other circle members who have assignments
|
||||
circleMembers.forEach(member => {
|
||||
if (
|
||||
member.userId !== userProfile.id &&
|
||||
assignedUserIds.has(member.userId)
|
||||
) {
|
||||
legendItems.push({
|
||||
name: `Assigned to ${member.displayName}`,
|
||||
color: TASK_COLOR.ASSIGNED_TO_OTHER,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return legendItems.map((item, index) => (
|
||||
<Grid
|
||||
key={index}
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-xs' ml={0.3}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-xs' ml={0.3}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
))
|
||||
})()}
|
||||
</Grid>
|
||||
)}
|
||||
{selectedDate && (
|
||||
@@ -257,16 +297,37 @@ const CalendarView = ({ chores }) => {
|
||||
>
|
||||
{moment(chore.nextDueDate).format('h:mm A')}
|
||||
</Typography>
|
||||
<Typography
|
||||
{/* <Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getAssigneeColor(chore.assignedTo, userProfile),
|
||||
}}
|
||||
>
|
||||
{chore.assignedTo === userProfile.id
|
||||
? 'Assigned to me'
|
||||
: 'Assigned to other'}
|
||||
</Typography>
|
||||
{getAssigneeName(chore.assignedTo)}
|
||||
</Typography> */}
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
src={
|
||||
circleMembers.find(
|
||||
member => member.userId === chore.assignedTo,
|
||||
)?.image
|
||||
}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
backgroundColor: getAssigneeColor(
|
||||
chore.assignedTo,
|
||||
userProfile,
|
||||
),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{getAssigneeName(chore.assignedTo)}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -375,7 +375,6 @@ export const parseAssignees = (inputSentence, users) => {
|
||||
const sentence = inputSentence.toLowerCase()
|
||||
const result = []
|
||||
const highlight = []
|
||||
console.log('users:', users)
|
||||
|
||||
for (const user of users) {
|
||||
if (sentence.includes(`@${user.username.toLowerCase()}`)) {
|
||||
|
||||
@@ -51,3 +51,135 @@
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Material-UI style customizations for Quill toolbar */
|
||||
.quill-root .ql-toolbar.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05));
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.quill-root .ql-container.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
}
|
||||
|
||||
/* Style toolbar buttons with Material-UI look */
|
||||
.quill-root .ql-toolbar .ql-formats {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar button {
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
margin: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar button:hover {
|
||||
background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08));
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar button.ql-active {
|
||||
background: var(--joy-palette-primary-softActiveBg, rgba(25, 118, 210, 0.12));
|
||||
color: var(--joy-palette-primary-solidBg, #1976d2);
|
||||
}
|
||||
|
||||
/* Style dropdown selectors */
|
||||
.quill-root .ql-toolbar .ql-picker {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker-label {
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--joy-palette-neutral-solidBg, #333);
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker-label:hover {
|
||||
background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08));
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker.ql-expanded .ql-picker-label {
|
||||
background: var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.12));
|
||||
}
|
||||
|
||||
/* Style the dropdown options */
|
||||
.quill-root .ql-toolbar .ql-picker-options {
|
||||
border-radius: 8px;
|
||||
background: var(--joy-palette-background-popup, #fff);
|
||||
box-shadow: var(
|
||||
--joy-shadow-md,
|
||||
0px 4px 8px -2px rgba(16, 24, 40, 0.1),
|
||||
0px 2px 4px -2px rgba(16, 24, 40, 0.06)
|
||||
);
|
||||
/* padding: 4px;
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker-item {
|
||||
/* border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease; */
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker-item:hover {
|
||||
background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08));
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar .ql-picker-item.ql-selected {
|
||||
background: var(--joy-palette-primary-softActiveBg, rgba(25, 118, 210, 0.12));
|
||||
color: var(--joy-palette-primary-solidBg, #1976d2);
|
||||
}
|
||||
|
||||
/* Add separator styling */
|
||||
.quill-root .ql-toolbar .ql-formats:not(:last-child)::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--joy-palette-divider, #e7ebf0);
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Remove default Quill borders and spacing */
|
||||
.quill-root .ql-editor {
|
||||
padding: 16px;
|
||||
min-height: 120px;
|
||||
font-family: var(
|
||||
--joy-fontFamily-body,
|
||||
Inter,
|
||||
system-ui,
|
||||
Avenir,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif
|
||||
);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Custom focus styles */
|
||||
.quill-root:focus-within .ql-toolbar.ql-snow {
|
||||
border-color: var(--joy-palette-primary-outlinedBorder, #1976d2);
|
||||
}
|
||||
|
||||
.quill-root:focus-within .ql-container.ql-snow {
|
||||
border-color: var(--joy-palette-primary-outlinedBorder, #1976d2);
|
||||
}
|
||||
|
||||
/* Variant-specific styling */
|
||||
.quill-variant-bubble .ql-toolbar {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import imageCompression from 'browser-image-compression'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import QuillMarkdown from 'quilljs-markdown'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useContext, useEffect, useRef } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { UploadFile } from '../../utils/TokenManager'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
@@ -12,16 +13,28 @@ const RichTextEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
isEditable = true,
|
||||
placeholder = 'Enter description...',
|
||||
variant = 'outlined',
|
||||
entityId,
|
||||
entityType,
|
||||
}) => {
|
||||
const { showError } = useError()
|
||||
const { userProfile } = useContext(UserContext)
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
|
||||
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
||||
const handleImageUpload = useCallback(() => {
|
||||
// Check if user has plus account
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
@@ -77,6 +90,19 @@ const RichTextEditor = ({
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
})
|
||||
return
|
||||
} else if (response.status === 403 && !isPlusAccount()) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message:
|
||||
'Image uploads are only available for Plus accounts. Please ',
|
||||
})
|
||||
return
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
})
|
||||
return
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
@@ -98,7 +124,7 @@ const RichTextEditor = ({
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [entityId, entityType, showError]) // Dependencies for useCallback
|
||||
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
|
||||
|
||||
useEffect(() => {
|
||||
if (!quillRef.current) return
|
||||
@@ -120,7 +146,7 @@ const RichTextEditor = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
placeholder: 'Enter description...',
|
||||
placeholder: placeholder,
|
||||
})
|
||||
new QuillMarkdown(editorRef.current, {})
|
||||
editorRef.current.root.innerHTML = value
|
||||
@@ -141,7 +167,7 @@ const RichTextEditor = ({
|
||||
editorRef.current.readOnly = false
|
||||
}
|
||||
}
|
||||
}, [onChange, value, isEditable, variant, handleImageUpload]) // Added handleImageUpload to dependency array
|
||||
}, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && isEditable) {
|
||||
@@ -178,7 +204,13 @@ const RichTextEditor = ({
|
||||
|
||||
return (
|
||||
<div className={`quill-root quill-variant-${variant}`}>
|
||||
<div ref={quillRef} style={{ minHeight: 120 }} />
|
||||
<div
|
||||
ref={quillRef}
|
||||
style={{
|
||||
minHeight: 120,
|
||||
background: 'var(--joy-palette-background-surface, #fff)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user