From ea5e3503548be6950f74617a96307c9a13f1a8ae Mon Sep 17 00:00:00 2001 From: wmiq84 Date: Tue, 17 Feb 2026 13:56:59 -0800 Subject: [PATCH 1/6] Add volunteer profile modal and view page --- backend/package-lock.json | 26 +- frontend/package-lock.json | 30 +- frontend/public/ic_close.svg | 3 + frontend/src/app/volunteers/page.tsx | 17 + .../VolunteerProfileModal.module.css | 313 ++++++++++++++++++ .../src/components/VolunteerProfileModal.tsx | 110 ++++++ frontend/src/components/VolunteerTable.tsx | 10 +- 7 files changed, 482 insertions(+), 27 deletions(-) create mode 100644 frontend/public/ic_close.svg create mode 100644 frontend/src/components/VolunteerProfileModal.module.css create mode 100644 frontend/src/components/VolunteerProfileModal.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 064f24d5..98d0a24d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -173,7 +173,6 @@ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -194,7 +193,6 @@ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.28.5" }, @@ -211,7 +209,6 @@ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -674,6 +671,7 @@ "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", @@ -827,6 +825,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -947,6 +946,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1165,7 +1165,6 @@ "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", @@ -1180,7 +1179,6 @@ "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" @@ -1211,7 +1209,6 @@ "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" @@ -1222,8 +1219,7 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -1244,6 +1240,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1472,6 +1469,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2046,7 +2044,6 @@ "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -2131,6 +2128,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2820,8 +2818,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -3617,6 +3614,7 @@ "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -3758,7 +3756,6 @@ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -4962,7 +4959,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5393,7 +5389,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5910,7 +5905,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6365,6 +6359,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6541,6 +6536,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 77088f74..dbfe7cff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -189,6 +189,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1405,6 +1406,7 @@ "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-glob": "3.3.1" } @@ -1624,6 +1626,7 @@ "integrity": "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.52.0", @@ -1723,6 +1726,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1750,6 +1754,7 @@ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", @@ -1779,6 +1784,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2279,7 +2285,6 @@ "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.26", @@ -2294,7 +2299,6 @@ "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.26", "@vue/shared": "3.5.26" @@ -2325,7 +2329,6 @@ "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/shared": "3.5.26" @@ -2336,8 +2339,7 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", @@ -2345,6 +2347,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2711,6 +2714,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3271,7 +3275,6 @@ "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -3485,6 +3488,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3817,6 +3821,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4001,6 +4006,7 @@ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -4195,6 +4201,7 @@ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", @@ -4591,8 +4598,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -5763,6 +5769,7 @@ "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -5955,7 +5962,6 @@ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -7458,7 +7464,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7573,6 +7578,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7582,6 +7588,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8602,6 +8609,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8815,6 +8823,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -9061,6 +9070,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/public/ic_close.svg b/frontend/public/ic_close.svg new file mode 100644 index 00000000..aae32ed5 --- /dev/null +++ b/frontend/public/ic_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/volunteers/page.tsx b/frontend/src/app/volunteers/page.tsx index 20d8f7ea..cd06596f 100644 --- a/frontend/src/app/volunteers/page.tsx +++ b/frontend/src/app/volunteers/page.tsx @@ -1,15 +1,19 @@ "use client"; import VolunteerTable from "@/components/VolunteerTable"; +import VolunteerProfileModal from "@/components/VolunteerProfileModal"; import TitleBar from "@/components/TitleBar"; import SearchBar from "@/components/SearchBar"; import PageBar from "@/components/PageBar"; import styles from "../page.module.css"; +import { Volunteer } from "@/types/volunteer"; import { useState } from "react"; export default function Page() { const [currentPage, setCurrentPage] = useState(1); const [totalItems, setTotalItems] = useState(0); + const [selectedVolunteer, setSelectedVolunteer] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); const itemsPerPage = 6; const handlePageChange = (page: number) => { @@ -20,6 +24,10 @@ export default function Page() { setTotalItems(total); }; + const handleSheetClose = () => { + setIsSheetOpen(false); + }; + return (
@@ -29,6 +37,10 @@ export default function Page() { itemsPerPage={itemsPerPage} pageNumber={currentPage} onTotalItemsChange={handleTotalItemsChange} + onVolunteerSelect={(volunteer) => { + setSelectedVolunteer(volunteer); + setIsSheetOpen(true); + }} />
+
); } diff --git a/frontend/src/components/VolunteerProfileModal.module.css b/frontend/src/components/VolunteerProfileModal.module.css new file mode 100644 index 00000000..576e15db --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.module.css @@ -0,0 +1,313 @@ +@import url("https://fonts.googleapis.com/css2?family=Viga&display=swap"); + +.modal { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 40px 0 34px; + position: absolute; + width: 460px; + right: 0; + top: 0; + bottom: 0; + background: #ffffff; + box-shadow: -50px 0px 100px rgba(118, 135, 165, 0.4); + border-radius: 16px 0 0 16px; + z-index: 2; +} + +.heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + padding-bottom: 15px; +} + +.topper { + height: 24px; + width: 100%; +} + +.headerRow { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0 32px; + width: 100%; + box-sizing: border-box; +} + +.modalHeader { + margin: 0; + font-family: "Viga", sans-serif; + font-weight: 400; + font-size: 24px; + line-height: 24px; + letter-spacing: 1.2px; + color: #000000; +} + +.closeButton { + width: 24px; + height: 24px; + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.tabs { + display: flex; + flex-direction: row; + width: 100%; + height: 32px; + border-bottom: 1px solid #e8e8e8; + box-sizing: border-box; +} + +.tabButton { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + height: 32px; + border: none; + background: none; + cursor: pointer; + border-radius: 4px 4px 0 0; + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + text-align: center; + box-sizing: border-box; +} + +.tabActive { + font-weight: 700; + color: #1d3a6b; + border-bottom: 2px solid #1d3a6b; +} + +.tabInactive { + font-weight: 400; + color: #676767; + border-bottom: 2px solid transparent; +} + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px 32px; + gap: 24px; + width: 100%; + flex: 1; + overflow-y: auto; + box-sizing: border-box; +} + +.infoSection { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.infoField { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; +} + +.fieldLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.fieldValue { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.editSection { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.editField { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.editLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #979797; +} + +.editInput { + box-sizing: border-box; + width: 100%; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #141414; + outline: none; +} + +.editInput:focus { + border-color: #1d3a6b; +} + +.tagsSection { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.sectionTitle { + font-family: "Open Sans", sans-serif; + font-weight: 700; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.sectionHeaderWithHint { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.sectionHint { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.tagsRow { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + display: flex; + justify-content: center; + align-items: center; + padding: 8px 12px; + gap: 8px; + border-radius: 100px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; +} + +.tagTeal { + background: #e6f2f3; + color: #007a8a; +} + +.tagOrange { + background: #f9efe6; + color: #c46200; +} + +.tagGreen { + background: #e6f2ec; + color: #007f3f; +} + +.tagRemove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; +} + +.searchAddRow { + display: flex; + flex-direction: row; + gap: 8px; + width: 100%; +} + +.searchAddField { + display: flex; + align-items: center; + box-sizing: border-box; + flex: 1; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + gap: 12px; +} + +.searchAddInput { + flex: 1; + border: none; + outline: none; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #141414; +} + +.searchAddInput::placeholder { + color: #676767; +} + +.caretIcon { + width: 24px; + height: 24px; +} + +.addButton { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + width: 44px; + height: 48px; + border: 1px solid #e8e8e8; + border-radius: 4px; + background: none; + cursor: pointer; + padding: 8px 12px; +} diff --git a/frontend/src/components/VolunteerProfileModal.tsx b/frontend/src/components/VolunteerProfileModal.tsx new file mode 100644 index 00000000..316f58d0 --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.tsx @@ -0,0 +1,110 @@ +"use client"; +import { Volunteer } from "../types/volunteer"; +import styles from "./VolunteerProfileModal.module.css"; +import { useState } from "react"; + +interface VolunteerProfileModalProps { + volunteer: Volunteer | null; + isOpen: boolean; + onClose: () => void; +} + +export default function VolunteerProfileModal({ + volunteer, + isOpen, + onClose, +}: VolunteerProfileModalProps) { + const [activeTab, setActiveTab] = useState("view"); + + if (!isOpen || !volunteer) return null; + + return ( +
+
+
+
+
View Volunteer
+ +
+
+ +
+ + +
+ +
+ {activeTab === "view" ? ( + + ) : ( + + )} +
+
+ ); +} + +function ViewContent({ volunteer }: { volunteer: Volunteer }) { + return ( + <> +
+
+ First Name + {volunteer.firstName} +
+
+ Last Name + {volunteer.lastName} +
+
+ Email + {volunteer.email} +
+
+ Phone + {volunteer.phoneNumber} +
+
+ +
+ Tags +
+ {volunteer.tags.map((tag, index) => { + let colorClass = styles.tagTeal; + // Might want to automatically cycle through colors for more tags + if (tag == "Outside Volunteer") { + colorClass = styles.tagOrange; + } + if (tag.includes("More")) { + colorClass = styles.tagGreen; + } + return ( + + {tag} + + ); + })} +
+
+ + ); +} + +function EditContent({ volunteer }: { volunteer: Volunteer }) { + return
temp
; +} diff --git a/frontend/src/components/VolunteerTable.tsx b/frontend/src/components/VolunteerTable.tsx index 2b88aa46..84d2460e 100644 --- a/frontend/src/components/VolunteerTable.tsx +++ b/frontend/src/components/VolunteerTable.tsx @@ -7,12 +7,14 @@ interface VolunteerTableProps { itemsPerPage: number; pageNumber: number; onTotalItemsChange: (total: number) => void; + onVolunteerSelect?: (volunteer: Volunteer) => void; } export default function VolunteerTable({ itemsPerPage, pageNumber, onTotalItemsChange, + onVolunteerSelect, }: VolunteerTableProps) { const [volunteers, setVolunteers] = useState([]); @@ -33,7 +35,6 @@ export default function VolunteerTable({ fetchVolunteers(); }, [onTotalItemsChange]); - // Calculate which volunteers to display based on current page const startIndex = (pageNumber - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; const displayedVolunteers = volunteers.slice(startIndex, endIndex); @@ -94,7 +95,12 @@ export default function VolunteerTable({ {displayedVolunteers.map((volunteer) => ( - + onVolunteerSelect?.(volunteer)} + // Changes cursor to hand when hovering over volunteer + style={{ cursor: "pointer" }} + > {volunteer.firstName}, {volunteer.lastName} From a182337a29a59a54513f88ca9f639c3f58d287f1 Mon Sep 17 00:00:00 2001 From: siwenshao <146389391+siwenshao@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:28:51 -0800 Subject: [PATCH 2/6] added the edit page for user profile, with data stored back after change --- backend/src/app.ts | 2 +- .../src/controllers/volunteerController.ts | 38 +++ backend/src/routes/volunteerRoutes.ts | 1 + backend/src/validators/volunteerValidator.ts | 20 +- frontend/public/plus.svg | 3 + frontend/public/redx.svg | 3 + frontend/src/app/volunteers/page.tsx | 6 + .../VolunteerProfileModal.module.css | 28 ++ .../src/components/VolunteerProfileModal.tsx | 252 +++++++++++++++++- frontend/src/components/VolunteerTable.tsx | 4 +- 10 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 frontend/public/plus.svg create mode 100644 frontend/public/redx.svg diff --git a/backend/src/app.ts b/backend/src/app.ts index 86c386ed..2aebe6ef 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,7 +14,7 @@ app.use(express.json()); app.use( cors({ - origin: process.env.FRONTEND_ORIGIN, + origin: process.env.FRONTEND_ORIGIN ?? "http://localhost:3000", }), ); diff --git a/backend/src/controllers/volunteerController.ts b/backend/src/controllers/volunteerController.ts index 36f3436a..335cbec8 100644 --- a/backend/src/controllers/volunteerController.ts +++ b/backend/src/controllers/volunteerController.ts @@ -111,6 +111,44 @@ export const createVolunteer: RequestHandler = async (req, res, next) => { } }; +type UpdateVolunteerBody = { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + tags?: string[]; +}; + +export const updateVolunteer: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + const volunteerId = req.params.id; + const { firstName, lastName, email, phoneNumber, tags = [] } = req.body as UpdateVolunteerBody; + + try { + validationErrorParser(errors); + + const volunteer = await VolunteerModel.findByIdAndUpdate( + volunteerId, + { + firstName, + lastName, + email, + phoneNumber, + tags, + }, + { new: true, runValidators: true }, + ); + + if (!volunteer) { + return res.status(404).json({ error: "Could not find volunteer" }); + } + + res.status(200).json(volunteer); + } catch (err) { + next(err); + } +}; + type UpdateVolunteerContactBody = { email: string; phoneNumber: string; diff --git a/backend/src/routes/volunteerRoutes.ts b/backend/src/routes/volunteerRoutes.ts index 4468f048..0c910315 100644 --- a/backend/src/routes/volunteerRoutes.ts +++ b/backend/src/routes/volunteerRoutes.ts @@ -10,6 +10,7 @@ router.get("/", volunteer.getVolunteers); router.delete("/:id", volunteer.deleteVolunteer); router.post("/", VolunteerValidator.createVolunteerValidator, volunteer.createVolunteer); +router.put("/:id", VolunteerValidator.updateVolunteerValidator, volunteer.updateVolunteer); router.put( "/contact/:id", VolunteerValidator.updateVolunteerContactValidator, diff --git a/backend/src/validators/volunteerValidator.ts b/backend/src/validators/volunteerValidator.ts index b8910f51..217d5130 100644 --- a/backend/src/validators/volunteerValidator.ts +++ b/backend/src/validators/volunteerValidator.ts @@ -52,9 +52,14 @@ const makePhoneValidator = () => body("phoneNumber") .exists() .withMessage("phone is required") - .bail() // What kind of phone number do we want to enforce? - .isMobilePhone("any") - .withMessage("phoneNumber must be a valid mobile phone number"); + .bail() + .custom((value: string) => { + const digitsOnly = value.replace(/\D/g, ""); + if (digitsOnly.length !== 10) { + throw new Error("phoneNumber must be a valid phone number"); + } + return true; + }); const tagsValidator = () => body("tags").optional().isArray(); @@ -66,6 +71,15 @@ export const createVolunteerValidator = [ tagsValidator(), ]; +export const updateVolunteerValidator = [ + makeParamIDValidator(), + makeFirstNameValidator(), + makeLastNameValidator(), + makeEmailValidator(), + makePhoneValidator(), + tagsValidator(), +]; + export const updateVolunteerContactValidator = [ makeParamIDValidator(), makeEmailValidator(), diff --git a/frontend/public/plus.svg b/frontend/public/plus.svg new file mode 100644 index 00000000..a40d571b --- /dev/null +++ b/frontend/public/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/redx.svg b/frontend/public/redx.svg new file mode 100644 index 00000000..0bfe381a --- /dev/null +++ b/frontend/public/redx.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/volunteers/page.tsx b/frontend/src/app/volunteers/page.tsx index cd06596f..7e310428 100644 --- a/frontend/src/app/volunteers/page.tsx +++ b/frontend/src/app/volunteers/page.tsx @@ -14,6 +14,7 @@ export default function Page() { const [totalItems, setTotalItems] = useState(0); const [selectedVolunteer, setSelectedVolunteer] = useState(null); const [isSheetOpen, setIsSheetOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); const itemsPerPage = 6; const handlePageChange = (page: number) => { @@ -37,6 +38,7 @@ export default function Page() { itemsPerPage={itemsPerPage} pageNumber={currentPage} onTotalItemsChange={handleTotalItemsChange} + refreshKey={refreshKey} onVolunteerSelect={(volunteer) => { setSelectedVolunteer(volunteer); setIsSheetOpen(true); @@ -53,6 +55,10 @@ export default function Page() { volunteer={selectedVolunteer} isOpen={isSheetOpen} onClose={handleSheetClose} + onVolunteerUpdated={(volunteer) => { + setSelectedVolunteer(volunteer); + setRefreshKey((prev) => prev + 1); + }} />
); diff --git a/frontend/src/components/VolunteerProfileModal.module.css b/frontend/src/components/VolunteerProfileModal.module.css index 576e15db..955ebf7e 100644 --- a/frontend/src/components/VolunteerProfileModal.module.css +++ b/frontend/src/components/VolunteerProfileModal.module.css @@ -311,3 +311,31 @@ cursor: pointer; padding: 8px 12px; } + +.saveError { + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + color: #a40026; +} + +.saveButton { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 16px; + width: 100%; + border: none; + border-radius: 8px; + background: #1d3a6b; + color: #ffffff; + font-family: "Open Sans", sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.saveButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/components/VolunteerProfileModal.tsx b/frontend/src/components/VolunteerProfileModal.tsx index 316f58d0..98d25578 100644 --- a/frontend/src/components/VolunteerProfileModal.tsx +++ b/frontend/src/components/VolunteerProfileModal.tsx @@ -1,20 +1,117 @@ "use client"; import { Volunteer } from "../types/volunteer"; import styles from "./VolunteerProfileModal.module.css"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; interface VolunteerProfileModalProps { volunteer: Volunteer | null; isOpen: boolean; onClose: () => void; + onVolunteerUpdated?: (volunteer: Volunteer) => void; } +const STATUS_TAGS = ["Returner", "Expert", "New"]; +const VOLUNTEER_TYPE_TAGS = ["Intern", "Outside Volunteer"]; + export default function VolunteerProfileModal({ volunteer, isOpen, onClose, + onVolunteerUpdated, }: VolunteerProfileModalProps) { const [activeTab, setActiveTab] = useState("view"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [statusTags, setStatusTags] = useState([]); + const [typeTags, setTypeTags] = useState([]); + const [statusInput, setStatusInput] = useState(""); + const [typeInput, setTypeInput] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + + useEffect(() => { + if (!volunteer) return; + setFirstName(volunteer.firstName); + setLastName(volunteer.lastName); + setEmail(volunteer.email); + setPhoneNumber(volunteer.phoneNumber); + + const nextTypeTags = volunteer.tags.filter((tag) => VOLUNTEER_TYPE_TAGS.includes(tag)); + const nextStatusTags = volunteer.tags.filter((tag) => !VOLUNTEER_TYPE_TAGS.includes(tag)); + setTypeTags(nextTypeTags); + setStatusTags(nextStatusTags); + setStatusInput(""); + setTypeInput(""); + setSaveError(""); + }, [volunteer]); + + const combinedTags = useMemo(() => { + const merged = [...statusTags, ...typeTags]; + return merged.filter((tag, index) => merged.indexOf(tag) === index); + }, [statusTags, typeTags]); + + const handleAddStatusTag = () => { + const value = statusInput.trim(); + if (!value || statusTags.includes(value)) return; + setStatusTags((prev) => [...prev, value]); + setStatusInput(""); + }; + + const handleAddTypeTag = () => { + const value = typeInput.trim(); + if (!value || typeTags.includes(value)) return; + setTypeTags((prev) => [...prev, value]); + setTypeInput(""); + }; + + const handleRemoveStatusTag = (tag: string) => { + setStatusTags((prev) => prev.filter((value) => value !== tag)); + }; + + const handleRemoveTypeTag = (tag: string) => { + setTypeTags((prev) => prev.filter((value) => value !== tag)); + }; + + const handleSave = async () => { + if (!volunteer) return; + setIsSaving(true); + setSaveError(""); + try { + const response = await fetch(`http://localhost:4000/api/volunteer/${volunteer._id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + firstName, + lastName, + email, + phoneNumber, + tags: combinedTags, + }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + throw new Error(payload.error || "Failed to update volunteer"); + } + + const updated = (await response.json()) as Volunteer; + onVolunteerUpdated?.(updated); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Failed to update volunteer"); + } finally { + setIsSaving(false); + } + }; + + const getTagColorClass = (tag: string) => { + if (tag === "Outside Volunteer") return styles.tagOrange; + if (tag.includes("More")) return styles.tagGreen; + return styles.tagTeal; + }; if (!isOpen || !volunteer) return null; @@ -49,7 +146,154 @@ export default function VolunteerProfileModal({ {activeTab === "view" ? ( ) : ( - +
+
+ + setFirstName(event.target.value)} + /> +
+
+ + setLastName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPhoneNumber(event.target.value)} + /> +
+ +
+
+ Status + Tap x to Remove +
+
+ {statusTags.map((tag) => ( + + {tag} + + + ))} +
+
+
+ setStatusInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddStatusTag(); + } + }} + /> +
+ +
+
+ +
+
+ Volunteer Type + Tap x to Remove +
+
+ {typeTags.map((tag) => ( + + {tag} + + + ))} +
+
+
+ setTypeInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddTypeTag(); + } + }} + /> +
+ +
+
+ + {saveError ? {saveError} : null} + +
)} @@ -104,7 +348,3 @@ function ViewContent({ volunteer }: { volunteer: Volunteer }) { ); } - -function EditContent({ volunteer }: { volunteer: Volunteer }) { - return
temp
; -} diff --git a/frontend/src/components/VolunteerTable.tsx b/frontend/src/components/VolunteerTable.tsx index 84d2460e..9c657078 100644 --- a/frontend/src/components/VolunteerTable.tsx +++ b/frontend/src/components/VolunteerTable.tsx @@ -8,6 +8,7 @@ interface VolunteerTableProps { pageNumber: number; onTotalItemsChange: (total: number) => void; onVolunteerSelect?: (volunteer: Volunteer) => void; + refreshKey?: number; } export default function VolunteerTable({ @@ -15,6 +16,7 @@ export default function VolunteerTable({ pageNumber, onTotalItemsChange, onVolunteerSelect, + refreshKey, }: VolunteerTableProps) { const [volunteers, setVolunteers] = useState([]); @@ -33,7 +35,7 @@ export default function VolunteerTable({ } fetchVolunteers(); - }, [onTotalItemsChange]); + }, [onTotalItemsChange, refreshKey]); const startIndex = (pageNumber - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; From 5172869fcfa2eff1a4e0627fa2fd59b2e2056ad9 Mon Sep 17 00:00:00 2001 From: siwenshao <146389391+siwenshao@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:27:27 -0800 Subject: [PATCH 3/6] adjusted UI for phone view according to figma and updated volunteer tag disply in user profile. Some are temporary as they are based on other PRs and need to be updated later --- .secret-scan/secret-scan-cache.json | 2 +- .../src/controllers/volunteerController.ts | 40 +- backend/src/models/volunteerModel.ts | 16 + backend/src/validators/volunteerValidator.ts | 12 + .../VolunteerProfileModal.module.css | 122 +++++ .../src/components/VolunteerProfileModal.tsx | 512 +++++++++++------- frontend/src/types/volunteer.ts | 4 + 7 files changed, 509 insertions(+), 199 deletions(-) diff --git a/.secret-scan/secret-scan-cache.json b/.secret-scan/secret-scan-cache.json index e3d673ef..41deea5b 100644 --- a/.secret-scan/secret-scan-cache.json +++ b/.secret-scan/secret-scan-cache.json @@ -1 +1 @@ -{"config":{"allowedStrings":["mongodb://127.0.0.1","mongodb://localhost"],"secretRegexes":{"mongodbUrl":"mongodb([+]srv)?://[^\\s]+","firebaseJsonPrivateKeyFile":"-----BEGIN PRIVATE KEY-----[^\\s]+"},"skippedFiles":[".secret-scan/secret-scan-config.json",".secret-scan/secret-scan-cache.json"]},"script":"const child_process = require(\"node:child_process\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst process = require(\"node:process\");\nconst util = require(\"node:util\");\nconst tty = require(\"node:tty\");\n\nconst CACHE_PATH = path.join(__dirname, \"secret-scan-cache.json\");\nconst CONFIG_PATH = path.join(__dirname, \"secret-scan-config.json\");\nconst REPORT_PATH = path.join(__dirname, \"secret-scan-report.json\");\nconst JSON_ENCODING = \"utf8\";\n\nconst EOL = /\\r?\\n/;\n\nconst secretRemovalAdvice = `\n1. If you are absolutely confident that the reported\n secrets are not actually secrets, see\n ${CONFIG_PATH}\n for next steps and try again. Ask your engineering\n manager or VP Technology if you have any uncertainty\n whatsoever.\n\n2. If the secrets are in a file in the working tree, and\n this file should not be committed to Git, update your\n .gitignore and try again.\n\n3. If the secrets are in the index, unstage them with\n git restore --staged and try again.\n\n4. If the secrets are in an existing commit, you are\n REQUIRED to report this to your engineering manager AND\n VP Technology, even if you are sure that the commit was\n never pushed. This is because a secret being committed\n anywhere (even locally) indicates a potential issue with\n the implementation or configuration of this secret\n scanning tool.\n\n If the commit was pushed, assume that the secret is now\n publicly known, and revoke it as soon as possible.\n\n Proper secret management is an important part of building\n secure software for our clients. You'll never be punished\n for reporting a problem; please err on the side of\n letting us know if you're unsure. If something goes wrong\n and is reported promptly, our policy is that the\n responsibility belongs to the processes and tooling, not\n the individual.\n`.trim();\n\n/**\n * @param {string} text\n * @returns {string}\n */\nfunction redText(text) {\n if (process.stdout.isTTY && process.stdout.hasColors()) {\n // https://github.com/nodejs/node/issues/42770#issuecomment-1101093517\n const red = util.inspect.colors.red;\n if (red !== undefined) {\n return `\\u001b[${red[0]}m` + text + `\\u001b[${red[1]}m`;\n }\n }\n return text;\n}\n\n/**\n * @param {string} filePath\n * @returns {unknown}\n */\nfunction parseJSONFromFile(filePath) {\n const text = fs.readFileSync(filePath, { encoding: JSON_ENCODING });\n return JSON.parse(text);\n}\n\n/**\n * @template T\n * @param {() => T} callback\n * @returns {T | null}\n */\nfunction nullIfFileNotFound(callback) {\n try {\n return callback();\n } catch (e) {\n if (typeof e === \"object\" && e !== null && \"code\" in e && (e.code === \"ENOENT\" || e.code === \"EISDIR\")) {\n return null;\n }\n throw e;\n }\n}\n\n/**\n * @param {unknown} array\n * @returns {string[]}\n */\nfunction asStringArray(array) {\n if (!Array.isArray(array)) {\n throw new Error(`Not a string array: ${JSON.stringify(array)}`);\n }\n\n return array.map((s) => {\n if (typeof s === \"string\") {\n return s;\n }\n throw new Error(`Not a string: ${JSON.stringify(s)}`);\n });\n}\n\n/**\n * @typedef {{\n * allowedStrings: string[];\n * secretRegexes: Record;\n * skippedFiles: string[];\n * }} SecretScanConfig\n */\n\n/**\n * @returns {SecretScanConfig}\n */\nfunction loadConfig() {\n const parsed = parseJSONFromFile(CONFIG_PATH);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"allowedStrings\" in parsed &&\n \"secretRegexes\" in parsed &&\n \"skippedFiles\" in parsed &&\n typeof parsed.secretRegexes === \"object\" &&\n parsed.secretRegexes !== null\n ) {\n const secretRegexes = Object.fromEntries(\n Object.entries(parsed.secretRegexes).map(([k, v]) => {\n if (typeof v !== \"string\") {\n throw new Error(`Not a string: ${JSON.stringify(v)}`);\n }\n return [k, v];\n })\n );\n\n return {\n allowedStrings: asStringArray(parsed.allowedStrings),\n secretRegexes,\n skippedFiles: asStringArray(parsed.skippedFiles),\n };\n }\n throw new Error(\"Config format is invalid.\");\n}\n\n/**\n * @typedef {{\n * config: unknown;\n * script: string;\n * safeCommitHashes: string[];\n * }} SecretScanCache\n */\n\n/** @returns {SecretScanCache | null} */\nfunction loadCache() {\n return nullIfFileNotFound(() => {\n const parsed = parseJSONFromFile(CACHE_PATH);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"config\" in parsed &&\n \"script\" in parsed &&\n typeof parsed.script === \"string\" &&\n \"safeCommitHashes\" in parsed\n ) {\n return {\n config: parsed.config,\n script: parsed.script,\n safeCommitHashes: asStringArray(parsed.safeCommitHashes),\n };\n } else {\n console.log(\"Cache format is invalid, so it will not be used.\");\n return null;\n }\n });\n}\n\n/**\n * @param {SecretScanCache} cache\n * @returns {void}\n */\nfunction saveCache(cache) {\n fs.writeFileSync(CACHE_PATH, JSON.stringify(cache), { encoding: JSON_ENCODING });\n}\n\nfunction deleteReport() {\n if (fs.statSync(REPORT_PATH, { throwIfNoEntry: false })?.isFile()) {\n fs.unlinkSync(REPORT_PATH);\n }\n}\n\n/**\n * @typedef {{\n * where: string;\n * path: string;\n * line: number;\n * regexName: string;\n * matchedText: string;\n * }[]} SecretScanReport\n */\n\n/**\n * @param {SecretScanReport} report\n */\nfunction saveReport(report) {\n fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2) + \"\\n\", {\n encoding: JSON_ENCODING,\n });\n}\n\n/**\n * @param {readonly [string, ...string[]]} command\n * @returns {string}\n */\nfunction runCommand(command) {\n const process = child_process.spawnSync(command[0], command.slice(1), {\n cwd: __dirname,\n encoding: \"utf8\",\n maxBuffer: Infinity,\n });\n\n if (process.status === 0) {\n return process.stdout;\n }\n\n console.log(process);\n throw new Error(`Command did not execute successfully: ${JSON.stringify(command)}`);\n}\n\n/**\n * @param {string} text\n * @returns {string[]}\n */\nfunction nonEmptyLines(text) {\n return text.split(EOL).filter((line) => line.length > 0);\n}\n\n/** @returns {void} */\nfunction checkGitVersion() {\n const command = [\"git\", \"--version\"];\n\n let output;\n try {\n output = runCommand([\"git\", \"--version\"]);\n } catch (e) {\n console.log(e);\n const msg =\n \"Could not run git from the command line. Make sure it is installed and on your PATH, and try again.\";\n throw new Error(msg);\n }\n\n const expectedPrefix = \"git version \";\n if (!output.startsWith(expectedPrefix)) {\n const msg = `Output of command ${JSON.stringify(\n command\n )} did not start with expected prefix ${JSON.stringify(\n expectedPrefix\n )}. Maybe the text encoding for child process output is not utf8 in this environment?`;\n throw new Error(msg);\n }\n}\n\n/** @returns {string} */\nfunction getRepoRoot() {\n const repoRoot = runCommand([\"git\", \"rev-parse\", \"--show-toplevel\"]).replace(EOL, \"\");\n\n // Make sure we don't get \"file not found\" later and assume the file was\n // deleted from the working tree, when the actual cause is having an incorrect\n // path for the repo root. Don't ask me how I know...\n if (!fs.statSync(path.join(repoRoot, \".git\"), { throwIfNoEntry: false })?.isDirectory()) {\n throw new Error(\n `Could not determine repo root: got ${JSON.stringify(repoRoot)}, but this is incorrect?`\n );\n }\n\n return repoRoot;\n}\n\n/** @returns {number} */\nfunction checkGitHooks() {\n checkGitVersion();\n\n const expectedHooksPath = \".husky/_\";\n const command = /** @type {const} */ ([\"git\", \"config\", \"--get\", \"core.hooksPath\"]);\n const helpMsg = `Husky has not installed the required Git hooks. Run \"npm run prepare\" and try again.`;\n\n let hooksPath;\n try {\n hooksPath = runCommand(command).replace(EOL, \"\");\n } catch (e) {\n console.log(e);\n throw new Error(helpMsg);\n }\n\n if (hooksPath !== expectedHooksPath) {\n const msg = [\n `Command ${JSON.stringify(command)} returned ${JSON.stringify(\n hooksPath\n )}, expected ${JSON.stringify(expectedHooksPath)}.`,\n helpMsg,\n ].join(\"\\n\\n\");\n throw new Error(msg);\n }\n\n console.log(`Git hooks are correctly installed in ${JSON.stringify(expectedHooksPath)}.`)\n return 0;\n}\n\n/** @returns {number} */\nfunction runSecretScan() {\n const shouldSaveReport = process.env[\"SECRET_SCAN_WRITE_REPORT\"] === \"1\";\n\n deleteReport();\n\n /**\n * @type {SecretScanReport}\n */\n const report = [];\n\n console.log(`${__filename}: Scanning commit history and working tree for secrets.`);\n\n checkGitVersion();\n const repoRoot = getRepoRoot();\n\n const config = loadConfig();\n const script = fs.readFileSync(__filename, { encoding: \"utf8\" });\n\n let loadedCache = loadCache();\n if (loadedCache !== null) {\n if (JSON.stringify(config) !== JSON.stringify(loadedCache.config)) {\n console.log(\"Invalidating cache because config has changed.\");\n loadedCache = null;\n } else if (script !== loadedCache.script) {\n console.log(\"Invalidating cache because script has changed.\");\n loadedCache = null;\n }\n }\n\n /** @type {SecretScanCache} */\n const cache = loadedCache ?? {\n config: JSON.parse(JSON.stringify(config)),\n script,\n safeCommitHashes: [],\n };\n\n const previouslyScannedCommitHashes = new Set(cache.safeCommitHashes);\n const filesToSkip = new Set(config.skippedFiles);\n const secretRegexes = Object.fromEntries(\n Object.entries(config.secretRegexes).map(([k, v]) => [k, new RegExp(v, \"g\")])\n );\n\n /** @param {string} matchedText */\n function isFalsePositive(matchedText) {\n return config.allowedStrings.some((allowed) => matchedText.includes(allowed));\n }\n\n /**\n * Scan the commit with the given hash, or null to scan the index and working\n * tree.\n *\n * @param {string | null} maybeCommitHash\n * @returns {void}\n */\n function scan(maybeCommitHash) {\n /** @type {{ path: string; where: string; contents: string; }[]} */\n const changedFiles = [];\n\n // Don't try to read deleted files. If you ever get an error message like\n // \"unknown revision or path not in the working tree\", double check this.\n const gitListFileOptions = [\"--no-renames\", \"--diff-filter=d\", \"--name-only\"];\n\n if (maybeCommitHash === null) {\n const workingTreePaths = nonEmptyLines(runCommand([\"git\", \"status\", \"--porcelain\"])).map(\n (line) => line.slice(3)\n );\n for (const workingTreePath of workingTreePaths) {\n // If the file was deleted, we can ignore it. I was a bit too lazy to\n // parse the status letters of `git status --porcelain`.\n let contents = nullIfFileNotFound(() =>\n fs.readFileSync(path.join(repoRoot, workingTreePath), { encoding: \"utf8\" })\n );\n\n if (contents !== null) {\n changedFiles.push({\n path: workingTreePath,\n where: \"working tree\",\n contents,\n });\n }\n }\n\n const stagedPaths = nonEmptyLines(\n runCommand([\"git\", \"diff\", \"--staged\", ...gitListFileOptions])\n );\n for (const stagedPath of stagedPaths) {\n changedFiles.push({\n path: stagedPath,\n where: \"index\",\n contents: runCommand([\"git\", \"show\", \":\" + stagedPath]),\n });\n }\n } else {\n const [commitDescription, ...changedPaths] = nonEmptyLines(\n runCommand([\"git\", \"show\", \"--oneline\", ...gitListFileOptions, maybeCommitHash])\n );\n const where = `commit ${JSON.stringify(commitDescription)}`;\n for (const changedPath of changedPaths) {\n changedFiles.push({\n path: changedPath,\n where,\n contents: runCommand([\"git\", \"show\", `${maybeCommitHash}:${changedPath}`]),\n });\n }\n }\n\n let secretDetected = false;\n for (const { path, where, contents } of changedFiles) {\n if (filesToSkip.has(path)) {\n continue;\n }\n\n for (const [regexName, regex] of Object.entries(secretRegexes)) {\n for (const match of contents.matchAll(regex)) {\n const matchedText = match[0];\n if (isFalsePositive(matchedText)) {\n continue;\n }\n\n const line = contents.substring(0, match.index).split(\"\\n\").length;\n\n secretDetected = true;\n report.push({\n where,\n path,\n line,\n regexName,\n matchedText,\n });\n\n console.log(\n `SECRET DETECTED in ${where}, file ${JSON.stringify(\n path\n )}, line ${line}: regex ${regexName} (${regex}) matched text ${JSON.stringify(\n matchedText\n )}`\n );\n }\n }\n }\n\n if (!secretDetected && maybeCommitHash !== null) {\n cache.safeCommitHashes.push(maybeCommitHash);\n }\n }\n\n // Scan every commit.\n const allCommitHashes = nonEmptyLines(runCommand([\"git\", \"log\", \"--pretty=format:%H\"]));\n for (const hash of allCommitHashes) {\n if (!previouslyScannedCommitHashes.has(hash)) {\n scan(hash);\n }\n }\n\n // Scan the index and working tree.\n scan(null);\n\n if (shouldSaveReport) {\n saveReport(report);\n console.log(`Report written to ${JSON.stringify(REPORT_PATH)}`);\n }\n\n saveCache(cache);\n\n if (report.length > 0) {\n console.log(redText(`Secret scan completed with errors.\\n\\n${secretRemovalAdvice}\\n`));\n return 1;\n } else {\n console.log(\"Secret scan completed successfully.\");\n return 0;\n }\n}\n\n/** @returns {number} */\nfunction main() {\n let args = process.argv.slice(2);\n if (args[0] === \"--\") {\n args = args.slice(1);\n }\n\n switch (JSON.stringify(args)) {\n case JSON.stringify([]):\n return runSecretScan();\n case JSON.stringify([\"--check-git-hooks\"]):\n return checkGitHooks();\n default:\n const msg = `Invalid command-line arguments: ${JSON.stringify(args)}`;\n throw new Error(msg);\n }\n}\n\nprocess.exit(main());\n","safeCommitHashes":["d806f1123b42dc7e08a652e8eee477b666530dde","aab8638304d928b6b3f71da2f9dd09fe1de9aca6","8b37e17409f5b29a218c7fc424f53b4651bd6814","cf86292e505a2b9802312d95a5ebf2ad79fd9980","7d7551ebc114b8e032446578f2716b7d5e0acc07","93f7f2c87848844685375e2329582eeb87405baa","2623a9817337678ff5e94ed8fe42033e23f80c98","c0999c368ff4b784399716602fe997ba9261cd17","4f03fb92e250b3aaf27246658721cda0d035129b","5c868f685da73eb4262d299cbf08e40ac426502f","c9a897913463fb9f4ffe0dc095b3e050fe2a72c2","d044e4cbb94541b4e1e3892c4a09005b294df7ea","adea539b3b578b051da946cf71d58fe57d430600","2712498f316b35b4cf1ad9dd9c8c81d25b4eba82","9155ddc34a9e29195ffc43e1308a78a55e1337d0","132372adc76035422ae4159324e5aac48a377505","db7c200d6c3a997adc8a4e5ffc5664bff1061fd2","cf2e854235bded3862b36949c0d4202e8f81fb37","c9fd569d8f3fb20aafe7a4316a3955f20231081d","7906022f80bca50d8b263b8f29f553a1e2e9d7a3","c7e1975cd9ca7ef7bc6195f6fffcc5e7bc1f8535","0935e6413496c410bba96226cf2be46dd0c211c9","b914b4cb5aa6df81e61f1ce64d29cf0a3d2aa87e","df45f0922fcd8d8a47dc0a7e80bebddb69617850","ff0b4ebedd9ce3e6800c6b212cf7b9df1868548c","9932deeba70b6344854a1c8ecc3b10e793f89bf8","8da3d242633b353a6d5f42fc6d54c594ac942d37","d89bdc7cef4e1bc357703f87a7fa26fde3d41e0d","a416af82f0675725308f8f55a4ff8ff1f2ba86e3","0debf2df5c359f41cfd87176efc234d7bc7fbdce","c6c937c9f06e3ad9f78c0693392cc8d847c0f4c0","8ff7f7f4e22835cf8dab941b20f7721c759c4506","5c7798d81f7a05fbbae6c6f3bd99f7e4f52c6ba4","cef31ca620070a0f4ff995edae75a53efaaffb73","bcab5100cd13897b4c3d387d2259d1c8468c52c3","050681269920e90ab396d8e877d26d0f3dca89df","511f5f9dacd1400e1215585bf63bb52adda929aa","9fde14e3afb53da81a37ede80c756b3870b5b82d","5881c54a4cd8ffe6e11039798387b0c188fc9976","85e13a6b062c5644031bf38ce8113af99c73a6fd","9b57307693cc87f10d0c0a85d4c2ca7337e718f6","3f39f569975dd68bbdcb015febe7ef270ff799ca","1638cddc217c7466f5a43411860176653c7a15c3","287bb523849387f1503731cf0b34751b04e67915"]} \ No newline at end of file +{"config":{"allowedStrings":["mongodb://127.0.0.1","mongodb://localhost"],"secretRegexes":{"mongodbUrl":"mongodb([+]srv)?://[^\\s]+","firebaseJsonPrivateKeyFile":"-----BEGIN PRIVATE KEY-----[^\\s]+"},"skippedFiles":[".secret-scan/secret-scan-config.json",".secret-scan/secret-scan-cache.json"]},"script":"const child_process = require(\"node:child_process\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst process = require(\"node:process\");\nconst util = require(\"node:util\");\nconst tty = require(\"node:tty\");\n\nconst CACHE_PATH = path.join(__dirname, \"secret-scan-cache.json\");\nconst CONFIG_PATH = path.join(__dirname, \"secret-scan-config.json\");\nconst REPORT_PATH = path.join(__dirname, \"secret-scan-report.json\");\nconst JSON_ENCODING = \"utf8\";\n\nconst EOL = /\\r?\\n/;\n\nconst secretRemovalAdvice = `\n1. If you are absolutely confident that the reported\n secrets are not actually secrets, see\n ${CONFIG_PATH}\n for next steps and try again. Ask your engineering\n manager or VP Technology if you have any uncertainty\n whatsoever.\n\n2. If the secrets are in a file in the working tree, and\n this file should not be committed to Git, update your\n .gitignore and try again.\n\n3. If the secrets are in the index, unstage them with\n git restore --staged and try again.\n\n4. If the secrets are in an existing commit, you are\n REQUIRED to report this to your engineering manager AND\n VP Technology, even if you are sure that the commit was\n never pushed. This is because a secret being committed\n anywhere (even locally) indicates a potential issue with\n the implementation or configuration of this secret\n scanning tool.\n\n If the commit was pushed, assume that the secret is now\n publicly known, and revoke it as soon as possible.\n\n Proper secret management is an important part of building\n secure software for our clients. You'll never be punished\n for reporting a problem; please err on the side of\n letting us know if you're unsure. If something goes wrong\n and is reported promptly, our policy is that the\n responsibility belongs to the processes and tooling, not\n the individual.\n`.trim();\n\n/**\n * @param {string} text\n * @returns {string}\n */\nfunction redText(text) {\n if (process.stdout.isTTY && process.stdout.hasColors()) {\n // https://github.com/nodejs/node/issues/42770#issuecomment-1101093517\n const red = util.inspect.colors.red;\n if (red !== undefined) {\n return `\\u001b[${red[0]}m` + text + `\\u001b[${red[1]}m`;\n }\n }\n return text;\n}\n\n/**\n * @param {string} filePath\n * @returns {unknown}\n */\nfunction parseJSONFromFile(filePath) {\n const text = fs.readFileSync(filePath, { encoding: JSON_ENCODING });\n return JSON.parse(text);\n}\n\n/**\n * @template T\n * @param {() => T} callback\n * @returns {T | null}\n */\nfunction nullIfFileNotFound(callback) {\n try {\n return callback();\n } catch (e) {\n if (typeof e === \"object\" && e !== null && \"code\" in e && (e.code === \"ENOENT\" || e.code === \"EISDIR\")) {\n return null;\n }\n throw e;\n }\n}\n\n/**\n * @param {unknown} array\n * @returns {string[]}\n */\nfunction asStringArray(array) {\n if (!Array.isArray(array)) {\n throw new Error(`Not a string array: ${JSON.stringify(array)}`);\n }\n\n return array.map((s) => {\n if (typeof s === \"string\") {\n return s;\n }\n throw new Error(`Not a string: ${JSON.stringify(s)}`);\n });\n}\n\n/**\n * @typedef {{\n * allowedStrings: string[];\n * secretRegexes: Record;\n * skippedFiles: string[];\n * }} SecretScanConfig\n */\n\n/**\n * @returns {SecretScanConfig}\n */\nfunction loadConfig() {\n const parsed = parseJSONFromFile(CONFIG_PATH);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"allowedStrings\" in parsed &&\n \"secretRegexes\" in parsed &&\n \"skippedFiles\" in parsed &&\n typeof parsed.secretRegexes === \"object\" &&\n parsed.secretRegexes !== null\n ) {\n const secretRegexes = Object.fromEntries(\n Object.entries(parsed.secretRegexes).map(([k, v]) => {\n if (typeof v !== \"string\") {\n throw new Error(`Not a string: ${JSON.stringify(v)}`);\n }\n return [k, v];\n })\n );\n\n return {\n allowedStrings: asStringArray(parsed.allowedStrings),\n secretRegexes,\n skippedFiles: asStringArray(parsed.skippedFiles),\n };\n }\n throw new Error(\"Config format is invalid.\");\n}\n\n/**\n * @typedef {{\n * config: unknown;\n * script: string;\n * safeCommitHashes: string[];\n * }} SecretScanCache\n */\n\n/** @returns {SecretScanCache | null} */\nfunction loadCache() {\n return nullIfFileNotFound(() => {\n const parsed = parseJSONFromFile(CACHE_PATH);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"config\" in parsed &&\n \"script\" in parsed &&\n typeof parsed.script === \"string\" &&\n \"safeCommitHashes\" in parsed\n ) {\n return {\n config: parsed.config,\n script: parsed.script,\n safeCommitHashes: asStringArray(parsed.safeCommitHashes),\n };\n } else {\n console.log(\"Cache format is invalid, so it will not be used.\");\n return null;\n }\n });\n}\n\n/**\n * @param {SecretScanCache} cache\n * @returns {void}\n */\nfunction saveCache(cache) {\n fs.writeFileSync(CACHE_PATH, JSON.stringify(cache), { encoding: JSON_ENCODING });\n}\n\nfunction deleteReport() {\n if (fs.statSync(REPORT_PATH, { throwIfNoEntry: false })?.isFile()) {\n fs.unlinkSync(REPORT_PATH);\n }\n}\n\n/**\n * @typedef {{\n * where: string;\n * path: string;\n * line: number;\n * regexName: string;\n * matchedText: string;\n * }[]} SecretScanReport\n */\n\n/**\n * @param {SecretScanReport} report\n */\nfunction saveReport(report) {\n fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2) + \"\\n\", {\n encoding: JSON_ENCODING,\n });\n}\n\n/**\n * @param {readonly [string, ...string[]]} command\n * @returns {string}\n */\nfunction runCommand(command) {\n const process = child_process.spawnSync(command[0], command.slice(1), {\n cwd: __dirname,\n encoding: \"utf8\",\n maxBuffer: Infinity,\n });\n\n if (process.status === 0) {\n return process.stdout;\n }\n\n console.log(process);\n throw new Error(`Command did not execute successfully: ${JSON.stringify(command)}`);\n}\n\n/**\n * @param {string} text\n * @returns {string[]}\n */\nfunction nonEmptyLines(text) {\n return text.split(EOL).filter((line) => line.length > 0);\n}\n\n/** @returns {void} */\nfunction checkGitVersion() {\n const command = [\"git\", \"--version\"];\n\n let output;\n try {\n output = runCommand([\"git\", \"--version\"]);\n } catch (e) {\n console.log(e);\n const msg =\n \"Could not run git from the command line. Make sure it is installed and on your PATH, and try again.\";\n throw new Error(msg);\n }\n\n const expectedPrefix = \"git version \";\n if (!output.startsWith(expectedPrefix)) {\n const msg = `Output of command ${JSON.stringify(\n command\n )} did not start with expected prefix ${JSON.stringify(\n expectedPrefix\n )}. Maybe the text encoding for child process output is not utf8 in this environment?`;\n throw new Error(msg);\n }\n}\n\n/** @returns {string} */\nfunction getRepoRoot() {\n const repoRoot = runCommand([\"git\", \"rev-parse\", \"--show-toplevel\"]).replace(EOL, \"\");\n\n // Make sure we don't get \"file not found\" later and assume the file was\n // deleted from the working tree, when the actual cause is having an incorrect\n // path for the repo root. Don't ask me how I know...\n if (!fs.statSync(path.join(repoRoot, \".git\"), { throwIfNoEntry: false })?.isDirectory()) {\n throw new Error(\n `Could not determine repo root: got ${JSON.stringify(repoRoot)}, but this is incorrect?`\n );\n }\n\n return repoRoot;\n}\n\n/** @returns {number} */\nfunction checkGitHooks() {\n checkGitVersion();\n\n const expectedHooksPath = \".husky/_\";\n const command = /** @type {const} */ ([\"git\", \"config\", \"--get\", \"core.hooksPath\"]);\n const helpMsg = `Husky has not installed the required Git hooks. Run \"npm run prepare\" and try again.`;\n\n let hooksPath;\n try {\n hooksPath = runCommand(command).replace(EOL, \"\");\n } catch (e) {\n console.log(e);\n throw new Error(helpMsg);\n }\n\n if (hooksPath !== expectedHooksPath) {\n const msg = [\n `Command ${JSON.stringify(command)} returned ${JSON.stringify(\n hooksPath\n )}, expected ${JSON.stringify(expectedHooksPath)}.`,\n helpMsg,\n ].join(\"\\n\\n\");\n throw new Error(msg);\n }\n\n console.log(`Git hooks are correctly installed in ${JSON.stringify(expectedHooksPath)}.`)\n return 0;\n}\n\n/** @returns {number} */\nfunction runSecretScan() {\n const shouldSaveReport = process.env[\"SECRET_SCAN_WRITE_REPORT\"] === \"1\";\n\n deleteReport();\n\n /**\n * @type {SecretScanReport}\n */\n const report = [];\n\n console.log(`${__filename}: Scanning commit history and working tree for secrets.`);\n\n checkGitVersion();\n const repoRoot = getRepoRoot();\n\n const config = loadConfig();\n const script = fs.readFileSync(__filename, { encoding: \"utf8\" });\n\n let loadedCache = loadCache();\n if (loadedCache !== null) {\n if (JSON.stringify(config) !== JSON.stringify(loadedCache.config)) {\n console.log(\"Invalidating cache because config has changed.\");\n loadedCache = null;\n } else if (script !== loadedCache.script) {\n console.log(\"Invalidating cache because script has changed.\");\n loadedCache = null;\n }\n }\n\n /** @type {SecretScanCache} */\n const cache = loadedCache ?? {\n config: JSON.parse(JSON.stringify(config)),\n script,\n safeCommitHashes: [],\n };\n\n const previouslyScannedCommitHashes = new Set(cache.safeCommitHashes);\n const filesToSkip = new Set(config.skippedFiles);\n const secretRegexes = Object.fromEntries(\n Object.entries(config.secretRegexes).map(([k, v]) => [k, new RegExp(v, \"g\")])\n );\n\n /** @param {string} matchedText */\n function isFalsePositive(matchedText) {\n return config.allowedStrings.some((allowed) => matchedText.includes(allowed));\n }\n\n /**\n * Scan the commit with the given hash, or null to scan the index and working\n * tree.\n *\n * @param {string | null} maybeCommitHash\n * @returns {void}\n */\n function scan(maybeCommitHash) {\n /** @type {{ path: string; where: string; contents: string; }[]} */\n const changedFiles = [];\n\n // Don't try to read deleted files. If you ever get an error message like\n // \"unknown revision or path not in the working tree\", double check this.\n const gitListFileOptions = [\"--no-renames\", \"--diff-filter=d\", \"--name-only\"];\n\n if (maybeCommitHash === null) {\n const workingTreePaths = nonEmptyLines(runCommand([\"git\", \"status\", \"--porcelain\"])).map(\n (line) => line.slice(3)\n );\n for (const workingTreePath of workingTreePaths) {\n // If the file was deleted, we can ignore it. I was a bit too lazy to\n // parse the status letters of `git status --porcelain`.\n let contents = nullIfFileNotFound(() =>\n fs.readFileSync(path.join(repoRoot, workingTreePath), { encoding: \"utf8\" })\n );\n\n if (contents !== null) {\n changedFiles.push({\n path: workingTreePath,\n where: \"working tree\",\n contents,\n });\n }\n }\n\n const stagedPaths = nonEmptyLines(\n runCommand([\"git\", \"diff\", \"--staged\", ...gitListFileOptions])\n );\n for (const stagedPath of stagedPaths) {\n changedFiles.push({\n path: stagedPath,\n where: \"index\",\n contents: runCommand([\"git\", \"show\", \":\" + stagedPath]),\n });\n }\n } else {\n const [commitDescription, ...changedPaths] = nonEmptyLines(\n runCommand([\"git\", \"show\", \"--oneline\", ...gitListFileOptions, maybeCommitHash])\n );\n const where = `commit ${JSON.stringify(commitDescription)}`;\n for (const changedPath of changedPaths) {\n changedFiles.push({\n path: changedPath,\n where,\n contents: runCommand([\"git\", \"show\", `${maybeCommitHash}:${changedPath}`]),\n });\n }\n }\n\n let secretDetected = false;\n for (const { path, where, contents } of changedFiles) {\n if (filesToSkip.has(path)) {\n continue;\n }\n\n for (const [regexName, regex] of Object.entries(secretRegexes)) {\n for (const match of contents.matchAll(regex)) {\n const matchedText = match[0];\n if (isFalsePositive(matchedText)) {\n continue;\n }\n\n const line = contents.substring(0, match.index).split(\"\\n\").length;\n\n secretDetected = true;\n report.push({\n where,\n path,\n line,\n regexName,\n matchedText,\n });\n\n console.log(\n `SECRET DETECTED in ${where}, file ${JSON.stringify(\n path\n )}, line ${line}: regex ${regexName} (${regex}) matched text ${JSON.stringify(\n matchedText\n )}`\n );\n }\n }\n }\n\n if (!secretDetected && maybeCommitHash !== null) {\n cache.safeCommitHashes.push(maybeCommitHash);\n }\n }\n\n // Scan every commit.\n const allCommitHashes = nonEmptyLines(runCommand([\"git\", \"log\", \"--pretty=format:%H\"]));\n for (const hash of allCommitHashes) {\n if (!previouslyScannedCommitHashes.has(hash)) {\n scan(hash);\n }\n }\n\n // Scan the index and working tree.\n scan(null);\n\n if (shouldSaveReport) {\n saveReport(report);\n console.log(`Report written to ${JSON.stringify(REPORT_PATH)}`);\n }\n\n saveCache(cache);\n\n if (report.length > 0) {\n console.log(redText(`Secret scan completed with errors.\\n\\n${secretRemovalAdvice}\\n`));\n return 1;\n } else {\n console.log(\"Secret scan completed successfully.\");\n return 0;\n }\n}\n\n/** @returns {number} */\nfunction main() {\n let args = process.argv.slice(2);\n if (args[0] === \"--\") {\n args = args.slice(1);\n }\n\n switch (JSON.stringify(args)) {\n case JSON.stringify([]):\n return runSecretScan();\n case JSON.stringify([\"--check-git-hooks\"]):\n return checkGitHooks();\n default:\n const msg = `Invalid command-line arguments: ${JSON.stringify(args)}`;\n throw new Error(msg);\n }\n}\n\nprocess.exit(main());\n","safeCommitHashes":["d806f1123b42dc7e08a652e8eee477b666530dde","aab8638304d928b6b3f71da2f9dd09fe1de9aca6","8b37e17409f5b29a218c7fc424f53b4651bd6814","cf86292e505a2b9802312d95a5ebf2ad79fd9980","7d7551ebc114b8e032446578f2716b7d5e0acc07","93f7f2c87848844685375e2329582eeb87405baa","2623a9817337678ff5e94ed8fe42033e23f80c98","c0999c368ff4b784399716602fe997ba9261cd17","4f03fb92e250b3aaf27246658721cda0d035129b","5c868f685da73eb4262d299cbf08e40ac426502f","c9a897913463fb9f4ffe0dc095b3e050fe2a72c2","d044e4cbb94541b4e1e3892c4a09005b294df7ea","adea539b3b578b051da946cf71d58fe57d430600","2712498f316b35b4cf1ad9dd9c8c81d25b4eba82","9155ddc34a9e29195ffc43e1308a78a55e1337d0","132372adc76035422ae4159324e5aac48a377505","db7c200d6c3a997adc8a4e5ffc5664bff1061fd2","cf2e854235bded3862b36949c0d4202e8f81fb37","c9fd569d8f3fb20aafe7a4316a3955f20231081d","7906022f80bca50d8b263b8f29f553a1e2e9d7a3","c7e1975cd9ca7ef7bc6195f6fffcc5e7bc1f8535","0935e6413496c410bba96226cf2be46dd0c211c9","b914b4cb5aa6df81e61f1ce64d29cf0a3d2aa87e","df45f0922fcd8d8a47dc0a7e80bebddb69617850","ff0b4ebedd9ce3e6800c6b212cf7b9df1868548c","9932deeba70b6344854a1c8ecc3b10e793f89bf8","8da3d242633b353a6d5f42fc6d54c594ac942d37","d89bdc7cef4e1bc357703f87a7fa26fde3d41e0d","a416af82f0675725308f8f55a4ff8ff1f2ba86e3","0debf2df5c359f41cfd87176efc234d7bc7fbdce","c6c937c9f06e3ad9f78c0693392cc8d847c0f4c0","8ff7f7f4e22835cf8dab941b20f7721c759c4506","5c7798d81f7a05fbbae6c6f3bd99f7e4f52c6ba4","cef31ca620070a0f4ff995edae75a53efaaffb73","bcab5100cd13897b4c3d387d2259d1c8468c52c3","050681269920e90ab396d8e877d26d0f3dca89df","511f5f9dacd1400e1215585bf63bb52adda929aa","9fde14e3afb53da81a37ede80c756b3870b5b82d","5881c54a4cd8ffe6e11039798387b0c188fc9976","85e13a6b062c5644031bf38ce8113af99c73a6fd","9b57307693cc87f10d0c0a85d4c2ca7337e718f6","3f39f569975dd68bbdcb015febe7ef270ff799ca","1638cddc217c7466f5a43411860176653c7a15c3","287bb523849387f1503731cf0b34751b04e67915","ea5e3503548be6950f74617a96307c9a13f1a8ae","0037628e84f138064be15b65919a6966652225ba","a182337a29a59a54513f88ca9f639c3f58d287f1"]} \ No newline at end of file diff --git a/backend/src/controllers/volunteerController.ts b/backend/src/controllers/volunteerController.ts index 335cbec8..da693290 100644 --- a/backend/src/controllers/volunteerController.ts +++ b/backend/src/controllers/volunteerController.ts @@ -85,11 +85,25 @@ type CreateVolunteerBody = { email: string; phoneNumber: string; tags?: string[]; + statusTags?: string[]; + volunteerTypeTags?: string[]; + events?: string[]; + additionalNotes?: string; }; export const createVolunteer: RequestHandler = async (req, res, next) => { const errors = validationResult(req); - const { firstName, lastName, email, phoneNumber, tags = [] } = req.body as CreateVolunteerBody; + const { + firstName, + lastName, + email, + phoneNumber, + tags = [], + statusTags = [], + volunteerTypeTags = [], + events = [], + additionalNotes = "", + } = req.body as CreateVolunteerBody; try { validationErrorParser(errors); @@ -104,6 +118,10 @@ export const createVolunteer: RequestHandler = async (req, res, next) => { email, phoneNumber, tags, + statusTags, + volunteerTypeTags, + events, + additionalNotes, }); res.status(201).json(newVolunteer); } catch (err) { @@ -117,12 +135,26 @@ type UpdateVolunteerBody = { email: string; phoneNumber: string; tags?: string[]; + statusTags?: string[]; + volunteerTypeTags?: string[]; + events?: string[]; + additionalNotes?: string; }; export const updateVolunteer: RequestHandler = async (req, res, next) => { const errors = validationResult(req); const volunteerId = req.params.id; - const { firstName, lastName, email, phoneNumber, tags = [] } = req.body as UpdateVolunteerBody; + const { + firstName, + lastName, + email, + phoneNumber, + tags = [], + statusTags = [], + volunteerTypeTags = [], + events = [], + additionalNotes = "", + } = req.body as UpdateVolunteerBody; try { validationErrorParser(errors); @@ -135,6 +167,10 @@ export const updateVolunteer: RequestHandler = async (req, res, next) => { email, phoneNumber, tags, + statusTags, + volunteerTypeTags, + events, + additionalNotes, }, { new: true, runValidators: true }, ); diff --git a/backend/src/models/volunteerModel.ts b/backend/src/models/volunteerModel.ts index 8e531d86..1cb40c5e 100644 --- a/backend/src/models/volunteerModel.ts +++ b/backend/src/models/volunteerModel.ts @@ -12,6 +12,22 @@ const volunteerSchema = new Schema({ default: [], required: true, }, + statusTags: { + type: [String], + default: [], + }, + volunteerTypeTags: { + type: [String], + default: [], + }, + events: { + type: [String], + default: [], + }, + additionalNotes: { + type: String, + default: "", + }, }); type Volunteer = InferSchemaType; diff --git a/backend/src/validators/volunteerValidator.ts b/backend/src/validators/volunteerValidator.ts index 217d5130..d5b9b426 100644 --- a/backend/src/validators/volunteerValidator.ts +++ b/backend/src/validators/volunteerValidator.ts @@ -62,6 +62,10 @@ const makePhoneValidator = () => }); const tagsValidator = () => body("tags").optional().isArray(); +const statusTagsValidator = () => body("statusTags").optional().isArray(); +const volunteerTypeTagsValidator = () => body("volunteerTypeTags").optional().isArray(); +const eventsValidator = () => body("events").optional().isArray(); +const additionalNotesValidator = () => body("additionalNotes").optional().isString(); export const createVolunteerValidator = [ makeFirstNameValidator(), @@ -69,6 +73,10 @@ export const createVolunteerValidator = [ makeEmailValidator(), makePhoneValidator(), tagsValidator(), + statusTagsValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), ]; export const updateVolunteerValidator = [ @@ -78,6 +86,10 @@ export const updateVolunteerValidator = [ makeEmailValidator(), makePhoneValidator(), tagsValidator(), + statusTagsValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), ]; export const updateVolunteerContactValidator = [ diff --git a/frontend/src/components/VolunteerProfileModal.module.css b/frontend/src/components/VolunteerProfileModal.module.css index 955ebf7e..f227a25e 100644 --- a/frontend/src/components/VolunteerProfileModal.module.css +++ b/frontend/src/components/VolunteerProfileModal.module.css @@ -1,5 +1,9 @@ @import url("https://fonts.googleapis.com/css2?family=Viga&display=swap"); +.backdrop { + display: none; +} + .modal { display: flex; flex-direction: column; @@ -339,3 +343,121 @@ opacity: 0.6; cursor: not-allowed; } + +@media (max-width: 768px) { + .backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 1; + } + + .modal { + width: 100%; + left: 0; + right: 0; + top: 62px; + bottom: 16px; + padding: 0 0 16px; + border-radius: 16px 16px 0 0; + box-shadow: 0 -20px 60px rgba(118, 135, 165, 0.25); + } + + .heading { + gap: 4px; + padding-bottom: 8px; + } + + .topper { + height: 0; + } + + .headerRow { + padding: 0 16px; + } + + .modalHeader { + font-size: 20px; + line-height: 24px; + letter-spacing: 0.8px; + margin-top: 34px; + } + + .tabs { + height: 36px; + } + + .tabButton { + height: 36px; + font-size: 12px; + } + + .content { + padding: 16px; + gap: 16px; + } + + .infoSection { + gap: 12px; + } + + .fieldValue { + font-size: 14px; + line-height: 18px; + } + + .editSection { + gap: 10px; + } + + .editField { + gap: 6px; + } + + .editInput { + padding: 10px 12px; + font-size: 14px; + } + + .sectionTitle { + font-size: 14px; + line-height: 18px; + } + + .sectionHint { + font-size: 11px; + } + + .tagsRow { + gap: 6px; + } + + .tag { + padding: 6px 10px; + font-size: 11px; + line-height: 14px; + } + + .searchAddRow { + gap: 6px; + } + + .searchAddField { + padding: 10px 12px; + } + + .searchAddInput { + font-size: 12px; + } + + .addButton { + width: 40px; + height: 44px; + } + + .saveButton { + padding: 12px; + font-size: 13px; + } +} diff --git a/frontend/src/components/VolunteerProfileModal.tsx b/frontend/src/components/VolunteerProfileModal.tsx index 98d25578..97f9f0fd 100644 --- a/frontend/src/components/VolunteerProfileModal.tsx +++ b/frontend/src/components/VolunteerProfileModal.tsx @@ -13,6 +13,12 @@ interface VolunteerProfileModalProps { const STATUS_TAGS = ["Returner", "Expert", "New"]; const VOLUNTEER_TYPE_TAGS = ["Intern", "Outside Volunteer"]; +const getTagColorClass = (tag: string, styles: Record) => { + if (tag === "Outside Volunteer") return styles.tagOrange; + if (tag.includes("More")) return styles.tagGreen; + return styles.tagTeal; +}; + export default function VolunteerProfileModal({ volunteer, isOpen, @@ -26,8 +32,11 @@ export default function VolunteerProfileModal({ const [phoneNumber, setPhoneNumber] = useState(""); const [statusTags, setStatusTags] = useState([]); const [typeTags, setTypeTags] = useState([]); + const [eventTags, setEventTags] = useState([]); const [statusInput, setStatusInput] = useState(""); const [typeInput, setTypeInput] = useState(""); + const [eventInput, setEventInput] = useState(""); + const [additionalNotes, setAdditionalNotes] = useState(""); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(""); @@ -38,12 +47,15 @@ export default function VolunteerProfileModal({ setEmail(volunteer.email); setPhoneNumber(volunteer.phoneNumber); - const nextTypeTags = volunteer.tags.filter((tag) => VOLUNTEER_TYPE_TAGS.includes(tag)); - const nextStatusTags = volunteer.tags.filter((tag) => !VOLUNTEER_TYPE_TAGS.includes(tag)); - setTypeTags(nextTypeTags); - setStatusTags(nextStatusTags); + const fallbackTypeTags = volunteer.tags.filter((tag) => VOLUNTEER_TYPE_TAGS.includes(tag)); + const fallbackStatusTags = volunteer.tags.filter((tag) => !VOLUNTEER_TYPE_TAGS.includes(tag)); + setTypeTags(volunteer.volunteerTypeTags ?? fallbackTypeTags); + setStatusTags(volunteer.statusTags ?? fallbackStatusTags); + setEventTags(volunteer.events ?? []); + setAdditionalNotes(volunteer.additionalNotes ?? ""); setStatusInput(""); setTypeInput(""); + setEventInput(""); setSaveError(""); }, [volunteer]); @@ -66,6 +78,13 @@ export default function VolunteerProfileModal({ setTypeInput(""); }; + const handleAddEventTag = () => { + const value = eventInput.trim(); + if (!value || eventTags.includes(value)) return; + setEventTags((prev) => [...prev, value]); + setEventInput(""); + }; + const handleRemoveStatusTag = (tag: string) => { setStatusTags((prev) => prev.filter((value) => value !== tag)); }; @@ -74,6 +93,10 @@ export default function VolunteerProfileModal({ setTypeTags((prev) => prev.filter((value) => value !== tag)); }; + const handleRemoveEventTag = (tag: string) => { + setEventTags((prev) => prev.filter((value) => value !== tag)); + }; + const handleSave = async () => { if (!volunteer) return; setIsSaving(true); @@ -90,6 +113,10 @@ export default function VolunteerProfileModal({ email, phoneNumber, tags: combinedTags, + statusTags, + volunteerTypeTags: typeTags, + events: eventTags, + additionalNotes, }), }); @@ -107,200 +134,270 @@ export default function VolunteerProfileModal({ } }; - const getTagColorClass = (tag: string) => { - if (tag === "Outside Volunteer") return styles.tagOrange; - if (tag.includes("More")) return styles.tagGreen; - return styles.tagTeal; - }; - if (!isOpen || !volunteer) return null; return ( -
-
-
-
-
View Volunteer
- + <> +