├── .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 |
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 |
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 |
--------------------------------------------------------------------------------