├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── labeler.yml ├── pull_request_template.md └── workflows │ ├── cleanup.yml │ ├── label.yml │ ├── linters.yml │ └── tests.yml ├── .gitignore ├── .vscode └── launch.json ├── README.md ├── assets ├── mock_data.json ├── mock_user_data.json ├── resource_template.json └── team │ ├── alan.jpeg │ ├── albert.jpg │ ├── alice.jpeg │ ├── angad.jpeg │ ├── aryn.jpg │ ├── eugenia.jpg │ ├── evan.png │ ├── gene.jpeg │ ├── joshburke.jpg │ ├── joshbyster.jpeg │ ├── lauren.jpeg │ └── rebecca.jpeg ├── backend ├── Dockerfile ├── app.js ├── appListener.js ├── models │ ├── GroupResource.js │ ├── IndividualResource.js │ ├── Resource.js │ ├── TangibleResource.js │ └── User.js ├── package-lock.json ├── package.json ├── routes │ ├── api │ │ ├── auth │ │ │ ├── index.js │ │ │ ├── login.js │ │ │ └── logout.js │ │ ├── index.js │ │ ├── resources.js │ │ ├── test.js │ │ └── users.js │ └── index.js ├── test │ ├── auth_stubs.js │ ├── resource.test.js │ ├── setup.test.js │ └── user.test.js └── utils │ ├── auth-middleware.js │ ├── auth │ └── auth_validators.js │ ├── constants.js │ ├── error-handler.js │ ├── error-wrap.js │ ├── generate_mock_data.js │ ├── joi-validators.js │ ├── logging-middleware.js │ ├── passport-setup.js │ ├── resource-utils.js │ ├── user-utils.js │ └── volunteer-sheet-parser.js ├── codecov.yml ├── docker-compose.cypress.yml ├── docker-compose.yml ├── frontend ├── Dockerfile ├── Dockerfile.prod ├── README.md ├── core ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ ├── access_control │ │ │ ├── pending.spec.js │ │ │ ├── rejected.spec.js │ │ │ └── volunteer.spec.js │ │ └── features │ │ │ ├── directory.spec.js │ │ │ ├── extra.spec.js │ │ │ ├── navbar.spec.js │ │ │ ├── resource_map.spec.js │ │ │ └── user_panel.spec.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── App.test.jsx │ ├── assets │ └── images │ │ ├── business-resource.svg │ │ ├── close.svg │ │ ├── close2.svg │ │ ├── close3.svg │ │ ├── download.svg │ │ ├── edit-black.svg │ │ ├── edit.svg │ │ ├── expand-black.svg │ │ ├── expand.svg │ │ ├── google_logo.svg │ │ ├── individual-resource.svg │ │ ├── lah-logo-2.png │ │ ├── lah-logo.png │ │ ├── location-directory.svg │ │ ├── location-icon-highlighted.svg │ │ ├── location-icon.svg │ │ ├── location.svg │ │ ├── logo.svg │ │ ├── marker-atlas.png │ │ ├── marker.png │ │ ├── marker.svg │ │ ├── maximize-white.svg │ │ ├── maximize.svg │ │ ├── pencil-edit-button-black.svg │ │ ├── pencil-edit-button.svg │ │ ├── pending-check.svg │ │ ├── search-directory.svg │ │ ├── search.svg │ │ ├── tag-directory.svg │ │ ├── tag.svg │ │ ├── tangible-resource.svg │ │ ├── user-avatar.svg │ │ └── user.svg │ ├── components │ ├── Analytics │ │ └── index.jsx │ ├── Auth │ │ └── AdminView.jsx │ ├── CSVExporter │ │ ├── CSVExporter.jsx │ │ └── styles.scss │ ├── Loader │ │ ├── index.jsx │ │ ├── mini-loader.jsx │ │ └── styles.scss │ ├── Modal │ │ ├── LastModifiedInfo │ │ │ ├── index.jsx │ │ │ └── styles.scss │ │ ├── ModalInput │ │ │ └── index.jsx │ │ ├── ModalManager │ │ │ └── index.jsx │ │ ├── ModalTagComplete │ │ │ └── index.jsx │ │ ├── ResourceModal │ │ │ ├── GroupResourceForm.jsx │ │ │ ├── IndividualResourceForm.jsx │ │ │ ├── TangibleResourceForm.jsx │ │ │ └── index.jsx │ │ ├── UserModal │ │ │ └── index.jsx │ │ ├── index.jsx │ │ └── styles.scss │ ├── Navbar │ │ ├── index.jsx │ │ └── styles.scss │ ├── PrivateRoute │ │ └── index.jsx │ └── TagAutocomplete │ │ └── index.jsx │ ├── index.js │ ├── pages │ ├── AdminView │ │ ├── UserCard │ │ │ └── index.jsx │ │ ├── index.jsx │ │ └── styles.scss │ ├── Auth │ │ ├── Login │ │ │ └── index.jsx │ │ ├── Pending │ │ │ └── index.jsx │ │ └── styles.scss │ ├── DirectoryView │ │ ├── DirectoryTagSearch │ │ │ └── index.jsx │ │ ├── ResourceCard │ │ │ ├── index.jsx │ │ │ └── styles.scss │ │ ├── ResourceLabels │ │ │ └── index.jsx │ │ ├── ResourceList │ │ │ ├── index.jsx │ │ │ └── styles.scss │ │ ├── SearchBar │ │ │ └── index.jsx │ │ ├── index.jsx │ │ └── styles.scss │ └── MapView │ │ ├── ActionButtons │ │ └── index.jsx │ │ ├── CardView │ │ └── index.jsx │ │ ├── Map │ │ └── index.jsx │ │ ├── Popup │ │ └── index.jsx │ │ ├── RadiusFilter │ │ ├── index.jsx │ │ └── styles.scss │ │ ├── ResourceCard │ │ ├── index.jsx │ │ └── styles.scss │ │ ├── SearchBar │ │ ├── MapSearchAutocomplete.jsx │ │ ├── index.jsx │ │ └── styles.scss │ │ ├── index.jsx │ │ └── styles.scss │ ├── redux │ ├── actions │ │ ├── api.js │ │ ├── auth.js │ │ ├── loader.js │ │ ├── map.js │ │ ├── modal.js │ │ ├── nav.js │ │ ├── resources.js │ │ ├── search.js │ │ ├── sort.js │ │ ├── tags.js │ │ └── users.js │ ├── middleware │ │ └── api_middleware.js │ ├── reducers │ │ ├── auth.js │ │ ├── index.js │ │ ├── loading.js │ │ ├── map.js │ │ ├── modal.js │ │ ├── resources.js │ │ ├── search.js │ │ ├── sort.js │ │ ├── tags.js │ │ └── users.js │ ├── selectors │ │ ├── map.js │ │ ├── modal.js │ │ ├── resource.js │ │ ├── tags.js │ │ └── users.js │ └── store.js │ ├── serviceWorker.js │ ├── styles │ └── index.scss │ └── utils │ ├── api.js │ ├── apiHelpers.js │ ├── csv.js │ ├── enums.js │ └── formatters.js ├── package-lock.json ├── package.json ├── scripts ├── commit_info.js ├── db_backup │ ├── README.md │ ├── backup │ │ ├── backup.js │ │ ├── mongodump │ │ ├── package-lock.json │ │ └── package.json │ ├── deploy.sh │ └── template.yml ├── get_coverage.sh ├── lahutil └── setup_ci.sh └── vercel.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/serviceWorker.js 2 | frontend/cypress/examples 3 | frontend/cypress/plugins -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Additional context** 16 | Add details, any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 4 9 | ignore: 10 | - dependency-name: cypress 11 | versions: 12 | - 6.3.0 13 | - 6.4.0 14 | - 6.5.0 15 | - 6.6.0 16 | - 6.7.0 17 | - 6.7.1 18 | - 6.8.0 19 | - 7.0.0 20 | - 7.0.1 21 | - 7.1.0 22 | - dependency-name: "@cypress/code-coverage" 23 | versions: 24 | - 3.9.3 25 | - dependency-name: chai 26 | versions: 27 | - 4.3.2 28 | - package-ecosystem: npm 29 | directory: "/frontend" 30 | schedule: 31 | interval: daily 32 | time: "11:00" 33 | open-pull-requests-limit: 4 34 | ignore: 35 | - dependency-name: react-hook-form 36 | versions: 37 | - "> 6.4.1" 38 | - dependency-name: react-map-gl 39 | versions: 40 | - 5.3.1 41 | - 6.1.0 42 | - 6.1.1 43 | - 6.1.10 44 | - 6.1.11 45 | - 6.1.2 46 | - 6.1.3 47 | - 6.1.4 48 | - 6.1.5 49 | - 6.1.6 50 | - 6.1.7 51 | - 6.1.8 52 | - 6.1.9 53 | - dependency-name: react-scripts 54 | versions: 55 | - 4.0.1 56 | - 4.0.2 57 | - package-ecosystem: npm 58 | directory: "/backend" 59 | schedule: 60 | interval: daily 61 | time: "11:00" 62 | open-pull-requests-limit: 4 63 | ignore: 64 | - dependency-name: sinon 65 | versions: 66 | - 10.0.0 67 | - dependency-name: connect-mongo 68 | versions: 69 | - 4.1.0 70 | - 4.2.0 71 | - 4.2.2 72 | - 4.3.0 73 | - 4.3.1 74 | - 4.4.0 75 | - dependency-name: chai 76 | versions: 77 | - 4.3.2 78 | - package-ecosystem: docker 79 | directory: "/frontend" 80 | schedule: 81 | interval: daily 82 | time: "11:00" 83 | open-pull-requests-limit: 4 84 | ignore: 85 | - dependency-name: node 86 | versions: 87 | - 15.9.0 88 | - package-ecosystem: docker 89 | directory: "/backend" 90 | schedule: 91 | interval: daily 92 | time: "11:00" 93 | open-pull-requests-limit: 4 94 | ignore: 95 | - dependency-name: node 96 | versions: 97 | - 15.9.0 98 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | frontend: 2 | - frontend/**/* 3 | 4 | backend: 5 | - backend/**/* 6 | 7 | infrastructure: 8 | - scripts/**/* 9 | - .github/**/* -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Status: 7 | 8 | 13 | 14 | ## Description 15 | 16 | 19 | 20 | Fixes # 21 | 22 | ## Todos 23 | 24 | 28 | 29 | ## Screenshots 30 | 31 | 36 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: PR Tasks 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | cleanup-branch: 8 | name: Post-Merge Branch Cleanup 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: jessfraz/branch-cleanup-action@master 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/label.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | on: [pull_request_target] 3 | 4 | jobs: 5 | label: 6 | name: Label Branch 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/labeler@v2 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: [push] 4 | 5 | jobs: 6 | format: 7 | name: Prettier Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Use Node.js 12.x 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | - name: Prettier check 16 | run: | 17 | npm ci 18 | npm run prettier-check 19 | env: 20 | CI: true 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | 7 | .vercel 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | .env_url 81 | .env.test 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | 86 | # next.js build output 87 | .next 88 | 89 | # nuxt.js build output 90 | .nuxt 91 | 92 | # gatsby files 93 | .cache/ 94 | 95 | 96 | # frontend build 97 | frontend/build 98 | 99 | 100 | frontend/cypress/videos 101 | frontend/cypress/screenshots 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # Serverless directories 107 | .serverless/ 108 | 109 | # FuseBox cache 110 | .fusebox/ 111 | 112 | # DynamoDB Local files 113 | .dynamodb/ 114 | 115 | .vercel -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Docker: Attach to Node", 8 | "localRoot": "${workspaceRoot}/backend", 9 | "remoteRoot": "/var/www/app" 10 | }, 11 | { 12 | "name": "FE Debugging", 13 | "type": "chrome", 14 | "request": "launch", 15 | "url": "http://localhost:3000", 16 | "webRoot": "${workspaceRoot}/frontend", 17 | "sourceMapPathOverrides": { 18 | "/var/www/app/*": "${webRoot}/*" 19 | }, 20 | "runtimeArgs": ["--remote-debugging-port=9222"] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /assets/resource_template.json: -------------------------------------------------------------------------------- 1 | [ 2 | "{{repeat(100)}}", 3 | { 4 | "companyName": "{{company()}} {{company()}}", 5 | "contactName": "{{firstName()}} {{surname()}}", 6 | "contactPhone": "{{phone()}}", 7 | "contactEmail": "{{email()}}", 8 | "description": "{{lorem(3, \"sentences\")}}", 9 | "address": "{{integer(100, 999)}} {{street()}}, {{city()}}, {{state()}}, {{integer(100, 10000)}}", 10 | "location": { 11 | "type": "Point", 12 | "coordinates": ["{{floating(-120, -80)}}", "{{floating(30, 50)}}"] 13 | }, 14 | "notes": "{{lorem(2, \"sentences\")}}", 15 | "tags": ["{{repeat(0,7)}}", "{{lorem(1, \"words\")}}"] 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /assets/team/alan.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/alan.jpeg -------------------------------------------------------------------------------- /assets/team/albert.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/albert.jpg -------------------------------------------------------------------------------- /assets/team/alice.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/alice.jpeg -------------------------------------------------------------------------------- /assets/team/angad.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/angad.jpeg -------------------------------------------------------------------------------- /assets/team/aryn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/aryn.jpg -------------------------------------------------------------------------------- /assets/team/eugenia.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/eugenia.jpg -------------------------------------------------------------------------------- /assets/team/evan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/evan.png -------------------------------------------------------------------------------- /assets/team/gene.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/gene.jpeg -------------------------------------------------------------------------------- /assets/team/joshburke.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/joshburke.jpg -------------------------------------------------------------------------------- /assets/team/joshbyster.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/joshbyster.jpeg -------------------------------------------------------------------------------- /assets/team/lauren.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/lauren.jpeg -------------------------------------------------------------------------------- /assets/team/rebecca.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/assets/team/rebecca.jpeg -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.0.0 2 | 3 | WORKDIR '/var/www/app' 4 | # Install app dependencies 5 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 6 | # where available (npm@5+) 7 | COPY package*.json ./ 8 | 9 | RUN npm ci --no-optional 10 | # If you are building your code for production 11 | # RUN npm ci --only=production 12 | # Bundle app source 13 | COPY . . 14 | EXPOSE 3000 15 | CMD [ "npm", "start" ] 16 | -------------------------------------------------------------------------------- /backend/appListener.js: -------------------------------------------------------------------------------- 1 | const app = require("./app"); 2 | const port = process.env.PORT; 3 | 4 | app.listen(port, () => console.log(`Example app listening on port ${port}!`)); 5 | -------------------------------------------------------------------------------- /backend/models/GroupResource.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const BaseResource = require("./Resource"); 3 | const { resourceEnum } = require("./Resource"); 4 | 5 | const GroupResource = BaseResource.discriminator( 6 | resourceEnum.GROUP, 7 | new mongoose.Schema({ 8 | description: { type: String, default: "" }, 9 | companyName: { type: String, default: "" }, 10 | }) 11 | ); 12 | 13 | module.exports = GroupResource; 14 | -------------------------------------------------------------------------------- /backend/models/IndividualResource.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const BaseResource = require("./Resource"); 3 | const { resourceEnum } = require("./Resource"); 4 | 5 | const IndividualResource = BaseResource.discriminator( 6 | resourceEnum.INDIVIDUAL, 7 | new mongoose.Schema({ 8 | availability: { type: String, default: "" }, 9 | howDiscovered: { type: String, default: "" }, 10 | volunteerReason: { type: String, default: "" }, 11 | skills: { type: String, default: "" }, 12 | volunteerRoles: { type: String, default: "" }, 13 | }) 14 | ); 15 | 16 | module.exports = IndividualResource; 17 | -------------------------------------------------------------------------------- /backend/models/Resource.js: -------------------------------------------------------------------------------- 1 | /** Schema representing a LAH resource 2 | */ 3 | 4 | const mongoose = require("mongoose"); 5 | 6 | const resourceEnum = { 7 | GROUP: "GROUP", 8 | INDIVIDUAL: "INDIVIDUAL", 9 | TANGIBLE: "TANGIBLE", 10 | }; 11 | 12 | const options = { discriminatorKey: "type", collection: "resources" }; 13 | 14 | const Resource = new mongoose.Schema( 15 | { 16 | contactName: { type: String, required: true }, 17 | contactPhone: { type: String, default: "" }, 18 | contactEmail: { type: String, default: "" }, 19 | address: { 20 | streetAddress: { type: String, default: "" }, 21 | city: { type: String, default: "" }, 22 | state: { type: String, default: "" }, 23 | postalCode: { type: String, default: "" }, 24 | }, 25 | location: { 26 | type: { type: String, default: "Point" }, 27 | coordinates: { type: Array, default: [] }, 28 | }, 29 | websiteURL: { type: String, default: "" }, 30 | dateCreated: { type: Date, default: Date.now }, 31 | dateLastModified: { type: Date }, 32 | lastModifiedUser: { type: String }, 33 | federalRegion: { type: Number, default: 0 }, 34 | notes: { type: String }, 35 | tags: { type: Array, default: [] }, 36 | }, 37 | options 38 | ); 39 | 40 | module.exports = mongoose.model("BaseResource", Resource); 41 | module.exports.resourceEnum = resourceEnum; 42 | -------------------------------------------------------------------------------- /backend/models/TangibleResource.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const BaseResource = require("./Resource"); 3 | const { resourceEnum } = require("./Resource"); 4 | 5 | const TangibleResource = BaseResource.discriminator( 6 | resourceEnum.TANGIBLE, 7 | new mongoose.Schema({ 8 | resourceName: { type: String, default: "", required: true }, 9 | description: { type: String, default: "" }, 10 | quantity: { type: String, default: "" }, 11 | }) 12 | ); 13 | 14 | module.exports = TangibleResource; 15 | -------------------------------------------------------------------------------- /backend/models/User.js: -------------------------------------------------------------------------------- 1 | /** Schema representing a LAH user 2 | * Role should be one of the following: [Admin, Volunteer, Pending, Rejected] 3 | */ 4 | const mongoose = require("mongoose"); 5 | 6 | const roleEnum = { 7 | ADMIN: "ADMIN", 8 | VOLUNTEER: "VOLUNTEER", 9 | PENDING: "PENDING", 10 | REJECTED: "REJECTED", 11 | }; 12 | 13 | const locationEnum = { 14 | NORTH: "NORTH", 15 | SOUTH: "SOUTH", 16 | }; 17 | 18 | const User = new mongoose.Schema({ 19 | firstName: { type: String, required: true }, 20 | lastName: { type: String, required: false, default: "" }, 21 | oauthId: { type: String, required: true, unique: true }, 22 | propicUrl: { type: String, required: false }, 23 | role: { 24 | type: String, 25 | enum: [ 26 | roleEnum.ADMIN, 27 | roleEnum.VOLUNTEER, 28 | roleEnum.PENDING, 29 | roleEnum.REJECTED, 30 | ], 31 | required: true, 32 | }, 33 | title: { 34 | type: String, 35 | required: false, 36 | default: "", 37 | setDefaultsOnInsert: true, 38 | }, 39 | location: { 40 | type: String, 41 | enum: [locationEnum.NORTH, locationEnum.SOUTH], 42 | required: true, 43 | }, 44 | email: { type: String, required: true, unique: true }, 45 | }); 46 | 47 | module.exports = mongoose.model("User", User); 48 | module.exports.roleEnum = roleEnum; 49 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lah-backend", 3 | "version": "1.0.0", 4 | "description": "Backend for LAH systems.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/hack4impact-uiuc/life-after-hate" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "mocha --reporter spec --exit", 12 | "test:coverage": "nyc --reporter=lcov mocha --reporter spec --exit", 13 | "start:dev": "nodemon appListener.js", 14 | "start": "node appListener.js", 15 | "start:coverage": "nyc --reporter=lcov node appListener.js" 16 | }, 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@hapi/boom": "^9.1.2", 21 | "axios": "^0.21.1", 22 | "body-parser": "^1.19.0", 23 | "celebrate": "^14.0.0", 24 | "chai": "^4.3.4", 25 | "colors": "^1.4.0", 26 | "connect-mongo": "^3.2.0", 27 | "cors": "^2.8.5", 28 | "dotenv": "^8.2.0", 29 | "express": "^4.17.1", 30 | "express-session": "^1.17.1", 31 | "express-winston": "^4.1.0", 32 | "fuse.js": "^6.4.6", 33 | "geolib": "^3.3.1", 34 | "helmet": "^4.6.0", 35 | "honeycomb-beeline": "^2.7.0", 36 | "joi-objectid": "^3.0.1", 37 | "keyword-extractor": "0.0.19", 38 | "mocha": "^8.3.2", 39 | "mongoose": "^5.12.7", 40 | "morgan": "^1.10.0", 41 | "node-fetch": "^2.6.1", 42 | "nodemon": "^2.0.7", 43 | "nyc": "^15.1.0", 44 | "passport": "^0.4.1", 45 | "passport-google-oauth": "^2.0.0", 46 | "progress": "^2.0.3", 47 | "ramda": "^0.27.0", 48 | "ramda-adjunct": "^2.32.0", 49 | "sinon": "^10.0.1", 50 | "supertest": "^6.1.3", 51 | "winston": "^3.3.3", 52 | "winston-loggly-bulk": "^3.2.1", 53 | "xlsx": "^0.16.9" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backend/routes/api/auth/index.js: -------------------------------------------------------------------------------- 1 | var router = require("express").Router(); 2 | 3 | /* 4 | * Put all your auth routes here! 5 | * When you add a new route file, 6 | * you must include it for it to be accessible under /api/ 7 | * For example, here, a route matching "/" in "sample.js" 8 | * will correspond to endpoint /api/sample, 9 | * And a route matching "/info" in "sample.js" 10 | * will correspond to endpoint /api/sample/info 11 | */ 12 | 13 | router.use("/login", require("./login")); 14 | router.use("/logout", require("./logout")); 15 | 16 | module.exports = router; 17 | -------------------------------------------------------------------------------- /backend/routes/api/auth/login.js: -------------------------------------------------------------------------------- 1 | const passport = require("passport"); 2 | const router = require("express").Router(); 3 | const Boom = require("@hapi/boom"); 4 | const { 5 | requireAdminStatus, 6 | requireVolunteerStatus, 7 | } = require("../../../utils/auth-middleware"); 8 | // Defines the endpoint which will be serializecd in state 9 | const CALLBACK_ENDPOINT = "/api/auth/login/callback"; 10 | // Where to go affter a succ 11 | const LOGIN_SUCCESS_REDIRECT = process.env.FE_URI ? process.env.FE_URI : "/"; 12 | 13 | router.get("/", (req, res, next) => { 14 | // Construct the "callback" url by concatenating the current base URL (host) with the callback URL 15 | // Anything we put into Passport's state can be accessed after the login is successful, as it gets encoded in the URL 16 | const callbackUrl = `${req.protocol}://${req.get( 17 | "host" 18 | )}${CALLBACK_ENDPOINT}`; 19 | const state = callbackUrl 20 | ? Buffer.from(JSON.stringify({ callbackUrl })).toString("base64") 21 | : undefined; 22 | const auth = passport.authenticate("google", { 23 | scope: ["openid", "profile", "email"], 24 | state, 25 | }); 26 | auth(req, res, next); 27 | next(); 28 | }); 29 | 30 | /* Serves as a middle point workaround for Google OAuth only allowing one callback URL 31 | * Essentially, we're using our main, production now deployment, for example lah.hack4impact.now.sh 32 | * Which is registered in Google OAuth console. We instruct Passport to redirect to this URL 33 | * (even if we're checking out, for example, lah-branch-deploy.hack4impact.now.sh) 34 | * Based on the serialized info above, this will reconstruct the original callback URL (lah-branch-deploy.hack4impact.now.sh) 35 | * And will redirect to lah-branch-deploy.hack4impact.now.sh/CALLBACK_ENDPOINT 36 | * TLDR: will take PROD_URL/api/auth/login/redirectURI?queryparams -> ORIGINAL_URL/api/auth/login/callback?queryparams 37 | */ 38 | router.get("/redirectURI", (req, res) => { 39 | try { 40 | // If we are here, this endpoint is likely being run on the MAIN deployment 41 | const { state } = req.query; 42 | // Grab the branch deployment (lah-branch-deploy.hack4impact.now.sh) for example 43 | const { callbackUrl } = JSON.parse(Buffer.from(state, "base64").toString()); 44 | if (typeof callbackUrl === "string") { 45 | // Reconstruct the URL and redirect 46 | const callbackURL = `${callbackUrl}?${req._parsedUrl.query}`; 47 | return res.redirect(callbackURL); 48 | } 49 | // There was no base 50 | return res.redirect(CALLBACK_ENDPOINT); 51 | } catch (e) { 52 | return Boom.badRequest("Something went wrong with the URI redirection"); 53 | } 54 | }); 55 | 56 | router.get( 57 | "/callback", 58 | passport.authenticate("google", { 59 | failureRedirect: "/login", 60 | }), 61 | (req, res) => { 62 | res.redirect(LOGIN_SUCCESS_REDIRECT); 63 | } 64 | ); 65 | 66 | router.get("/testVolunteer", requireVolunteerStatus, function (req, res) { 67 | res.json({ 68 | code: 200, 69 | result: "you are a volunteer boo yah", 70 | success: true, 71 | }); 72 | }); 73 | 74 | router.get("/testAdmin", requireAdminStatus, function (req, res) { 75 | res.json({ 76 | code: 200, 77 | result: "you are an admin boo yah", 78 | success: true, 79 | }); 80 | }); 81 | 82 | module.exports = router; 83 | -------------------------------------------------------------------------------- /backend/routes/api/auth/logout.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | 3 | router.get("/", (req, res) => { 4 | req.logout(); 5 | res.send({ 6 | code: 200, 7 | message: "You have been signed out!", 8 | success: true, 9 | }); 10 | }); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /backend/routes/api/index.js: -------------------------------------------------------------------------------- 1 | var router = require("express").Router(); 2 | 3 | /* 4 | * Put all your routes here! 5 | * When you add a new route file, 6 | * you must include it for it to be accessible under /api/ 7 | * For example, here, a route matching "/" in "sample.js" 8 | * will correspond to endpoint /api/sample, 9 | * And a route matching "/info" in "sample.js" 10 | * will correspond to endpoint /api/sample/info 11 | */ 12 | 13 | router.use("/auth", require("./auth")); 14 | router.use("/users", require("./users")); 15 | router.use("/resources", require("./resources")); 16 | if (process.env.NODE_ENV !== "production" && process.env.BYPASS_AUTH_ROLE) { 17 | router.use("/test", require("./test")); 18 | } 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /backend/routes/api/test.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { celebrate, Joi } = require("celebrate"); 4 | const errorWrap = require("../../utils/error-wrap"); 5 | const { roleEnum } = require("../../models/User"); 6 | 7 | // get all users 8 | router.get( 9 | "/setRole/:role", 10 | celebrate({ 11 | params: { 12 | role: Joi.string() 13 | .valid(...Object.values(roleEnum)) 14 | .insensitive() 15 | .required(), 16 | }, 17 | }), 18 | errorWrap((req, res) => { 19 | req.app.locals.mockRole = req.params.role; 20 | res.json({ 21 | code: 200, 22 | result: `Mock user changed to ${req.app.locals.mockRole}.`, 23 | success: true, 24 | }); 25 | }) 26 | ); 27 | 28 | module.exports = router; 29 | -------------------------------------------------------------------------------- /backend/routes/index.js: -------------------------------------------------------------------------------- 1 | var router = require("express").Router(); 2 | 3 | router.use("/api", require("./api")); 4 | 5 | module.exports = router; 6 | -------------------------------------------------------------------------------- /backend/test/auth_stubs.js: -------------------------------------------------------------------------------- 1 | const sinon = require("sinon"); 2 | const authValidator = require("../utils/auth/auth_validators"); 3 | const { roleEnum } = require("../models/User"); 4 | 5 | // Use sinon to stub the validator to (by default, unless another function is specified) 6 | // return true in all instances 7 | const stubOutAuth = (fn = () => true) => { 8 | const stub = sinon.stub(authValidator, "validateRequestForRole"); 9 | stub.callsFake(fn); 10 | return stub; 11 | }; 12 | 13 | const unstubAuth = () => authValidator.validateRequestForRole.restore(); 14 | 15 | // Assrets that the validator was called on a specific type of user 16 | const authValidatorCalled = (type) => 17 | authValidator.validateRequestForRole.calledWith(sinon.match.any, type); 18 | 19 | const didCheckIsVolunteer = () => authValidatorCalled(roleEnum.VOLUNTEER); 20 | const didCheckIsAdmin = () => authValidatorCalled(roleEnum.ADMIN); 21 | const didCheckIsPending = () => authValidatorCalled(roleEnum.PENDING); 22 | 23 | module.exports = { 24 | stubOutAuth, 25 | unstubAuth, 26 | didCheckIsAdmin, 27 | didCheckIsVolunteer, 28 | didCheckIsPending, 29 | }; 30 | -------------------------------------------------------------------------------- /backend/test/setup.test.js: -------------------------------------------------------------------------------- 1 | const User = require("../models/User"); 2 | const { stubOutAuth, unstubAuth } = require("./auth_stubs"); 3 | 4 | // Universal global hooks which should run before every test 5 | beforeEach(async () => { 6 | await User.remove({}); 7 | stubOutAuth(); 8 | }); 9 | 10 | afterEach(unstubAuth); 11 | -------------------------------------------------------------------------------- /backend/utils/auth-middleware.js: -------------------------------------------------------------------------------- 1 | const Boom = require("@hapi/boom"); 2 | const roleEnum = require("../models/User.js").roleEnum; 3 | const authValidators = require("./auth/auth_validators"); 4 | 5 | const requireVolunteerStatus = (req, res, next) => { 6 | // Anything that a volunteer is authorized to do, an admin can do as well 7 | if ( 8 | authValidators.validateRequestForRole(req, roleEnum.VOLUNTEER) || 9 | authValidators.validateRequestForRole(req, roleEnum.ADMIN) 10 | ) { 11 | return next(); 12 | } 13 | res 14 | .status(401) 15 | .send( 16 | Boom.unauthorized("You are not authorized (requires volunteer status).") 17 | ); 18 | }; 19 | 20 | const requireAdminStatus = (req, res, next) => { 21 | if (authValidators.validateRequestForRole(req, roleEnum.ADMIN)) { 22 | return next(); 23 | } 24 | res 25 | .status(401) 26 | .send(Boom.unauthorized("You are not authorized (requires admin status).")); 27 | }; 28 | 29 | const requirePendingStatus = (req, res, next) => { 30 | if ( 31 | authValidators.validateRequestForRole(req, roleEnum.PENDING) || 32 | authValidators.validateRequestForRole(req, roleEnum.VOLUNTEER) || 33 | authValidators.validateRequestForRole(req, roleEnum.ADMIN) 34 | ) { 35 | return next(); 36 | } 37 | res 38 | .status(401) 39 | .send( 40 | Boom.unauthorized("You are not authorized (requires pending status).") 41 | ); 42 | }; 43 | 44 | const setMockUserRole = (app, role) => { 45 | app.locals.mockRole = role; 46 | }; 47 | 48 | // Middleware that'll set a mock user if the bypass authorization environment variable gets set 49 | const mockUserMiddleware = (req, _, next) => { 50 | req.user = { 51 | firstName: "John", 52 | lastName: "Doe", 53 | oauthId: "12345678", 54 | propicUrl: "https://eus.wiki/images/f/f4/H4i_square_small.png", 55 | role: req.app.locals.mockRole, 56 | location: "SOUTH", 57 | email: "abc@def.xyz", 58 | }; 59 | req.isAuthenticated = () => true; 60 | next(); 61 | }; 62 | module.exports = { 63 | requirePendingStatus, 64 | requireVolunteerStatus, 65 | requireAdminStatus, 66 | mockUserMiddleware, 67 | setMockUserRole, 68 | }; 69 | -------------------------------------------------------------------------------- /backend/utils/auth/auth_validators.js: -------------------------------------------------------------------------------- 1 | // Helper functions to return whether the user is authorized for a given role 2 | // Returns true if authorized, false otherwise; handled by external auth middleware 3 | 4 | // This is in an external module so it can be dynamically stubbed out by sinon during testing 5 | const validateRequestForRole = (req, role) => { 6 | if (!req.isAuthenticated()) { 7 | return false; 8 | } 9 | if (!req.user) { 10 | return false; 11 | } 12 | return req.user.role === role; 13 | }; 14 | 15 | module.exports = { validateRequestForRole }; 16 | -------------------------------------------------------------------------------- /backend/utils/constants.js: -------------------------------------------------------------------------------- 1 | const STATE_REGION_MAP = [ 2 | { State: "AL", Region: 4 }, 3 | { State: "AK", Region: 10 }, 4 | { State: "AZ", Region: 9 }, 5 | { State: "AR", Region: 6 }, 6 | { State: "CA", Region: 9 }, 7 | { State: "CO", Region: 8 }, 8 | { State: "CT", Region: 1 }, 9 | { State: "DE", Region: 3 }, 10 | { State: "FL", Region: 4 }, 11 | { State: "GA", Region: 4 }, 12 | { State: "HI", Region: 9 }, 13 | { State: "ID", Region: 10 }, 14 | { State: "IL", Region: 5 }, 15 | { State: "IN", Region: 5 }, 16 | { State: "IA", Region: 7 }, 17 | { State: "KS", Region: 7 }, 18 | { State: "KY", Region: 4 }, 19 | { State: "LA", Region: 6 }, 20 | { State: "ME", Region: 1 }, 21 | { State: "MD", Region: 3 }, 22 | { State: "MA", Region: 1 }, 23 | { State: "MI", Region: 5 }, 24 | { State: "MN", Region: 5 }, 25 | { State: "MS", Region: 4 }, 26 | { State: "MO", Region: 7 }, 27 | { State: "MT", Region: 8 }, 28 | { State: "NE", Region: 7 }, 29 | { State: "NV", Region: 9 }, 30 | { State: "NH", Region: 1 }, 31 | { State: "NJ", Region: 2 }, 32 | { State: "NM", Region: 6 }, 33 | { State: "NY", Region: 2 }, 34 | { State: "NC", Region: 4 }, 35 | { State: "ND", Region: 8 }, 36 | { State: "OH", Region: 5 }, 37 | { State: "OK", Region: 6 }, 38 | { State: "OR", Region: 10 }, 39 | { State: "PA", Region: 3 }, 40 | { State: "RI", Region: 1 }, 41 | { State: "SC", Region: 4 }, 42 | { State: "SD", Region: 8 }, 43 | { State: "TN", Region: 4 }, 44 | { State: "TX", Region: 6 }, 45 | { State: "UT", Region: 8 }, 46 | { State: "VT", Region: 1 }, 47 | { State: "VA", Region: 3 }, 48 | { State: "WA", Region: 10 }, 49 | { State: "WV", Region: 3 }, 50 | { State: "WI", Region: 5 }, 51 | { State: "WY", Region: 8 }, 52 | { State: "PR", Region: 2 }, 53 | { State: "US Virgin Islands", Region: 2 }, 54 | { State: "District of Columbia", Region: 3 }, 55 | { State: "American Samoa", Region: 9 }, 56 | { State: "Guam", Region: 9 }, 57 | { State: "Northern Mariana Islands", Region: 9 }, 58 | ]; 59 | 60 | const DEFAULT_FILTER_OPTIONS = { 61 | shouldSort: true, 62 | threshold: 0.2, 63 | location: 0, 64 | distance: 10000000, 65 | findAllMatches: true, 66 | keys: [ 67 | { 68 | name: "allText", 69 | weight: 0.99, 70 | }, 71 | ], 72 | }; 73 | 74 | const TAG_ONLY_OPTIONS = { 75 | shouldSort: true, 76 | threshold: 0.4, 77 | findAllMatches: true, 78 | location: 0, 79 | distance: 100, 80 | maxPatternLength: 32, 81 | minMatchCharLength: 1, 82 | keys: [ 83 | { 84 | name: "tags", 85 | weight: 0.99, 86 | }, 87 | ], 88 | }; 89 | 90 | module.exports = { 91 | STATE_REGION_MAP, 92 | TAG_ONLY_OPTIONS, 93 | DEFAULT_FILTER_OPTIONS, 94 | }; 95 | -------------------------------------------------------------------------------- /backend/utils/error-handler.js: -------------------------------------------------------------------------------- 1 | const Boom = require("@hapi/boom"); 2 | const errorHandler = (err, req, res, next) => { 3 | console.error(err); 4 | res.status(500).send(Boom.badImplementation("terrible implementation")); 5 | }; 6 | 7 | module.exports = errorHandler; 8 | -------------------------------------------------------------------------------- /backend/utils/error-wrap.js: -------------------------------------------------------------------------------- 1 | const errorWrap = (fn) => (req, res, next) => { 2 | Promise.resolve(fn(req, res, next)).catch(next); 3 | }; 4 | module.exports = errorWrap; 5 | -------------------------------------------------------------------------------- /backend/utils/joi-validators.js: -------------------------------------------------------------------------------- 1 | const { Joi: JoiOriginal } = require("celebrate"); 2 | const { resourceEnum } = require("../models/Resource"); 3 | // Joi.objectId = require("joi-objectid")(Joi); 4 | // Workaround 5 | // https://github.com/hapijs/joi/issues/556#issuecomment-593115711 6 | const Joi = JoiOriginal.extend({ 7 | // we want to apply this extension to all available types 8 | type: /.*/, 9 | rules: { 10 | requiredAtFirst: { 11 | method() { 12 | // we apply 'required()' only if the schema 13 | // is tailored to the 'finally' target 14 | // see https://github.com/hapijs/joi/blob/master/API.md#anyaltertargets 15 | return this.alter({ 16 | post: (schema) => schema.required(), 17 | }); 18 | }, 19 | }, 20 | }, 21 | }); 22 | 23 | const BASE_RESOURCE = Joi.object().keys({ 24 | contactName: Joi.string().requiredAtFirst(), 25 | contactPhone: Joi.string().allow(""), 26 | contactEmail: Joi.string().allow(""), 27 | address: Joi.string().requiredAtFirst(), 28 | location: Joi.object({ 29 | type: Joi.string().default("Point"), 30 | coordinates: Joi.array().length(2).items(Joi.number()), 31 | }).default({ type: "Point", coordinates: [0, 0] }), 32 | websiteURL: Joi.string().allow(""), 33 | notes: Joi.string().allow(""), 34 | tags: Joi.array().items(Joi.string()), 35 | type: Joi.string() 36 | .valid(resourceEnum.INDIVIDUAL, resourceEnum.GROUP, resourceEnum.TANGIBLE) 37 | .default(resourceEnum.INDIVIDUAL), 38 | }); 39 | 40 | const INDIVIDUAL_RESOURCE = BASE_RESOURCE.keys({ 41 | availability: Joi.string().allow(""), 42 | howDiscovered: Joi.string().allow(""), 43 | volunteerReason: Joi.string().allow(""), 44 | skills: Joi.string().allow(""), 45 | volunteerRoles: Joi.string().allow(""), 46 | }); 47 | 48 | const GROUP_RESOURCE = BASE_RESOURCE.keys({ 49 | description: Joi.string().allow(""), 50 | companyName: Joi.string().requiredAtFirst(), 51 | }); 52 | 53 | const TANGIBLE_RESOURCE = BASE_RESOURCE.keys({ 54 | description: Joi.string().allow(""), 55 | quantity: Joi.string().allow(""), 56 | resourceName: Joi.string().requiredAtFirst(), 57 | }); 58 | 59 | const RESOURCE_SCHEMA = Joi.alternatives().conditional(".type", { 60 | switch: [ 61 | { is: resourceEnum.INDIVIDUAL, then: INDIVIDUAL_RESOURCE }, 62 | { is: resourceEnum.GROUP, then: GROUP_RESOURCE }, 63 | { is: resourceEnum.TANGIBLE, then: TANGIBLE_RESOURCE }, 64 | ], 65 | }); 66 | 67 | module.exports = { 68 | POST_RESOURCE_SCHEMA: RESOURCE_SCHEMA.tailor("post"), 69 | PUT_RESOURCE_SCHEMA: RESOURCE_SCHEMA, 70 | }; 71 | -------------------------------------------------------------------------------- /backend/utils/logging-middleware.js: -------------------------------------------------------------------------------- 1 | const expressWinston = require("express-winston"); 2 | const winston = require("winston"); 3 | const { filterSensitiveInfo } = require("./user-utils"); 4 | require("winston-loggly-bulk"); 5 | 6 | // Will throw if LOGGLY_TOKEN isn't set upon instantiation, so check that we're in a prod environment with the token 7 | if (process.env.LOGGLY_TOKEN) { 8 | module.exports.requestLogger = expressWinston.logger({ 9 | transports: [ 10 | new winston.transports.Loggly({ 11 | subdomain: "h4i", 12 | inputToken: process.env.LOGGLY_TOKEN, 13 | json: true, 14 | colorize: true, 15 | tags: ["Winston-Request"], 16 | }), 17 | ], 18 | exitOnError: false, 19 | format: winston.format.json(), 20 | dynamicMeta: (req) => ({ 21 | user: req.user ? filterSensitiveInfo(req.user) : null, 22 | }), 23 | }); 24 | 25 | module.exports.errorLogger = expressWinston.errorLogger({ 26 | transports: [ 27 | new winston.transports.Loggly({ 28 | subdomain: "h4i", 29 | inputToken: process.env.LOGGLY_TOKEN, 30 | json: true, 31 | colorize: true, 32 | tags: ["Winston-Error"], 33 | }), 34 | ], 35 | exitOnError: false, 36 | format: winston.format.json(), 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/passport-setup.js: -------------------------------------------------------------------------------- 1 | const GoogleStrategy = require("passport-google-oauth20").Strategy; 2 | const passport = require("passport"); 3 | const User = require("../models/User"); 4 | const { roleEnum } = require("../models/User"); 5 | const beeline = require("honeycomb-beeline"); 6 | 7 | // Defines the default role a user gets assigned with upon first sign-in 8 | const DEFAULT_ROLE = process.env.DEFAULT_ROLE || roleEnum.PENDING; 9 | // Currently not being used but may be implemented in the future 10 | const DEFAULT_LOC = "NORTH"; 11 | 12 | passport.serializeUser((user, done) => { 13 | done(null, user._id); 14 | }); 15 | 16 | passport.deserializeUser((id, done) => { 17 | const span = beeline.startSpan({ name: "Deserialize User Query" }); 18 | // Find in DB and return user 19 | User.findById(id, function (err, user) { 20 | beeline.finishSpan(span); 21 | if (err) { 22 | console.error("err"); 23 | done(err); 24 | } 25 | done(null, user); 26 | }); 27 | }); 28 | 29 | passport.use( 30 | new GoogleStrategy( 31 | { 32 | clientID: process.env.GOOGLE_CLIENT_ID, 33 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 34 | callbackURL: process.env.OAUTH_CALLBACK_URI, 35 | }, 36 | function (accessToken, refreshToken, profile, cb) { 37 | // find the user in the database based on their facebook id 38 | const span = beeline.startSpan({ name: "OAuth DB Fetch" }); 39 | User.findOne({ oauthId: profile.id }, async function (err, user) { 40 | if (err) { 41 | return cb(err); 42 | } 43 | 44 | if (user) { 45 | return cb(null, user); 46 | } 47 | const newUser = await new User({ 48 | firstName: profile.name.givenName, 49 | lastName: profile.name.familyName, 50 | oauthId: profile.id, 51 | propicUrl: profile.photos[0].value, 52 | role: DEFAULT_ROLE, 53 | location: DEFAULT_LOC, 54 | email: profile.emails[0].value, 55 | }).save(); 56 | beeline.finishSpan(span); 57 | 58 | cb(null, newUser); 59 | }); 60 | } 61 | ) 62 | ); 63 | -------------------------------------------------------------------------------- /backend/utils/user-utils.js: -------------------------------------------------------------------------------- 1 | const R = require("ramda"); 2 | const { renameKeys } = require("ramda-adjunct"); 3 | module.exports.filterSensitiveInfo = R.pipe( 4 | R.pick([ 5 | "_id", 6 | "firstName", 7 | "lastName", 8 | "role", 9 | "title", 10 | "location", 11 | "propicUrl", 12 | "email", 13 | ]), 14 | renameKeys({ _id: "id" }) 15 | ); 16 | -------------------------------------------------------------------------------- /backend/utils/volunteer-sheet-parser.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | // run `node utils/volunteer-sheet-parser.js ` from the `/backend` folder 3 | 4 | const XLSX = require("xlsx"); 5 | const extractor = require("keyword-extractor"); 6 | const resourceUtils = require("./resource-utils"); 7 | const IndividualResource = require("../models/IndividualResource"); 8 | const mongoose = require("mongoose"); 9 | 10 | mongoose.connect(process.env.DB_URI, { 11 | useUnifiedTopology: true, 12 | useNewUrlParser: true, 13 | }); 14 | 15 | const createTags = ({ 16 | "18 or Older?": eighteen, 17 | "Skills, Qualifications, Current Occupation": skills, 18 | "Do you have a degree in the mental health field?": mentalHealth, 19 | "Volunteer Roles": roles, 20 | "Willing to travel?": travel, 21 | }) => [ 22 | eighteen === "Yes" ? "18+" : null, 23 | mentalHealth === "Yes" ? "Mental Health Certified" : null, 24 | travel === "Yes" ? "Can Travel" : null, 25 | // ...extractor.extract(`${skills} ${roles}`, { 26 | // language: "english", 27 | // remove_digits: true, 28 | // return_changed_case: true, 29 | // remove_duplicates: true, 30 | // }), 31 | ]; 32 | 33 | const getLocation = async (mailingAddress) => { 34 | if (!mailingAddress) { 35 | return { 36 | location: { 37 | coordinates: [null, null], 38 | }, 39 | federalRegion: -1, 40 | }; 41 | } 42 | try { 43 | const { lat, lng, region, ...address } = await resourceUtils.geocodeAddress( 44 | mailingAddress 45 | ); 46 | return { 47 | location: { 48 | coordinates: [lng, lat], 49 | }, 50 | federalRegion: region, 51 | address, 52 | }; 53 | } catch (err) { 54 | console.log(mailingAddress); 55 | console.log(err); 56 | throw "Bad address"; 57 | } 58 | }; 59 | 60 | const formatDate = (date) => new Date(date); 61 | 62 | const convertSchema = async (entry) => ({ 63 | contactName: `${entry["First Name"]} ${entry["Last Name"]}`, 64 | dateCreated: formatDate(entry["Form Received"]), 65 | contactEmail: entry["Email Address"], 66 | skills: entry["Skills, Qualifications, Current Occupation"], 67 | availability: entry["Availability"], 68 | volunteerRoles: entry["Volunteer Roles"], 69 | volunteerReason: entry["Why would you like to volunteer with us?"], 70 | howDiscovered: entry["How did you hear about Life After Hate?"], 71 | notes: "", 72 | tags: createTags(entry).filter((tag) => tag !== null), 73 | ...(await getLocation(entry["Mailing Address"])), 74 | }); 75 | 76 | const main = async () => { 77 | const spreadsheet = process.argv[2]; 78 | 79 | const workbook = XLSX.readFile(spreadsheet); 80 | const json = XLSX.utils.sheet_to_json( 81 | workbook.Sheets[workbook.SheetNames[0]], 82 | { defval: "", raw: false } 83 | ); 84 | try { 85 | const mongoData = await Promise.all(json.map(convertSchema)); 86 | const resources = mongoData.map( 87 | (resource) => new IndividualResource(resource) 88 | ); 89 | await Promise.all(resources.map((r) => r.save())); 90 | } catch (err) { 91 | console.log(err); 92 | } finally { 93 | mongoose.connection.close(); 94 | } 95 | }; 96 | 97 | main(); 98 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "frontend/src/serviceWorker.js" 3 | - "backend/utils/logging-middleware.js" 4 | - "backend/utils/passport-setup.js" 5 | - "backend/routes/api/auth/login.js" 6 | coverage: 7 | status: 8 | project: 9 | default: 10 | threshold: 15 # Allow coverage to drop by up to 15% in a PR before marking it failed 11 | patch: off 12 | -------------------------------------------------------------------------------- /docker-compose.cypress.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | version: "3.7" 3 | services: 4 | 5 | prod-backend: 6 | build: ./backend 7 | command: "npm run start:coverage" 8 | depends_on: 9 | - db 10 | env_file: 11 | - ./.env 12 | environment: 13 | - PORT=5000 14 | - NODE_ENV=test 15 | - BYPASS_AUTH_ROLE=ADMIN 16 | ports: 17 | - "5000:5000" 18 | 19 | db: 20 | image: mongo 21 | init: true 22 | logging: 23 | driver: none 24 | ports: 25 | - "27017:27017" 26 | volumes: 27 | - "mongo_data:/data/db" 28 | 29 | prod-frontend: 30 | stdin_open: true 31 | env_file: 32 | - ./.env 33 | build: 34 | context: ./frontend 35 | dockerfile: Dockerfile 36 | command: "npm run start:coverage" 37 | depends_on: 38 | - prod-backend 39 | - db 40 | environment: 41 | - REACT_APP_API_PORT=5000 42 | - PORT=3000 43 | - DEBUG=instrument-cra 44 | ports: 45 | - "3000:3000" 46 | 47 | volumes: 48 | mongo_data: ~ 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | version: "3.7" 3 | services: 4 | frontend: 5 | env_file: 6 | - ./.env 7 | build: ./frontend 8 | volumes: 9 | - ./frontend:/var/www/app 10 | # expose a named volume that works because installing 11 | # and then mounting will hide that folder, so we need to explicitly 12 | # show it again 13 | # note that when reinstalling modules, we need to remove this volume 14 | - fe_modules:/var/www/app/node_modules 15 | ports: 16 | - 3000:3000 17 | # Debugger port 18 | - 9222:9222 19 | environment: 20 | # The port hooked up to the API 21 | - REACT_APP_API_PORT=5000 22 | # Defines what port to use for development, i.e. localhost:$PORT for the frontend 23 | - PORT=3000 24 | command: 25 | 'npm start' 26 | depends_on: 27 | - backend 28 | - db 29 | tty: true 30 | backend: 31 | build: ./backend 32 | env_file: 33 | - ./.env 34 | volumes: 35 | - ./backend:/var/www/app 36 | - ./assets:/var/www/app/assets 37 | # expose a named volume that works because installing 38 | # and then mounting will hide that folder, so we need to explicitly 39 | # show it again 40 | # note that when reinstalling modules, we need to remove this volume 41 | - be_modules:/var/www/app/node_modules 42 | ports: 43 | - 5000:5000 44 | # Debugger port 45 | - 9229:9229 46 | environment: 47 | # Defines what port to use for development, i.e. localhost:$PORT for the frontend 48 | - PORT=5000 49 | - BYPASS_AUTH_ROLE=${BYPASS_AUTH_ROLE} 50 | command: 51 | 'npm run start:dev' 52 | depends_on: 53 | - db 54 | db: 55 | image: 'mongo' 56 | init: true 57 | logging: 58 | driver: none 59 | ports: 60 | - "27017:27017" 61 | volumes: 62 | - mongo_data:/data/db 63 | 64 | # explicitly defines the volumes so that docker-compose down -v will destroy them 65 | volumes: 66 | fe_modules: 67 | be_modules: 68 | mongo_data: 69 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.0.0 2 | 3 | WORKDIR '/var/www/app' 4 | # Install app dependencies 5 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 6 | # where available (npm@5+) 7 | COPY package*.json ./ 8 | 9 | RUN npm ci --no-optional 10 | # If you are building your code for production 11 | # RUN npm ci --only=production 12 | # Bundle app source 13 | COPY . . 14 | EXPOSE 3000 15 | CMD [ "npm", "start" ] 16 | 17 | -------------------------------------------------------------------------------- /frontend/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM node:20.0.0 2 | 3 | WORKDIR '/var/www/app' 4 | # Install app dependencies 5 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 6 | # where available (npm@5+) 7 | 8 | 9 | COPY package*.json ./ 10 | 11 | RUN npm ci --only=production 12 | 13 | RUN npm install -g serve 14 | 15 | # If you are building your code for production 16 | 17 | # Bundle app source 18 | COPY . . 19 | EXPOSE 3000 20 | ENV NODE_ENV production 21 | RUN npm run build 22 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /frontend/core: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/core -------------------------------------------------------------------------------- /frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "wkbv1g" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /frontend/cypress/integration/access_control/pending.spec.js: -------------------------------------------------------------------------------- 1 | context("Pending", () => { 2 | beforeEach(() => { 3 | // Set the current role to PENDING 4 | cy.setRole("PENDING"); 5 | cy.visit(Cypress.env("BASE_URI")); 6 | }); 7 | 8 | it("Shows the LAH logo", () => { 9 | cy.get("[data-cy=logo]").should("be.visible"); 10 | }); 11 | 12 | it("Displays a pending message", () => { 13 | cy.get("[data-cy=pending]").should("be.visible"); 14 | }); 15 | 16 | it("Should not have a navbar", () => { 17 | cy.get("[data-cy=nav-links]").should("not.exist"); 18 | }); 19 | 20 | it("Should not display any map", () => { 21 | cy.get("#deckgl-overlay").should("not.exist"); 22 | }); 23 | 24 | it("Visiting the URLs as a pending user shows pending", () => { 25 | cy.visit(`${Cypress.env("BASE_URI")}/users`); 26 | 27 | cy.get("[data-cy=pending]").should("be.visible"); 28 | 29 | cy.visit(`${Cypress.env("BASE_URI")}/directory`); 30 | cy.get("[data-cy=pending]").should("be.visible"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/cypress/integration/access_control/rejected.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Rejected User", () => { 4 | beforeEach(() => { 5 | // Set the current role to REJECTED 6 | cy.setRole("REJECTED"); 7 | cy.visit(Cypress.env("BASE_URI")); 8 | }); 9 | 10 | it("By default goes to the login page", () => { 11 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/login`); 12 | }); 13 | 14 | it("Does not show the navbar", () => { 15 | cy.get("[data-cy=nav-links]").should("not.exist"); 16 | }); 17 | 18 | it("Redirects to login after trying to visit all pages", () => { 19 | cy.visit(`${Cypress.env("BASE_URI")}/users`); 20 | 21 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/login`); 22 | 23 | cy.visit(`${Cypress.env("BASE_URI")}/directory`); 24 | 25 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/login`); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /frontend/cypress/integration/access_control/volunteer.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Volunteer User", () => { 4 | beforeEach(() => { 5 | // Set the current role to VOLUNTEER 6 | cy.setRole("VOLUNTEER"); 7 | cy.visit(Cypress.env("BASE_URI")); 8 | }); 9 | 10 | it("Shows a navbar without user management", () => { 11 | cy.get("[data-cy=nav-links]") 12 | .children() 13 | .should("have.length", 3) 14 | .should("contain.text", "Directory") 15 | .and("contain.text", "Map") 16 | .and("not.contain.text", "Account Management"); 17 | }); 18 | 19 | it("Redirects to home after trying to visit users page", () => { 20 | cy.visit(`${Cypress.env("BASE_URI")}/users`); 21 | 22 | cy.url().should("not.be", `${Cypress.env("BASE_URI")}/users`); 23 | }); 24 | 25 | it("Should have no edit button on directory page", () => { 26 | cy.visit(`${Cypress.env("BASE_URI")}/directory`); 27 | cy.get("#search-button").click(); 28 | 29 | // Should display results 30 | cy.get("[data-cy=card-address]").should("have.length.gt", 10); 31 | 32 | cy.get(".edit-button").should("not.exist"); 33 | }); 34 | 35 | it("Should show only edit buttons on map view", () => { 36 | cy.visit(`${Cypress.env("BASE_URI")}`); 37 | cy.get(".submitSearch").click(); 38 | cy.get(".card-title").first().click(); 39 | 40 | // Wait until the view button is visible 41 | cy.get("[data-cy=card-resource-view-btn]").first().should("be.visible"); 42 | 43 | cy.get("[data-cy=card-resource-edit-btn]").should("not.exist"); 44 | 45 | cy.get("[data-cy=card-resource-view-btn]") 46 | .first() 47 | .should("contain.text", "View") 48 | .click(); 49 | 50 | cy.get(".modal-input-field") 51 | .first() 52 | .should("be.visible") 53 | .and("be.disabled"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /frontend/cypress/integration/features/extra.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Extra Tests", () => { 4 | beforeEach(() => { 5 | // Set the current role to ADMIN 6 | cy.setRole("ADMIN"); 7 | cy.visit(`${Cypress.env("BASE_URI")}/login`); 8 | }); 9 | 10 | it("Properly redirects on login when authed", () => { 11 | cy.get("[data-cy=nav-links]").children().eq(0).should("have.text", "Map"); 12 | }); 13 | 14 | it("Properly boots user on unauthorized request", () => { 15 | cy.server(); 16 | cy.route({ 17 | method: "GET", 18 | url: "**/api/resources/filter**", 19 | status: 401, 20 | response: [], 21 | }); 22 | 23 | cy.get(".submitSearch").click(); 24 | 25 | cy.get(".Toastify__toast-body").should( 26 | "contain.text", 27 | "You have been signed out" 28 | ); 29 | 30 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/login`); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/cypress/integration/features/navbar.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Navbar", () => { 4 | beforeEach(() => { 5 | // Since the toast hides the navigation buttons 6 | Cypress.config("defaultCommandTimeout", 10000); 7 | cy.setRole("ADMIN"); 8 | cy.visit(Cypress.env("BASE_URI")); 9 | }); 10 | 11 | it("Page title is correct", () => { 12 | // https://on.cypress.io/title 13 | cy.title().should("eq", "Map View - Life After Hate"); 14 | }); 15 | 16 | it("Has a navbar with 3 links", () => { 17 | cy.get("[data-cy=nav-links]").children().eq(0).should("have.text", "Map"); 18 | cy.get("[data-cy=nav-links]") 19 | .children() 20 | // Should be up to 4 since the dropdown counts as a nav child 21 | .should("have.length", 4) 22 | .should("contain.text", "Directory") 23 | .and("contain.text", "Map") 24 | .and("contain.text", "Account Management"); 25 | }); 26 | 27 | it("Dropdown functionality with logout works as expected", () => { 28 | cy.get(".dropdown-toggle").click(); 29 | cy.get(".dropdown-header") 30 | .should("be.visible") 31 | .and("contain.text", "John Doe"); 32 | 33 | cy.get("#signout-button").click(); 34 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/login`); // tests won't fail in case the port changes 35 | }); 36 | 37 | it("Page navigation works as expected", () => { 38 | cy.get("[data-cy=nav-links]") 39 | .children() 40 | .eq(1) 41 | .should("have.text", "Directory") 42 | .click(); 43 | 44 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/directory`); 45 | cy.title().should("eq", "Directory View - Life After Hate"); 46 | 47 | cy.get("[data-cy=nav-links]") 48 | .children() 49 | .eq(2) 50 | .should("have.text", "Account Management") 51 | .click(); 52 | 53 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/users`); 54 | cy.title().should("eq", "Account Management - Life After Hate"); 55 | 56 | cy.get("[data-cy=nav-links]") 57 | .children() 58 | .eq(0) 59 | .should("have.text", "Map") 60 | .click(); 61 | 62 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/`); 63 | cy.title().should("eq", "Map View - Life After Hate"); 64 | }); 65 | 66 | it("Clicking on the logo takes back to the map view", () => { 67 | cy.visit(`${Cypress.env("BASE_URI")}/directory`); 68 | cy.get("#logo").click(); 69 | 70 | cy.url().should("eq", `${Cypress.env("BASE_URI")}/`); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /frontend/cypress/integration/features/user_panel.spec.js: -------------------------------------------------------------------------------- 1 | // Though waits are almost always discouraged, we have no choice but to wait 2 | // For the click event listener to attach to the map view pins 3 | /* eslint-disable cypress/no-unnecessary-waiting */ 4 | /// 5 | 6 | context("User Panel", () => { 7 | beforeEach(() => { 8 | cy.setRole("ADMIN"); 9 | cy.visit(`${Cypress.env("BASE_URI")}/users`); 10 | }); 11 | 12 | it("Has the correct directory elements", () => { 13 | cy.get(".manager-header").should("contain.text", "User Directory"); 14 | cy.get(".user-labels").should("contain.text", "Account Type"); 15 | }); 16 | 17 | it("Should have clickable users", () => { 18 | cy.get(".card-wrapper > :nth-child(1)") 19 | .should("have.length", 30) 20 | .first() 21 | .should("have.text", "Amanda Osborn") 22 | .click(); 23 | 24 | cy.get(".modal-title").should("have.text", "Amanda Osborn"); 25 | 26 | cy.get("[data-cy=modal-name]") 27 | .should("have.value", "Amanda Osborn") 28 | .and("be.disabled"); 29 | cy.get("[data-cy=modal-submit]").should("not.be.visible"); 30 | 31 | // Close modal 32 | cy.get(".close-button").click(); 33 | cy.get(".modal-title").should("not.be.visible"); 34 | }); 35 | 36 | it("Allows editing", () => { 37 | cy.get(".edit-button").should("have.length", 30).first().click(); 38 | 39 | cy.get(".modal-title").should("have.text", "Edit User"); 40 | 41 | // Name should be un-editable 42 | cy.get("[data-cy=modal-name]") 43 | .should("have.value", "Amanda Osborn") 44 | .and("be.disabled"); 45 | 46 | cy.get("[data-cy=modal-role]").select("VOLUNTEER"); 47 | 48 | cy.get("[data-cy=modal-submit]").click(); 49 | 50 | cy.get(".Toastify__toast").should( 51 | "contain.text", 52 | "Successfully edited user" 53 | ); 54 | 55 | cy.get(".modal-title").should("not.exist"); 56 | cy.get(".card-wrapper > :nth-child(3)") 57 | .first() 58 | .should("have.text", "VOLUNTEER"); 59 | }); 60 | 61 | it("Properly filters by role", () => { 62 | cy.get("[data-cy=user-filter]").click(); 63 | cy.get('[value="PENDING"]').click(); 64 | 65 | cy.get(".card-wrapper > :nth-child(3)").each(($el) => { 66 | cy.wrap($el).should("have.text", "PENDING"); 67 | }); 68 | 69 | cy.get("[data-cy=user-filter]").click(); 70 | cy.get('[value="REJECTED"]').click(); 71 | 72 | cy.get(".card-wrapper > :nth-child(3)").each(($el) => { 73 | cy.wrap($el).should("have.text", "REJECTED"); 74 | }); 75 | 76 | cy.get("[data-cy=user-filter]").click(); 77 | cy.get('[value="ACTIVE"]').click(); 78 | 79 | cy.get(".card-wrapper > :nth-child(3)").each(($el) => { 80 | cy.wrap($el).contains(/ADMIN|VOLUNTEER/g); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | require("@cypress/code-coverage/task")(on, config); 16 | // IMPORTANT to return the config object 17 | // with the any changed environment variables 18 | return config; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | // Used to set the current role 28 | Cypress.Commands.add("setRole", (role) => { 29 | cy.request({ 30 | url: `${Cypress.env("API_URI")}/api/test/setRole/${role}`, 31 | method: "GET", 32 | }); 33 | }); 34 | 35 | Cypress.Commands.add("createDirectoryViewResource", (name) => { 36 | cy.get("#add-button").click(); 37 | cy.get(".modal-title").should("have.text", "Add Resource"); 38 | cy.get("[data-cy=modal-resourceType]").select("GROUP"); 39 | cy.get("[data-cy=modal-companyName]").type(name); 40 | cy.get("[data-cy=modal-contactName]").type("Testing"); 41 | cy.get("[data-cy=modal-contactPhone]").type("1-234-567-8900"); 42 | cy.get("[data-cy=modal-contactEmail]").type("alanfang@gmail.com"); 43 | cy.get("[data-cy=modal-description").type( 44 | "This is a test description! This should show up in its entirety." 45 | ); 46 | cy.get(".add-edit-resource-form > > [data-cy=tag-autocomplete]") 47 | .type("Sample Tag{enter}") 48 | .type("c{enter}"); 49 | cy.get("[data-cy=modal-address").type("1234 W. Main St. Urbana, IL 61801"); 50 | cy.get("[data-cy=modal-notes").type( 51 | "This is a notes field! You can enter notes here." 52 | ); 53 | cy.get("#submit-form-button").click(); 54 | cy.get(".Toastify__toast-body").should("contain.text", "Success"); 55 | }); 56 | -------------------------------------------------------------------------------- /frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | import "@cypress/code-coverage/support"; 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "life-after-hate", 3 | "version": "0.1.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/hack4impact-uiuc/life-after-hate" 8 | }, 9 | "dependencies": { 10 | "@cypress/instrument-cra": "^1.4.0", 11 | "@material-ui/core": "^4.11.3", 12 | "@material-ui/lab": "^4.0.0-alpha.57", 13 | "axios": "^0.21.1", 14 | "bootstrap": "^4.5.3", 15 | "date-fns": "^2.16.1", 16 | "deck.gl": "^8.4.13", 17 | "file-saver": "^2.0.5", 18 | "istanbul-lib-coverage": "^3.0.0", 19 | "json2csv": "^5.0.5", 20 | "logrocket": "^1.0.14", 21 | "mapbox-gl": "^1.13.0", 22 | "node-fetch": "^2.6.1", 23 | "nprogress": "^0.2.0", 24 | "nyc": "^15.1.0", 25 | "ramda": "^0.27.1", 26 | "react": "^16.14.0", 27 | "react-dom": "^16.14.0", 28 | "react-hook-form": "^6.4.1", 29 | "react-input-range": "^1.3.0", 30 | "react-map-gl": "^5.2.11", 31 | "react-redux": "^7.2.2", 32 | "react-router-dom": "^5.2.0", 33 | "react-scripts": "^5.0.1", 34 | "react-spinners": "^0.10.4", 35 | "react-toastify": "^7.0.3", 36 | "react-virtualized": "^9.22.3", 37 | "reactstrap": "^8.7.1", 38 | "redux": "^4.0.5", 39 | "redux-logger": "^3.0.6", 40 | "reselect": "^4.0.0", 41 | "sass": "^1.71.1", 42 | "url-join": "^4.0.1" 43 | }, 44 | "scripts": { 45 | "start": "react-scripts start", 46 | "start:coverage": "react-scripts -r @cypress/instrument-cra start", 47 | "build": "react-scripts build", 48 | "test": "react-scripts test", 49 | "eject": "react-scripts eject" 50 | }, 51 | "browserslist": { 52 | "production": [ 53 | ">0.2%", 54 | "not dead", 55 | "not op_mini all" 56 | ], 57 | "development": [ 58 | "last 1 chrome version", 59 | "last 1 firefox version", 60 | "last 1 safari version" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 19 | 20 | 29 | Life After Hate 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "LAH", 3 | "name": "Life After Hate", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-filename-extension */ 2 | import React, { Component } from "react"; 3 | import { 4 | BrowserRouter as Router, 5 | Route, 6 | Redirect, 7 | Switch, 8 | } from "react-router-dom"; 9 | import { Provider } from "react-redux"; 10 | import PrivateRoute from "./components/PrivateRoute"; 11 | import Login from "./pages/Auth/Login"; 12 | import MapView from "./pages/MapView"; 13 | import DirectoryView from "./pages/DirectoryView"; 14 | import AdminView from "./pages/AdminView"; 15 | import MiniLoader from "./components/Loader/mini-loader"; 16 | import ModalManager from "./components/Modal/ModalManager"; 17 | import { roleEnum } from "./utils/enums"; 18 | import store from "./redux/store"; 19 | import { refreshGlobalAuth } from "./utils/api"; 20 | import { ToastContainer } from "react-toastify"; 21 | import "react-toastify/dist/ReactToastify.css"; 22 | import Analytics from "./components/Analytics"; 23 | class App extends Component { 24 | componentDidMount = refreshGlobalAuth; 25 | 26 | render() { 27 | return ( 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 47 | 48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /frontend/src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/assets/images/business-resource.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /frontend/src/assets/images/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/close2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/close3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/images/edit-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/assets/images/expand-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /frontend/src/assets/images/expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/images/google_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/images/individual-resource.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /frontend/src/assets/images/lah-logo-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/src/assets/images/lah-logo-2.png -------------------------------------------------------------------------------- /frontend/src/assets/images/lah-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/src/assets/images/lah-logo.png -------------------------------------------------------------------------------- /frontend/src/assets/images/location-directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/location-icon-highlighted.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/location-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/location.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/marker-atlas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/src/assets/images/marker-atlas.png -------------------------------------------------------------------------------- /frontend/src/assets/images/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/frontend/src/assets/images/marker.png -------------------------------------------------------------------------------- /frontend/src/assets/images/maximize-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/maximize.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/pencil-edit-button-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/images/pencil-edit-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/images/pending-check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /frontend/src/assets/images/search-directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/tag-directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/tag.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/tangible-resource.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/user-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/images/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/Analytics/index.jsx: -------------------------------------------------------------------------------- 1 | import LogRocket from "logrocket"; 2 | import { connect } from "react-redux"; 3 | import PropTypes from "prop-types"; 4 | import { roleEnum } from "../../utils/enums"; 5 | if (process.env.REACT_APP_LOGROCKET_ACCESS_TOKEN) { 6 | LogRocket.init(process.env.REACT_APP_LOGROCKET_ACCESS_TOKEN); 7 | } 8 | // LogRocket.init("znq4bo/life-after-hate-prod"); 9 | const Analytics = ({ firstName, lastName, email, role }) => { 10 | if (email && process.env.REACT_APP_LOGROCKET_ACCESS_TOKEN) { 11 | LogRocket.identify(email, { 12 | name: `${firstName} ${lastName}`, 13 | email, 14 | role, 15 | }); 16 | } 17 | return null; 18 | }; 19 | 20 | const mapStateToProps = (state) => ({ 21 | firstName: state.auth.firstName, 22 | lastName: state.auth.lastName, 23 | email: state.auth.email, 24 | role: state.auth.role, 25 | }); 26 | 27 | const mapDispatchToProps = {}; 28 | 29 | Analytics.propTypes = { 30 | firstName: PropTypes.string, 31 | lastName: PropTypes.string, 32 | email: PropTypes.string, 33 | role: PropTypes.oneOf(Object.values(roleEnum)), 34 | }; 35 | 36 | export default connect(mapStateToProps, mapDispatchToProps)(Analytics); 37 | -------------------------------------------------------------------------------- /frontend/src/components/Auth/AdminView.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | import { roleEnum } from "../../utils/enums"; 3 | import PropTypes from "prop-types"; 4 | 5 | const AdminView = ({ role, children }) => 6 | role === roleEnum.ADMIN ? children : null; 7 | 8 | const mapStateToProps = (state) => ({ 9 | role: state.auth.role, 10 | }); 11 | 12 | AdminView.propTypes = { 13 | role: PropTypes.oneOf(Object.values(roleEnum)).isRequired, 14 | }; 15 | 16 | export default connect(mapStateToProps)(AdminView); 17 | -------------------------------------------------------------------------------- /frontend/src/components/CSVExporter/CSVExporter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button } from "reactstrap"; 4 | import FileSaver from "file-saver"; 5 | import { getCSV } from "../../utils/csv"; 6 | import Download from "../../assets/images/download.svg"; 7 | import "./styles.scss"; 8 | export const CSVExporter = ({ data, name = "resources.csv" }) => { 9 | const downloadFile = () => { 10 | const csv = getCSV(data); 11 | const csvBlob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); 12 | FileSaver.saveAs(csvBlob, name); 13 | }; 14 | return ( 15 | data.length > 0 && ( 16 |
17 | 25 |

