├── .gitignore ├── README.md ├── backend ├── .env.example ├── .sequelizerc ├── app.js ├── bin │ └── www ├── config │ ├── database.js │ └── index.js ├── db │ ├── migrations │ │ └── 20210602173059-create-user.js │ ├── models │ │ ├── index.js │ │ └── user.js │ └── seeders │ │ └── 20210602173712-demo-user.js ├── package-lock.json ├── package.json ├── routes │ ├── api │ │ ├── index.js │ │ ├── maps.js │ │ ├── session.js │ │ ├── users.js │ │ └── validators │ │ │ ├── sessionValidators.js │ │ │ └── usersValidators.js │ └── index.js └── utils │ ├── auth.js │ └── validation.js └── frontend ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── index.html └── manifest.json └── src ├── App.js ├── components ├── LoginFormModal │ ├── LoginForm.js │ ├── LoginForm.module.css │ └── index.js ├── Maps │ ├── Maps.js │ └── index.js ├── Navigation │ ├── Navigation.js │ ├── Navigation.module.css │ ├── ProfileButton.js │ ├── ProfileButton.module.css │ └── index.js └── SignupFormPage │ ├── SignupFormPage.js │ ├── SignupFormPage.module.css │ └── index.js ├── context ├── Modal.js └── Modal.module.css ├── index.css ├── index.js └── store ├── csrf.js ├── index.js ├── maps.js └── session.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | build 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Setting up the Google Maps API with Authenticate Me 2 | 3 | This is a project built off Authenticate Me that integrates the Google Maps API 4 | to render maps in React with an API key from Google Cloud Services. 5 | 6 | This walkthrough will take you through: 7 | 8 | 1. Getting an API key from Google Cloud Services to use Google Maps 9 | 2. Building in your API into the backend of your application 10 | 3. Bringing your API key into the frontend via an API route 11 | 4. Utilizing your API with the `@react-google-maps/api` package to render a map 12 | on the page 13 | 14 | ## Getting an API key 15 | 16 | First, let's generate an API key. 17 | 18 | To generate a working key, we need to do the following: 19 | 20 | 1. Create a Google developers project. 21 | 2. Enable Maps JavaScript API in that project. 22 | 3. Go to credentials to generate an API key. 23 | 4. Add the API key to our app. 24 | 5. Add billing info to unlock the full set of Maps functionality. 25 | 26 | ### Create a Google developers project 27 | 28 | To get started, first navigate to the [Google Cloud console][gcp-console]. 29 | 30 | You will need a Google account to be able to log in to the Google Cloud console. 31 | 32 | Once you are logged in, click on the `Select a Project` button in the nav bar. 33 | In the newly opened modal, select `New Project`. 34 | 35 | ![New GCP Project example][maps-api-1] 36 | 37 | Choose a name for your project. You can name it something like `benchbnb`. Leave 38 | the `Location` set to `No organization`. Then, click `Create`. 39 | 40 | ![New GCP Project example 2][maps-api-2] 41 | 42 | 43 | [gcp-console]: https://console.cloud.google.com/ 44 | [maps-api-1]: https://assets.aaonline.io/fullstack/react/projects/bench_bnb/maps_api_1.png 45 | [maps-api-2]: https://assets.aaonline.io/fullstack/react/projects/bench_bnb/maps_api_2.png 46 | 47 | ### Enable Maps JavaScript API 48 | 49 | Once you've created your project, make sure you have selected your newly created 50 | project. Then, open the side menu, select `APIs & Services` > `Library`. 51 | 52 | Search for and select `Maps JavaScript API`. Now, `Enable` this API for your 53 | project. (Again, be sure you have selected your newly created project when 54 | enabling this API.) 55 | 56 | You can also take this opportunity to sign up for other Google Cloud Services to 57 | use in your app. 58 | 59 | ![Enable JavaScript API][maps-api-3] 60 | 61 | [maps-api-3]: https://assets.aaonline.io/fullstack/react/projects/bench_bnb/maps_api_3.png 62 | 63 | ### Generate an API Key 64 | 65 | Open the side menu navigation again, and this time, select `APIs & Services` > 66 | `Credentials`. 67 | 68 | Then, click `Create credentials` and select the `API key` option. 69 | 70 | Once you've generated a new API key, click `Restrict key`. This will take you to 71 | a new page where you can now rename the API key to something more specific (ie. 72 | 'BenchBnB Maps Api Key'). You should also restrict your API key so that it can 73 | only call on the `Maps JavaScript API`. 74 | 75 | ![Restrict API Access][maps-api-4] 76 | 77 | In general, we want to follow the [Principle of Least 78 | Privilege][principle-least-privilege] when it comes to managing our API keys and 79 | what it has access to. 80 | 81 | [principle-least-privilege]: https://en.wikipedia.org/wiki/Principle_of_least_privilege 82 | [maps-api-4]: https://assets.aaonline.io/fullstack/react/projects/bench_bnb/maps_api_4.png 83 | 84 | ### Add API Key to our App 85 | 86 | **Quick Note**: It's important to take this upcoming section very seriously! 87 | There have been multiple anecdotes from students who accidentally pushed API 88 | keys to a public GitHub repo. Those keys were then scraped by bad actors who 89 | racked up tens of thousands of dollars in charges using those keys. 90 | 91 | With that in mind, let's add our API key to our project! 92 | 93 | Any time we add an API key to our project, we want to take precautions to 94 | prevent bad actors from stealing and misusing our keys. In the case of our Maps 95 | API key, because billing is calculated per map load, bad actors might want to 96 | steal your key so that they can load up maps on their own projects. 97 | 98 | Because the Maps API key is loaded in the frontend in our HTML, there's actually 99 | not much we can do to prevent the key from being publicly accessible if our web 100 | app is deployed to production. Since you'll most likely be deploying your app 101 | (for example, using Heroku), then it's highly recommended that you protect your 102 | publicly accessible API key by restricting the websites that can use your key. 103 | 104 | ![How to restrict API key to specific websites][maps-api-5] 105 | 106 | To prevent our API key from being pushed to GitHub while still being able to 107 | conveniently use the key locally, we have a couple of options. 108 | 109 | The first option is to use React's built-in system to build in environment 110 | variables on build. This does expose the environment variable to bad actors, so 111 | it's best that you restrict where your API key can be used from once you push 112 | you app to production. 113 | 114 | The next option, which we'll be using for this walkthrough is to make the API 115 | key an environment variable available in your backend, and to fetch a custom API 116 | route to get that key to the frontend. This way, you can use Google's system to 117 | restrict where your API key can be used from and have backend validations to 118 | make sure the people who are trying to fetch for your API key are actually using 119 | your application. 120 | 121 | [maps-api-5]: https://assets.aaonline.io/fullstack/react/projects/bench_bnb/maps_api_5.png 122 | 123 | #### Backend 124 | 125 | In the root of your backend, add your Google Maps API key to your `.env` file. 126 | It should now look something like 127 | 128 | ```plaintext 129 | PORT=5000 130 | DB_USERNAME=«db_username» 131 | DB_PASSWORD=«db_password» 132 | DB_DATABASE=«db_database» 133 | DB_HOST=localhost 134 | JWT_SECRET=«secret» 135 | JWT_EXPIRES_IN=604800 136 | MAPS_API_KEY=«maps_api_key» 137 | ``` 138 | 139 | Next, let's configure our `config/index.js` file to match what we've been doing 140 | with our environment variables throughout the Authenticate Me walkthrough. We'll 141 | add a key of `googleMapsAPIKey` to the exported object with a value of the 142 | environment variable we just added. 143 | 144 | It should look something like this: 145 | 146 | ```js 147 | // backend/config/index.js 148 | module.exports = { 149 | // All other keys from Authenticate Me set up 150 | googleMapsAPIKey: process.env.MAPS_API_KEY, 151 | }; 152 | ``` 153 | 154 | Now, we'll create a backend API route to send our API key to the frontend where 155 | it will actually be used. The whole point of putting our key in the backend just 156 | to send it through a route to the frontend is so that we can add middleware to 157 | prevent bad actors from using our key. 158 | 159 | In your `backend/routes/api` folder, make a new file `maps.js`, and in your 160 | `backend/routes/api/index.js` file, connect what will be the maps router to the 161 | rest of your backend application. 162 | 163 | ```js 164 | // backend/routes/api/index.js 165 | // Other imports 166 | const mapsRouter = require('./maps'); 167 | 168 | router.use('/maps', mapsRouter); 169 | 170 | // Other router.use statements and 171 | // Export statement 172 | ``` 173 | 174 | In your `backend/routes/api/maps.js` file, instantiate a new router, export it 175 | at the bottom of the file, and start the framework of a new route for `POST 176 | /api/maps/key`. We're using a POST route because this gives you the option to 177 | send a public key to the backend to encrypt the API key so that it doesn't get 178 | taken in transit from the backend to the frontend. For our example, we won't go 179 | through all of that. We can also make use of the CSRF protection we have enabled 180 | in our app, and even add some middleware to protect against bad actors. Also, 181 | the actual endpoint doesn't have to be exactly like the one in this example, 182 | this was just chosen to be specific about what the endpoint should return. Your 183 | code should look something like: 184 | 185 | ```js 186 | // backend/routes/api/maps.js 187 | const router = require('express').Router(); 188 | const { googleMapsAPIKey } = require('../../config'); 189 | 190 | router.post('/key', (req, res) => { 191 | res.json({ googleMapsAPIKey }); 192 | }); 193 | 194 | module.exports = router; 195 | ``` 196 | 197 | #### Frontend 198 | 199 | In your frontend, install `@react-google-maps/api`. We'll be using this package 200 | to utilize Google Maps in our app. In this example, we'll be using Redux to make 201 | our API key available across our app. 202 | 203 | Start by creating a new slice of state. In your `frontend/src/store` folder, 204 | create a new file `maps.js` to store our maps slice of state. In that file, make 205 | a new reducer with a default case and export it as the default export. In your 206 | `frontend/src/store/index.js` file, import that new `mapsReducer` and insert it 207 | into the `rootReducer` as a new slice of state. Upon refreshing your browser, 208 | you should be able to see the `maps` slice of state in your Redux DevTools. 209 | 210 | Going back to the `frontend/src/store/maps.js` file, create an action creator 211 | and its corresponding type and case in the reducer. Then, create a thunk creator 212 | that will access our `POST /api/maps/key` route to get the API key from the 213 | backend. 214 | 215 | Your code will end up looking something like this: 216 | 217 | ```js 218 | // frontend/src/store/maps.js 219 | import { csrfFetch } from './csrf'; 220 | 221 | const LOAD_API_KEY = 'maps/LOAD_API_KEY'; 222 | 223 | const loadApiKey = (key) => ({ 224 | type: LOAD_API_KEY, 225 | payload: key, 226 | }); 227 | 228 | export const getKey = () => async (dispatch) => { 229 | const res = await csrfFetch('/api/maps/key', { 230 | method: 'POST', 231 | }); 232 | const data = await res.json(); 233 | dispatch(loadApiKey(data.googleMapsAPIKey)); 234 | }; 235 | 236 | const initialState = { key: null }; 237 | 238 | const mapsReducer = (state = initialState, action) => { 239 | switch (action.type) { 240 | case LOAD_API_KEY: 241 | return { key: action.payload }; 242 | default: 243 | return state; 244 | } 245 | }; 246 | 247 | export default mapsReducer; 248 | ``` 249 | 250 | Now, we can move our focus to creating a component that will house our maps. 251 | For this walkthrough, we're going to create a Maps component. You'll find that 252 | component in `frontend/src/components/Maps/Maps.js`. It'll end up looking 253 | something like 254 | 255 | ```js 256 | // frontend/src/components/Maps/Maps.js 257 | import React from 'react'; 258 | import { GoogleMap, useJsApiLoader } from '@react-google-maps/api'; 259 | 260 | const containerStyle = { 261 | width: '400px', 262 | height: '400px', 263 | }; 264 | 265 | const center = { 266 | lat: 38.9072, 267 | lng: 77.0369, 268 | }; 269 | 270 | const Maps = ({ apiKey }) => { 271 | const { isLoaded } = useJsApiLoader({ 272 | id: 'google-map-script', 273 | googleMapsApiKey: apiKey, 274 | }); 275 | 276 | return ( 277 | <> 278 | {isLoaded && ( 279 | 284 | )} 285 | 286 | ); 287 | }; 288 | 289 | export default React.memo(Maps); 290 | ``` 291 | 292 | You'll notice that we didn't dispatch our thunk in this component, and that's to 293 | avoid an extra render when we haven't received the API key from the backend yet. 294 | We'll be dispatching our thunk in `frontend/src/components/Maps/index.js`. 295 | 296 | ```js 297 | // frontend/src/components/Maps/index.js 298 | import { useEffect } from 'react'; 299 | import { useDispatch, useSelector } from 'react-redux'; 300 | 301 | import { getKey } from '../../store/maps'; 302 | import Maps from './Maps'; 303 | 304 | const MapContainer = () => { 305 | const key = useSelector((state) => state.maps.key); 306 | const dispatch = useDispatch(); 307 | 308 | useEffect(() => { 309 | if (!key) { 310 | dispatch(getKey()); 311 | } 312 | }, [dispatch, key]); 313 | 314 | if (!key) { 315 | return null; 316 | } 317 | 318 | return ( 319 | 320 | ); 321 | }; 322 | 323 | export default MapContainer; 324 | ``` 325 | 326 | ### Add billing info 327 | 328 | Finally, in order for our Maps API to work without restrictions, we'll need to 329 | add billing info to our Google account. To do this, open the side navigation 330 | menu again and go to `Billing` to add your credit card info. 331 | 332 | ## Final thoughts 333 | 334 | You can access Google's official docs of the [Maps JavaScript 335 | API][maps-javascript-api] for more info. 336 | 337 | You can also find out more about the [React Google Maps 338 | API][react-google-maps-api] to find out more ways to use Google Maps in your 339 | React app. 340 | 341 | [maps-javascript-api]: https://developers.google.com/maps/documentation/javascript/get-api-key 342 | [react-google-maps-api]: https://www.npmjs.com/package/@react-google-maps/api 343 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=«port» 2 | DB_USERNAME=«username» 3 | DB_PASSWORD=«password» 4 | DB_DATABASE=«database» 5 | DB_HOST=«host» 6 | JWT_SECRET=«secret» 7 | JWT_EXPIRES_IN=604800 8 | MAPS_API_KEY=«maps_api_key» 9 | -------------------------------------------------------------------------------- /backend/.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | config: path.resolve('config', 'database.js'), 5 | 'models-path': path.resolve('db', 'models'), 6 | 'seeders-path': path.resolve('db', 'seeders'), 7 | 'migrations-path': path.resolve('db', 'migrations',) 8 | }; 9 | -------------------------------------------------------------------------------- /backend/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const morgan = require('morgan'); 3 | const cors = require('cors'); 4 | const csurf = require('csurf'); 5 | const helmet = require('helmet'); 6 | const cookieParser = require('cookie-parser'); 7 | const { ValidationError } = require('sequelize'); 8 | 9 | const { environment } = require('./config'); 10 | const routes = require('./routes'); 11 | 12 | const isProduction = environment === 'production'; 13 | const app = express(); 14 | 15 | app.use(morgan('dev')); 16 | app.use(cookieParser()); 17 | app.use(express.json()); 18 | 19 | if (!isProduction) { 20 | app.use(cors()); 21 | } 22 | 23 | app.use(helmet({ 24 | contentSecurityPolicy: false, 25 | })); 26 | 27 | app.use( 28 | csurf({ 29 | cookie: { 30 | secure: isProduction, 31 | sameSite: isProduction && 'Lax', 32 | httpOnly: true, 33 | }, 34 | }) 35 | ); 36 | 37 | // Routes go AFTER middleware 38 | app.use(routes); 39 | 40 | // Error handlers go AFTER routes 41 | // Route Not Found Error Handler 42 | app.use((_req, _res, next) => { 43 | const err = new Error('The requested resource couldn\'t be found.'); 44 | err.title = 'Resource Not Found'; 45 | err.errors = ['The requested resource couldn\'t be found.']; 46 | err.status = 404; 47 | next(err); 48 | }); 49 | 50 | // Sequelize Validation Error Handler 51 | app.use((err, _req, _res, next) => { 52 | if (err instanceof ValidationError) { 53 | err.errors = err.errors.map((e) => e.message); 54 | err.title = 'Validation error'; 55 | } 56 | next(err); 57 | }); 58 | 59 | // Error Formatter 60 | app.use((err, _req, res, _next) => { 61 | res.status(err.status || 500); 62 | console.error(err); 63 | res.json({ 64 | title: err.title || 'Server Error', 65 | message: err.message, 66 | errors: err.errors, 67 | stack: isProduction ? null : err.stack, 68 | }); 69 | }); 70 | 71 | module.exports = app; 72 | -------------------------------------------------------------------------------- /backend/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { port } = require('../config'); 4 | 5 | const app = require('../app'); 6 | const db = require('../db/models'); 7 | 8 | db.sequelize 9 | .authenticate() 10 | .then(() => { 11 | // Testing to see whether this works 12 | console.log('Database connection success! Sequelize is ready to use...'); 13 | app.listen(port, () => console.log(`Listening on port ${port}...`)); 14 | }) 15 | .catch((err) => { 16 | // It didn't 17 | console.log('Database connection failure.'); 18 | console.error(err); 19 | }); 20 | -------------------------------------------------------------------------------- /backend/config/database.js: -------------------------------------------------------------------------------- 1 | const config = require('./index'); 2 | 3 | const db = config.db; 4 | const username = db.username; 5 | const password = db.password; 6 | const database = db.database; 7 | const host = db.host; 8 | 9 | module.exports = { 10 | development: { 11 | username, 12 | password, 13 | database, 14 | host, 15 | dialect: 'postgres', 16 | seederStorage: 'sequelize', 17 | }, 18 | production: { 19 | use_env_variable: 'DATABASE_URL', 20 | dialect: 'postgres', 21 | seederStorage: 'sequelize', 22 | dialectOptions: { 23 | ssl: { 24 | require: true, 25 | rejectUnauthorized: false, 26 | }, 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /backend/config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environment: process.env.NODE_ENV || 'development', 3 | port: process.env.PORT || 5000, 4 | db: { 5 | username: process.env.DB_USERNAME, 6 | password: process.env.DB_PASSWORD, 7 | database: process.env.DB_DATABASE, 8 | host: process.env.DB_HOST, 9 | }, 10 | jwtConfig: { 11 | secret: process.env.JWT_SECRET, 12 | expiresIn: process.env.JWT_EXPIRES_IN, 13 | }, 14 | googleMapsAPIKey: process.env.MAPS_API_KEY, 15 | }; 16 | -------------------------------------------------------------------------------- /backend/db/migrations/20210602173059-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Users', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | username: { 12 | type: Sequelize.STRING, 13 | allowNull: false, 14 | unique: true, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | allowNull: false, 19 | unique: true, 20 | }, 21 | hashedPassword: { 22 | type: Sequelize.STRING.BINARY, 23 | allowNull: false, 24 | }, 25 | createdAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE, 28 | defaultValue: Sequelize.fn('now'), 29 | }, 30 | updatedAt: { 31 | allowNull: false, 32 | type: Sequelize.DATE, 33 | defaultValue: Sequelize.fn('now'), 34 | } 35 | }); 36 | }, 37 | down: (queryInterface, _Sequelize) => { 38 | return queryInterface.dropTable('Users'); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /backend/db/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Sequelize = require('sequelize'); 6 | const basename = path.basename(__filename); 7 | const env = process.env.NODE_ENV || 'development'; 8 | const config = require(__dirname + '/../../config/database.js')[env]; 9 | const db = {}; 10 | 11 | let sequelize; 12 | if (config.use_env_variable) { 13 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 14 | } else { 15 | sequelize = new Sequelize(config.database, config.username, config.password, config); 16 | } 17 | 18 | fs 19 | .readdirSync(__dirname) 20 | .filter(file => { 21 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); 22 | }) 23 | .forEach(file => { 24 | const model = sequelize['import'](path.join(__dirname, file)); 25 | db[model.name] = model; 26 | }); 27 | 28 | Object.keys(db).forEach(modelName => { 29 | if (db[modelName].associate) { 30 | db[modelName].associate(db); 31 | } 32 | }); 33 | 34 | db.sequelize = sequelize; 35 | db.Sequelize = Sequelize; 36 | 37 | module.exports = db; 38 | -------------------------------------------------------------------------------- /backend/db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { Validator } = require('sequelize'); 3 | const bcrypt = require('bcryptjs'); 4 | 5 | module.exports = (sequelize, DataTypes) => { 6 | const User = sequelize.define('User', { 7 | username: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | validate: { 11 | len: [4, 30], 12 | isNotEmail(value) { 13 | if (Validator.isEmail(value)) { 14 | throw new Error('Cannot be an email.'); 15 | } 16 | }, 17 | }, 18 | }, 19 | email: { 20 | type: DataTypes.STRING, 21 | allowNull: false, 22 | validate: { 23 | len: [3, 256], 24 | }, 25 | }, 26 | hashedPassword: { 27 | type: DataTypes.STRING, 28 | allowNull: false, 29 | validate: { 30 | len: [60, 60], 31 | }, 32 | }, 33 | }, 34 | { 35 | defaultScope: { 36 | attributes: { 37 | exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt'], 38 | }, 39 | }, 40 | scopes: { 41 | currentUser: { 42 | attributes: { exclude: ['hashedPassword'] }, 43 | }, 44 | loginUser: { 45 | attributes: {}, 46 | }, 47 | }, 48 | }); 49 | 50 | User.associate = function(models) { 51 | // associations can be defined here 52 | }; 53 | 54 | User.prototype.toSafeObject = function () { 55 | const { id, username, email } = this; 56 | return { id, username, email }; 57 | }; 58 | 59 | User.prototype.validatePassword = function (password) { 60 | return bcrypt.compareSync(password, this.hashedPassword.toString()); 61 | }; 62 | 63 | User.getCurrentUserById = async function (id) { 64 | return await User.scope('currentUser').findByPk(id); 65 | }; 66 | 67 | User.login = async function ({ credential, password }) { 68 | const { Op } = require('sequelize'); 69 | const user = await User.scope('loginUser').findOne({ 70 | where: { 71 | [Op.or]: { 72 | username: credential, 73 | email: credential, 74 | }, 75 | }, 76 | }); 77 | if (user && user.validatePassword(password)) { 78 | return await User.scope('currentUser').findByPk(user.id); 79 | } 80 | }; 81 | 82 | User.signup = async function ({ username, email, password }) { 83 | const hashedPassword = bcrypt.hashSync(password); 84 | const user = await User.create({ 85 | username, 86 | email, 87 | hashedPassword, 88 | }); 89 | return await User.scope('currentUser').findByPk(user.id); 90 | }; 91 | 92 | return User; 93 | }; 94 | -------------------------------------------------------------------------------- /backend/db/seeders/20210602173712-demo-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const faker = require('faker'); 3 | const bcrypt = require('bcryptjs'); 4 | 5 | module.exports = { 6 | up: (queryInterface, _Sequelize) => { 7 | return queryInterface.bulkInsert('Users', [ 8 | { 9 | email: 'demo@user.io', 10 | username: 'Demo-lition', 11 | hashedPassword: bcrypt.hashSync('password'), 12 | }, 13 | { 14 | email: faker.internet.email(), 15 | username: 'FakeUser1', 16 | hashedPassword: bcrypt.hashSync(faker.internet.password()), 17 | }, 18 | { 19 | email: faker.internet.email(), 20 | username: 'FakeUser2', 21 | hashedPassword: bcrypt.hashSync(faker.internet.password()), 22 | }, 23 | ], {}); 24 | }, 25 | 26 | down: (queryInterface, Sequelize) => { 27 | const Op = Sequelize.Op; 28 | return queryInterface.bulkDelete('Users', { 29 | username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] } 30 | }, {}); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "sequelize": "sequelize", 8 | "sequelize-cli": "sequelize-cli", 9 | "start": "per-env", 10 | "start:development": "nodemon -r dotenv/config ./bin/www", 11 | "start:production": "node ./bin/www" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "bcryptjs": "^2.4.3", 18 | "cookie-parser": "^1.4.5", 19 | "cors": "^2.8.5", 20 | "csurf": "^1.11.0", 21 | "dotenv": "^10.0.0", 22 | "express": "^4.17.1", 23 | "express-async-handler": "^1.1.4", 24 | "express-validator": "^6.11.1", 25 | "faker": "^5.5.3", 26 | "helmet": "^4.6.0", 27 | "jsonwebtoken": "^8.5.1", 28 | "morgan": "^1.10.0", 29 | "per-env": "^1.0.2", 30 | "pg": "^8.6.0", 31 | "sequelize": "^5.22.4", 32 | "sequelize-cli": "^5.5.1" 33 | }, 34 | "devDependencies": { 35 | "dotenv-cli": "^4.0.0", 36 | "nodemon": "^2.0.7" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/routes/api/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | /** Imports for testing routes */ 4 | // const asyncHandler = require('express-async-handler'); 5 | // const { setTokenCookie, restoreUser, requireAuth } = require('../../utils/auth'); 6 | // const { User } = require('../../db/models'); 7 | 8 | const sessionRouter = require('./session'); 9 | const usersRouter = require('./users'); 10 | const mapsRouter = require('./maps'); 11 | 12 | router.use('/session', sessionRouter); 13 | router.use('/users', usersRouter); 14 | router.use('/maps', mapsRouter); 15 | 16 | /** 17 | * Route for testing backend then csrfFetch from frontend 18 | */ 19 | // router.post('/test', (req, res) => { 20 | // res.json({ requestBody: req.body }); 21 | // }); 22 | 23 | /** Routes for testing user auth */ 24 | // router.get('/set-token-cookie', asyncHandler(async (_req, res) => { 25 | // const user = await User.findOne({ 26 | // where: { 27 | // username: 'Demo-lition', 28 | // }, 29 | // }); 30 | // setTokenCookie(res, user); 31 | // return res.json({ user }); 32 | // })); 33 | 34 | // router.get('/restore-user', restoreUser, (req, res) => { 35 | // return res.json(req.user); 36 | // }); 37 | 38 | // router.get('/require-auth', requireAuth, (req, res) => { 39 | // return res.json(req.user); 40 | // }); 41 | 42 | module.exports = router; 43 | -------------------------------------------------------------------------------- /backend/routes/api/maps.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { googleMapsAPIKey } = require('../../config'); 3 | 4 | router.post('/key', (req, res) => { 5 | res.json({ googleMapsAPIKey }); 6 | }); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /backend/routes/api/session.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const asyncHandler = require('express-async-handler'); 3 | 4 | const { validateLogin } = require('./validators/sessionValidators'); 5 | const { setTokenCookie, restoreUser } = require('../../utils/auth'); 6 | const { User } = require('../../db/models'); 7 | 8 | router.post('', validateLogin, asyncHandler(async (req, res, next) => { 9 | const { credential, password } = req.body; 10 | 11 | const user = await User.login({ credential, password }); 12 | 13 | if (!user) { 14 | const err = new Error('Login failed'); 15 | err.status = 401; 16 | err.title = 'Login failed'; 17 | err.errors = ['The provided credentials were invalid.']; 18 | return next(err); 19 | } 20 | 21 | setTokenCookie(res, user); 22 | 23 | res.json({ 24 | user, 25 | }); 26 | })); 27 | 28 | router.delete('', (_req, res) => { 29 | res.clearCookie('token'); 30 | return res.json({ message: 'success' }); 31 | }) 32 | 33 | router.get('', restoreUser, (req, res) => { 34 | const { user } = req; 35 | if (user) { 36 | res.json({ 37 | user: user.toSafeObject() 38 | }); 39 | } else { 40 | res.json({}); 41 | } 42 | }); 43 | 44 | module.exports = router; 45 | -------------------------------------------------------------------------------- /backend/routes/api/users.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const asyncHandler = require('express-async-handler'); 3 | 4 | const { setTokenCookie, requireAuth } = require('../../utils/auth'); 5 | const { User } = require('../../db/models'); 6 | const { validateSignup } = require('./validators/usersValidators'); 7 | 8 | router.post('', validateSignup, asyncHandler(async (req, res) => { 9 | const { email, password, username } = req.body; 10 | const user = await User.signup({ email, username, password }); 11 | 12 | setTokenCookie(res, user); 13 | 14 | res.json({ 15 | user, 16 | }); 17 | })); 18 | 19 | module.exports = router; 20 | -------------------------------------------------------------------------------- /backend/routes/api/validators/sessionValidators.js: -------------------------------------------------------------------------------- 1 | const { check } = require('express-validator'); 2 | const { handleValidationErrors } = require('../../../utils/validation'); 3 | 4 | const validateLogin = [ 5 | check('credential') 6 | .exists({ checkFalsy: true }) 7 | .withMessage('Please provide a valid email or username.') 8 | .notEmpty() 9 | .withMessage('Please provide a valid email or username.'), 10 | check('password') 11 | .exists({ checkFalsy: true }) 12 | .withMessage('Please provide a password.'), 13 | handleValidationErrors, 14 | ]; 15 | 16 | module.exports = { 17 | validateLogin, 18 | }; 19 | -------------------------------------------------------------------------------- /backend/routes/api/validators/usersValidators.js: -------------------------------------------------------------------------------- 1 | const { check } = require('express-validator'); 2 | const { handleValidationErrors } = require('../../../utils/validation'); 3 | 4 | const validateSignup = [ 5 | check('email') 6 | .exists({ checkFalsy: true }) 7 | .withMessage('Please provide an email.') 8 | .isEmail() 9 | .withMessage('Please provide a valid email.'), 10 | check('username') 11 | .exists({ checkFalsy: true }) 12 | .withMessage('Please provide a username.') 13 | .isLength({ min: 4 }) 14 | .withMessage('Please provide a username with at least 4 characters.'), 15 | check('username') 16 | .not() 17 | .isEmail() 18 | .withMessage('Username cannot be an email.'), 19 | check('password') 20 | .exists({ checkFalsy: true }) 21 | .withMessage('Please provide a password.') 22 | .isLength({ min: 6 }) 23 | .withMessage('Password must be 6 characters or more.'), 24 | handleValidationErrors, 25 | ]; 26 | 27 | module.exports = { 28 | validateSignup, 29 | }; 30 | -------------------------------------------------------------------------------- /backend/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const apiRouter = require('./api'); 3 | 4 | router.use('/api', apiRouter); 5 | 6 | if (process.env.NODE_ENV === 'production') { 7 | const path = require('path'); 8 | 9 | router.get('/', (req, res) => { 10 | res.cookie('XSRF-TOKEN', req.csrfToken()); 11 | return res.sendFile( 12 | path.resolve(__dirname, '../../frontend', 'build', 'index.html') 13 | ); 14 | }); 15 | 16 | router.use(express.static(path.resolve('../frontend/build'))); 17 | 18 | router.get(/^(?!\/?api).*/, (req, res) => { 19 | res.cookie('XSRF-TOKEN', req.csrfToken()); 20 | return res.sendFile( 21 | path.resolve(__dirname, '../../frontend', 'build', 'index.html') 22 | ); 23 | }); 24 | } 25 | 26 | if (process.env.NODE_ENV !== 'production') { 27 | router.get('/api/csrf/restore', (req, res) => { 28 | res.cookie('XSRF-TOKEN', req.csrfToken()); 29 | res.json({}); 30 | }); 31 | } 32 | 33 | /** Route for testing backend API routes */ 34 | // router.get('/hello/world', (req, res) => { 35 | // res.cookie('XSRF-TOKEN', req.csrfToken()); 36 | // res.send('Hello World!'); 37 | // }); 38 | 39 | module.exports = router; 40 | -------------------------------------------------------------------------------- /backend/utils/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const { jwtConfig: { secret, expiresIn } } = require('../config'); 3 | const { User } = require('../db/models'); 4 | 5 | const setTokenCookie = (res, user) => { 6 | const token = jwt.sign( 7 | { data: user.toSafeObject() }, 8 | secret, 9 | { expiresIn: parseInt(expiresIn) }, 10 | ); 11 | 12 | const isProduction = process.env.NODE_ENV === 'production'; 13 | 14 | res.cookie('token', token, { 15 | maxAge: expiresIn * 1000, 16 | httpOnly: true, 17 | secure: isProduction, 18 | sameSite: isProduction & 'Lax', 19 | }); 20 | 21 | return token; 22 | }; 23 | 24 | // Middleware function 25 | const restoreUser = (req, res, next) => { 26 | const { token } = req.cookies; 27 | return jwt.verify(token, secret, null, async (err, jwtPayload) => { 28 | if (err) { 29 | return next(); 30 | } 31 | 32 | try { 33 | const { id } = jwtPayload.data; 34 | req.user = await User.scope('currentUser').findByPk(id); 35 | } catch (e) { 36 | res.clearCookie('token'); 37 | return next(); 38 | } 39 | 40 | if (!req.user) res.clearCookie('token'); 41 | 42 | return next(); 43 | }); 44 | }; 45 | 46 | // Middleware function 47 | const requireAuth = [ 48 | restoreUser, 49 | function(req, _res, next) { 50 | if (req.user) return next(); 51 | 52 | const err = new Error('Unauthorized'); 53 | err.title = 'Unauthorized'; 54 | err.errors = ['Unauthorized']; 55 | err.status = 401; 56 | return next(err); 57 | }, 58 | ]; 59 | 60 | module.exports = { 61 | setTokenCookie, 62 | restoreUser, 63 | requireAuth, 64 | }; 65 | -------------------------------------------------------------------------------- /backend/utils/validation.js: -------------------------------------------------------------------------------- 1 | const { validationResult } = require('express-validator'); 2 | 3 | const handleValidationErrors = (req, _res, next) => { 4 | const validationErrors = validationResult(req); 5 | 6 | if (!validationErrors.isEmpty()) { 7 | const errors = validationErrors 8 | .array() 9 | .map((error) => `${error.msg}`); 10 | 11 | const err = new Error('Bad request.'); 12 | err.errors = errors; 13 | err.status = 400; 14 | err.title = 'Bad request.'; 15 | next(err); 16 | } 17 | 18 | next(); 19 | }; 20 | 21 | module.exports = { 22 | handleValidationErrors, 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Create React App Template 3 | 4 | A no-frills template from which to create React applications with 5 | [Create React App](https://github.com/facebook/create-react-app). 6 | 7 | ```sh 8 | npx create-react-app my-app --template @appacademy/simple --use-npm 9 | ``` 10 | 11 | ## Available Scripts 12 | 13 | In the project directory, you can run: 14 | 15 | ### `npm start` 16 | 17 | Runs the app in the development mode.\ 18 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 19 | 20 | The page will reload if you make edits.\ 21 | You will also see any lint errors in the console. 22 | 23 | ### `npm test` 24 | 25 | Launches the test runner in the interactive watch mode.\ 26 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 27 | 28 | ### `npm run build` 29 | 30 | Builds the app for production to the `build` folder.\ 31 | It correctly bundles React in production mode and optimizes the build for the best performance. 32 | 33 | The build is minified and the filenames include the hashes.\ 34 | Your app is ready to be deployed! 35 | 36 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 37 | 38 | ### `npm run eject` 39 | 40 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 41 | 42 | 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. 43 | 44 | 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. 45 | 46 | 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. 47 | 48 | ## Learn More 49 | 50 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 51 | 52 | To learn React, check out the [React documentation](https://reactjs.org/). 53 | 54 | ### Code Splitting 55 | 56 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 57 | 58 | ### Analyzing the Bundle Size 59 | 60 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 61 | 62 | ### Making a Progressive Web App 63 | 64 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 65 | 66 | ### Advanced Configuration 67 | 68 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 69 | 70 | ### Deployment 71 | 72 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 73 | 74 | ### `npm run build` fails to minify 75 | 76 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 77 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@react-google-maps/api": "^2.2.0", 7 | "@testing-library/jest-dom": "^5.12.0", 8 | "@testing-library/react": "^11.2.7", 9 | "@testing-library/user-event": "^12.8.3", 10 | "js-cookie": "^2.2.1", 11 | "react": "^17.0.2", 12 | "react-dom": "^17.0.2", 13 | "react-redux": "^7.2.4", 14 | "react-router-dom": "^5.2.0", 15 | "react-scripts": "4.0.3", 16 | "redux": "^4.1.0", 17 | "redux-thunk": "^2.3.0" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject" 24 | }, 25 | "eslintConfig": { 26 | "extends": "react-app" 27 | }, 28 | "prettier": { 29 | "singleQuote": true 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "devDependencies": { 44 | "redux-logger": "^3.0.6" 45 | }, 46 | "proxy": "http://localhost:5000" 47 | } 48 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | Authenticate Me: Google Maps Edition 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React Template", 3 | "name": "Create React App Template", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { Switch, Route } from 'react-router-dom'; 4 | 5 | import { restoreUser } from './store/session'; 6 | import SignupFormPage from './components/SignupFormPage'; 7 | import Navigation from './components/Navigation'; 8 | import MapContainer from './components/Maps'; 9 | 10 | function App() { 11 | const dispatch = useDispatch(); 12 | const [isLoaded, setIsLoaded] = useState(false); 13 | 14 | useEffect(() => { 15 | (async () => { 16 | await dispatch(restoreUser()); 17 | setIsLoaded(true); 18 | })(); 19 | }, [dispatch]); 20 | 21 | return ( 22 | <> 23 |

