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:
Mo Tarbin
2025-05-31 01:36:55 -04:00
parent 05f4c42bf9
commit 34a386abce
15 changed files with 1707 additions and 281 deletions

293
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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>
) )
} }

View 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

View File

@@ -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']

View File

@@ -601,7 +601,6 @@ const ChoreView = () => {
} }
setNote(e.target.value) setNote(e.target.value)
}} }}
size='md'
sx={{ sx={{
mb: 1, mb: 1,
}} }}

View 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&apos;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

View File

@@ -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}

View File

@@ -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'>

View File

@@ -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&apos;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>

View File

@@ -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>
))} ))}

View File

@@ -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()}`)) {

View File

@@ -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;
}

View File

@@ -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>
) )
} }