Download CSV

26 |
27 | ) 28 | ); 29 | }; 30 | 31 | CSVExporter.propTypes = { 32 | data: PropTypes.array.isRequired, 33 | name: PropTypes.string, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/components/CSVExporter/styles.scss: -------------------------------------------------------------------------------- 1 | #csv-download-btn { 2 | padding: 0; 3 | img { 4 | height: 1.2em; 5 | opacity: 0.5; 6 | transition: all 0.2s; 7 | &:hover { 8 | opacity: 1; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Logo from "../../assets/images/lah-logo.png"; 3 | import ClipLoader from "react-spinners/ClipLoader"; 4 | import "./styles.scss"; 5 | function Loader() { 6 | return ( 7 |
8 | LAH Logo 9 | 10 |
11 | ); 12 | } 13 | export default Loader; 14 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/mini-loader.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { connect } from "react-redux"; 3 | import PropTypes from "prop-types"; 4 | import nProgress from "nprogress"; 5 | import "nprogress/nprogress.css"; 6 | 7 | // Shows progress bar at top when things are loading... 8 | const MiniLoader = ({ shouldShowLoader }) => { 9 | useEffect(() => { 10 | if (shouldShowLoader) { 11 | nProgress.start(); 12 | } else { 13 | nProgress.done(); 14 | } 15 | }); 16 | 17 | return null; 18 | }; 19 | const MapStateToProps = (state) => ({ 20 | shouldShowLoader: state.isLoading, 21 | }); 22 | 23 | MiniLoader.propTypes = { 24 | shouldShowLoader: PropTypes.bool, 25 | }; 26 | 27 | export default connect(MapStateToProps)(MiniLoader); 28 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/styles.scss: -------------------------------------------------------------------------------- 1 | .loading-spinner { 2 | position: absolute; 3 | img { 4 | position: absolute; 5 | margin: auto; 6 | margin: 50px; 7 | width: 100px; 8 | height: 100px; 9 | } 10 | left: 50%; 11 | top: 50%; 12 | -webkit-transform: translate(-50%, -50%); 13 | transform: translate(-50%, -50%); 14 | } 15 | 16 | #nprogress .bar, 17 | #nprogress .spinner { 18 | z-index: 1200 !important; 19 | height: 3px !important; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/LastModifiedInfo/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { formatDistance } from "date-fns"; 4 | 5 | import "./styles.scss"; 6 | const LastModifiedInfo = ({ resource }) => { 7 | const { dateLastModified, lastModifiedUser, dateCreated } = resource; 8 | return ( 9 | 20 | ); 21 | }; 22 | 23 | LastModifiedInfo.propTypes = { 24 | resource: PropTypes.shape({ 25 | dateLastModified: PropTypes.string, 26 | lastModifiedUser: PropTypes.string, 27 | dateCreated: PropTypes.string, 28 | }).isRequired, 29 | }; 30 | 31 | export default LastModifiedInfo; 32 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/LastModifiedInfo/styles.scss: -------------------------------------------------------------------------------- 1 | .last-modified { 2 | opacity: 0.7; 3 | font-size: 14px; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ModalInput/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const ModalInput = ({ 5 | componentRef, 6 | shortName, 7 | resource, 8 | errors, 9 | required, 10 | disabled, 11 | labelText, 12 | tag, 13 | ...passedInProps 14 | }) => { 15 | const props = { 16 | ref: componentRef, 17 | type: "text", 18 | name: shortName, 19 | "data-cy": `modal-${shortName}`, 20 | defaultValue: resource[shortName], 21 | className: `modal-input-field ${ 22 | required && errors[shortName] ? "invalid" : "" 23 | }`, 24 | disabled, 25 | ...passedInProps, 26 | }; 27 | 28 | const Tag = tag; 29 | return ( 30 | 34 | ); 35 | }; 36 | 37 | ModalInput.propTypes = { 38 | componentRef: PropTypes.elementType.isRequired, 39 | shortName: PropTypes.string.isRequired, 40 | resource: PropTypes.object.isRequired, 41 | errors: PropTypes.object, 42 | required: PropTypes.bool, 43 | disabled: PropTypes.bool, 44 | labelText: PropTypes.string, 45 | tag: PropTypes.string, 46 | }; 47 | 48 | export default ModalInput; 49 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ModalManager/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { modalEnum } from "../../../utils/enums"; 5 | import ResourceModal from "../ResourceModal"; 6 | import UserModal from "../UserModal"; 7 | 8 | const getComponent = (modalType) => { 9 | switch (modalType) { 10 | case modalEnum.RESOURCE: 11 | return ; 12 | case modalEnum.USER: 13 | return ; 14 | default: 15 | return <>; 16 | } 17 | }; 18 | 19 | const ModalManager = ({ isOpen, modalType }) => ( 20 |
{isOpen && getComponent(modalType)}
21 | ); 22 | 23 | const mapStateToProps = (state) => ({ 24 | modalType: state.modal.modalType, 25 | isOpen: state.modal.isOpen, 26 | }); 27 | 28 | ModalManager.propTypes = { 29 | isOpen: PropTypes.bool.isRequired, 30 | modalType: PropTypes.oneOf(Object.values(modalEnum)), 31 | }; 32 | 33 | export default connect(mapStateToProps)(ModalManager); 34 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ModalTagComplete/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import TagAutocomplete from "../../../components/TagAutocomplete"; 5 | import { globalTagListSelector } from "../../../redux/selectors/tags"; 6 | 7 | export const ModalTagComplete = ({ 8 | tags, 9 | onChange, 10 | globalTagList, 11 | defaultValue, 12 | disabled, 13 | }) => ( 14 | 22 | ); 23 | 24 | const mapStateToProps = (state) => ({ 25 | globalTagList: globalTagListSelector(state), 26 | }); 27 | 28 | ModalTagComplete.propTypes = { 29 | tags: PropTypes.arrayOf(PropTypes.string).isRequired, 30 | onChange: PropTypes.func, 31 | globalTagList: PropTypes.arrayOf(PropTypes.string).isRequired, 32 | defaultValue: PropTypes.string, 33 | disabled: PropTypes.bool, 34 | }; 35 | 36 | export default connect(mapStateToProps, null)(ModalTagComplete); 37 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ResourceModal/GroupResourceForm.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ResourceFormInput } from "./index"; 3 | 4 | const GroupResourceFields = [ 5 | { labelText: "Contact Name", shortName: "contactName", required: true }, 6 | { labelText: "Company Name", shortName: "companyName", required: true }, 7 | { labelText: "Contact Phone", shortName: "contactPhone" }, 8 | { labelText: "Contact Email", shortName: "contactEmail" }, 9 | { labelText: "Website", shortName: "websiteURL" }, 10 | { 11 | labelText: "Description", 12 | shortName: "description", 13 | tag: "textarea", 14 | rows: 7, 15 | }, 16 | { labelText: "Address", shortName: "address", required: true }, 17 | { labelText: "Notes", shortName: "notes", tag: "textarea", rows: 7 }, 18 | ]; 19 | 20 | const GroupResourceForm = (props) => 21 | GroupResourceFields.map((field) => ( 22 | 23 | )); 24 | 25 | export default GroupResourceForm; 26 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ResourceModal/IndividualResourceForm.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ResourceFormInput } from "./index"; 3 | 4 | const IndividualResourceFields = [ 5 | { labelText: "Contact Name", shortName: "contactName", required: true }, 6 | { labelText: "Contact Phone", shortName: "contactPhone" }, 7 | { labelText: "Contact Email", shortName: "contactEmail" }, 8 | { labelText: "Website", shortName: "websiteURL" }, 9 | { 10 | labelText: "Skills & Qualifications", 11 | shortName: "skills", 12 | tag: "textarea", 13 | rows: 7, 14 | }, 15 | { labelText: "Volunteer Roles", shortName: "volunteerRoles" }, 16 | { labelText: "Availability", shortName: "availability" }, 17 | { 18 | labelText: "Why Volunteer?", 19 | shortName: "volunteerReason", 20 | tag: "textarea", 21 | rows: 4, 22 | }, 23 | { labelText: "Address", shortName: "address", required: true }, 24 | { labelText: "Notes", shortName: "notes", tag: "textarea", rows: 7 }, 25 | ]; 26 | 27 | const IndividualResourceForm = (props) => 28 | IndividualResourceFields.map((field) => ( 29 | 30 | )); 31 | export default IndividualResourceForm; 32 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/ResourceModal/TangibleResourceForm.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ResourceFormInput } from "./index"; 3 | 4 | const TangibleResourceFields = [ 5 | { labelText: "Contact Name", shortName: "contactName", required: true }, 6 | { labelText: "Name of Resource", shortName: "resourceName", required: true }, 7 | { labelText: "Contact Phone", shortName: "contactPhone" }, 8 | { labelText: "Contact Email", shortName: "contactEmail" }, 9 | { labelText: "Website", shortName: "websiteURL" }, 10 | { 11 | labelText: "Description", 12 | shortName: "description", 13 | tag: "textarea", 14 | rows: 7, 15 | }, 16 | { 17 | labelText: "Resource Quantity", 18 | shortName: "quantity", 19 | tag: "input", 20 | }, 21 | { labelText: "Address", shortName: "address", required: true }, 22 | { labelText: "Notes", shortName: "notes", tag: "textarea", rows: 7 }, 23 | ]; 24 | 25 | const TangibleResourceForm = (props) => 26 | TangibleResourceFields.map((field) => ( 27 | 28 | )); 29 | export default TangibleResourceForm; 30 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button, Modal, ModalHeader, ModalBody } from "reactstrap"; 4 | import Close from "../../assets/images/close.svg"; 5 | import { connect } from "react-redux"; 6 | import { closeModal } from "../../redux/actions/modal"; 7 | import { titleSelector } from "../../redux/selectors/modal"; 8 | import "./styles.scss"; 9 | 10 | const LAHModal = ({ isOpen, closeModal, title, children }) => ( 11 | 12 | 13 | {title} 14 | 17 | 18 | 24 | {children} 25 | 26 | 27 | ); 28 | 29 | const mapStateToProps = (state) => ({ 30 | isOpen: state.modal.isOpen, 31 | title: titleSelector(state), 32 | }); 33 | 34 | const mapDispatchToProps = { 35 | closeModal, 36 | }; 37 | 38 | LAHModal.propTypes = { 39 | isOpen: PropTypes.bool, 40 | closeModal: PropTypes.func, 41 | title: PropTypes.string, 42 | children: PropTypes.element, 43 | }; 44 | export default connect(mapStateToProps, mapDispatchToProps)(LAHModal); 45 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/styles.scss: -------------------------------------------------------------------------------- 1 | .add-edit-resource-form { 2 | display: flex; 3 | flex-direction: column; 4 | padding-left: 30px; 5 | padding-right: 30px; 6 | padding-bottom: 20px; 7 | } 8 | 9 | .modal-input-field, 10 | .modal-select-field, 11 | .modal-lab { 12 | display: block; 13 | width: 100%; 14 | outline: none; 15 | } 16 | .modal-input-textarea { 17 | resize: vertical; 18 | min-height: 170px; 19 | padding: 5px 12px 5px 12px !important; 20 | } 21 | .modal-lab { 22 | margin-top: 20px; 23 | } 24 | 25 | .modal-lab p { 26 | font-size: 12px; 27 | margin: 0; 28 | margin-left: 5px; 29 | margin-bottom: 5px; 30 | text-transform: uppercase; 31 | } 32 | 33 | .modal-input-field { 34 | border: solid #f6f6f6 1.5px; 35 | background-color: rgb(243, 243, 243); 36 | border-radius: 3px; 37 | padding: 5px 12px 5px 12px; 38 | font-weight: 400 !important; 39 | transition: all 0.3s; 40 | font-size: 15px; 41 | } 42 | 43 | .modal-input-field.invalid { 44 | transition: all 0.3s; 45 | border: 1.5px solid rgb(255, 190, 190); 46 | } 47 | .modal-input-field.invalid:focus { 48 | border: solid red 1.5px; 49 | } 50 | 51 | .modal-input-field:focus { 52 | border: solid #f7922f 1.5px; 53 | } 54 | 55 | .modal-input-field:disabled { 56 | color: #2a2a2a; 57 | -webkit-text-fill-color: #2a2a2a; 58 | opacity: 1; /* required on iOS */ 59 | } 60 | 61 | .modal-backdrop { 62 | opacity: 0.7; 63 | } 64 | 65 | .modal { 66 | padding-top: 50px; 67 | } 68 | 69 | #delete-form-button { 70 | background-color: #f7462f; 71 | color: white; 72 | width: 90px !important; 73 | margin-left: 30px; 74 | font-size: 13px; 75 | text-transform: uppercase; 76 | font-weight: bold; 77 | border: none; 78 | outline: none; 79 | padding: 12px 0 12px 0; 80 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 81 | transition: all 0.2s; 82 | border-radius: 4px; 83 | &:hover, 84 | &:focus { 85 | background-color: #b31313; 86 | } 87 | } 88 | 89 | .modal-title { 90 | display: flex; 91 | flex-grow: 1; 92 | justify-content: space-between; 93 | align-items: center; 94 | padding-left: 10px; 95 | padding-right: 10px; 96 | font-size: 17px; 97 | } 98 | 99 | .close-button { 100 | outline: none !important; 101 | transition: all 0.2s; 102 | border-radius: 4px !important; 103 | } 104 | 105 | .close-button:hover { 106 | background-color: #f9f9f9 !important; 107 | } 108 | 109 | #close-image { 110 | height: 20px; 111 | } 112 | 113 | #submit-form-button { 114 | background-color: #f7922f; 115 | color: white; 116 | width: 90px !important; 117 | font-size: 13px; 118 | text-transform: uppercase; 119 | font-weight: bold; 120 | border: none; 121 | outline: none; 122 | padding: 12px 0 12px 0; 123 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 124 | transition: all 0.2s; 125 | border-radius: 4px; 126 | &:hover, 127 | &:focus { 128 | background-color: #f9ac61; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /frontend/src/components/Navbar/styles.scss: -------------------------------------------------------------------------------- 1 | .lah_navbar { 2 | background-color: white; 3 | box-shadow: 0 2px 6px 0px rgba(0, 0, 0, 0.25); 4 | z-index: 1000; 5 | font-family: "Roboto"; 6 | font-size: 0.9em; 7 | 8 | #logo { 9 | height: 30px; 10 | @media (max-width: 768px) { 11 | height: 20px; 12 | } 13 | } 14 | } 15 | 16 | .hover-orange { 17 | transition: all 0.2s; 18 | &:hover { 19 | color: #f79537 !important; 20 | transition: all 0.2s; 21 | } 22 | } 23 | 24 | #user-icon { 25 | height: 33px; 26 | padding-right: 3px; 27 | border-radius: 50%; 28 | } 29 | 30 | #signout-button { 31 | border: none; 32 | background-color: #f79230; 33 | color: #ffffff; 34 | font-weight: 700; 35 | text-transform: uppercase; 36 | font-size: 12px; 37 | padding: 8px; 38 | border-radius: 3px; 39 | width: 100%; 40 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 41 | &:hover { 42 | background-color: #f9ac61; 43 | } 44 | } 45 | 46 | div.navbar-dropdown { 47 | width: 50px; 48 | margin-left: 12px; 49 | 50 | #dropdown-button { 51 | outline: none; 52 | background-color: transparent; 53 | background-color: none; 54 | box-shadow: none; 55 | } 56 | 57 | .caret { 58 | color: #8b8b8b; 59 | } 60 | .dropdown-menu { 61 | border: none; 62 | box-shadow: 0 0 4px 0px rgba(0, 0, 0, 0.4); 63 | transform: translate3d(-100px, 45px, 0px) !important; 64 | outline: none; 65 | border-radius: 3px; 66 | text-align: right; 67 | width: auto; 68 | min-width: auto; 69 | padding: 8px 8px 6px 8px; 70 | width: 160px; 71 | max-width: 200px; 72 | } 73 | .dropdown-header { 74 | outline: none; 75 | padding: 0; 76 | margin: 0; 77 | width: auto; 78 | } 79 | 80 | .dropdown-menu p { 81 | color: #2a2a2a; 82 | text-align: left; 83 | margin: 0; 84 | margin-bottom: 12px; 85 | margin-top: 4px; 86 | font-size: 14px; 87 | } 88 | } 89 | 90 | .spacing { 91 | flex-grow: 1; 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/components/PrivateRoute/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Route, Redirect } from "react-router-dom"; 4 | import Loader from "../Loader"; 5 | import Navbar from "../Navbar"; 6 | import Pending from "../../pages/Auth/Pending"; 7 | import { connect } from "react-redux"; 8 | import { roleEnum } from "../../utils/enums"; 9 | 10 | function PrivateRoute({ 11 | component: Component, 12 | authed, 13 | role, 14 | showLoader, 15 | roleRequired, 16 | ...rest 17 | }) { 18 | const pending = role === roleEnum.PENDING; 19 | return ( 20 |
21 | { 24 | if (showLoader) { 25 | return ; 26 | } 27 | if (authed === true) { 28 | if (!pending) { 29 | if (!roleRequired || roleRequired === role) { 30 | return ( 31 |
32 | {authed && !pending && } 33 | 34 |
35 | ); 36 | } 37 | return ( 38 | 42 | ); 43 | } 44 | return ; 45 | } 46 | return ( 47 | 51 | ); 52 | }} 53 | /> 54 |
55 | ); 56 | } 57 | const mapStateToProps = (state) => ({ 58 | authed: state.auth.authenticated, 59 | role: state.auth.role, 60 | showLoader: state.auth.isFetchingAuth, 61 | }); 62 | 63 | PrivateRoute.propTypes = { 64 | component: PropTypes.elementType.isRequired, 65 | authed: PropTypes.bool.isRequired, 66 | role: PropTypes.oneOf(Object.values(roleEnum)), 67 | showLoader: PropTypes.bool.isRequired, 68 | roleRequired: PropTypes.oneOf(Object.values(roleEnum)), 69 | }; 70 | 71 | export default connect(mapStateToProps)(PrivateRoute); 72 | -------------------------------------------------------------------------------- /frontend/src/components/TagAutocomplete/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Chip from "@material-ui/core/Chip"; 4 | import Autocomplete from "@material-ui/lab/Autocomplete"; 5 | import { 6 | createMuiTheme, 7 | ThemeProvider as MuiThemeProvider, 8 | } from "@material-ui/core/styles"; 9 | import TextField from "@material-ui/core/TextField"; 10 | 11 | const theme = createMuiTheme({ 12 | overrides: { 13 | MuiInputBase: { 14 | root: { 15 | "&&&": { 16 | paddingTop: 0, 17 | paddingBottom: 0, 18 | paddingRight: 0, 19 | borderRadius: "4px", 20 | backgroundColor: "#f6f6f6", 21 | }, 22 | }, 23 | }, 24 | MuiFilledInput: { 25 | underline: { 26 | "&&&:before": { 27 | borderBottom: "none", 28 | }, 29 | 30 | "&&&:after": { 31 | borderBottom: "2px solid #f79230", 32 | }, 33 | }, 34 | }, 35 | MuiChip: { 36 | label: { 37 | "&&&": { 38 | color: "#f79230", 39 | fontWeight: 700, 40 | fontSize: "12px", 41 | textTransform: "uppercase", 42 | }, 43 | }, 44 | deleteIcon: { 45 | "&": { 46 | color: "#f79230", 47 | }, 48 | "&:hover": { 49 | color: "#f9ac61", 50 | }, 51 | }, 52 | outlined: { 53 | "&&&": { 54 | border: "1px solid #f79230", 55 | backgroundColor: "transparent", 56 | }, 57 | }, 58 | }, 59 | }, 60 | }); 61 | 62 | const TagAutocomplete = ({ onChange, tags, tagOptions, ...props }) => ( 63 | 64 | 71 | value.map((option, index) => ( 72 | 79 | )) 80 | } 81 | {...props} 82 | value={tags} 83 | renderInput={(params) => ( 84 | 85 | )} 86 | /> 87 | 88 | ); 89 | 90 | TagAutocomplete.propTypes = { 91 | onChange: PropTypes.func.isRequired, 92 | tags: PropTypes.arrayOf(PropTypes.string).isRequired, 93 | tagOptions: PropTypes.arrayOf(PropTypes.string).isRequired, 94 | }; 95 | 96 | export default TagAutocomplete; 97 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-filename-extension */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import "./styles/index.scss"; 5 | import "react-input-range/lib/bundle/react-input-range.css"; 6 | import App from "./App"; 7 | import * as serviceWorker from "./serviceWorker"; 8 | import "bootstrap/dist/css/bootstrap.css"; 9 | 10 | ReactDOM.render(, document.getElementById("root")); 11 | 12 | // If you want your app to work offline and load faster, you can change 13 | // unregister() to register() below. Note this comes with some pitfalls. 14 | // Learn more about service workers: https://bit.ly/CRA-PWA 15 | serviceWorker.unregister(); 16 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminView/UserCard/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button } from "reactstrap"; 4 | import { connect } from "react-redux"; 5 | import { roleEnum } from "../../../utils/enums"; 6 | import AdminView from "../../../components/Auth/AdminView"; 7 | import { openUserModalWithPayload } from "../../../redux/actions/modal"; 8 | import Edit from "../../../assets/images/pencil-edit-button-black.svg"; 9 | 10 | const UserCard = ({ user, openUserModalWithPayload }) => { 11 | const toggleModal = (event) => { 12 | event.stopPropagation(); 13 | openUserModalWithPayload({ userId: user.id }); 14 | }; 15 | 16 | const toggleViewOnlyModal = (event) => { 17 | event.stopPropagation(); 18 | openUserModalWithPayload({ 19 | userId: user.id, 20 | editable: false, 21 | }); 22 | }; 23 | 24 | return ( 25 |
32 |
33 |

