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 ? (
+
+ ) : (
+
+ 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 (
)
}