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",
|
"@capgo/capacitor-social-login": "^7.5.3",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.11.3",
|
"@emotion/react": "^11.11.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.16.13",
|
"@mui/icons-material": "^5.16.13",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"@openreplay/tracker": "^14.0.4",
|
"@openreplay/tracker": "^14.0.4",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"aos": "^2.3.4",
|
"aos": "^2.3.4",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"capacitor-plugin-safe-area": "^4.0.0",
|
"capacitor-plugin-safe-area": "^4.0.0",
|
||||||
"chrono-node": "^2.7.7",
|
"chrono-node": "^2.7.7",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -40,9 +42,13 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"murmurhash": "^2.0.1",
|
"murmurhash": "^2.0.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"quill": "^2.0.3",
|
||||||
|
"quilljs-markdown": "^1.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-calendar": "^5.1.0",
|
"react-calendar": "^5.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-easy-crop": "^5.4.2",
|
||||||
"react-router-dom": "^6.21.1",
|
"react-router-dom": "^6.21.1",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"reactjs-social-login": "^2.6.3",
|
"reactjs-social-login": "^2.6.3",
|
||||||
@@ -4871,7 +4877,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5472,6 +5477,15 @@
|
|||||||
"integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==",
|
"integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.24.5",
|
"version": "4.24.5",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
||||||
@@ -5614,7 +5628,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -6154,6 +6167,17 @@
|
|||||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.42.0",
|
"version": "3.42.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz",
|
||||||
@@ -6488,7 +6512,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6770,6 +6793,12 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
@@ -6956,7 +6985,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
@@ -7761,7 +7789,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
@@ -8224,7 +8251,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -9093,7 +9119,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -9843,12 +9868,31 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.ismatch": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz",
|
||||||
@@ -10534,6 +10578,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
@@ -10784,7 +10834,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -10803,6 +10852,12 @@
|
|||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -10837,7 +10892,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -10974,6 +11028,15 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -11406,6 +11604,51 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -11495,6 +11738,20 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
|
||||||
@@ -11862,6 +12119,12 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/regexp-to-ast": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
||||||
@@ -12224,7 +12487,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -12243,7 +12505,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/requireindex": {
|
"node_modules/requireindex": {
|
||||||
@@ -12593,7 +12854,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
@@ -13115,7 +13375,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -13263,7 +13522,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -14251,6 +14509,12 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"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",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "donetick",
|
"name": "donetick",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.101",
|
"version": "0.1.102",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"@capgo/capacitor-social-login": "^7.5.3",
|
"@capgo/capacitor-social-login": "^7.5.3",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.11.3",
|
"@emotion/react": "^11.11.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.16.13",
|
"@mui/icons-material": "^5.16.13",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"murmurhash": "^2.0.1",
|
"murmurhash": "^2.0.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"quilljs-markdown": "^1.2.0",
|
"quilljs-markdown": "^1.2.0",
|
||||||
"react": "^18.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) => {
|
const UpdateDueDate = (id, dueDate) => {
|
||||||
return Fetch(`/chores/${id}/dueDate`, {
|
return Fetch(`/chores/${id}/dueDate`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -498,6 +555,7 @@ export {
|
|||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
ChangePassword,
|
ChangePassword,
|
||||||
CompleteSubTask,
|
CompleteSubTask,
|
||||||
|
ConfirmMFA,
|
||||||
CreateChore,
|
CreateChore,
|
||||||
CreateLabel,
|
CreateLabel,
|
||||||
CreateLongLiveToken,
|
CreateLongLiveToken,
|
||||||
@@ -508,6 +566,7 @@ export {
|
|||||||
DeleteLabel,
|
DeleteLabel,
|
||||||
DeleteLongLiveToken,
|
DeleteLongLiveToken,
|
||||||
DeleteThing,
|
DeleteThing,
|
||||||
|
DisableMFA,
|
||||||
GetAllCircleMembers,
|
GetAllCircleMembers,
|
||||||
GetAllUsers,
|
GetAllUsers,
|
||||||
GetArchivedChores,
|
GetArchivedChores,
|
||||||
@@ -520,6 +579,7 @@ export {
|
|||||||
GetCircleMemberRequests,
|
GetCircleMemberRequests,
|
||||||
GetLabels,
|
GetLabels,
|
||||||
GetLongLiveTokens,
|
GetLongLiveTokens,
|
||||||
|
GetMFAStatus,
|
||||||
GetResource,
|
GetResource,
|
||||||
GetStorageUsage,
|
GetStorageUsage,
|
||||||
GetSubscriptionSession,
|
GetSubscriptionSession,
|
||||||
@@ -534,9 +594,11 @@ export {
|
|||||||
PutWebhookURL,
|
PutWebhookURL,
|
||||||
RedeemPoints,
|
RedeemPoints,
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
|
RegenerateBackupCodes,
|
||||||
ResetPassword,
|
ResetPassword,
|
||||||
SaveChore,
|
SaveChore,
|
||||||
SaveThing,
|
SaveThing,
|
||||||
|
SetupMFA,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
UnArchiveChore,
|
UnArchiveChore,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
@@ -550,6 +612,7 @@ export {
|
|||||||
UpdatePassword,
|
UpdatePassword,
|
||||||
UpdateThingState,
|
UpdateThingState,
|
||||||
UpdateUserDetails,
|
UpdateUserDetails,
|
||||||
|
VerifyMFA,
|
||||||
createChore,
|
createChore,
|
||||||
login,
|
login,
|
||||||
signUp,
|
signUp,
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ import Logo from '../../Logo'
|
|||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
import { GetUserProfile, login } from '../../utils/Fetcher'
|
import { GetUserProfile, login } from '../../utils/Fetcher'
|
||||||
import { apiManager } from '../../utils/TokenManager'
|
import { apiManager } from '../../utils/TokenManager'
|
||||||
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
const LoginView = () => {
|
const LoginView = () => {
|
||||||
const { userProfile, setUserProfile } = React.useContext(UserContext)
|
const { userProfile, setUserProfile } = React.useContext(UserContext)
|
||||||
const [username, setUsername] = React.useState('')
|
const [username, setUsername] = React.useState('')
|
||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
const [error, setError] = React.useState(null)
|
const [error, setError] = React.useState(null)
|
||||||
|
const [mfaModalOpen, setMfaModalOpen] = React.useState(false)
|
||||||
|
const [mfaSessionToken, setMfaSessionToken] = React.useState('')
|
||||||
const { data: resource } = useResource()
|
const { data: resource } = useResource()
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,10 +55,17 @@ const LoginView = () => {
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.json().then(data => {
|
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_token', data.token)
|
||||||
localStorage.setItem('ca_expiration', data.expire)
|
localStorage.setItem('ca_expiration', data.expire)
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
// console.log('redirectUrl', redirectUrl)
|
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
Cookies.remove('ca_redirect')
|
Cookies.remove('ca_redirect')
|
||||||
Navigate(redirectUrl)
|
Navigate(redirectUrl)
|
||||||
@@ -100,6 +110,14 @@ const LoginView = () => {
|
|||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.json().then(data => {
|
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_token', data.token)
|
||||||
localStorage.setItem('ca_expiration', data.expire)
|
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")
|
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 = () => {
|
const handleForgotPassword = () => {
|
||||||
Navigate('/forgot-password')
|
Navigate('/forgot-password')
|
||||||
}
|
}
|
||||||
@@ -434,6 +477,14 @@ const LoginView = () => {
|
|||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|
||||||
|
<MFAVerificationModal
|
||||||
|
open={mfaModalOpen}
|
||||||
|
onClose={handleMFAClose}
|
||||||
|
sessionToken={mfaSessionToken}
|
||||||
|
onSuccess={handleMFASuccess}
|
||||||
|
onError={handleMFAError}
|
||||||
|
/>
|
||||||
</Container>
|
</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',
|
'least_completed',
|
||||||
'keep_last_assigned',
|
'keep_last_assigned',
|
||||||
'random_except_last_assigned',
|
'random_except_last_assigned',
|
||||||
|
'round_robin',
|
||||||
]
|
]
|
||||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||||
|
|
||||||
|
|||||||
@@ -601,7 +601,6 @@ const ChoreView = () => {
|
|||||||
}
|
}
|
||||||
setNote(e.target.value)
|
setNote(e.target.value)
|
||||||
}}
|
}}
|
||||||
size='md'
|
|
||||||
sx={{
|
sx={{
|
||||||
mb: 1,
|
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 {
|
import {
|
||||||
|
Autocomplete,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
|
Input,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
@@ -12,6 +14,7 @@ import ModalDialog from '@mui/joy/ModalDialog'
|
|||||||
import { useContext, useRef, useState } from 'react'
|
import { useContext, useRef, useState } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { UserContext } from '../../contexts/UserContext'
|
import { UserContext } from '../../contexts/UserContext'
|
||||||
|
import { UpdateUserDetails } from '../../utils/Fetcher'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { getCroppedImg } from '../../utils/imageCropUtils'
|
import { getCroppedImg } from '../../utils/imageCropUtils'
|
||||||
import { UploadFile } from '../../utils/TokenManager'
|
import { UploadFile } from '../../utils/TokenManager'
|
||||||
@@ -19,8 +22,12 @@ import { UploadFile } from '../../utils/TokenManager'
|
|||||||
const ProfileSettings = () => {
|
const ProfileSettings = () => {
|
||||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||||
|
const [timezone, setTimezone] = useState(
|
||||||
|
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
)
|
||||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [snackbar, setSnackbar] = useState({
|
const [snackbar, setSnackbar] = useState({
|
||||||
open: false,
|
open: false,
|
||||||
message: '',
|
message: '',
|
||||||
@@ -33,6 +40,9 @@ const ProfileSettings = () => {
|
|||||||
const [showCropper, setShowCropper] = useState(false)
|
const [showCropper, setShowCropper] = useState(false)
|
||||||
const [selectedFile, setSelectedFile] = useState(null)
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
|
||||||
|
// Get available timezones
|
||||||
|
const timezones = Intl.supportedValuesOf('timeZone')
|
||||||
|
|
||||||
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
||||||
setCroppedAreaPixels(croppedAreaPixels)
|
setCroppedAreaPixels(croppedAreaPixels)
|
||||||
}
|
}
|
||||||
@@ -84,13 +94,31 @@ const ProfileSettings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
setUserProfile({ ...userProfile, displayName })
|
setIsSaving(true)
|
||||||
setSnackbar({
|
try {
|
||||||
open: true,
|
const userDetails = { displayName, timezone }
|
||||||
message: 'Profile updated!',
|
const response = await UpdateUserDetails(userDetails)
|
||||||
color: 'success',
|
|
||||||
})
|
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
|
// Helper to resolve photoURL with baseURL if needed
|
||||||
@@ -199,7 +227,7 @@ const ProfileSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/* <Box sx={{ maxWidth: 400 }}>
|
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
||||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||||
Display Name
|
Display Name
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -209,15 +237,51 @@ const ProfileSettings = () => {
|
|||||||
placeholder='Enter your display name'
|
placeholder='Enter your display name'
|
||||||
sx={{ mb: 2 }}
|
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
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='primary'
|
color='primary'
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
loading={isSaving}
|
||||||
sx={{ width: 120 }}
|
sx={{ width: 120 }}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Box> */}
|
</Box>
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={snackbar.open}
|
open={snackbar.open}
|
||||||
color={snackbar.color}
|
color={snackbar.color}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||||
import APITokenSettings from './APITokenSettings'
|
import APITokenSettings from './APITokenSettings'
|
||||||
|
import MFASettings from './MFASettings'
|
||||||
import NotificationSetting from './NotificationSetting'
|
import NotificationSetting from './NotificationSetting'
|
||||||
import ProfileSettings from './ProfileSettings'
|
import ProfileSettings from './ProfileSettings'
|
||||||
import StorageSettings from './StorageSettings'
|
import StorageSettings from './StorageSettings'
|
||||||
@@ -589,6 +590,7 @@ const Settings = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NotificationSetting />
|
<NotificationSetting />
|
||||||
|
<MFASettings />
|
||||||
<APITokenSettings />
|
<APITokenSettings />
|
||||||
<StorageSettings />
|
<StorageSettings />
|
||||||
<div className='grid gap-4 py-4'>
|
<div className='grid gap-4 py-4'>
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ import {
|
|||||||
import { FormControl } from '@mui/material'
|
import { FormControl } from '@mui/material'
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useContext, useEffect, useRef, useState } from 'react'
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { UserContext } from '../../contexts/UserContext'
|
import { UserContext } from '../../contexts/UserContext'
|
||||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||||
import { useAllUsers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import useDebounce from '../../utils/Debounce'
|
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
|
import {
|
||||||
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
|
parseAssignees,
|
||||||
|
parseLabels,
|
||||||
|
parsePriority,
|
||||||
|
parseRepeatV2,
|
||||||
|
} from './CustomParsers'
|
||||||
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
import LearnMoreButton from './LearnMore'
|
import LearnMoreButton from './LearnMore'
|
||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
@@ -31,13 +34,12 @@ import SubTasks from './SubTask'
|
|||||||
|
|
||||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||||
const { data: allUsers, isLoading: isAllUserLoading } = useAllUsers()
|
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||||
|
useCircleMembers()
|
||||||
const createChoreMutation = useCreateChore()
|
const createChoreMutation = useCreateChore()
|
||||||
|
|
||||||
const { userProfile } = useContext(UserContext)
|
const { userProfile } = useContext(UserContext)
|
||||||
const navigate = useNavigate()
|
|
||||||
const [taskText, setTaskText] = useState('')
|
const [taskText, setTaskText] = useState('')
|
||||||
const debounceParsing = useDebounce(taskText, 30)
|
|
||||||
const [taskTitle, setTaskTitle] = useState('')
|
const [taskTitle, setTaskTitle] = useState('')
|
||||||
const [renderedParts, setRenderedParts] = useState([])
|
const [renderedParts, setRenderedParts] = useState([])
|
||||||
|
|
||||||
@@ -46,7 +48,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [priority, setPriority] = useState(0)
|
const [priority, setPriority] = useState(0)
|
||||||
const [dueDate, setDueDate] = useState(null)
|
const [dueDate, setDueDate] = useState(null)
|
||||||
const [description, setDescription] = useState(null)
|
const [description, setDescription] = useState(null)
|
||||||
const [assignedTo, setAssignedTo] = useState(userProfile?.id)
|
|
||||||
const [assignees, setAssignees] = useState([])
|
const [assignees, setAssignees] = useState([])
|
||||||
const [labelsV2, setLabelsV2] = useState([])
|
const [labelsV2, setLabelsV2] = useState([])
|
||||||
const [frequency, setFrequency] = useState(null)
|
const [frequency, setFrequency] = useState(null)
|
||||||
@@ -71,13 +72,231 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
}, [autoFocus])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isModalOpen || userLabelsLoading || isAllUserLoading) {
|
if (!isModalOpen || userLabelsLoading || isCircleMembersLoading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
processText(taskText)
|
processText(taskText)
|
||||||
}, [taskText, userLabelsLoading, isAllUserLoading])
|
}, [
|
||||||
|
taskText,
|
||||||
|
userLabelsLoading,
|
||||||
|
isCircleMembersLoading,
|
||||||
|
isModalOpen,
|
||||||
|
processText,
|
||||||
|
])
|
||||||
|
|
||||||
const handleEnterPressed = () => {
|
const handleEnterPressed = () => {
|
||||||
createChore()
|
createChore()
|
||||||
@@ -96,6 +315,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setSubTasks(null)
|
setSubTasks(null)
|
||||||
setHasSubTasks(false)
|
setHasSubTasks(false)
|
||||||
setLabelsV2([])
|
setLabelsV2([])
|
||||||
|
setAssignees([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@@ -104,197 +324,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setTaskText('')
|
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 createChore = () => {
|
||||||
const chore = {
|
const chore = {
|
||||||
name: taskTitle,
|
name: taskTitle,
|
||||||
assignees: [{ userId: userProfile.id }],
|
assignees: assignees.map(assignee => ({ userId: assignee.userId })),
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||||
assignedTo: userProfile.id,
|
assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id,
|
||||||
assignStrategy: 'random',
|
assignStrategy: 'random',
|
||||||
isRolling: false,
|
isRolling: false,
|
||||||
notification: false,
|
notification: false,
|
||||||
@@ -346,7 +381,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (userLabelsLoading || isAllUserLoading) {
|
if (userLabelsLoading || isCircleMembersLoading) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +408,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||||
This feature lets you create a task simply by typing a
|
This feature lets you create a task simply by typing a
|
||||||
sentence. It attempt parses the sentence to identify the
|
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>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
@@ -438,14 +473,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
'@': {
|
'@': {
|
||||||
// value: 'userId',
|
value: 'userId',
|
||||||
// display: 'displayName',
|
display: 'displayName',
|
||||||
// options: allUsers ? allUsers : [],
|
options: circleMembers?.res || [],
|
||||||
value: 'id',
|
|
||||||
display: 'name',
|
|
||||||
options: [
|
|
||||||
{ id: userProfile.id, name: userProfile.displayName },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -561,11 +591,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography level='body-sm'>Assignee</Typography>
|
<Typography level='body-sm'>Assignees</Typography>
|
||||||
<Select value={'0'} disabled>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
<Option value='0'>Me</Option>
|
{assignees.length > 0 ? (
|
||||||
{/* <Option value='1'>Other</Option> */}
|
assignees.map((assignee, index) => (
|
||||||
</Select>
|
<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>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography level='body-sm'>Frequency</Typography>
|
<Typography level='body-sm'>Frequency</Typography>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { CalendarMonth } from '@mui/icons-material'
|
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 moment from 'moment'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Calendar from 'react-calendar'
|
import Calendar from 'react-calendar'
|
||||||
import 'react-calendar/dist/Calendar.css'
|
import 'react-calendar/dist/Calendar.css'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { UserContext } from '../../contexts/UserContext'
|
import { UserContext } from '../../contexts/UserContext'
|
||||||
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { TASK_COLOR } from '../../utils/Colors'
|
import { TASK_COLOR } from '../../utils/Colors'
|
||||||
import './Calendar.css'
|
import './Calendar.css'
|
||||||
|
|
||||||
@@ -20,6 +21,19 @@ const CalendarView = ({ chores }) => {
|
|||||||
const [selectedDate, setSeletedDate] = useState(null)
|
const [selectedDate, setSeletedDate] = useState(null)
|
||||||
const Navigate = useNavigate()
|
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 }) => {
|
const tileContent = ({ date, view }) => {
|
||||||
if (view === 'month') {
|
if (view === 'month') {
|
||||||
const dayChores = chores.filter(chore => {
|
const dayChores = chores.filter(chore => {
|
||||||
@@ -109,34 +123,60 @@ const CalendarView = ({ chores }) => {
|
|||||||
/>
|
/>
|
||||||
{!selectedDate && (
|
{!selectedDate && (
|
||||||
<Grid container ml={-3} mt={1}>
|
<Grid container ml={-3} mt={1}>
|
||||||
{[
|
{/* Show legend with current user first, then other circle members who have assignments */}
|
||||||
{ name: 'Assigned to me', color: TASK_COLOR.ASSIGNED_TO_ME },
|
{(() => {
|
||||||
{ name: 'Assigned to other', color: TASK_COLOR.ASSIGNED_TO_OTHER },
|
const assignedUserIds = new Set(
|
||||||
].map((item, index) => (
|
chores.map(chore => chore.assignedTo).filter(Boolean),
|
||||||
<Grid
|
)
|
||||||
key={index}
|
const legendItems = []
|
||||||
item
|
|
||||||
xs={12}
|
// Add current user if they have assignments
|
||||||
sx={{
|
if (assignedUserIds.has(userProfile.id)) {
|
||||||
display: 'flex',
|
legendItems.push({
|
||||||
alignItems: 'center',
|
name: 'Assigned to me',
|
||||||
justifyContent: 'start',
|
color: TASK_COLOR.ASSIGNED_TO_ME,
|
||||||
}}
|
})
|
||||||
>
|
}
|
||||||
<Box
|
|
||||||
|
// 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={{
|
sx={{
|
||||||
display: 'inline-block',
|
display: 'flex',
|
||||||
width: 5,
|
alignItems: 'center',
|
||||||
height: 5,
|
justifyContent: 'start',
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: item.color,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Typography level='body-xs' ml={0.3}>
|
<Box
|
||||||
{item.name}
|
sx={{
|
||||||
</Typography>
|
display: 'inline-block',
|
||||||
</Grid>
|
width: 5,
|
||||||
))}
|
height: 5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography level='body-xs' ml={0.3}>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
@@ -257,16 +297,37 @@ const CalendarView = ({ chores }) => {
|
|||||||
>
|
>
|
||||||
{moment(chore.nextDueDate).format('h:mm A')}
|
{moment(chore.nextDueDate).format('h:mm A')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
{/* <Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
sx={{
|
sx={{
|
||||||
color: getAssigneeColor(chore.assignedTo, userProfile),
|
color: getAssigneeColor(chore.assignedTo, userProfile),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chore.assignedTo === userProfile.id
|
{getAssigneeName(chore.assignedTo)}
|
||||||
? 'Assigned to me'
|
</Typography> */}
|
||||||
: 'Assigned to other'}
|
<Chip
|
||||||
</Typography>
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -375,7 +375,6 @@ export const parseAssignees = (inputSentence, users) => {
|
|||||||
const sentence = inputSentence.toLowerCase()
|
const sentence = inputSentence.toLowerCase()
|
||||||
const result = []
|
const result = []
|
||||||
const highlight = []
|
const highlight = []
|
||||||
console.log('users:', users)
|
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (sentence.includes(`@${user.username.toLowerCase()}`)) {
|
if (sentence.includes(`@${user.username.toLowerCase()}`)) {
|
||||||
|
|||||||
@@ -51,3 +51,135 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
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 from 'quill'
|
||||||
import 'quill/dist/quill.snow.css'
|
import 'quill/dist/quill.snow.css'
|
||||||
import QuillMarkdown from 'quilljs-markdown'
|
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 { useError } from '../../service/ErrorProvider'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { UploadFile } from '../../utils/TokenManager'
|
import { UploadFile } from '../../utils/TokenManager'
|
||||||
import './RichTextEditor.css'
|
import './RichTextEditor.css'
|
||||||
|
|
||||||
@@ -12,16 +13,28 @@ const RichTextEditor = ({
|
|||||||
value = '',
|
value = '',
|
||||||
onChange,
|
onChange,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
|
placeholder = 'Enter description...',
|
||||||
variant = 'outlined',
|
variant = 'outlined',
|
||||||
entityId,
|
entityId,
|
||||||
entityType,
|
entityType,
|
||||||
}) => {
|
}) => {
|
||||||
const { showError } = useError()
|
const { showError } = useError()
|
||||||
|
const { userProfile } = useContext(UserContext)
|
||||||
const quillRef = useRef(null)
|
const quillRef = useRef(null)
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
|
|
||||||
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
||||||
const handleImageUpload = useCallback(() => {
|
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')
|
const input = document.createElement('input')
|
||||||
input.setAttribute('type', 'file')
|
input.setAttribute('type', 'file')
|
||||||
input.setAttribute('accept', 'image/*')
|
input.setAttribute('accept', 'image/*')
|
||||||
@@ -77,6 +90,19 @@ const RichTextEditor = ({
|
|||||||
message: 'The file you are trying to upload is too large.',
|
message: 'The file you are trying to upload is too large.',
|
||||||
})
|
})
|
||||||
return
|
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) {
|
} else if (!response.ok) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Upload Failed',
|
title: 'Upload Failed',
|
||||||
@@ -98,7 +124,7 @@ const RichTextEditor = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [entityId, entityType, showError]) // Dependencies for useCallback
|
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quillRef.current) return
|
if (!quillRef.current) return
|
||||||
@@ -120,7 +146,7 @@ const RichTextEditor = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
placeholder: 'Enter description...',
|
placeholder: placeholder,
|
||||||
})
|
})
|
||||||
new QuillMarkdown(editorRef.current, {})
|
new QuillMarkdown(editorRef.current, {})
|
||||||
editorRef.current.root.innerHTML = value
|
editorRef.current.root.innerHTML = value
|
||||||
@@ -141,7 +167,7 @@ const RichTextEditor = ({
|
|||||||
editorRef.current.readOnly = false
|
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(() => {
|
useEffect(() => {
|
||||||
if (editorRef.current && isEditable) {
|
if (editorRef.current && isEditable) {
|
||||||
@@ -178,7 +204,13 @@ const RichTextEditor = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`quill-root quill-variant-${variant}`}>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user