{`${user.firstName} ${user.lastName}`}

34 |
35 |
36 |

{user.email}

37 |
38 |
39 |

{user.role}

40 |
41 |
42 |

{user.title}

43 |
44 | 45 |
46 | 54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | const mapDispatchToProps = { 61 | openUserModalWithPayload, 62 | }; 63 | 64 | UserCard.propTypes = { 65 | user: PropTypes.shape({ 66 | id: PropTypes.string.isRequired, 67 | firstName: PropTypes.string, 68 | lastName: PropTypes.string, 69 | email: PropTypes.string, 70 | role: PropTypes.oneOf(Object.values(roleEnum)).isRequired, 71 | title: PropTypes.string, 72 | }), 73 | openUserModalWithPayload: PropTypes.func.isRequired, 74 | }; 75 | 76 | export default connect(null, mapDispatchToProps)(UserCard); 77 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminView/styles.scss: -------------------------------------------------------------------------------- 1 | .user-directory { 2 | width: 100%; 3 | height: 100vh; 4 | padding: 20px 40px 20px 40px; 5 | font-family: "Roboto"; 6 | 7 | #page-title { 8 | margin-bottom: 0; 9 | } 10 | .users { 11 | background-color: #f9f9f9; 12 | margin-top: 8px; 13 | margin-bottom: 40px; 14 | padding: 10px 12px; 15 | border-radius: 6px; 16 | box-shadow: 0 0px 4px rgba(0, 0, 0, 0.15); 17 | } 18 | 19 | h1 { 20 | font-size: 30px; 21 | font-weight: 700; 22 | } 23 | 24 | h3 { 25 | font-size: 13px; 26 | margin: 0; 27 | text-transform: uppercase; 28 | font-weight: bold; 29 | } 30 | 31 | .user-labels { 32 | padding-bottom: 10px; 33 | } 34 | 35 | .card-click { 36 | outline: none; 37 | position: relative; 38 | top: 0px; 39 | transition: all 0.2s; 40 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0); 41 | } 42 | 43 | .card-click:hover { 44 | top: -2px; 45 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 46 | } 47 | 48 | #edit-icon { 49 | height: 25px; 50 | min-height: 25px; 51 | // Give width & height so space is reserved before loading 52 | // (fixes CellMeasurer race condition if images aren't loaded before measuring cell size) 53 | width: 25px; 54 | padding-right: 5px; 55 | margin-top: -2px; 56 | } 57 | .edit-button { 58 | background-color: transparent; 59 | transition: all 0.2s; 60 | text-transform: uppercase; 61 | font-size: 12px; 62 | font-weight: 500; 63 | outline: none !important; 64 | } 65 | .edit-button:focus, 66 | .edit-button:hover { 67 | background-color: #f6f6f6; 68 | } 69 | 70 | .card-wrapper { 71 | background-color: white; 72 | align-items: center; 73 | justify-content: center; 74 | border-radius: 4px; 75 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); 76 | padding-top: 15px; 77 | padding-bottom: 15px; 78 | font-size: 14px; 79 | margin-bottom: 8px; 80 | 81 | p { 82 | margin: 0; 83 | } 84 | } 85 | .col p { 86 | opacity: 0.8; 87 | } 88 | 89 | .card-wrapper .col-desc { 90 | width: 35%; 91 | } 92 | 93 | .btn-custom { 94 | background-color: #f7922f; 95 | color: white; 96 | font-size: 13px; 97 | text-transform: uppercase; 98 | font-weight: bold; 99 | border: none; 100 | outline: none; 101 | padding: 11px 32px 11px 32px; 102 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 103 | transition: all 0.5s; 104 | border-radius: 4px; 105 | } 106 | .btn-custom:hover { 107 | background-color: #f9ac61; 108 | color: white; 109 | transition: all 0.5s; 110 | } 111 | 112 | .btn-custom:focus { 113 | background-color: #f9ac61; 114 | box-shadow: 0 0 0 0.2rem rgba(249, 172, 97, 0.25); 115 | } 116 | .col-desc-collapsed { 117 | overflow: hidden; 118 | display: -webkit-box; 119 | -webkit-line-clamp: 2; 120 | -webkit-box-orient: vertical; 121 | } 122 | 123 | .manager-header { 124 | align-items: center; 125 | margin-top: 10px; 126 | margin-bottom: 20px; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /frontend/src/pages/Auth/Login/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { connect } from "react-redux"; 3 | import PropTypes from "prop-types"; 4 | import Logo from "../../../assets/images/lah-logo-2.png"; 5 | import Avatar from "../../../assets/images/user-avatar.svg"; 6 | import GoogleLogo from "../../../assets/images/google_logo.svg"; 7 | import { getURLForEndpoint } from "../../../utils/apiHelpers.js"; 8 | 9 | import "../styles.scss"; 10 | import { Redirect } from "react-router-dom"; 11 | 12 | const Login = ({ authed }) => { 13 | useEffect(() => { 14 | document.title = "Login - Life After Hate"; 15 | }, []); 16 | if (authed) { 17 | return ; 18 | } 19 | return ( 20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | 39 |
40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | const MapStateToProps = (state) => ({ 47 | authed: state.auth.authenticated, 48 | }); 49 | 50 | Login.propTypes = { 51 | authed: PropTypes.bool.isRequired, 52 | }; 53 | export default connect(MapStateToProps)(Login); 54 | -------------------------------------------------------------------------------- /frontend/src/pages/Auth/Pending/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | import Logo from "../../../assets/images/lah-logo-2.png"; 4 | import Check from "../../../assets/images/pending-check.svg"; 5 | import "../styles.scss"; 6 | import { logout } from "../../../utils/api"; 7 | const Pending = () => ( 8 |
9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 | avatar 19 |

20 | Your request for access has been received.
21 | An administrator will review it shortly. 22 |

23 | 26 |
27 |
28 |
29 |
30 |
31 | ); 32 | 33 | export default withRouter(Pending); 34 | -------------------------------------------------------------------------------- /frontend/src/pages/Auth/styles.scss: -------------------------------------------------------------------------------- 1 | .login-wrapper { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | align-items: center; 6 | background-color: #f9f9f9; 7 | 8 | #lah-logo { 9 | width: 100%; 10 | } 11 | .login-card { 12 | width: 100%; 13 | margin-top: 20px; 14 | background-color: #fff; 15 | box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.25); 16 | text-align: center; 17 | padding: 10px 10px; 18 | border-radius: 0.25em; 19 | #user-avatar { 20 | width: 100%; 21 | display: block; 22 | margin: 20px auto; 23 | max-width: 75px; 24 | opacity: 0.25; 25 | transition: opacity 0.2s ease-in-out; 26 | &:hover { 27 | opacity: 0.4; 28 | transition: opacity 0.2s ease-in-out; 29 | } 30 | } 31 | .action-button { 32 | position: relative; 33 | width: 100%; 34 | display: block; 35 | text-decoration: none; 36 | text-align: center; 37 | font-weight: 800; 38 | padding: 0.5em 0.75em; 39 | font-size: 1em; 40 | line-height: 1.5; 41 | border-radius: 0.25em; 42 | border: 1px solid transparent; 43 | color: #fff; 44 | } 45 | 46 | .blue { 47 | background-color: #007bff; 48 | transition: background-color 0.2s ease-in-out; 49 | &:hover { 50 | background-color: #0069d9; 51 | transition: background-color 0.2s ease-in-out; 52 | } 53 | } 54 | .orange { 55 | background-color: rgb(239, 125, 48); 56 | transition: background-color 0.2s ease-in-out; 57 | &:hover { 58 | background-color: #f79230; 59 | transition: background-color 0.2s ease-in-out; 60 | } 61 | } 62 | #google-logo { 63 | height: 1.5em; 64 | position: absolute; 65 | left: 8px; 66 | } 67 | 68 | p { 69 | font-family: "Roboto"; 70 | margin-bottom: 20px; 71 | font-weight: 400; 72 | font-size: 18px; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/DirectoryTagSearch/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import TagAutocomplete from "../../../components/TagAutocomplete"; 5 | import { 6 | tagSelector, 7 | globalTagListSelector, 8 | } from "../../../redux/selectors/tags"; 9 | import { replaceTags } from "../../../redux/actions/tags"; 10 | 11 | export const DirectoryTagSearch = ({ tags, replaceTags, globalTagList }) => { 12 | const handleSelectionChange = (_, value) => { 13 | replaceTags(value); 14 | }; 15 | return ( 16 | 22 | ); 23 | }; 24 | 25 | const mapStateToProps = (state) => ({ 26 | tags: tagSelector(state), 27 | globalTagList: globalTagListSelector(state), 28 | }); 29 | 30 | const mapDispatchToProps = { replaceTags }; 31 | 32 | DirectoryTagSearch.propTypes = { 33 | tags: PropTypes.arrayOf(PropTypes.string), 34 | replaceTags: PropTypes.func.isRequired, 35 | globalTagList: PropTypes.arrayOf(PropTypes.string), 36 | }; 37 | 38 | export default connect(mapStateToProps, mapDispatchToProps)(DirectoryTagSearch); 39 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/ResourceCard/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button } from "reactstrap"; 4 | import { connect } from "react-redux"; 5 | import AdminView from "../../../components/Auth/AdminView"; 6 | import { openResourceModalWithPayload } from "../../../redux/actions/modal"; 7 | import Edit from "../../../assets/images/pencil-edit-button-black.svg"; 8 | 9 | import { distanceToString } from "../../../utils/formatters"; 10 | import { 11 | resourceName, 12 | resourceDescription, 13 | resourceLogo, 14 | } from "../../../redux/selectors/resource"; 15 | import "../styles.scss"; 16 | import "./styles.scss"; 17 | 18 | const ResourceCard = ({ 19 | resource, 20 | openResourceModalWithPayload, 21 | style, 22 | measure, 23 | }) => { 24 | const toggleModal = (event) => { 25 | event.stopPropagation(); 26 | openResourceModalWithPayload({ resourceId: resource._id }); 27 | }; 28 | 29 | const toggleViewOnlyModal = (event) => { 30 | event.stopPropagation(); 31 | openResourceModalWithPayload({ 32 | resourceId: resource._id, 33 | editable: false, 34 | }); 35 | }; 36 | 37 | return ( 38 |
39 |
46 |
47 | 48 |
49 |
50 |

{resourceName(resource)}

51 |
52 |
53 |

{resource.address}

54 | {"distanceFromSearchLoc" in resource && ( 55 |

56 | {distanceToString(resource.distanceFromSearchLoc)} 57 |

58 | )} 59 |
60 |
61 |

{resource.volunteerRoles}

62 |
63 |
64 |

{resourceDescription(resource)}

65 |
66 |
67 |

{resource.availability}

68 |
69 | 70 |
71 | 79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | const mapDispatchToProps = { 87 | openResourceModalWithPayload, 88 | }; 89 | 90 | ResourceCard.propTypes = { 91 | resource: PropTypes.object.isRequired, 92 | openResourceModalWithPayload: PropTypes.func.isRequired, 93 | style: PropTypes.object, 94 | measure: PropTypes.func.isRequired, 95 | }; 96 | 97 | export default connect(null, mapDispatchToProps)(ResourceCard); 98 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/ResourceCard/styles.scss: -------------------------------------------------------------------------------- 1 | .directory { 2 | .resource-type-logo { 3 | margin-left: 15px; 4 | width: 16px; 5 | opacity: 0.6; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/ResourceLabels/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import AdminView from "../../../components/Auth/AdminView"; 5 | import { sortFieldEnum } from "../../../utils/enums"; 6 | import { CSVExporter } from "../../../components/CSVExporter/CSVExporter"; 7 | import { updateSort } from "../../../redux/actions/sort"; 8 | 9 | const ResourceLabels = ({ sort, updateSort, resources }) => { 10 | const sortIcon = (field) => { 11 | if (field === sort.field) { 12 | return sort.order === "asc" ? <>▼ : <>▲; 13 | } 14 | return null; 15 | }; 16 | 17 | return ( 18 | resources.length > 0 && ( 19 |
20 |
updateSort(sortFieldEnum.RESOURCE_NAME)} 23 | > 24 |

25 | Resource Name {sortIcon(sortFieldEnum.RESOURCE_NAME)} 26 |

27 |
28 |
updateSort(sortFieldEnum.LOCATION)} 31 | > 32 |

33 | Location {sortIcon(sortFieldEnum.LOCATION)} 34 |

35 |
36 |
updateSort(sortFieldEnum.VOLUNTEER_ROLE)} 39 | > 40 |

41 | Volunteer Role {sortIcon(sortFieldEnum.VOLUNTEER_ROLE)} 42 |

43 |
44 |
updateSort(sortFieldEnum.DESCRIPTION)} 47 | > 48 |

49 | Description {sortIcon(sortFieldEnum.DESCRIPTION)} 50 |

51 |
52 |
updateSort(sortFieldEnum.AVAILABILITY)} 55 | > 56 |

57 | Availability {sortIcon(sortFieldEnum.AVAILABILITY)} 58 |

59 |
60 | 61 |
62 | 63 |
64 |
65 |
66 | ) 67 | ); 68 | }; 69 | 70 | const mapStateToProps = (state) => ({ sort: state.sort }); 71 | 72 | const mapDispatchToProps = { updateSort }; 73 | 74 | ResourceLabels.propTypes = { 75 | sort: PropTypes.shape({ 76 | field: PropTypes.oneOf(Object.values(sortFieldEnum)), 77 | order: PropTypes.string, 78 | }), 79 | updateSort: PropTypes.func.isRequired, 80 | resources: PropTypes.arrayOf(PropTypes.object), 81 | }; 82 | 83 | export default connect(mapStateToProps, mapDispatchToProps)(ResourceLabels); 84 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/ResourceList/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import ResourceCard from "../ResourceCard"; 4 | import { 5 | CellMeasurer, 6 | List, 7 | AutoSizer, 8 | CellMeasurerCache, 9 | } from "react-virtualized"; 10 | import "./styles.scss"; 11 | const cache = new CellMeasurerCache({ 12 | fixedWidth: true, 13 | defaultWidth: 324, 14 | defaultHeight: 300, 15 | }); 16 | 17 | class ResourceList extends React.Component { 18 | componentDidMount() { 19 | window.addEventListener("resize", this.handleResize); 20 | } 21 | 22 | handleResize = () => cache.clearAll(); 23 | 24 | componentWillUnmount() { 25 | window.removeEventListener("resize", this.handleResize); 26 | } 27 | purgeCache = () => { 28 | cache.clearAll(); 29 | if (this.list) { 30 | this.list.forceUpdateGrid(); 31 | this.list.scrollToPosition(0); 32 | } 33 | }; 34 | 35 | componentDidUpdate = () => this.purgeCache(); 36 | 37 | rowRenderer({ index, key, parent, style }) { 38 | const { resources } = this.props; 39 | // const source // This comes from your list data 40 | return ( 41 | 48 | {({ registerChild, measure }) => ( 49 | // 'style' attribute required to position cell (within parent List) 50 | 57 | )} 58 | 59 | ); 60 | } 61 | 62 | render() { 63 | const { resources } = this.props; 64 | return ( 65 | resources.length > 0 && ( 66 |
67 | 68 | {({ height, width }) => ( 69 | { 73 | this.list = list; 74 | }} 75 | deferredMeasurementCache={cache} 76 | rowHeight={cache.rowHeight} 77 | rowRenderer={this.rowRenderer.bind(this)} 78 | rowCount={resources.length} 79 | /> 80 | )} 81 | 82 |
83 | ) 84 | ); 85 | } 86 | } 87 | 88 | ResourceList.propTypes = { 89 | resources: PropTypes.arrayOf(PropTypes.object).isRequired, 90 | }; 91 | 92 | export default ResourceList; 93 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/ResourceList/styles.scss: -------------------------------------------------------------------------------- 1 | .resource-list { 2 | min-height: 500px; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/SearchBar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Button } from "reactstrap"; 5 | import { useForm } from "react-hook-form"; 6 | import { filterAndRefreshResource } from "../../../utils/api"; 7 | import DirectoryTagSearch from "../DirectoryTagSearch"; 8 | import "../styles.scss"; 9 | 10 | const SearchBar = ({ isLoading }) => { 11 | const { register, handleSubmit } = useForm(); 12 | const onSubmit = (data) => { 13 | filterAndRefreshResource(data.keyword, data.location, data.tag); 14 | }; 15 | 16 | return ( 17 |
18 |
19 | 29 | 39 |
40 | 41 |
42 | 43 |
44 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | const mapStateToProps = (state) => ({ 54 | isLoading: state.isLoading, 55 | }); 56 | 57 | SearchBar.propTypes = { 58 | isLoading: PropTypes.bool, 59 | }; 60 | 61 | export default connect(mapStateToProps)(SearchBar); 62 | -------------------------------------------------------------------------------- /frontend/src/pages/DirectoryView/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Button } from "reactstrap"; 5 | import AdminView from "../../components/Auth/AdminView"; 6 | import SearchBar from "./SearchBar"; 7 | import { openResourceModal } from "../../redux/actions/modal"; 8 | import { tagFilteredResourceSelector } from "../../redux/selectors/resource"; 9 | import "./styles.scss"; 10 | import { getTags } from "../../utils/api"; 11 | import ResourceLabels from "./ResourceLabels"; 12 | import ResourceList from "./ResourceList"; 13 | 14 | const ResourceManager = ({ openResourceModal, resources }) => { 15 | useEffect(() => { 16 | document.title = "Directory View - Life After Hate"; 17 | getTags(); 18 | }, []); 19 | 20 | return ( 21 |
22 |
23 |
24 |
25 |

Resource Directory

26 |
27 | 28 | 29 |
30 | 33 |
34 |
35 |
36 |
37 |
38 |
39 | 40 | {resources.length > 0 && ( 41 | 44 | )} 45 | 46 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | const MapStateToProps = (state) => ({ 54 | resources: tagFilteredResourceSelector(state), 55 | }); 56 | 57 | const mapDispatchToProps = { 58 | openResourceModal, 59 | }; 60 | 61 | ResourceManager.propTypes = { 62 | openResourceModal: PropTypes.func, 63 | resources: PropTypes.arrayOf(PropTypes.object), 64 | }; 65 | 66 | export default connect(MapStateToProps, mapDispatchToProps)(ResourceManager); 67 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/ActionButtons/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { openResourceModalWithPayload } from "../../../redux/actions/modal"; 5 | import AdminView from "../../../components/Auth/AdminView"; 6 | import Edit from "../../../assets/images/pencil-edit-button.svg"; 7 | import Expand from "../../../assets/images/expand.svg"; 8 | 9 | const ActionButtons = ({ resource, openResourceModalWithPayload }) => ( 10 |
11 | 25 | 26 | 40 | 41 |
42 | ); 43 | 44 | const mapDispatchToProps = { 45 | openResourceModalWithPayload, 46 | }; 47 | 48 | ActionButtons.propTypes = { 49 | resource: PropTypes.shape({ 50 | _id: PropTypes.string.isRequired, 51 | }), 52 | openResourceModalWithPayload: PropTypes.func, 53 | }; 54 | 55 | export default connect(null, mapDispatchToProps)(ActionButtons); 56 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/Popup/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Popup } from "react-map-gl"; 4 | import { openResourceModalWithPayload } from "../../../redux/actions/modal"; 5 | import { clearMapResource } from "../../../redux/actions/map"; 6 | import { connect } from "react-redux"; 7 | import { 8 | currentResourceSelector, 9 | mapResourceIdSelector, 10 | } from "../../../redux/selectors/map"; 11 | import { distanceToString } from "../../../utils/formatters"; 12 | import { 13 | resourceName, 14 | resourceDescription, 15 | } from "../../../redux/selectors/resource"; 16 | import ActionButtons from "../ActionButtons"; 17 | 18 | const MapPopup = ({ isResourceSelected, resource, clearMapResource }) => ( 19 |
20 | {isResourceSelected && ( 21 | 31 |
32 |
33 | {resourceName(resource)} 34 |
35 | {"distanceFromSearchLoc" in resource && ( 36 |
37 | {distanceToString(resource.distanceFromSearchLoc)} 38 |
39 | )} 40 |
{resourceDescription(resource)}
41 | 42 |
43 |
44 | )} 45 |
46 | ); 47 | 48 | const mapStateToProps = (state) => ({ 49 | resource: currentResourceSelector(state), 50 | isResourceSelected: mapResourceIdSelector(state) !== undefined, 51 | role: state.auth.role, 52 | }); 53 | 54 | MapPopup.propTypes = { 55 | isResourceSelected: PropTypes.bool, 56 | resource: PropTypes.shape({ 57 | location: PropTypes.PropTypes.shape({ 58 | coordinates: PropTypes.arrayOf(PropTypes.number).isRequired, 59 | }), 60 | distanceFromSearchLoc: PropTypes.number, 61 | }).isRequired, 62 | clearMapResource: PropTypes.func.isRequired, 63 | }; 64 | 65 | const mapDispatchToProps = { openResourceModalWithPayload, clearMapResource }; 66 | 67 | export default connect(mapStateToProps, mapDispatchToProps)(MapPopup); 68 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/RadiusFilter/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import "./styles.scss"; 5 | import InputRange from "react-input-range"; 6 | import { filterAndRefreshResource } from "../../../utils/api"; 7 | 8 | const RadiusFilter = ({ hasCenter, search }) => { 9 | const [searchRadius, updateSearchRadius] = useState(500); 10 | const runSearch = (newRadius) => { 11 | updateSearchRadius(newRadius); 12 | filterAndRefreshResource( 13 | search.keyword, 14 | search.address, 15 | search.tags, 16 | newRadius 17 | ); 18 | }; 19 | 20 | return ( 21 |
22 | `${value}mi`} 29 | /> 30 |
31 | ); 32 | }; 33 | 34 | const mapStateToProps = (state) => ({ 35 | hasCenter: !!state.map.center, 36 | search: state.search, 37 | }); 38 | 39 | RadiusFilter.propTypes = { 40 | hasCenter: PropTypes.bool, 41 | search: PropTypes.shape({ 42 | keyword: PropTypes.string, 43 | address: PropTypes.string, 44 | tags: PropTypes.arrayOf(PropTypes.string), 45 | }), 46 | }; 47 | 48 | export default connect(mapStateToProps)(RadiusFilter); 49 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/RadiusFilter/styles.scss: -------------------------------------------------------------------------------- 1 | .radius-filter { 2 | background: #fff; 3 | bottom: 30px; 4 | right: 12px; 5 | padding: 24px 24px 24px 24px; 6 | border-radius: 5px; 7 | position: absolute; 8 | width: 220px; 9 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 10 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 11 | } 12 | 13 | .input-range__label--value { 14 | top: -2em; 15 | } 16 | .input-range__label-container { 17 | font-size: 0.8em; 18 | } 19 | 20 | .radius-filter-hidden { 21 | display: none; 22 | } 23 | 24 | .input-range__track--active { 25 | background: #f79230; 26 | } 27 | 28 | .input-range__slider { 29 | background: #f79230; 30 | border: 1px solid #f79230; 31 | } 32 | 33 | .input-range__label { 34 | color: #2a2a2a; 35 | font-size: 1rem; 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/SearchBar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Button } from "reactstrap"; 5 | import SearchIcon from "../../../assets/images/search.svg"; 6 | import LocationIcon from "../../../assets/images/location-icon.svg"; 7 | import { filterAndRefreshResource, removeFilterTag } from "../../../utils/api"; 8 | import { 9 | updateSearchLocation, 10 | updateSearchQuery, 11 | } from "../../../redux/actions/map"; 12 | import { tagSelector } from "../../../redux/selectors/tags"; 13 | import { 14 | searchLocationSelector, 15 | searchQuerySelector, 16 | } from "../../../redux/selectors/map"; 17 | import "./styles.scss"; 18 | import MapSearchAutocomplete from "./MapSearchAutocomplete"; 19 | 20 | const SearchBar = ({ query, location, updateSearchLocation, tags }) => { 21 | const onSubmit = (e) => { 22 | e.preventDefault(); 23 | filterAndRefreshResource(query, location); 24 | }; 25 | 26 | const clearLocation = () => updateSearchLocation(""); 27 | 28 | return ( 29 |
30 |
31 |
32 | Location 33 |
34 | updateSearchLocation(e.target.value)} 42 | /> 43 |
51 |
52 |
53 | Search 54 | 55 | 58 |
59 | {tags.length > 0 && ( 60 |
61 | {tags.map((tag) => ( 62 |
63 | {tag} 64 |
70 | ))} 71 |
72 | )} 73 |
74 |
75 | ); 76 | }; 77 | 78 | const mapStateToProps = (state) => ({ 79 | tags: tagSelector(state), 80 | query: searchQuerySelector(state), 81 | location: searchLocationSelector(state), 82 | }); 83 | 84 | const mapDispatchToProps = { updateSearchLocation, updateSearchQuery }; 85 | 86 | SearchBar.propTypes = { 87 | query: PropTypes.string, 88 | location: PropTypes.string, 89 | updateSearchLocation: PropTypes.func.isRequired, 90 | tags: PropTypes.arrayOf(PropTypes.string), 91 | }; 92 | 93 | export default connect(mapStateToProps, mapDispatchToProps)(SearchBar); 94 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/SearchBar/styles.scss: -------------------------------------------------------------------------------- 1 | .search { 2 | background-color: white; 3 | border-radius: 5px; 4 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 5 | padding: 15px 0px 20px 12px; 6 | width: auto; 7 | } 8 | 9 | .searchLocation, 10 | .searchKeyword { 11 | display: flex; 12 | flex-wrap: nowrap; 13 | justify-content: flex-start; 14 | align-items: center; 15 | } 16 | 17 | .locationIcon, 18 | .searchIcon { 19 | width: 16px; 20 | margin-top: 4px; 21 | } 22 | 23 | .underlineField { 24 | margin-left: 4px; 25 | margin-right: 10px; 26 | padding-bottom: 4px; 27 | border-bottom: solid rgba(0, 0, 0, 0.2) 1px; 28 | transition: all 0.2s; 29 | 30 | &:focus-within { 31 | border-color: orange; 32 | } 33 | } 34 | 35 | #underlineLocation { 36 | margin-bottom: 10px; 37 | } 38 | 39 | #locationInput, 40 | #searchInput { 41 | font-family: "Roboto"; 42 | width: 230px; 43 | height: auto; 44 | box-sizing: border-box; 45 | font-size: 14px; 46 | font-weight: 400; 47 | color: #2a2a2a; 48 | border: none; 49 | border-radius: 0; 50 | outline: none; 51 | background-color: transparent; 52 | } 53 | 54 | .closeButtons { 55 | margin-left: 5px; 56 | outline: none; 57 | } 58 | 59 | .submitSearch { 60 | border: none; 61 | outline: none; 62 | background-color: #f79230; 63 | color: #ffffff; 64 | font-weight: 700; 65 | text-transform: uppercase; 66 | font-size: 12px; 67 | padding: 6px 8px 6px 8px; 68 | border-radius: 3px; 69 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 70 | margin-bottom: 0px; 71 | width: 42px; 72 | transition: all 0.2s; 73 | } 74 | 75 | .submitSearch:hover { 76 | background-color: #f9ac61; 77 | } 78 | 79 | .dropdownStyle { 80 | z-index: 2; 81 | position: absolute; 82 | background-color: white; 83 | width: 100%; 84 | } 85 | 86 | .card-tags-search { 87 | margin-top: 10px; 88 | display: flex; 89 | flex-wrap: wrap; 90 | justify-content: flex-start; 91 | align-items: center; 92 | } 93 | 94 | .card-tag-search { 95 | display: flex; 96 | justify-content: center; 97 | align-items: center; 98 | color: #f79230; 99 | font-size: 10px; 100 | border: solid #f79230 1px; 101 | margin: 0 3px 3px 0; 102 | font-weight: bold; 103 | text-transform: uppercase; 104 | padding: 1px 7px 1px 7px; 105 | border-radius: 15px; 106 | } 107 | 108 | .close-tag { 109 | font-size: 18px !important; 110 | color: #f79230 !important; 111 | opacity: 0.8 !important; 112 | padding-bottom: 3px !important; 113 | line-height: 0.5 !important; 114 | } 115 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | import CardView from "./CardView"; 4 | import SearchBar from "./SearchBar"; 5 | import Map from "./Map"; 6 | import RadiusFilter from "./RadiusFilter"; 7 | import "./styles.scss"; 8 | import { getTags } from "../../utils/api"; 9 | 10 | const MapView = () => { 11 | useEffect(() => { 12 | document.title = "Map View - Life After Hate"; 13 | getTags(); 14 | }, []); 15 | 16 | return ( 17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | export default MapView; 33 | -------------------------------------------------------------------------------- /frontend/src/pages/MapView/styles.scss: -------------------------------------------------------------------------------- 1 | .search-bar { 2 | width: 100%; 3 | } 4 | 5 | .fixed-height-container { 6 | display: flex; 7 | flex-direction: column; 8 | position: absolute; 9 | left: 12px; 10 | top: 72px; 11 | width: 342px; 12 | height: calc(100vh - 72px); 13 | z-index: 2; 14 | } 15 | 16 | .card-content { 17 | margin-top: 4px; 18 | margin-bottom: 30px; 19 | overflow: auto; 20 | height: 100%; 21 | width: 100%; 22 | background-color: #f9f9f9; 23 | border-radius: 5px; 24 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 25 | padding: 7px; 26 | padding-bottom: 2px; 27 | } 28 | 29 | .popup { 30 | max-width: 260px; 31 | max-height: 180px; 32 | overflow: hidden; 33 | } 34 | 35 | .mapboxgl-popup { 36 | z-index: 1; 37 | cursor: default; 38 | } 39 | 40 | .popup-title { 41 | font-weight: bold; 42 | color: #2a2a2a; 43 | font-size: 15px; 44 | margin: 0; 45 | overflow: hidden; 46 | display: -webkit-box; 47 | -webkit-line-clamp: 2; 48 | -webkit-box-orient: vertical; 49 | } 50 | 51 | .popup-distance { 52 | opacity: 0.6; 53 | font-size: 12px; 54 | margin: 0; 55 | } 56 | 57 | .popup-desc { 58 | font-size: 13.5px; 59 | line-height: 125%; 60 | opacity: 0.7; 61 | margin-top: 4px; 62 | margin-bottom: 15px; 63 | overflow: hidden; 64 | display: -webkit-box; 65 | -webkit-line-clamp: 3; 66 | -webkit-box-orient: vertical; 67 | } 68 | 69 | .card-action { 70 | display: flex; 71 | margin: 10px 0; 72 | } 73 | 74 | .card-action-btn { 75 | border: none; 76 | outline: none; 77 | background-color: #f79230; 78 | color: #ffffff; 79 | font-weight: 700; 80 | text-transform: uppercase; 81 | font-size: 12px; 82 | padding: 6px 8px 6px 8px; 83 | border-radius: 3px; 84 | box-shadow: 0 2px 4px 0px rgba(0, 0, 0, 0.15); 85 | margin-bottom: 8px; 86 | width: 100%; 87 | margin: 0 3px; 88 | transition: all 0.2s; 89 | white-space: nowrap; 90 | &.edit { 91 | background-color: rgb(239, 125, 48); 92 | } 93 | } 94 | 95 | .card-action-btn:hover { 96 | background-color: #f9ac61; 97 | } 98 | 99 | .popup-button-icon { 100 | height: 12px; 101 | margin-right: 5px; 102 | margin-top: -3px; 103 | } 104 | 105 | .mapboxgl-popup-content { 106 | padding: 8px 10px 2px 10px !important; 107 | border-radius: 5px !important; 108 | } 109 | 110 | .mapboxgl-popup-close-button { 111 | outline: none !important; 112 | transition: all 0.2s; 113 | } 114 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/api.js: -------------------------------------------------------------------------------- 1 | export const API_REQUEST = "API_REQUEST"; 2 | export const API_START = "API_START"; 3 | export const API_ERROR = "API_ERROR"; 4 | export const API_SUCCESS = "API_SUCCESS"; 5 | export const API_END = "API_END"; 6 | export const API_ACCESS_DENIED = "API_ACCESS_DENIED"; 7 | 8 | export const apiStart = (url) => ({ 9 | type: API_START, 10 | url, 11 | }); 12 | 13 | export const apiEnd = (url) => ({ 14 | type: API_END, 15 | url, 16 | }); 17 | 18 | export const apiError = (error) => ({ 19 | type: API_ERROR, 20 | error, 21 | }); 22 | 23 | export const apiSuccess = (payload) => ({ 24 | type: API_SUCCESS, 25 | payload, 26 | }); 27 | 28 | export const accessDenied = (url) => ({ 29 | type: API_ACCESS_DENIED, 30 | url, 31 | }); 32 | 33 | export const apiAction = (payload) => ({ 34 | type: API_REQUEST, 35 | payload, 36 | }); 37 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/auth.js: -------------------------------------------------------------------------------- 1 | export const AUTH_UPDATE = "AUTH_UPDATE"; 2 | export const AUTH_PURGE = "AUTH_PURGE"; 3 | 4 | export const authUpdateAction = (payload) => ({ 5 | type: AUTH_UPDATE, 6 | payload, 7 | }); 8 | 9 | export const authPurgeAction = () => ({ 10 | type: AUTH_PURGE, 11 | }); 12 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/loader.js: -------------------------------------------------------------------------------- 1 | export const LOADER_START = "LOADER_START"; 2 | export const LOADER_END = "LOADER_END"; 3 | 4 | export const startLoader = () => ({ type: LOADER_START }); 5 | export const endLoader = () => ({ type: LOADER_END }); 6 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/map.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_MAP_CENTER = "UPDATE_MAP_CENTER"; 2 | export const CLEAR_MAP_CENTER = "CLEAR_MAP_CENTER"; 3 | export const SELECT_MAP_RESOURCE = "SELECT_MAP_RESOURCE"; 4 | export const CLEAR_MAP_RESOURCE = "CLEAR_MAP_RESOURCE"; 5 | 6 | export const UPDATE_SEARCH_LOCATION = "UPDATE_SEARCH_LOCATION"; 7 | export const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY"; 8 | 9 | export const updateMapCenter = (payload) => ({ 10 | type: UPDATE_MAP_CENTER, 11 | payload, 12 | }); 13 | 14 | export const clearMapCenter = (payload) => ({ 15 | type: CLEAR_MAP_CENTER, 16 | payload, 17 | }); 18 | 19 | export const selectMapResource = (payload) => ({ 20 | type: SELECT_MAP_RESOURCE, 21 | payload, 22 | }); 23 | 24 | export const clearMapResource = () => ({ 25 | type: CLEAR_MAP_RESOURCE, 26 | }); 27 | 28 | export const updateSearchLocation = (payload) => ({ 29 | type: UPDATE_SEARCH_LOCATION, 30 | payload, 31 | }); 32 | 33 | export const updateSearchQuery = (payload) => ({ 34 | type: UPDATE_SEARCH_QUERY, 35 | payload, 36 | }); 37 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/modal.js: -------------------------------------------------------------------------------- 1 | import { modalEnum } from "../../utils/enums"; 2 | 3 | export const MODAL_OPEN = "MODAL_OPEN"; 4 | export const MODAL_CLOSE = "MODAL_CLOSE"; 5 | 6 | export const openModal = () => ({ type: MODAL_OPEN }); 7 | export const openResourceModal = () => ({ 8 | type: MODAL_OPEN, 9 | modalType: modalEnum.RESOURCE, 10 | }); 11 | export const openResourceModalWithPayload = (payload) => ({ 12 | type: MODAL_OPEN, 13 | modalType: modalEnum.RESOURCE, 14 | payload, 15 | }); 16 | export const openUserModalWithPayload = (payload) => ({ 17 | type: MODAL_OPEN, 18 | modalType: modalEnum.USER, 19 | payload, 20 | }); 21 | export const closeModal = () => ({ type: MODAL_CLOSE }); 22 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/nav.js: -------------------------------------------------------------------------------- 1 | export const CHANGE_PAGE = "CHANGE_PAGE"; 2 | 3 | export const changePage = () => ({ 4 | type: CHANGE_PAGE, 5 | }); 6 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/resources.js: -------------------------------------------------------------------------------- 1 | export const REPLACE_ALL_RESOURCES = "REPLACE_ALL_RESOURCES"; 2 | export const UPDATE_RESOURCE = "UPDATE_RESOURCE"; 3 | export const ADD_RESOURCE = "ADD_RESOURCE"; 4 | export const CLEAR_RESOURCES = "CLEAR_RESOURCES"; 5 | export const DELETE_RESOURCE = "DELETE_RESOURCE"; 6 | 7 | export const addResource = (payload) => ({ 8 | type: ADD_RESOURCE, 9 | payload, 10 | }); 11 | 12 | export const updateResource = (payload) => ({ 13 | type: UPDATE_RESOURCE, 14 | payload, 15 | }); 16 | 17 | export const deleteResource = (payload) => ({ 18 | type: DELETE_RESOURCE, 19 | payload, 20 | }); 21 | 22 | export const replaceAllResources = (payload) => ({ 23 | type: REPLACE_ALL_RESOURCES, 24 | payload, 25 | }); 26 | 27 | export const clearResources = () => ({ 28 | type: CLEAR_RESOURCES, 29 | }); 30 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/search.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_SEARCH_PARAMS = "UPDATE_SEARCH_PARAMS"; 2 | export const RESET_SEARCH = "RESET_SEARCH"; 3 | 4 | export const updateSearchParams = (payload) => ({ 5 | type: UPDATE_SEARCH_PARAMS, 6 | payload, 7 | }); 8 | 9 | export const resetSearch = () => ({ 10 | type: RESET_SEARCH, 11 | }); 12 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/sort.js: -------------------------------------------------------------------------------- 1 | export const SET_SORT_FIELD = "SET_SORT_FIELD"; 2 | 3 | export const updateSort = (payload) => ({ 4 | type: SET_SORT_FIELD, 5 | payload, 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/tags.js: -------------------------------------------------------------------------------- 1 | export const ADD_TAG = "ADD_TAG"; 2 | export const REMOVE_TAG = "REMOVE_TAG"; 3 | export const REPLACE_TAGS = "REPLACE_TAGS"; 4 | export const REFRESH_TAG_LIST = "REFRESH_TAG_LIST"; 5 | 6 | export const addTag = (payload) => ({ 7 | type: ADD_TAG, 8 | payload, 9 | }); 10 | export const removeTag = (payload) => ({ 11 | type: REMOVE_TAG, 12 | payload, 13 | }); 14 | 15 | export const replaceTags = (payload) => ({ 16 | type: REPLACE_TAGS, 17 | payload, 18 | }); 19 | 20 | export const refreshTagList = (payload) => ({ 21 | type: REFRESH_TAG_LIST, 22 | payload, 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/users.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_USERS = "UPDATE_USERS"; 2 | export const CHANGE_USER_FILTER = "CHANGE_USER_FILTER"; 3 | 4 | export const updateUsers = (payload) => ({ type: UPDATE_USERS, payload }); 5 | export const changeUserFilter = (payload) => ({ 6 | type: CHANGE_USER_FILTER, 7 | payload, 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/redux/middleware/api_middleware.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { 3 | accessDenied, 4 | apiError, 5 | API_REQUEST, 6 | apiSuccess, 7 | } from "../actions/api"; 8 | import { startLoader, endLoader } from "../actions/loader"; 9 | import { toast } from "react-toastify"; 10 | 11 | const apiMiddleware = ({ dispatch }) => (next) => (action) => { 12 | // Call the next method in the middleware 13 | next(action); 14 | 15 | if (action.type !== API_REQUEST) { 16 | return; 17 | } 18 | 19 | const { 20 | url, 21 | method, 22 | data, 23 | onSuccess, 24 | onFailure, 25 | headers, 26 | withLoader, 27 | notification, 28 | expectUnauthorizedResponse, 29 | } = action.payload; 30 | 31 | // Depending on the type of request, there might be a "data" or "params" field. 32 | const dataOrParams = ["GET", "DELETE"].includes(method) ? "params" : "data"; 33 | 34 | // Headers set for all requests 35 | axios.defaults.baseURL = process.env.REACT_APP_BASE_URL || ""; 36 | axios.defaults.headers.common["Content-Type"] = "application/json"; 37 | 38 | if (withLoader) { 39 | dispatch(startLoader()); 40 | } 41 | 42 | axios 43 | .request({ 44 | url, 45 | method, 46 | headers, 47 | [dataOrParams]: data, 48 | withCredentials: true, 49 | }) 50 | .then(({ data }) => { 51 | dispatch(apiSuccess(data)); 52 | // Request was successful, so call the callback for success 53 | onSuccess(data); 54 | if (notification && notification.successMessage) { 55 | toast.success(notification.successMessage); 56 | } 57 | }) 58 | .catch((error) => { 59 | dispatch(apiError(error.response)); 60 | onFailure(error.response); 61 | if (notification && notification.failureMessage) { 62 | toast.error(notification.failureMessage); 63 | } 64 | if ( 65 | expectUnauthorizedResponse !== true && 66 | error.response && 67 | error.response.status === 401 68 | ) { 69 | dispatch(accessDenied(window.location.pathname)); 70 | toast.info("You have been signed out due to an unauthorized request."); 71 | } 72 | }) 73 | .finally(() => { 74 | if (withLoader) { 75 | dispatch(endLoader()); 76 | } 77 | }); 78 | }; 79 | 80 | export default apiMiddleware; 81 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import { AUTH_UPDATE, AUTH_PURGE } from "../actions/auth"; 2 | import { API_ACCESS_DENIED } from "../actions/api"; 3 | 4 | const INITIAL_STATE = { authenticated: false, isFetchingAuth: true }; 5 | 6 | const auth = (state = INITIAL_STATE, action) => { 7 | switch (action.type) { 8 | case AUTH_UPDATE: 9 | return { ...action.payload, isFetchingAuth: false, authenticated: true }; 10 | case AUTH_PURGE: 11 | case API_ACCESS_DENIED: 12 | return { isFetchingAuth: false, authenticated: false }; 13 | default: 14 | return state; 15 | } 16 | }; 17 | 18 | export default auth; 19 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import loading from "./loading"; 3 | import auth from "./auth"; 4 | import resources from "./resources"; 5 | import modal from "./modal"; 6 | import map from "./map"; 7 | import users from "./users"; 8 | import tags from "./tags"; 9 | import search from "./search"; 10 | import sort from "./sort"; 11 | 12 | export default combineReducers({ 13 | isLoading: loading, 14 | auth, 15 | resources, 16 | modal, 17 | map, 18 | users, 19 | tags, 20 | search, 21 | sort, 22 | }); 23 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/loading.js: -------------------------------------------------------------------------------- 1 | import { LOADER_START, LOADER_END } from "../actions/loader"; 2 | 3 | const INITIAL_STATE = false; 4 | 5 | const loading = (state = INITIAL_STATE, action) => { 6 | switch (action.type) { 7 | case LOADER_START: 8 | return true; 9 | case LOADER_END: 10 | return false; 11 | default: 12 | return state; 13 | } 14 | }; 15 | 16 | export default loading; 17 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/map.js: -------------------------------------------------------------------------------- 1 | import { 2 | UPDATE_MAP_CENTER, 3 | SELECT_MAP_RESOURCE, 4 | CLEAR_MAP_RESOURCE, 5 | CLEAR_MAP_CENTER, 6 | UPDATE_SEARCH_LOCATION, 7 | UPDATE_SEARCH_QUERY, 8 | } from "../actions/map"; 9 | import { 10 | REPLACE_ALL_RESOURCES, 11 | UPDATE_RESOURCE, 12 | DELETE_RESOURCE, 13 | CLEAR_RESOURCES, 14 | } from "../actions/resources"; 15 | import { CHANGE_PAGE } from "../actions/nav"; 16 | const R = require("ramda"); 17 | 18 | const INITIAL_STATE = { search: { location: "", query: "" } }; 19 | 20 | const map = (state = INITIAL_STATE, action) => { 21 | switch (action.type) { 22 | case UPDATE_MAP_CENTER: 23 | return { ...state, center: action.payload }; 24 | case CLEAR_MAP_CENTER: 25 | return R.omit(["center"], state); 26 | case SELECT_MAP_RESOURCE: 27 | return { ...state, selectedId: action.payload }; 28 | case CLEAR_MAP_RESOURCE: 29 | case CLEAR_RESOURCES: 30 | case DELETE_RESOURCE: 31 | case UPDATE_RESOURCE: 32 | return R.omit(["selectedId"], state); 33 | case REPLACE_ALL_RESOURCES: 34 | // Check to see if the currently selected resource 35 | // is in the new resource list, and if not, remove it 36 | return R.find(R.propEq("_id", state.selectedId))(action.payload) 37 | ? state 38 | : R.omit(["selectedId"], state); 39 | case UPDATE_SEARCH_LOCATION: 40 | return { 41 | ...state, 42 | search: { ...state.search, location: action.payload }, 43 | }; 44 | case UPDATE_SEARCH_QUERY: 45 | return { ...state, search: { ...state.search, query: action.payload } }; 46 | case CHANGE_PAGE: 47 | return { ...INITIAL_STATE }; 48 | default: 49 | return state; 50 | } 51 | }; 52 | 53 | export default map; 54 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/modal.js: -------------------------------------------------------------------------------- 1 | import { MODAL_OPEN, MODAL_CLOSE } from "../actions/modal"; 2 | import { modalEnum } from "../../utils/enums"; 3 | import { CHANGE_PAGE } from "../actions/nav"; 4 | 5 | const INITIAL_STATE = { 6 | isOpen: false, 7 | editable: true, 8 | modalType: modalEnum.RESOURCE, 9 | }; 10 | 11 | const modal = (state = INITIAL_STATE, action) => { 12 | switch (action.type) { 13 | case MODAL_OPEN: 14 | return { 15 | ...state, 16 | ...action.payload, 17 | modalType: action.modalType, 18 | isOpen: true, 19 | }; 20 | case CHANGE_PAGE: 21 | case MODAL_CLOSE: 22 | return { ...INITIAL_STATE }; 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | export default modal; 29 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/resources.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_RESOURCE, 3 | REPLACE_ALL_RESOURCES, 4 | UPDATE_RESOURCE, 5 | CLEAR_RESOURCES, 6 | DELETE_RESOURCE, 7 | } from "../actions/resources"; 8 | import { CHANGE_PAGE } from "../actions/nav"; 9 | 10 | const INITIAL_STATE = []; 11 | 12 | const resources = (state = INITIAL_STATE, action) => { 13 | switch (action.type) { 14 | case ADD_RESOURCE: 15 | return [action.payload, ...state]; 16 | case UPDATE_RESOURCE: 17 | return state.map((resource) => 18 | resource._id === action.payload._id ? action.payload : resource 19 | ); 20 | case DELETE_RESOURCE: 21 | return state.filter((item) => item._id !== action.payload._id); 22 | case REPLACE_ALL_RESOURCES: 23 | return action.payload; 24 | case CHANGE_PAGE: 25 | case CLEAR_RESOURCES: 26 | return [...INITIAL_STATE]; 27 | default: 28 | return state; 29 | } 30 | }; 31 | 32 | export default resources; 33 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/search.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_SEARCH_PARAMS, RESET_SEARCH } from "../actions/search"; 2 | import { CHANGE_PAGE } from "../actions/nav"; 3 | 4 | const INITIAL_STATE = {}; 5 | 6 | const search = (state = INITIAL_STATE, action) => { 7 | switch (action.type) { 8 | case UPDATE_SEARCH_PARAMS: 9 | return action.payload; 10 | case CHANGE_PAGE: 11 | case RESET_SEARCH: 12 | return { ...INITIAL_STATE }; 13 | default: 14 | return state; 15 | } 16 | }; 17 | 18 | export default search; 19 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/sort.js: -------------------------------------------------------------------------------- 1 | import { SET_SORT_FIELD } from "../actions/sort"; 2 | import { CHANGE_PAGE } from "../actions/nav"; 3 | 4 | const INITIAL_STATE = { 5 | field: null, 6 | order: null, 7 | }; 8 | 9 | const sort = (state = INITIAL_STATE, action) => { 10 | switch (action.type) { 11 | case SET_SORT_FIELD: 12 | if (state.field === action.payload) { 13 | if (state.order === "asc") { 14 | return { ...state, order: "desc" }; 15 | } 16 | return { ...INITIAL_STATE }; 17 | } 18 | return { 19 | field: action.payload, 20 | order: "asc", 21 | }; 22 | case CHANGE_PAGE: 23 | return { ...INITIAL_STATE }; 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | export default sort; 30 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/tags.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import { 4 | ADD_TAG, 5 | REMOVE_TAG, 6 | REPLACE_TAGS, 7 | REFRESH_TAG_LIST, 8 | } from "../actions/tags"; 9 | import { CHANGE_PAGE } from "../actions/nav"; 10 | 11 | const INITIAL_STATE = []; 12 | 13 | const selected = (state = INITIAL_STATE, action) => { 14 | switch (action.type) { 15 | case ADD_TAG: 16 | if (state.indexOf(action.payload) !== -1) { 17 | return state; 18 | } 19 | return [...state, action.payload]; 20 | case REMOVE_TAG: 21 | if (state.indexOf(action.payload) !== -1) { 22 | const newState = [...state]; 23 | const ind = state.indexOf(action.payload); 24 | newState.splice(ind, 1); 25 | return newState; 26 | } 27 | return state; 28 | case REPLACE_TAGS: 29 | return action.payload; 30 | case CHANGE_PAGE: 31 | return [...INITIAL_STATE]; 32 | default: 33 | return state; 34 | } 35 | }; 36 | 37 | const all = (state = [], action) => { 38 | switch (action.type) { 39 | case REFRESH_TAG_LIST: 40 | return action.payload; 41 | default: 42 | return state; 43 | } 44 | }; 45 | 46 | export default combineReducers({ selected, all }); 47 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/users.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_USERS, CHANGE_USER_FILTER } from "../actions/users"; 2 | import { userFilterEnum } from "../../utils/enums"; 3 | import { CHANGE_PAGE } from "../actions/nav"; 4 | 5 | const INITIAL_STATE = { userFilterType: userFilterEnum.ALL, userList: [] }; 6 | 7 | const users = (state = INITIAL_STATE, action) => { 8 | switch (action.type) { 9 | case UPDATE_USERS: 10 | return { ...state, userList: action.payload }; 11 | case CHANGE_USER_FILTER: 12 | return { ...state, userFilterType: action.payload }; 13 | // Preserve user list 14 | case CHANGE_PAGE: 15 | return { ...INITIAL_STATE, userList: state.userList }; 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | export default users; 22 | -------------------------------------------------------------------------------- /frontend/src/redux/selectors/map.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from "reselect"; 2 | import { resourceSelector, tagFilteredResourceSelector } from "./resource"; 3 | 4 | export const searchQuerySelector = (state) => state.map.search.query; 5 | export const searchLocationSelector = (state) => state.map.search.location; 6 | 7 | // Gets the ID of the selected resource for the map 8 | export const mapResourceIdSelector = (state) => state.map.selectedId; 9 | 10 | // Gets the current resource selected on the map 11 | export const currentResourceSelector = createSelector( 12 | [resourceSelector, mapResourceIdSelector], 13 | (resources, id) => { 14 | if (!id) { 15 | return {}; 16 | } 17 | return resources.find((resource) => resource._id === id); 18 | } 19 | ); 20 | 21 | // Filter out resources that don't have a location 22 | export const mappableResourceSelector = createSelector( 23 | [tagFilteredResourceSelector], 24 | (resources) => 25 | resources.filter( 26 | (r) => r.location.coordinates[0] && r.location.coordinates[1] 27 | ) 28 | ); 29 | -------------------------------------------------------------------------------- /frontend/src/redux/selectors/modal.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from "reselect"; 2 | import { resourceSelector, resourceName } from "./resource"; 3 | import { userSelector } from "./users"; 4 | import { modalEnum } from "../../utils/enums"; 5 | 6 | // Gets the ID of the selected resource/user for the modal to display 7 | export const modalResourceIdSelector = (state) => state.modal.resourceId; 8 | export const modalUserIdSelector = (state) => state.modal.userId; 9 | 10 | // Gets the current resource for the modal 11 | export const currentResourceSelector = createSelector( 12 | [resourceSelector, modalResourceIdSelector], 13 | (resources, id) => { 14 | if (!id) { 15 | return {}; 16 | } 17 | return resources.find((resource) => resource._id === id); 18 | } 19 | ); 20 | 21 | export const currentUserSelector = createSelector( 22 | [userSelector, modalUserIdSelector], 23 | (users, id) => { 24 | if (!id) { 25 | return {}; 26 | } 27 | return users.find((user) => user.id === id); 28 | } 29 | ); 30 | 31 | // Derives the modal title 32 | export const titleSelector = createSelector( 33 | [ 34 | currentResourceSelector, 35 | currentUserSelector, 36 | (state) => state.modal.modalType, 37 | (state) => state.modal.editable, 38 | ], 39 | (resource, user, modalType, editable) => { 40 | if (modalType === modalEnum.RESOURCE) { 41 | if (!resource._id) { 42 | // If there's no currently selected resource, we're adding a new one 43 | return "Add Resource"; 44 | } 45 | if (!editable) { 46 | // View only, so just return the relevant name 47 | return resourceName(resource); 48 | } 49 | return "Edit Resource"; 50 | } else if (modalType === modalEnum.USER) { 51 | if (!user.id) { 52 | // There should never be no user ID 53 | return ""; 54 | } 55 | if (!editable) { 56 | // View only, so just return the name 57 | return `${user.firstName} ${user.lastName}`; 58 | } 59 | // We are editing, so use Edit User 60 | return "Edit User"; 61 | } 62 | } 63 | ); 64 | 65 | // Returns whether the user is adding a new resource 66 | export const isAddingResourceSelector = createSelector( 67 | [modalResourceIdSelector], 68 | (id) => (id ? false : true) 69 | ); 70 | -------------------------------------------------------------------------------- /frontend/src/redux/selectors/tags.js: -------------------------------------------------------------------------------- 1 | export const tagSelector = (state) => state.tags.selected; 2 | export const globalTagListSelector = (state) => state.tags.all; 3 | -------------------------------------------------------------------------------- /frontend/src/redux/selectors/users.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from "reselect"; 2 | import { roleEnum, userFilterEnum } from "../../utils/enums"; 3 | 4 | export const userSelector = (state) => state.users.userList; 5 | export const filterSelector = (state) => state.users.userFilterType; 6 | 7 | export const filteredUserSelector = createSelector( 8 | [filterSelector, userSelector], 9 | (filter, users) => { 10 | if (!users) { 11 | return []; 12 | } 13 | switch (filter) { 14 | case userFilterEnum.ACTIVE: 15 | return users.filter( 16 | (user) => 17 | user.role === roleEnum.VOLUNTEER || user.role === roleEnum.ADMIN 18 | ); 19 | case userFilterEnum.REJECTED: 20 | return users.filter((user) => user.role === roleEnum.REJECTED); 21 | case userFilterEnum.PENDING: 22 | return users.filter((user) => user.role === roleEnum.PENDING); 23 | default: 24 | return users; 25 | } 26 | } 27 | ); 28 | -------------------------------------------------------------------------------- /frontend/src/redux/store.js: -------------------------------------------------------------------------------- 1 | import LogRocket from "logrocket"; 2 | import logger from "redux-logger"; 3 | import { createStore, applyMiddleware, compose } from "redux"; 4 | import rootReducer from "./reducers"; 5 | import apiMiddleware from "./middleware/api_middleware"; 6 | 7 | // Add Redux DevTools support 8 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 9 | const store = createStore( 10 | rootReducer, 11 | composeEnhancers( 12 | applyMiddleware(logger, apiMiddleware, LogRocket.reduxMiddleware()) 13 | ) 14 | ); 15 | 16 | export default store; 17 | -------------------------------------------------------------------------------- /frontend/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/utils/apiHelpers.js: -------------------------------------------------------------------------------- 1 | import store from "../redux/store"; 2 | import { apiAction } from "../redux/actions/api"; 3 | import { authUpdateAction, authPurgeAction } from "../redux/actions/auth"; 4 | import urljoin from "url-join"; 5 | import { toast } from "react-toastify"; 6 | const R = require("ramda"); 7 | 8 | const API_URI = process.env.REACT_APP_API_URI 9 | ? process.env.REACT_APP_API_URI 10 | : "/api/"; 11 | 12 | console.log(`API URI is ${API_URI}`); 13 | 14 | // Map an object with key/value pairs to a query string of the form key=value&key2=value2 15 | export const toQueryString = R.pipe( 16 | Object.entries, 17 | R.filter(([, v]) => v), 18 | R.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`), 19 | R.join("&") 20 | ); 21 | 22 | export const getURLForEndpoint = (endpoint) => urljoin(API_URI, endpoint); 23 | /** 24 | * 25 | * @param {String} endpoint - A string representing the URI of the endpoint (just the end part) 26 | * @param {String} method - Either "GET", "POST", "PUT", or "DELETE" 27 | * @param {Object} data - The body of the request 28 | * @param {Boolean} withLoader - A switch on whether to show the loading bar during the request 29 | * @param {Object} notification - An object containing a message for failure and success 30 | * @param {Boolean} expectUnauthorizedResponse - Whether to dispatch an ACCESS DENIED action and notify the user the request was unauthorized 31 | * 32 | */ 33 | export const apiRequest = ({ 34 | endpoint = "", 35 | method = "GET", 36 | data = null, 37 | withLoader = true, 38 | notification, 39 | expectUnauthorizedResponse = false, 40 | }) => 41 | new Promise((resolve, reject) => { 42 | store.dispatch( 43 | apiAction({ 44 | url: getURLForEndpoint(endpoint), 45 | onSuccess: resolve, 46 | onFailure: reject, 47 | method, 48 | withLoader, 49 | data, 50 | notification, 51 | expectUnauthorizedResponse, 52 | }) 53 | ); 54 | }); 55 | 56 | export const updateGlobalAuthState = (payload) => { 57 | store.dispatch(authUpdateAction(payload)); 58 | if (process.env.NODE_ENV === "development") { 59 | toast.info(`Logged in with ${payload.role} role!`, { 60 | autoClose: 2000, 61 | }); 62 | } 63 | }; 64 | 65 | export const purgeGlobalAuthState = () => { 66 | store.dispatch(authPurgeAction()); 67 | }; 68 | -------------------------------------------------------------------------------- /frontend/src/utils/csv.js: -------------------------------------------------------------------------------- 1 | import json2csv from "json2csv"; 2 | import { distanceToString } from "../utils/formatters"; 3 | const R = require("ramda"); 4 | 5 | const getAllFields = (arr) => 6 | arr.reduce((fields, item) => { 7 | Object.keys(item).forEach((field) => { 8 | if (!fields.includes(field)) { 9 | fields.push(field); 10 | } 11 | }); 12 | 13 | return fields; 14 | }, []); 15 | 16 | const moveItemToFront = (target, arr) => 17 | arr.forEach((item, idx) => { 18 | if (item === target) { 19 | arr.splice(idx, 1); 20 | arr.unshift(item); 21 | } 22 | }); 23 | 24 | const moveItemsToFront = (targets, arr) => 25 | targets.reverse().forEach((i) => moveItemToFront(i, arr)); 26 | 27 | export const getCSV = (resources) => { 28 | const allFields = getAllFields(resources); 29 | 30 | const filteredFields = R.without( 31 | ["__v", "_id", "federalRegion", "location", "allText"], 32 | allFields 33 | ); 34 | // Move some of the items to the beginning in the CSV file 35 | moveItemsToFront(["contactName", "companyName", "type"], filteredFields); 36 | 37 | // Apply a function on a property if the property exists 38 | const applyFnOnProp = R.curry((prop, fn) => 39 | R.map(R.when(R.has(prop))(R.over(R.lensProp(prop), fn))) 40 | ); 41 | const formatTags = applyFnOnProp("tags", R.join(", ")); 42 | 43 | const formatDate = applyFnOnProp("dateCreated", (date) => 44 | new Date(date).toString() 45 | ); 46 | const formatDistance = applyFnOnProp( 47 | "distanceFromSearchLoc", 48 | distanceToString 49 | ); 50 | 51 | // const readableDate = R.map((r) => ) 52 | const formattedResources = R.pipe( 53 | formatTags, 54 | formatDate, 55 | formatDistance 56 | )(resources); 57 | console.log(formattedResources); 58 | return json2csv.parse(formattedResources, { 59 | fields: filteredFields, 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /frontend/src/utils/enums.js: -------------------------------------------------------------------------------- 1 | const roleEnum = { 2 | ADMIN: "ADMIN", 3 | VOLUNTEER: "VOLUNTEER", 4 | PENDING: "PENDING", 5 | REJECTED: "REJECTED", 6 | }; 7 | 8 | const resourceEnum = { 9 | GROUP: "GROUP", 10 | INDIVIDUAL: "INDIVIDUAL", 11 | TANGIBLE: "TANGIBLE", 12 | }; 13 | 14 | const userFilterEnum = { 15 | ALL: "ALL", 16 | ACTIVE: "ACTIVE", 17 | PENDING: "PENDING", 18 | REJECTED: "REJECTED", 19 | }; 20 | 21 | const modalEnum = { 22 | RESOURCE: "RESOURCE", 23 | USER: "USER", 24 | }; 25 | 26 | const sortFieldEnum = { 27 | RESOURCE_NAME: "RESOURCE NAME", 28 | LOCATION: "LOCATION", 29 | VOLUNTEER_ROLE: "VOLUNTEER ROLE", 30 | AVAILABILITY: "AVAILABILITY", 31 | DESCRIPTION: "DESCRIPTION", 32 | }; 33 | 34 | module.exports.roleEnum = roleEnum; 35 | module.exports.resourceEnum = resourceEnum; 36 | module.exports.userFilterEnum = userFilterEnum; 37 | module.exports.modalEnum = modalEnum; 38 | module.exports.sortFieldEnum = sortFieldEnum; 39 | -------------------------------------------------------------------------------- /frontend/src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | export const distanceToString = (distance) => 2 | `${distance.toFixed(2)} miles away`; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "life-after-hate", 3 | "version": "1.0.0", 4 | "description": "Package with the development tools for LAH (such as Prettier and Husky). Please see the individual frontend and backend sub-packages for the actual dependencies.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@cypress/code-coverage": "^3.9.5", 8 | "@cypress/commit-info": "^2.2.0", 9 | "chai": "^4.3.4", 10 | "colors": "^1.4.0", 11 | "commander": "^7.2.0", 12 | "express-session": "^1.17.1", 13 | "find-up": "^5.0.0", 14 | "istanbul-lib-coverage": "^3.0.0", 15 | "joi-objectid": "^3.0.1", 16 | "mocha": "^8.3.2", 17 | "node-fetch": "^2.6.1", 18 | "nyc": "^15.1.0", 19 | "sinon": "^10.0.1" 20 | }, 21 | "devDependencies": { 22 | "@hack4impact-uiuc/eslint-plugin": "^2.0.10", 23 | "@typescript-eslint/parser": "^4.33.0", 24 | "cypress": "^5.6.0", 25 | "eslint": "^7.25.0", 26 | "eslint-plugin-cypress": "^2.11.2", 27 | "husky": "^6.0.0", 28 | "lint-staged": "^10.5.4", 29 | "prettier": "^2.2.1", 30 | "typescript": "^4.2.4" 31 | }, 32 | "scripts": { 33 | "prettier": "prettier --write '{frontend/**/*.{js,jsx,css,scss,json,md},backend/**/*.{js,css,json,md}}'", 34 | "prettier-check": "prettier --check '{frontend/**/*.{js,jsx,css,scss,json,md},backend/**/*.{js,css,json,md}}'", 35 | "lint": "eslint '{frontend/**/*.{js,jsx},backend/**/*.{js,jsx}}'", 36 | "lint:fe": "eslint 'frontend/**/*.{js,jsx}'", 37 | "lint:be": "eslint 'backend/**/*.{js,jsx}'" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/hack4impact-uiuc/life-after-hate.git" 42 | }, 43 | "author": "", 44 | "license": "ISC", 45 | "bugs": { 46 | "url": "https://github.com/hack4impact-uiuc/life-after-hate/issues" 47 | }, 48 | "homepage": "https://github.com/hack4impact-uiuc/life-after-hate#readme", 49 | "husky": { 50 | "hooks": { 51 | "pre-commit": "lint-staged" 52 | } 53 | }, 54 | "lint-staged": { 55 | "*.{js,css,scss,json,md,jsx}": [ 56 | "prettier --write" 57 | ], 58 | "*.{js,jsx}": [ 59 | "eslint --fix" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/commit_info.js: -------------------------------------------------------------------------------- 1 | const { commitInfo } = require("@cypress/commit-info"); 2 | commitInfo().then(info => { 3 | console.log(`COMMIT_INFO_BRANCH=${process.env.GITHUB_REF.substring(11)}`); 4 | console.log( 5 | `COMMIT_INFO_MESSAGE=${JSON.stringify( 6 | info.message.replace(/(\r\n|\n|\r)/gm, "") 7 | ).slice(1, -1)}` 8 | ); 9 | console.log(`COMMIT_INFO_EMAIL=${JSON.stringify(info.email).slice(1, -1)}`); 10 | console.log(`COMMIT_INFO_AUTHOR=${JSON.stringify(info.author).slice(1, -1)}`); 11 | console.log(`COMMIT_INFO_SHA=${JSON.stringify(info.sha).slice(1, -1)}`); 12 | console.log(`COMMIT_INFO_REMOTE=${JSON.stringify(info.remote).slice(1, -1)}`); 13 | }); 14 | -------------------------------------------------------------------------------- /scripts/db_backup/README.md: -------------------------------------------------------------------------------- 1 | # Nightly Database Backup 😴🌙 2 | 3 | ## Problem 4 | 5 | We host mission critical data for LAH which also happens to be sensitive, so any loss _or_ leakage could be catastrophic. Hence the need for a backup solution, furthermore one which is secure. 6 | 7 | We also aim to keep costs of hosting this solution to a minimum, so a serverless solution is applicable: wake up, back up the database to somewhere safe, go back to sleep until tomorrow. 8 | 9 | ## Solution 10 | 11 | As a serverless platform, AWS Lambda is reliable, easy to use, and extensible enough to do pretty much anything within its limited computing resources. Lambda is not the tool for computation-intensive processes or long-running server code, but it is adept at handling many small workloads. Given this, it is a suitable tool for database backup as we will only pay for at most 2\*30 GB\*seconds (given the current configuration) of server time each day. 12 | 13 | ### Reliability 14 | 15 | We expect to be able to rely the CloudWatch Events bus for triggering, Lambda for execution, and S3 for storage. We can set alarms to notify us in the event of any failure. It might also be prudent to include the last successful backup time in the admin portal as a peace-of-mind measure. 16 | 17 | ### Implementation Details 18 | 19 | To deploy this system, run the deployment script: [`./deploy.sh`](./deploy.sh). As you can see in [`template.yml`](./template.yml), this will create a CloudFormation stack with three resources: a Lambda function, a trigger event which will call the Lambda function daily (the S3 bucket must already exist), and an SNS topic for any failure notifications. You may draw an analogy of CloudFormation deploy to GNU Make; it builds a dependecy graph and creates or updates resources as necessary. 20 | 21 | Each night, the CloudWatch Events trigger will send an asynchronous invocation event to the Lambda function. The Lambda will execute, and retry twice in the case of any failures. If this still doesn't work, it will send a JSON package containing the request and error response to the SNS topic which will email any subscibers this information. 22 | 23 | A note, backups are made using `mongodump --gzip . . . `, so any restoration must be made using `mongorestore --gzip . . . `. 24 | 25 | 26 | ``` 27 | _________________________________ 28 | | |_____ __ 29 | | off to deliver the backups! | |__| |_________ 30 | |_________________________________| |::| | / 31 | /\**/\ | \.____|::|__| < 32 | ( o_o )_ | \::/ \._______\ 33 | (u--u \_) | 34 | (||___ )==\ 35 | ,dP"/b/=( /P"/b\ 36 | |8 || 8\=== || 8 37 | `b, ,P `b, ,P 38 | """` """` 39 | ``` 40 | -------------------------------------------------------------------------------- /scripts/db_backup/backup/backup.js: -------------------------------------------------------------------------------- 1 | const exec = require("child_process").exec; 2 | const AWS = require("aws-sdk"); 3 | const fs = require("fs"); 4 | const dayjs = require("dayjs"); 5 | const ZipFolder = require("zip-a-folder"); 6 | 7 | const mongoUri = process.env.MONGO_URI; 8 | const s3Bucket = new AWS.S3({ 9 | params: { Bucket: process.env.S3_BUCKET }, 10 | }); 11 | 12 | exports.handler = (event, context) => { 13 | console.log(`Backup triggered by event: ${event} with context: ${context}.`); 14 | 15 | if (!mongoUri) { 16 | throw new Error("Did not receieve the MongoDB URI!"); 17 | } 18 | if (!s3Bucket) { 19 | throw new Error("Could not create the S3 bucket!"); 20 | } 21 | 22 | console.log( 23 | `Backing up database ${mongoUri} to S3 bucket ${process.env.S3_BUCKET}` 24 | ); 25 | 26 | const backupName = `backup_${dayjs().format("MM-DD-YYYY_HH-mm-ss")}`; 27 | const folder = `/tmp/${backupName}/`; 28 | const zipfile = `/tmp/${backupName}.zip`; 29 | 30 | // clear the /tmp directory in case anything is left from a previous execution 31 | exec("rm -rf /tmp/*", (error) => { 32 | if (error) { 33 | console.error("Could not clear /tmp directory!"); 34 | throw new Error(error); 35 | } 36 | 37 | exec(`./mongodump --gzip --uri=${mongoUri} --out=${folder}`, (error) => { 38 | if (error) { 39 | console.error("Mongodump failed!"); 40 | throw new Error(error); 41 | } 42 | 43 | ZipFolder.zipFolder(folder, zipfile, (error) => { 44 | if (error) { 45 | console.error("ZIP failed!"); 46 | throw new Error(error); 47 | } 48 | 49 | fs.readFile(zipfile, (error, data) => { 50 | s3Bucket.upload( 51 | { 52 | Key: `${backupName}.zip`, 53 | Body: data, 54 | ContentType: "application/zip", 55 | }, 56 | (error) => { 57 | if (error) { 58 | console.error("Upload to S3 failed!"); 59 | throw new Error(error); 60 | } 61 | 62 | console.log("Backup completed successfully!"); 63 | } 64 | ); 65 | }); 66 | }); 67 | }); 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /scripts/db_backup/backup/mongodump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/life-after-hate/931bc4daa025936c7686e5b9769bcc5467fe77f9/scripts/db_backup/backup/mongodump -------------------------------------------------------------------------------- /scripts/db_backup/backup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backup", 3 | "version": "1.0.0", 4 | "description": "Modeule to do nightly backups of MongoDB to S3", 5 | "main": "backup.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "aws-sdk": "^2.665.0", 13 | "child_process": "^1.0.2", 14 | "dayjs": "^1.8.25", 15 | "fs": "0.0.1-security", 16 | "zip-a-folder": "0.0.12" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/hack4impact-uiuc/life-after-hate.git" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/db_backup/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | source .env && \ 3 | sam deploy \ 4 | --template-file template.yml \ 5 | --stack-name lah-dev-db-backup \ 6 | --capabilities CAPABILITY_IAM \ 7 | --s3-bucket ${CLOUDFORMATION_BUCKET} \ 8 | --parameter-overrides \ 9 | S3Bucket=${MONGODB_BACKUP_BUCKET} \ 10 | MongoURI=${MONGODB_GLOBAL_URI} \ 11 | EmailAddress=${NOTIFICATION_EMAIL_ADDRESS} 12 | -------------------------------------------------------------------------------- /scripts/db_backup/template.yml: -------------------------------------------------------------------------------- 1 | Description: >- 2 | Every day at 0400 CST, back up the database using a Lambda function which 3 | dumps the result in an S3 bucket. 4 | 5 | Transform: AWS::Serverless-2016-10-31 6 | 7 | Parameters: 8 | MongoURI: 9 | Description: Required; the MongoDB URI from the environment. 10 | Type: String 11 | S3Bucket: 12 | Description: Required; the bucket where backups should be stored. 13 | Type: String 14 | EmailAddress: 15 | Description: Required; where the failure notifications get sent. 16 | Type: String 17 | 18 | Resources: 19 | BackupFunction: 20 | Description: Backup function which should get triggered once a day. 21 | Type: AWS::Serverless::Function 22 | Properties: 23 | Handler: backup.handler 24 | Runtime: nodejs12.x 25 | CodeUri: backup/ 26 | MemorySize: 2048 27 | Timeout: 30 28 | ReservedConcurrentExecutions: 1 # not more than 1 backup at a time 29 | Policies: 30 | - S3FullAccessPolicy: 31 | BucketName: !Ref S3Bucket 32 | Environment: 33 | Variables: 34 | MONGO_URI: !Ref MongoURI 35 | S3_BUCKET: !Ref S3Bucket 36 | EventInvokeConfig: 37 | DestinationConfig: 38 | OnFailure: 39 | Type: SNS 40 | Destination: !Ref SNSFailureTopic 41 | 42 | ScheduledRule: 43 | Description: In essence a cron job, but running on AWS. 44 | Type: AWS::Events::Rule 45 | Properties: 46 | ScheduleExpression: "cron(0 9 * * ? *)" # every day at 0900 UTC / 0400 CST 47 | State: "ENABLED" 48 | Targets: 49 | - Arn: !GetAtt BackupFunction.Arn 50 | Id: "TargetFunction" 51 | 52 | SNSFailureTopic: 53 | Description: The notification channel along which failures will be sent. 54 | Type: AWS::SNS::Topic 55 | Properties: 56 | Subscription: 57 | - Endpoint: !Ref EmailAddress 58 | Protocol: email 59 | -------------------------------------------------------------------------------- /scripts/get_coverage.sh: -------------------------------------------------------------------------------- 1 | mkdir backend/.nyc_output 2 | curl http://localhost:5000/__coverage__ > backend/.nyc_output/out.json 3 | $(npm bin)/nyc report --report-dir ./frontend/cover --temp-dir ./frontend/.nyc_output --reporter=lcov --reporter=clover --reporter=json 4 | docker-compose -f docker-compose.cypress.yml run -v "$(pwd)"/backend:/var/temp prod-backend /var/www/app/node_modules/.bin/nyc report --report-dir /var/temp/cover --temp-dir /var/temp/.nyc_output --reporter=lcov --reporter=clover --reporter=json -------------------------------------------------------------------------------- /scripts/setup_ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo DB_URI=${DB_URI} > .env 3 | echo GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} >> .env 4 | echo GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} >> .env 5 | echo SESSION_SECRET=${SESSION_SECRET} >> .env 6 | echo CYPRESS_RECORD_KEY=${CYPRESS_RECORD_KEY} >> .env 7 | echo MAPQUEST_KEY=${MAPQUEST_KEY} >> .env 8 | echo MAPQUEST_URI=${MAPQUEST_URI} >> .env 9 | echo REACT_APP_MAPBOX_ACCESS_TOKEN=${REACT_APP_MAPBOX_ACCESS_TOKEN} >> .env 10 | echo REACT_APP_API_URI=${REACT_APP_API_URI} >> .env 11 | echo FE_URI=${FE_URI} >> .env 12 | 13 | # Copy over the .env file to the frontend directory so that when we do a production build for FE, 14 | # it sees all these environment variables as well. Since 15 | # it's required that these environment variables are there at build time, 16 | # for example REACT_APP_API_URI will be able to 17 | # be integrated into the production bundle of the app. 18 | cp .env ./frontend/.env -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "regions": ["iad1"], 4 | "name": "life-after-hate", 5 | "alias": "lah.vercel.app", 6 | "builds": [ 7 | { "src": "backend/app.js", "use": "@vercel/node" }, 8 | { 9 | "src": "frontend/package.json", 10 | "use": "@vercel/static-build", 11 | "config": { "distDir": "build" } 12 | } 13 | ], 14 | "routes": [ 15 | { 16 | "src": "/api/(.*)", 17 | "headers": { "cache-control": "s-maxage=0" }, 18 | "dest": "backend/app.js" 19 | }, 20 | 21 | { 22 | "src": "/static/(.*)", 23 | "headers": { "cache-control": "s-maxage=31536000,immutable" }, 24 | "dest": "frontend/static/$1" 25 | }, 26 | { "src": "/favicon.ico", "dest": "/frontend/favicon.ico" }, 27 | { 28 | "src": "/asset-manifest.json", 29 | "dest": "frontend/asset-manifest.json" 30 | }, 31 | { "src": "/manifest.json", "dest": "frontend/manifest.json" }, 32 | { 33 | "src": "/precache-manifest.(.*)", 34 | "dest": "frontend/precache-manifest.$1" 35 | }, 36 | { 37 | "src": "/service-worker.js", 38 | "headers": { "cache-control": "s-maxage=0" }, 39 | "dest": "frontend/service-worker.js" 40 | }, 41 | { "src": "/(.*)", "dest": "frontend/index.html" } 42 | ], 43 | "env": { 44 | "GOOGLE_CLIENT_ID": "@lah_google_client_id", 45 | "GOOGLE_CLIENT_SECRET": "@lah_google_client_secret", 46 | "OAUTH_CALLBACK_URI": "@lah_oauth_callback_uri", 47 | "MAPQUEST_URI": "@lah_mapquest_uri", 48 | "LOGGLY_TOKEN": "@loggly_token", 49 | "SESSION_SECRET": "@lah_session_secret" 50 | }, 51 | "build": { 52 | "env": { 53 | "REACT_APP_MAPBOX_ACCESS_TOKEN": "@lah_mapbox_token" 54 | } 55 | } 56 | } 57 | --------------------------------------------------------------------------------