Hello from App

24 | 25 | {isLoaded && ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | )} 37 | 38 | ); 39 | } 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /frontend/src/components/LoginFormModal/LoginForm.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | 4 | import { login } from "../../store/session"; 5 | import styles from "./LoginForm.module.css"; 6 | 7 | const LoginFormPage = () => { 8 | const dispatch = useDispatch(); 9 | 10 | const [credential, setCredential] = useState(""); 11 | const [password, setPassword] = useState(""); 12 | const [errors, setErrors] = useState([]); 13 | 14 | const submitHandler = async (e) => { 15 | e.preventDefault(); 16 | try { 17 | await dispatch(login({ credential, password })); 18 | } catch (err) { 19 | (async () => { 20 | const { errors } = await err.json(); 21 | setErrors(errors); 22 | })(); 23 | } 24 | }; 25 | 26 | return ( 27 |
28 |

Login

29 | {errors.length > 0 && ( 30 |
    31 | {errors.map((err) => ( 32 |
  • {err}
  • 33 | ))} 34 |
35 | )} 36 |
37 | setCredential(e.target.value)} 43 | /> 44 |
45 |
46 | setPassword(e.target.value)} 52 | /> 53 |
54 |
55 | 58 |
59 |
60 | ); 61 | }; 62 | 63 | export default LoginFormPage; 64 | -------------------------------------------------------------------------------- /frontend/src/components/LoginFormModal/LoginForm.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | margin: 0 auto; 3 | width: fit-content; 4 | padding: 0.5rem; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | border-radius: 0.25rem; 9 | background-color: white; 10 | } 11 | 12 | .inputGroup { 13 | padding: 0.5rem; 14 | display: flex; 15 | justify-content: center; 16 | } 17 | 18 | .input { 19 | padding: 0.5rem; 20 | border-radius: 0.25rem; 21 | border: 0.1rem solid black; 22 | } 23 | 24 | .button { 25 | padding: 0.5rem; 26 | border-radius: 0.25rem; 27 | border: 0.1rem solid black; 28 | } 29 | 30 | .errors { 31 | list-style: none; 32 | color: red; 33 | } 34 | 35 | .heading { 36 | font-size: 2rem; 37 | font-weight: 600; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/components/LoginFormModal/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import styles from './LoginForm.module.css'; 4 | import { Modal } from '../../context/Modal'; 5 | import LoginForm from './LoginForm'; 6 | 7 | const LoginFormModal = () => { 8 | const [showModal, setShowModal] = useState(false); 9 | 10 | return ( 11 | <> 12 | 13 | {showModal && ( 14 | setShowModal(false)}> 15 | 16 | 17 | )} 18 | 19 | ); 20 | }; 21 | 22 | export default LoginFormModal; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Maps/Maps.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GoogleMap, useJsApiLoader } from '@react-google-maps/api'; 3 | 4 | const containerStyle = { 5 | width: '400px', 6 | height: '400px', 7 | }; 8 | 9 | const center = { 10 | lat: 38.9072, 11 | lng: -77.0369, 12 | }; 13 | 14 | const Maps = ({ apiKey }) => { 15 | const { isLoaded } = useJsApiLoader({ 16 | id: 'google-map-script', 17 | googleMapsApiKey: apiKey, 18 | }); 19 | 20 | return ( 21 | <> 22 | {isLoaded && ( 23 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default React.memo(Maps); 34 | -------------------------------------------------------------------------------- /frontend/src/components/Maps/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { getKey } from '../../store/maps'; 5 | import Maps from './Maps'; 6 | 7 | const MapContainer = () => { 8 | const key = useSelector((state) => state.maps.key); 9 | const dispatch = useDispatch(); 10 | 11 | useEffect(() => { 12 | if (!key) { 13 | dispatch(getKey()); 14 | } 15 | }, [dispatch, key]); 16 | 17 | if (!key) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 23 | ); 24 | }; 25 | 26 | export default MapContainer; 27 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/Navigation.js: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | import styles from './Navigation.module.css'; 5 | import ProfileButton from './ProfileButton'; 6 | import LoginFormModal from '../LoginFormModal'; 7 | 8 | const Navigation = ({ isLoaded }) => { 9 | const sessionUser = useSelector((state) => state.session.user); 10 | 11 | let sessionLinks; 12 | 13 | if (sessionUser) { 14 | sessionLinks = ( 15 | 16 | ); 17 | } else { 18 | sessionLinks = ( 19 | <> 20 | 21 | Sign Up 22 | 23 | ); 24 | } 25 | 26 | return ( 27 | 36 | ); 37 | }; 38 | 39 | export default Navigation; 40 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/Navigation.module.css: -------------------------------------------------------------------------------- 1 | .ul { 2 | list-style: none; 3 | } 4 | 5 | .li { 6 | padding: 0.25rem; 7 | } 8 | 9 | .navLink { 10 | padding: 0.25rem; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/ProfileButton.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import styles from './ProfileButton.module.css'; 5 | import { logout } from '../../store/session'; 6 | 7 | const ProfileButton = ({ user }) => { 8 | const dispatch = useDispatch(); 9 | const [showMenu, setShowMenu] = useState(false); 10 | 11 | const openMenu = () => { 12 | if (showMenu) return; 13 | setShowMenu(true); 14 | }; 15 | 16 | useEffect(() => { 17 | if (!showMenu) return; 18 | 19 | const closeMenu = () => { 20 | setShowMenu(false); 21 | }; 22 | 23 | document.addEventListener('click', closeMenu); 24 | return () => document.removeEventListener('click', closeMenu); 25 | }, [showMenu]); 26 | 27 | const logoutHandler = (e) => { 28 | e.preventDefault(); 29 | dispatch(logout()); 30 | }; 31 | 32 | return ( 33 | <> 34 | 37 | {showMenu && ( 38 |
    39 |
  • {user.username}
  • 40 |
  • {user.email}
  • 41 |
  • 42 | 43 |
  • 44 |
45 | )} 46 | 47 | ); 48 | }; 49 | 50 | export default ProfileButton; 51 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/ProfileButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | padding: 0.35rem; 3 | font-size: 1rem; 4 | } 5 | 6 | .logoutButton { 7 | padding: 0.35rem; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Navigation'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/SignupFormPage/SignupFormPage.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Redirect } from 'react-router-dom'; 4 | 5 | import styles from './SignupFormPage.module.css'; 6 | import { signup } from '../../store/session'; 7 | 8 | const SignupFormPage = () => { 9 | const dispatch = useDispatch(); 10 | const sessionUser = useSelector((state) => state.session.user); 11 | 12 | const [username, setUsername] = useState(''); 13 | const [email, setEmail] = useState(''); 14 | const [password, setPassword] = useState(''); 15 | const [confirmPassword, setConfirmPassword] = useState(''); 16 | const [errors, setErrors] = useState([]); 17 | 18 | const submitHandler = async (e) => { 19 | e.preventDefault(); 20 | try { 21 | await dispatch(signup({ username, email, password })); 22 | } catch (err) { 23 | (async () => { 24 | const { errors } = await err.json(); 25 | setErrors(errors); 26 | })(); 27 | } 28 | }; 29 | 30 | if (sessionUser) { 31 | return ; 32 | } 33 | 34 | return ( 35 |
36 |

Signup

37 | {errors.length > 0 && ( 38 |
    39 | {errors.map((err) => ( 40 |
  • {err}
  • 41 | ))} 42 |
43 | )} 44 |
45 | setUsername(e.target.value)} 51 | /> 52 |
53 |
54 | setEmail(e.target.value)} 60 | /> 61 |
62 |
63 | setPassword(e.target.value)} 69 | /> 70 |
71 |
72 | setConfirmPassword(e.target.value)} 78 | /> 79 |
80 |
81 | 84 |
85 |
86 | ); 87 | }; 88 | 89 | export default SignupFormPage; 90 | -------------------------------------------------------------------------------- /frontend/src/components/SignupFormPage/SignupFormPage.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | margin: 0 auto; 3 | width: fit-content; 4 | padding: 0.5rem; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .inputGroup { 10 | padding: 0.5rem; 11 | width: 50%; 12 | display: flex; 13 | justify-content: center; 14 | } 15 | 16 | .input { 17 | padding: 0.5rem; 18 | border-radius: 0.25rem; 19 | border: 0.1rem solid black; 20 | } 21 | 22 | .button { 23 | padding: 0.5rem; 24 | border-radius: 0.25rem; 25 | border: 0.1rem solid black; 26 | } 27 | 28 | .errors { 29 | list-style: none; 30 | color: red; 31 | } 32 | 33 | .heading { 34 | font-size: 2rem; 35 | font-weight: 600; 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/SignupFormPage/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SignupFormPage'; 2 | -------------------------------------------------------------------------------- /frontend/src/context/Modal.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect, useRef } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import styles from './Modal.module.css'; 5 | 6 | const ModalContext = createContext(); 7 | 8 | export const ModalProvider = ({ children }) => { 9 | const modalRef = useRef(); 10 | const [value, setValue] = useState(null); 11 | 12 | useEffect(() => { 13 | setValue(modalRef.current); 14 | }, []); 15 | 16 | return ( 17 | <> 18 | {children} 19 |
20 | 21 | ); 22 | }; 23 | 24 | export const Modal = ({ onClose, children }) => { 25 | const modalNode = useContext(ModalContext); 26 | 27 | if (!modalNode) return null; 28 | 29 | return ReactDOM.createPortal( 30 |
31 |
32 |
{children}
33 |
, 34 | modalNode 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/context/Modal.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | display: flex; 4 | top: 0; 5 | right: 0; 6 | left: 0; 7 | bottom: 0; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | 12 | .background { 13 | position: fixed; 14 | top: 0; 15 | right: 0; 16 | left: 0; 17 | bottom: 0; 18 | background-color: rgba(0, 0, 0, 0.7); 19 | } 20 | 21 | .content { 22 | position: absolute; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | /* TODO Add site wide styles */ 2 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | import './index.css'; 7 | import configureStore from './store'; 8 | import { restoreCSRF, csrfFetch } from './store/csrf'; 9 | import * as sessionActions from './store/session'; 10 | import { ModalProvider } from './context/Modal'; 11 | import App from './App'; 12 | 13 | const store = configureStore(); 14 | 15 | if (process.env.NODE_ENV !== 'production') { 16 | restoreCSRF(); 17 | 18 | window.store = store; 19 | window.csrfFetch = csrfFetch; 20 | window.sessionActions = sessionActions; 21 | } 22 | 23 | const Root = () => ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | ReactDOM.render( 34 | 35 | 36 | , 37 | document.getElementById('root') 38 | ); 39 | -------------------------------------------------------------------------------- /frontend/src/store/csrf.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | 3 | export async function csrfFetch(url, options = {}) { 4 | options.method = options.method || 'GET'; 5 | options.headers = options.headers || {}; 6 | 7 | if (options.method.toUpperCase() !== 'GET') { 8 | options.headers['Content-Type'] = 9 | options.headers['Content-Type'] || 'application/json'; 10 | options.headers['XSRF-Token'] = Cookies.get('XSRF-TOKEN'); 11 | } 12 | 13 | const res = await window.fetch(url, options); 14 | 15 | if (res.status >= 400) throw res; 16 | 17 | return res; 18 | } 19 | 20 | export function restoreCSRF() { 21 | return csrfFetch('/api/csrf/restore'); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | 4 | import session from './session'; 5 | import maps from './maps'; 6 | 7 | const rootReducer = combineReducers({ 8 | session, 9 | maps, 10 | }); 11 | 12 | let enhancer; 13 | 14 | if (process.env.NODE_ENV === 'production') { 15 | // Just thunking 16 | enhancer = applyMiddleware(thunk); 17 | } else { 18 | // Thunk and logs 19 | const logger = require('redux-logger').default; 20 | const composeEnhancers = 21 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 22 | enhancer = composeEnhancers(applyMiddleware(thunk, logger)); 23 | } 24 | 25 | const configureStore = (preloadedState) => { 26 | return createStore(rootReducer, preloadedState, enhancer); 27 | }; 28 | 29 | export default configureStore; 30 | -------------------------------------------------------------------------------- /frontend/src/store/maps.js: -------------------------------------------------------------------------------- 1 | import { csrfFetch } from './csrf'; 2 | 3 | const LOAD_API_KEY = 'maps/LOAD_API_KEY'; 4 | 5 | const loadApiKey = (key) => ({ 6 | type: LOAD_API_KEY, 7 | payload: key, 8 | }); 9 | 10 | export const getKey = () => async (dispatch) => { 11 | const res = await csrfFetch('/api/maps/key', { 12 | method: 'POST', 13 | }); 14 | const data = await res.json(); 15 | dispatch(loadApiKey(data.googleMapsAPIKey)); 16 | }; 17 | 18 | const initialState = { key: null }; 19 | 20 | const mapsReducer = (state = initialState, action) => { 21 | switch (action.type) { 22 | case LOAD_API_KEY: 23 | return { key: action.payload }; 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | export default mapsReducer; 30 | -------------------------------------------------------------------------------- /frontend/src/store/session.js: -------------------------------------------------------------------------------- 1 | import { csrfFetch } from './csrf'; 2 | 3 | const initialState = { user: null }; 4 | 5 | const SET_SESSION = 'session/SET_SESSION'; 6 | const REMOVE_SESSION = 'session/REMOVE_SESSION'; 7 | 8 | const setSession = (user) => ({ 9 | type: SET_SESSION, 10 | payload: user, 11 | }); 12 | 13 | const removeSession = () => ({ 14 | type: REMOVE_SESSION, 15 | }); 16 | 17 | export const login = (userData) => async (dispatch) => { 18 | const res = await csrfFetch('/api/session', { 19 | method: 'POST', 20 | body: JSON.stringify(userData), 21 | }); 22 | const data = await res.json(); 23 | if (!data.errors) { 24 | dispatch(setSession(data.user)); 25 | } 26 | return data; 27 | }; 28 | 29 | export const restoreUser = () => async (dispatch) => { 30 | const res = await csrfFetch('/api/session'); 31 | const data = await res.json(); 32 | if (data.user) { 33 | dispatch(setSession(data.user)); 34 | } 35 | return data; 36 | }; 37 | 38 | export const signup = (userData) => async (dispatch) => { 39 | const res = await csrfFetch('/api/users', { 40 | method: 'POST', 41 | body: JSON.stringify(userData), 42 | }); 43 | const data = await res.json(); 44 | if (!data.errors) { 45 | dispatch(setSession(data.user)); 46 | } 47 | return data; 48 | }; 49 | 50 | export const logout = () => async (dispatch) => { 51 | const res = await csrfFetch('/api/session', { 52 | method: 'DELETE', 53 | }); 54 | if (res.ok) { 55 | dispatch(removeSession()); 56 | } 57 | }; 58 | 59 | const sessionReducer = (state = initialState, action) => { 60 | switch (action.type) { 61 | case SET_SESSION: 62 | return { ...state, user: action.payload }; 63 | case REMOVE_SESSION: 64 | return { ...state, user: null }; 65 | default: 66 | return state; 67 | } 68 | }; 69 | 70 | export default sessionReducer; 71 | --------------------------------------------------------------------------------