From 34a386abce0cd9e3ea1bc5d5c08484f11e75a0f5 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 31 May 2025 01:36:55 -0400 Subject: [PATCH] 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. --- package-lock.json | 293 +++++++++- package.json | 4 +- src/utils/Fetcher.jsx | 63 ++ src/views/Authorization/LoginView.jsx | 55 +- .../Authorization/MFAVerificationModal.jsx | 160 +++++ src/views/ChoreEdit/ChoreEdit.jsx | 1 + src/views/ChoreEdit/ChoreView.jsx | 1 - src/views/Settings/MFASettings.jsx | 553 ++++++++++++++++++ src/views/Settings/ProfileSettings.jsx | 82 ++- src/views/Settings/Settings.jsx | 2 + src/views/components/AddTaskModal.jsx | 472 ++++++++------- src/views/components/CalendarView.jsx | 125 +++- src/views/components/CustomParsers.js | 1 - src/views/components/RichTextEditor.css | 132 +++++ src/views/components/RichTextEditor.jsx | 44 +- 15 files changed, 1707 insertions(+), 281 deletions(-) create mode 100644 src/views/Authorization/MFAVerificationModal.jsx create mode 100644 src/views/Settings/MFASettings.jsx diff --git a/package-lock.json b/package-lock.json index 52b37de..55b27b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@capgo/capacitor-social-login": "^7.5.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.16.13", @@ -30,6 +31,7 @@ "@openreplay/tracker": "^14.0.4", "@tanstack/react-query": "^5.17.0", "aos": "^2.3.4", + "browser-image-compression": "^2.0.2", "capacitor-plugin-safe-area": "^4.0.0", "chrono-node": "^2.7.7", "dotenv": "^16.4.5", @@ -40,9 +42,13 @@ "moment": "^2.30.1", "murmurhash": "^2.0.1", "prop-types": "^15.8.1", + "qrcode": "^1.5.4", + "quill": "^2.0.3", + "quilljs-markdown": "^1.2.0", "react": "^18.2.0", "react-calendar": "^5.1.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.4.2", "react-router-dom": "^6.21.1", "react-transition-group": "^4.4.5", "reactjs-social-login": "^2.6.3", @@ -4871,7 +4877,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5472,6 +5477,15 @@ "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==", "license": "Apache-2.0" }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "license": "MIT", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -5614,7 +5628,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6154,6 +6167,17 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", + "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.42.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", @@ -6488,7 +6512,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6770,6 +6793,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6956,7 +6985,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -7761,7 +7789,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/fast-equals": { @@ -8224,7 +8251,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9093,7 +9119,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9843,12 +9868,31 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -10534,6 +10578,12 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -10784,7 +10834,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10803,6 +10852,12 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10837,7 +10892,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10974,6 +11028,15 @@ "node": ">=10.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11375,6 +11438,141 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11406,6 +11604,51 @@ "node": ">=8" } }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/quill/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/quilljs-markdown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/quilljs-markdown/-/quilljs-markdown-1.2.0.tgz", + "integrity": "sha512-/Fqm0d7QF+n3dvFGZDosq5W4kBloD4QR6qDzv6ATFAmShDYRtnijP0cODmG+bk+2P+233wivbragV+6DNzePJg==", + "license": "MIT", + "dependencies": { + "core-js": "^3.23.5", + "regenerator-runtime": "^0.13.9" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -11495,6 +11738,20 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.4.2.tgz", + "integrity": "sha512-V+GQUTkNWD8gK0mbZQfwTvcDxyCB4GS0cM36is8dAcvnsHY7DMEDP2D5IqHju55TOiCHwElJPVOYDgiu8BEiHQ==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-is": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", @@ -11862,6 +12119,12 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/regexp-to-ast": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", @@ -12224,7 +12487,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12243,7 +12505,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, "license": "ISC" }, "node_modules/requireindex": { @@ -12593,7 +12854,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -13115,7 +13375,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13263,7 +13522,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14251,6 +14509,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -14520,7 +14784,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true, "license": "ISC" }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index 51b84c6..253a62c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "donetick", "private": true, - "version": "0.1.101", + "version": "0.1.102", "type": "module", "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -36,6 +36,7 @@ "@capgo/capacitor-social-login": "^7.5.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.16.13", @@ -55,6 +56,7 @@ "moment": "^2.30.1", "murmurhash": "^2.0.1", "prop-types": "^15.8.1", + "qrcode": "^1.5.4", "quill": "^2.0.3", "quilljs-markdown": "^1.2.0", "react": "^18.2.0", diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index ba17597..41d425a 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -434,6 +434,63 @@ const ResetPassword = email => { }) } +// MFA Related Functions +const GetMFAStatus = () => { + return Fetch(`/users/mfa/status`, { + method: 'GET', + headers: HEADERS(), + }) +} + +const SetupMFA = () => { + return Fetch(`/users/mfa/setup`, { + method: 'POST', + headers: HEADERS(), + }) +} + +const ConfirmMFA = (secret, code, backupCodes) => { + return Fetch(`/users/mfa/confirm`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ + secret, + code, + backupCodes, + }), + }) +} + +const DisableMFA = code => { + return Fetch(`/users/mfa/disable`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ code }), + }) +} + +const RegenerateBackupCodes = code => { + return Fetch(`/users/mfa/regenerate-backup-codes`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ code }), + }) +} + +const VerifyMFA = (sessionToken, code) => { + const baseURL = apiManager.getApiURL() + return fetch(`${baseURL}/auth/mfa/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sessionToken, + code, + }), + }) +} + const UpdateDueDate = (id, dueDate) => { return Fetch(`/chores/${id}/dueDate`, { method: 'PUT', @@ -498,6 +555,7 @@ export { CancelSubscription, ChangePassword, CompleteSubTask, + ConfirmMFA, CreateChore, CreateLabel, CreateLongLiveToken, @@ -508,6 +566,7 @@ export { DeleteLabel, DeleteLongLiveToken, DeleteThing, + DisableMFA, GetAllCircleMembers, GetAllUsers, GetArchivedChores, @@ -520,6 +579,7 @@ export { GetCircleMemberRequests, GetLabels, GetLongLiveTokens, + GetMFAStatus, GetResource, GetStorageUsage, GetSubscriptionSession, @@ -534,9 +594,11 @@ export { PutWebhookURL, RedeemPoints, RefreshToken, + RegenerateBackupCodes, ResetPassword, SaveChore, SaveThing, + SetupMFA, SkipChore, UnArchiveChore, UpdateChoreAssignee, @@ -550,6 +612,7 @@ export { UpdatePassword, UpdateThingState, UpdateUserDetails, + VerifyMFA, createChore, login, signUp, diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 86ea151..aba8e6d 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -25,12 +25,15 @@ import Logo from '../../Logo' import { useResource } from '../../queries/ResourceQueries' import { GetUserProfile, login } from '../../utils/Fetcher' import { apiManager } from '../../utils/TokenManager' +import MFAVerificationModal from './MFAVerificationModal' const LoginView = () => { const { userProfile, setUserProfile } = React.useContext(UserContext) const [username, setUsername] = React.useState('') const [password, setPassword] = React.useState('') const [error, setError] = React.useState(null) + const [mfaModalOpen, setMfaModalOpen] = React.useState(false) + const [mfaSessionToken, setMfaSessionToken] = React.useState('') const { data: resource } = useResource() const Navigate = useNavigate() useEffect(() => { @@ -52,10 +55,17 @@ const LoginView = () => { .then(response => { if (response.status === 200) { return response.json().then(data => { + // Check if MFA is required + if (data.mfaRequired) { + setMfaSessionToken(data.sessionToken) + setMfaModalOpen(true) + return + } + + // Normal login without MFA localStorage.setItem('ca_token', data.token) localStorage.setItem('ca_expiration', data.expire) const redirectUrl = Cookies.get('ca_redirect') - // console.log('redirectUrl', redirectUrl) if (redirectUrl) { Cookies.remove('ca_redirect') Navigate(redirectUrl) @@ -100,6 +110,14 @@ const LoginView = () => { }).then(response => { if (response.status === 200) { return response.json().then(data => { + // Check if MFA is required for OAuth login + if (data.mfaRequired) { + setMfaSessionToken(data.sessionToken) + setMfaModalOpen(true) + return + } + + // Normal OAuth login without MFA localStorage.setItem('ca_token', data.token) localStorage.setItem('ca_expiration', data.expire) @@ -112,7 +130,7 @@ const LoginView = () => { } }) } - return response.json().then(error => { + return response.json().then(() => { setError("Couldn't log in with Google, please try again") }) }) @@ -132,6 +150,31 @@ const LoginView = () => { }) }) } + + const handleMFASuccess = data => { + localStorage.setItem('ca_token', data.token) + localStorage.setItem('ca_expiration', data.expire) + setMfaModalOpen(false) + setMfaSessionToken('') + + const redirectUrl = Cookies.get('ca_redirect') + if (redirectUrl) { + Cookies.remove('ca_redirect') + Navigate(redirectUrl) + } else { + Navigate('/my/chores') + } + } + + const handleMFAError = errorMessage => { + setError(errorMessage) + } + + const handleMFAClose = () => { + setMfaModalOpen(false) + setMfaSessionToken('') + } + const handleForgotPassword = () => { Navigate('/forgot-password') } @@ -434,6 +477,14 @@ const LoginView = () => { > {error} + + ) } diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx new file mode 100644 index 0000000..5f76e98 --- /dev/null +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -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 ( + + + + + + + + Two-Factor Authentication + + + Enter the verification code from your authenticator app + + + + + + + {isBackupCode ? 'Backup Code' : 'Verification Code'} + + 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={} + autoFocus + /> + + + {error && ( + + {error} + + )} + + + + + { + setIsBackupCode(!isBackupCode) + setVerificationCode('') + setError('') + }} + sx={{ fontSize: 'sm' }} + > + {isBackupCode + ? 'Use authenticator app instead' + : "Can't access your authenticator? Use a backup code"} + + + + + + Having trouble? Make sure your authenticator app is synced and try + again. Each backup code can only be used once. + + + + + + ) +} + +export default MFAVerificationModal diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 8aee228..27dd9a7 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -54,6 +54,7 @@ const ASSIGN_STRATEGIES = [ 'least_completed', 'keep_last_assigned', 'random_except_last_assigned', + 'round_robin', ] const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month'] diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 1e3f80b..1eeebad 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -601,7 +601,6 @@ const ChoreView = () => { } setNote(e.target.value) }} - size='md' sx={{ mb: 1, }} diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx new file mode 100644 index 0000000..1089fc0 --- /dev/null +++ b/src/views/Settings/MFASettings.jsx @@ -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 ( + + + + ) + } + + return ( +
+ Multi-Factor Authentication + + + 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. + + + {success && ( + setSuccess('')}> + {success} + + )} + + {error && ( + setError('')}> + {error} + + )} + + + + + + + + Two-Factor Authentication + + + {mfaEnabled + ? 'Your account is protected with 2FA' + : 'Secure your account with an authenticator app'} + + + + + {mfaEnabled ? ( + + ) : ( + + )} + + + + + {mfaEnabled && ( + + + + + + Backup Codes + + Generate new backup codes in case you lose access to your + authenticator + + + + + + + )} + + {/* Setup MFA Modal */} + + + + + Set up Multi-Factor Authentication + + + {setupStep === 1 && setupData && ( + + + Step 1: Scan the QR code below with your + authenticator app (Google Authenticator, Authy, etc.) + + + + {qrCodeDataUrl || setupData.qrCode ? ( + MFA QR Code + ) : ( + + QR code could not be generated. Please try again or use the + manual entry key below. + + )} + + + + + Manual entry key: {setupData.secret} + + + + + + )} + + {setupStep === 2 && ( + + + Step 2: Enter the 6-digit verification code + from your authenticator app + + + { + 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 && {error}} + + + + + + + )} + + {setupStep === 3 && ( + + + + + MFA Successfully Enabled! + + + + + + Save these backup codes in a safe place + + + You can use these codes to access your account if you lose + your authenticator device. Each code can only be used once. + + + + + + {backupCodes?.map((code, index) => ( + + {code} + + ))} + + + + + + )} + + + + {/* Disable MFA Modal */} + + + + + Disable Multi-Factor Authentication + + + + + + Disabling MFA will make your account less secure. Are you sure + you want to continue? + + + + + Enter a verification code from your authenticator app to confirm: + + + { + 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 && {error}} + + + + + + + + + + {/* Backup Codes Modal */} + setBackupCodesModalOpen(false)} + > + + + + New Backup Codes + + + + + + Your previous backup codes are now invalid. Save these new codes + in a safe place. Each code can only be used once. + + + + + + {backupCodes?.map((code, index) => ( + + {code} + + ))} + + + + + + + +
+ ) +} + +export default MFASettings diff --git a/src/views/Settings/ProfileSettings.jsx b/src/views/Settings/ProfileSettings.jsx index 4e0d54a..02e8c59 100644 --- a/src/views/Settings/ProfileSettings.jsx +++ b/src/views/Settings/ProfileSettings.jsx @@ -1,9 +1,11 @@ import { + Autocomplete, Avatar, Box, Button, Card, Divider, + Input, Snackbar, Typography, } from '@mui/joy' @@ -12,6 +14,7 @@ import ModalDialog from '@mui/joy/ModalDialog' import { useContext, useRef, useState } from 'react' import Cropper from 'react-easy-crop' import { UserContext } from '../../contexts/UserContext' +import { UpdateUserDetails } from '../../utils/Fetcher' import { resolvePhotoURL } from '../../utils/Helpers' import { getCroppedImg } from '../../utils/imageCropUtils' import { UploadFile } from '../../utils/TokenManager' @@ -19,8 +22,12 @@ import { UploadFile } from '../../utils/TokenManager' const ProfileSettings = () => { const { userProfile, setUserProfile } = useContext(UserContext) const [displayName, setDisplayName] = useState(userProfile?.displayName || '') + const [timezone, setTimezone] = useState( + userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + ) const [photoURL, setPhotoURL] = useState(userProfile?.image || '') const [isUploading, setIsUploading] = useState(false) + const [isSaving, setIsSaving] = useState(false) const [snackbar, setSnackbar] = useState({ open: false, message: '', @@ -33,6 +40,9 @@ const ProfileSettings = () => { const [showCropper, setShowCropper] = useState(false) const [selectedFile, setSelectedFile] = useState(null) + // Get available timezones + const timezones = Intl.supportedValuesOf('timeZone') + const onCropComplete = (croppedArea, croppedAreaPixels) => { setCroppedAreaPixels(croppedAreaPixels) } @@ -84,13 +94,31 @@ const ProfileSettings = () => { } } - const handleSave = () => { - setUserProfile({ ...userProfile, displayName }) - setSnackbar({ - open: true, - message: 'Profile updated!', - color: 'success', - }) + const handleSave = async () => { + setIsSaving(true) + try { + const userDetails = { displayName, timezone } + const response = await UpdateUserDetails(userDetails) + + if (response.ok) { + setUserProfile({ ...userProfile, displayName, timezone }) + setSnackbar({ + open: true, + message: 'Profile updated successfully!', + color: 'success', + }) + } else { + throw new Error('Failed to update profile') + } + } catch (err) { + setSnackbar({ + open: true, + message: 'Failed to update profile.', + color: 'danger', + }) + } finally { + setIsSaving(false) + } } // Helper to resolve photoURL with baseURL if needed @@ -199,7 +227,7 @@ const ProfileSettings = () => { - {/* + Display Name @@ -209,15 +237,51 @@ const ProfileSettings = () => { placeholder='Enter your display name' sx={{ mb: 2 }} /> + + + Timezone + + 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 }} + /> + - */} + { )} +
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 8bccd76..7c4afdf 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -14,16 +14,19 @@ import { import { FormControl } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' -import { useContext, useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { UserContext } from '../../contexts/UserContext' import { useCreateChore } from '../../queries/ChoreQueries' -import { useAllUsers } from '../../queries/UserQueries' -import useDebounce from '../../utils/Debounce' +import { useCircleMembers } from '../../queries/UserQueries' import { isPlusAccount } from '../../utils/Helpers' import { useLabels } from '../Labels/LabelQueries' -import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput' -import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers' +import { + parseAssignees, + parseLabels, + parsePriority, + parseRepeatV2, +} from './CustomParsers' +import SmartTaskTitleInput from './SmartTaskTitleInput' import LearnMoreButton from './LearnMore' import RichTextEditor from './RichTextEditor' @@ -31,13 +34,12 @@ import SubTasks from './SubTask' const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const { data: userLabels, isLoading: userLabelsLoading } = useLabels() - const { data: allUsers, isLoading: isAllUserLoading } = useAllUsers() + const { data: circleMembers, isLoading: isCircleMembersLoading } = + useCircleMembers() const createChoreMutation = useCreateChore() const { userProfile } = useContext(UserContext) - const navigate = useNavigate() const [taskText, setTaskText] = useState('') - const debounceParsing = useDebounce(taskText, 30) const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) @@ -46,7 +48,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) - const [assignedTo, setAssignedTo] = useState(userProfile?.id) const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) @@ -71,13 +72,231 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } }, [autoFocus]) + const renderHighlightedSentence = useCallback( + ( + sentence, + repeatHighlight, + priorityHighlight, + labelsHighlight, + dueDateHighlight, + ) => { + const parts = [] + let lastIndex = 0 + let plainText = '' + + // Combine all highlight ranges and sort them by their start index + const allHighlights = [] + if (repeatHighlight) { + repeatHighlight.forEach(h => + allHighlights.push({ ...h, type: 'repeat', priority: 40 }), + ) + } + if (priorityHighlight) { + priorityHighlight.forEach(h => + allHighlights.push({ ...h, type: 'priority', priority: 30 }), + ) + } + if (labelsHighlight) { + labelsHighlight.forEach(h => + allHighlights.push({ ...h, type: 'label', priority: 20 }), + ) + } + if (dueDateHighlight) { + allHighlights.push({ + ...dueDateHighlight, + type: 'dueDate', + priority: 10, + }) + } + + allHighlights.sort((a, b) => a.start - b.start) + const resolvedHighlights = [] + for (let i = 0; i < allHighlights.length; i++) { + const current = allHighlights[i] + const previous = resolvedHighlights[resolvedHighlights.length - 1] + + if (previous && current.start < previous.end) { + if (current.priority > previous.priority) { + resolvedHighlights.pop() + resolvedHighlights.push(current) + } + } else { + // No overlap, add the current highlight + resolvedHighlights.push(current) + } + } + + for (const highlight of resolvedHighlights) { + // Add the text before the highlight + if (highlight.start > lastIndex) { + const textBefore = sentence.substring(lastIndex, highlight.start) + parts.push(textBefore) + plainText += textBefore + } + + // Determine the class name based on the highlight type + let className = '' + switch (highlight.type) { + case 'repeat': + className = 'highlight-repeat' + break + case 'priority': + className = 'highlight-priority' + break + case 'label': + className = 'highlight-label' + break + case 'dueDate': + className = 'highlight-date' + break + default: + break + } + + // Add the highlighted span + const highlightedText = sentence.substring( + highlight.start, + highlight.end, + ) + parts.push( + + {highlightedText} + , + ) + + // Update the last index to the end of the current highlight + lastIndex = highlight.end + } + + // Add any remaining text after the last highlight + if (lastIndex < sentence.length) { + const remainingText = sentence.substring(lastIndex) + parts.push(remainingText) + plainText += remainingText + } + + return { + parts, + plainText, + } + }, + [], + ) + + const processText = useCallback( + sentence => { + let cleanedSentence = sentence + const priority = parsePriority(sentence) + if (priority.result) setPriority(priority.result) + cleanedSentence = priority.cleanedSentence + const labels = parseLabels(sentence, userLabels) + if (labels.result) { + cleanedSentence = labels.cleanedSentence + setLabelsV2(labels.result) + } + + const repeat = parseRepeatV2(sentence) + if (repeat.result) { + setFrequency(repeat.result) + setFrequencyHumanReadable(repeat.name) + cleanedSentence = repeat.cleanedSentence + } + // Parse assignees using circle members + const circleMembersList = circleMembers?.res || [] + const assigneesForParsing = circleMembersList.map(member => ({ + userId: member.userId, + username: + member.username || + member.displayName?.toLowerCase().replace(/\s+/g, ''), + displayName: member.displayName, + name: member.displayName, + id: member.userId, + })) + + const assigneesResult = parseAssignees(sentence, assigneesForParsing) + if (assigneesResult.result) { + cleanedSentence = assigneesResult.cleanedSentence + setAssignees(assigneesResult.result) + } else { + // Default to current user if no assignees parsed + setAssignees([ + { + userId: userProfile.id, + username: userProfile.username, + displayName: userProfile.displayName, + name: userProfile.displayName, + id: userProfile.id, + }, + ]) + } + const parsedDueDate = chrono.parse(sentence, new Date(), { + forwardDate: true, + }) + if (parsedDueDate[0]?.index > -1) { + setDueDate( + moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), + ) + cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '') + } + + if (repeat.result) { + // if repeat has result the cleaned sentence will remove the date related info which mean + // we need to reparse the date again to get the correct due date: + const parsedDueDate = chrono.parse(sentence, new Date(), { + forwardDate: true, + }) + if (parsedDueDate[0]?.index > -1) { + setDueDate( + moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), + ) + } + } + + setTaskText(sentence) + setTaskTitle(cleanedSentence.trim()) + const { parts, plainText } = renderHighlightedSentence( + sentence, + repeat.highlight, + priority.highlight, + labels.highlight, + parsedDueDate && parsedDueDate[0] + ? { + start: parsedDueDate[0].index, + end: parsedDueDate[0].index + parsedDueDate[0].text.length, + text: parsedDueDate[0].text, + } + : null, + ) + + setRenderedParts(parts) + setTaskTitle(plainText) + }, + [circleMembers, userLabels, userProfile, renderHighlightedSentence], + ) + useEffect(() => { - if (!isModalOpen || userLabelsLoading || isAllUserLoading) { + if (!isModalOpen || userLabelsLoading || isCircleMembersLoading) { return } processText(taskText) - }, [taskText, userLabelsLoading, isAllUserLoading]) + }, [ + taskText, + userLabelsLoading, + isCircleMembersLoading, + isModalOpen, + processText, + ]) const handleEnterPressed = () => { createChore() @@ -96,6 +315,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setSubTasks(null) setHasSubTasks(false) setLabelsV2([]) + setAssignees([]) } const handleSubmit = () => { @@ -104,197 +324,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setTaskText('') } - const handleTextChange = e => { - if (!e.target.value) { - setTaskText('') - setDueDate(null) - setFrequency(null) - setFrequencyHumanReadable(null) - setPriority(0) - return - } - setTaskText(e.target.value) - } - const processText = sentence => { - let cleanedSentence = sentence - const priority = parsePriority(sentence) - if (priority.result) setPriority(priority.result) - cleanedSentence = priority.cleanedSentence - const labels = parseLabels(sentence, userLabels) - if (labels.result) { - cleanedSentence = labels.cleanedSentence - setLabelsV2(labels.result) - } - - const repeat = parseRepeatV2(sentence) - if (repeat.result) { - setFrequency(repeat.result) - setFrequencyHumanReadable(repeat.name) - cleanedSentence = repeat.cleanedSentence - } - // const assignees = parseAssignees(sentence) - // if (assignees.result) { - // cleanedSentence = assignees.cleanedSentence - // set - // } - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { - setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), - ) - cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '') - } - - if (repeat.result) { - // if repeat has result the cleaned sentence will remove the date related info which mean - // we need to reparse the date again to get the correct due date: - const parsedDueDate = chrono.parse(sentence, new Date(), { - forwardDate: true, - }) - if (parsedDueDate[0]?.index > -1) { - setDueDate( - moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), - ) - } - } - - setTaskText(sentence) - setTaskTitle(cleanedSentence.trim()) - const { parts, plainText } = renderHighlightedSentence( - sentence, - repeat.highlight, - priority.highlight, - labels.highlight, - parsedDueDate && parsedDueDate[0] - ? { - start: parsedDueDate[0].index, - end: parsedDueDate[0].index + parsedDueDate[0].text.length, - text: parsedDueDate[0].text, - } - : null, - ) - - setRenderedParts(parts) - setTaskTitle(plainText) - } - const renderHighlightedSentence = ( - sentence, - repeatHighlight, - priorityHighlight, - labelsHighlight, - dueDateHighlight, - ) => { - const parts = [] - let lastIndex = 0 - let plainText = '' - - // Combine all highlight ranges and sort them by their start index - const allHighlights = [] - if (repeatHighlight) { - repeatHighlight.forEach(h => - allHighlights.push({ ...h, type: 'repeat', priority: 40 }), - ) - } - if (priorityHighlight) { - priorityHighlight.forEach(h => - allHighlights.push({ ...h, type: 'priority', priority: 30 }), - ) - } - if (labelsHighlight) { - labelsHighlight.forEach(h => - allHighlights.push({ ...h, type: 'label', priority: 20 }), - ) - } - if (dueDateHighlight) { - allHighlights.push({ ...dueDateHighlight, type: 'dueDate', priority: 10 }) - } - - allHighlights.sort((a, b) => a.start - b.start) - const resolvedHighlights = [] - for (let i = 0; i < allHighlights.length; i++) { - const current = allHighlights[i] - const previous = resolvedHighlights[resolvedHighlights.length - 1] - - if (previous && current.start < previous.end) { - if (current.priority > previous.priority) { - resolvedHighlights.pop() - resolvedHighlights.push(current) - } - } else { - // No overlap, add the current highlight - resolvedHighlights.push(current) - } - } - - for (const highlight of resolvedHighlights) { - // Add the text before the highlight - if (highlight.start > lastIndex) { - const textBefore = sentence.substring(lastIndex, highlight.start) - parts.push(textBefore) - plainText += textBefore - } - - // Determine the class name based on the highlight type - let className = '' - switch (highlight.type) { - case 'repeat': - className = 'highlight-repeat' - break - case 'priority': - className = 'highlight-priority' - break - case 'label': - className = 'highlight-label' - break - case 'dueDate': - className = 'highlight-date' - break - default: - break - } - - // Add the highlighted span - const highlightedText = sentence.substring(highlight.start, highlight.end) - parts.push( - - {highlightedText} - , - ) - - // Update the last index to the end of the current highlight - lastIndex = highlight.end - } - - // Add any remaining text after the last highlight - if (lastIndex < sentence.length) { - const remainingText = sentence.substring(lastIndex) - parts.push(remainingText) - plainText += remainingText - } - - return { - parts, - plainText, - } - } const createChore = () => { const chore = { name: taskTitle, - assignees: [{ userId: userProfile.id }], + assignees: assignees.map(assignee => ({ userId: assignee.userId })), dueDate: dueDate ? new Date(dueDate).toISOString() : null, - assignedTo: userProfile.id, + assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id, assignStrategy: 'random', isRolling: false, notification: false, @@ -346,7 +381,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } }) } - if (userLabelsLoading || isAllUserLoading) { + if (userLabelsLoading || isCircleMembersLoading) { return <> } @@ -373,7 +408,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { This feature lets you create a task simply by typing a sentence. It attempt parses the sentence to identify the - task's due date, priority, and frequency. + task's due date, priority, and frequency. { ], }, '@': { - // value: 'userId', - // display: 'displayName', - // options: allUsers ? allUsers : [], - value: 'id', - display: 'name', - options: [ - { id: userProfile.id, name: userProfile.displayName }, - ], + value: 'userId', + display: 'displayName', + options: circleMembers?.res || [], }, }} /> @@ -561,11 +591,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }} > - Assignee - + Assignees + + {assignees.length > 0 ? ( + assignees.map((assignee, index) => ( + + {assignee.displayName || assignee.username} + + )) + ) : ( + + {userProfile.displayName} + + )} + Frequency diff --git a/src/views/components/CalendarView.jsx b/src/views/components/CalendarView.jsx index 9842405..1b22636 100644 --- a/src/views/components/CalendarView.jsx +++ b/src/views/components/CalendarView.jsx @@ -1,11 +1,12 @@ import { CalendarMonth } from '@mui/icons-material' -import { Box, Chip, Grid, Typography } from '@mui/joy' +import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy' import moment from 'moment' import React, { useState } from 'react' import Calendar from 'react-calendar' import 'react-calendar/dist/Calendar.css' import { useNavigate } from 'react-router-dom' import { UserContext } from '../../contexts/UserContext' +import { useCircleMembers } from '../../queries/UserQueries' import { TASK_COLOR } from '../../utils/Colors' import './Calendar.css' @@ -20,6 +21,19 @@ const CalendarView = ({ chores }) => { const [selectedDate, setSeletedDate] = useState(null) const Navigate = useNavigate() + // Fetch circle members data to get assignee names + const { data: circleMembersData } = useCircleMembers() + const circleMembers = circleMembersData?.res || [] + + // Helper function to get assignee display name + const getAssigneeName = assignedTo => { + if (assignedTo === userProfile.id) { + return userProfile.displayName + } + const assignee = circleMembers.find(member => member.userId === assignedTo) + return assignee ? `${assignee.displayName}` : 'Assigned to other' + } + const tileContent = ({ date, view }) => { if (view === 'month') { const dayChores = chores.filter(chore => { @@ -109,34 +123,60 @@ const CalendarView = ({ chores }) => { /> {!selectedDate && ( - {[ - { name: 'Assigned to me', color: TASK_COLOR.ASSIGNED_TO_ME }, - { name: 'Assigned to other', color: TASK_COLOR.ASSIGNED_TO_OTHER }, - ].map((item, index) => ( - - { + const assignedUserIds = new Set( + chores.map(chore => chore.assignedTo).filter(Boolean), + ) + const legendItems = [] + + // Add current user if they have assignments + if (assignedUserIds.has(userProfile.id)) { + legendItems.push({ + name: 'Assigned to me', + color: TASK_COLOR.ASSIGNED_TO_ME, + }) + } + + // Add other circle members who have assignments + circleMembers.forEach(member => { + if ( + member.userId !== userProfile.id && + assignedUserIds.has(member.userId) + ) { + legendItems.push({ + name: `Assigned to ${member.displayName}`, + color: TASK_COLOR.ASSIGNED_TO_OTHER, + }) + } + }) + + return legendItems.map((item, index) => ( + - - {item.name} - - - ))} + > + + + {item.name} + + + )) + })()} )} {selectedDate && ( @@ -257,16 +297,37 @@ const CalendarView = ({ chores }) => { > {moment(chore.nextDueDate).format('h:mm A')} - - {chore.assignedTo === userProfile.id - ? 'Assigned to me' - : 'Assigned to other'} - + {getAssigneeName(chore.assignedTo)} + */} + member.userId === chore.assignedTo, + )?.image + } + /> + } + sx={{ + backgroundColor: getAssigneeColor( + chore.assignedTo, + userProfile, + ), + color: 'white', + }} + > + {getAssigneeName(chore.assignedTo)} + ))} diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index 632ea59..eb44912 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -375,7 +375,6 @@ export const parseAssignees = (inputSentence, users) => { const sentence = inputSentence.toLowerCase() const result = [] const highlight = [] - console.log('users:', users) for (const user of users) { if (sentence.includes(`@${user.username.toLowerCase()}`)) { diff --git a/src/views/components/RichTextEditor.css b/src/views/components/RichTextEditor.css index 78decca..5c5cf95 100644 --- a/src/views/components/RichTextEditor.css +++ b/src/views/components/RichTextEditor.css @@ -51,3 +51,135 @@ max-width: 100%; height: auto; } + +/* Material-UI style customizations for Quill toolbar */ +.quill-root .ql-toolbar.ql-snow { + border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee); + border-bottom: none; + border-radius: 8px 8px 0 0; + background: var(--joy-palette-background-surface, #fff); + box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05)); + padding: 12px 16px; +} + +.quill-root .ql-container.ql-snow { + border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee); + border-top: none; + border-radius: 0 0 8px 8px; + background: var(--joy-palette-background-surface, #fff); +} + +/* Style toolbar buttons with Material-UI look */ +.quill-root .ql-toolbar .ql-formats { + margin-right: 12px; +} + +.quill-root .ql-toolbar button { + border-radius: 6px; + padding: 6px; + margin: 0 2px; + transition: all 0.2s ease; + border: none; + background: transparent; +} + +.quill-root .ql-toolbar button:hover { + background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08)); +} + +.quill-root .ql-toolbar button.ql-active { + background: var(--joy-palette-primary-softActiveBg, rgba(25, 118, 210, 0.12)); + color: var(--joy-palette-primary-solidBg, #1976d2); +} + +/* Style dropdown selectors */ +.quill-root .ql-toolbar .ql-picker { + border-radius: 6px; +} + +.quill-root .ql-toolbar .ql-picker-label { + border-radius: 6px; + transition: all 0.2s ease; + color: var(--joy-palette-neutral-solidBg, #333); +} + +.quill-root .ql-toolbar .ql-picker-label:hover { + background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08)); +} + +.quill-root .ql-toolbar .ql-picker.ql-expanded .ql-picker-label { + background: var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.12)); +} + +/* Style the dropdown options */ +.quill-root .ql-toolbar .ql-picker-options { + border-radius: 8px; + background: var(--joy-palette-background-popup, #fff); + box-shadow: var( + --joy-shadow-md, + 0px 4px 8px -2px rgba(16, 24, 40, 0.1), + 0px 2px 4px -2px rgba(16, 24, 40, 0.06) + ); + /* padding: 4px; +} + +.quill-root .ql-toolbar .ql-picker-item { + /* border-radius: 4px; + padding: 8px 12px; + margin: 2px 0; + transition: all 0.2s ease; */ +} + +.quill-root .ql-toolbar .ql-picker-item:hover { + background: var(--joy-palette-neutral-softHoverBg, rgba(99, 107, 116, 0.08)); +} + +.quill-root .ql-toolbar .ql-picker-item.ql-selected { + background: var(--joy-palette-primary-softActiveBg, rgba(25, 118, 210, 0.12)); + color: var(--joy-palette-primary-solidBg, #1976d2); +} + +/* Add separator styling */ +.quill-root .ql-toolbar .ql-formats:not(:last-child)::after { + content: ''; + display: inline-block; + width: 1px; + height: 20px; + background: var(--joy-palette-divider, #e7ebf0); + margin-left: 8px; + vertical-align: middle; +} + +/* Remove default Quill borders and spacing */ +.quill-root .ql-editor { + padding: 16px; + min-height: 120px; + font-family: var( + --joy-fontFamily-body, + Inter, + system-ui, + Avenir, + Helvetica, + Arial, + sans-serif + ); + font-size: 14px; + line-height: 1.5; +} + +/* Custom focus styles */ +.quill-root:focus-within .ql-toolbar.ql-snow { + border-color: var(--joy-palette-primary-outlinedBorder, #1976d2); +} + +.quill-root:focus-within .ql-container.ql-snow { + border-color: var(--joy-palette-primary-outlinedBorder, #1976d2); +} + +/* Variant-specific styling */ +.quill-variant-bubble .ql-toolbar { + background: transparent; + border: none; + box-shadow: none; + padding: 8px; +} diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx index f538f95..3a0eb40 100644 --- a/src/views/components/RichTextEditor.jsx +++ b/src/views/components/RichTextEditor.jsx @@ -2,9 +2,10 @@ import imageCompression from 'browser-image-compression' import Quill from 'quill' import 'quill/dist/quill.snow.css' import QuillMarkdown from 'quilljs-markdown' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useContext, useEffect, useRef } from 'react' +import { UserContext } from '../../contexts/UserContext' import { useError } from '../../service/ErrorProvider' -import { resolvePhotoURL } from '../../utils/Helpers' +import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' import { UploadFile } from '../../utils/TokenManager' import './RichTextEditor.css' @@ -12,16 +13,28 @@ const RichTextEditor = ({ value = '', onChange, isEditable = true, + placeholder = 'Enter description...', variant = 'outlined', entityId, entityType, }) => { const { showError } = useError() + const { userProfile } = useContext(UserContext) const quillRef = useRef(null) const editorRef = useRef(null) // Image upload handler - wrapped in useCallback to avoid recreating on every render const handleImageUpload = useCallback(() => { + // Check if user has plus account + if (!isPlusAccount(userProfile)) { + showError({ + title: 'Plus Feature', + message: + 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', + }) + return + } + const input = document.createElement('input') input.setAttribute('type', 'file') input.setAttribute('accept', 'image/*') @@ -77,6 +90,19 @@ const RichTextEditor = ({ message: 'The file you are trying to upload is too large.', }) return + } else if (response.status === 403 && !isPlusAccount()) { + showError({ + title: 'Upgrade Required', + message: + 'Image uploads are only available for Plus accounts. Please ', + }) + return + } else if (response.status === 403) { + showError({ + title: 'Permission Denied', + message: 'You do not have permission to upload files.', + }) + return } else if (!response.ok) { showError({ title: 'Upload Failed', @@ -98,7 +124,7 @@ const RichTextEditor = ({ }) } } - }, [entityId, entityType, showError]) // Dependencies for useCallback + }, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback useEffect(() => { if (!quillRef.current) return @@ -120,7 +146,7 @@ const RichTextEditor = ({ }, }, }, - placeholder: 'Enter description...', + placeholder: placeholder, }) new QuillMarkdown(editorRef.current, {}) editorRef.current.root.innerHTML = value @@ -141,7 +167,7 @@ const RichTextEditor = ({ editorRef.current.readOnly = false } } - }, [onChange, value, isEditable, variant, handleImageUpload]) // Added handleImageUpload to dependency array + }, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array useEffect(() => { if (editorRef.current && isEditable) { @@ -178,7 +204,13 @@ const RichTextEditor = ({ return (
-
+
) }