diff --git a/lectures/07-async-error-handling/README.md b/lectures/07-async-error-handling/README.md new file mode 100644 index 0000000..bbda122 --- /dev/null +++ b/lectures/07-async-error-handling/README.md @@ -0,0 +1,15 @@ +# Authentication & Routing in depth + +- [Presentation](https://docs.google.com/presentation/d/1JCvCloP-MmZAFA6oY0tCUs6jVs_pIzUavwJMl5UYtPY/edit) +- [Video](https://www.youtube.com/watch?v=V5URcw_KYFM) + +## Homework + +- move logic to chosen async solution _(default: redux-thunks, advanced: redux-sagas, redux-observables)_ +- handle refresh tokens +- notify user about errors `server/network` and `success` _(product added to cart/deleted from cart/logged in)_ + +## Aditional info + +- To mock server responses `401/500` you can use https://www.charlesproxy.com/ +- If you found homework too easy, please refer to `Up for the challenge?` slide for [problem inspiration](https://docs.google.com/presentation/d/1JCvCloP-MmZAFA6oY0tCUs6jVs_pIzUavwJMl5UYtPY/edit#slide=id.g56a65efc51_0_57) diff --git a/package.json b/package.json index 908c397..b446697 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "react-router": "^5.0.0", "react-router-dom": "^5.0.0", "react-scripts": "2.1.8", + "react-toastify": "^5.1.0", "redux": "^4.0.1", + "redux-thunk": "^2.3.0", "sanitize.css": "^8.0.0", "styled-components": "^4.2.0", "styled-system": "^4.0.8", diff --git a/src/App.js b/src/App.js index 1cd9acb..c4299ae 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,7 @@ import React from 'react' import { Switch, Route, Redirect } from 'react-router-dom' import { Provider } from 'react-redux' +import { ToastContainer, toast } from 'react-toastify' import GlobalStyles from './globalStyles' import { ProductList } from './pages/ProductList' @@ -8,9 +9,11 @@ import { ProductDetail } from './pages/ProductDetail' import { Cart } from './pages/Cart' import { SignUp } from './pages/SignUp' import { LogIn } from './pages/LogIn' +import { Logout } from './pages/Logout' import { Account } from './pages/Account' import { NotFound } from './pages/NotFound' import { PrivateRoute } from './components/PrivateRoute' +import { ErrorBoundary } from './components/ErrorBoundary' import { getCustomer } from './utils/customer' import { configureStore } from './store' import * as routes from './routes' @@ -23,20 +26,24 @@ const App = () => ( - - } - /> - - - - - - - - + + + + } + /> + + + + + + + + + + ) diff --git a/src/api/api-client.js b/src/api/api-client.js index 49aa527..4eb018f 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -1,15 +1,15 @@ +/* eslint-disable no-constant-condition */ +/* eslint-disable no-await-in-loop */ + import config from '../config' +import { LOGOUT } from '../routes' import { getGuestToken } from './get-guest-token' +import { refreshCustomerToken } from './customers/refresh-customer-token' import { getToken } from '../utils/token' +import { getRefreshToken } from '../utils/refresh-token' -export const api = async (url, options) => { - let token = getToken() - - if (!token) { - token = await getGuestToken() - } - - const response = await fetch(`${config.apiUrl}${url}`, { +const makeRequest = (url, options, token) => + fetch(`${config.apiUrl}${url}`, { method: 'GET', headers: { 'Content-Type': 'application/vnd.api+json', @@ -18,5 +18,41 @@ export const api = async (url, options) => { ...options, }) - return response.json() +export const api = async (url, options) => { + // Grab the token from the store or from the API + let token = getToken() || (await getGuestToken()) + + try { + // Do the request + let response = await makeRequest(url, options, token) + + // 401 unauthorized means we have expired token + if (response && response.status === 401) { + const refreshToken = getRefreshToken() + + // If we have a refresh token this means we have logged in user + // and we need to refresh access token + if (refreshToken) { + token = await refreshCustomerToken() + } else { + // If no refresh token is present just get new guest token + token = await getGuestToken() + } + + // Repeat the request with the new token + response = await makeRequest(url, options, token) + } + + // Here is a place to handle special cases + // CASE: second 401 we need to logout + if (response && response.status === 401) { + window.location.assign(LOGOUT) + } + + // If everything went fine just return the result + return response.json() + } catch (e) { + // Place to handle global api errors + throw e + } } diff --git a/src/api/customers/create-customer.js b/src/api/customers/create-customer.js index 42f8d76..159c77b 100644 --- a/src/api/customers/create-customer.js +++ b/src/api/customers/create-customer.js @@ -1,5 +1,4 @@ import { api } from '../api-client' -import { getCustomerToken } from './get-customer-token' export const createCustomer = async ({ email, password, firstName }) => { const requestBody = { @@ -20,19 +19,7 @@ export const createCustomer = async ({ email, password, firstName }) => { body: JSON.stringify(requestBody), }) - if (!response.errors) { - const { - data: { attributes }, - } = response - - const { ownerId } = await getCustomerToken({ username: email, password }) - - return { - ownerId, - username: attributes.email, - firstName: attributes.metadata.firstName, - } - } else { + if (response.errors) { const firstError = response.errors[0] if (firstError.status === '422') { throw new Error('Email is already registered') diff --git a/src/api/customers/get-customer-token.js b/src/api/customers/get-customer-token.js index c103a89..f7b047e 100644 --- a/src/api/customers/get-customer-token.js +++ b/src/api/customers/get-customer-token.js @@ -1,5 +1,5 @@ import config from '../../config' -import { setToken } from '../../utils/token' +import { AsyncValidationError } from '../../utils/errors' export const getCustomerToken = async ({ username, password }) => { const response = await fetch(`${config.apiUrl}/oauth/token`, { @@ -18,13 +18,12 @@ export const getCustomerToken = async ({ username, password }) => { switch (response.status) { case 200: { - const { owner_id, access_token } = await response.json() - setToken(access_token) + const { owner_id, access_token, refresh_token } = await response.json() - return { ownerId: owner_id, access_token } + return { ownerId: owner_id, access_token, refresh_token } } case 401: - throw new Error('Email or password are incorrect') + throw new AsyncValidationError('Email or password are incorrect') default: throw new Error('Unexpected error') } diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js new file mode 100644 index 0000000..606d621 --- /dev/null +++ b/src/api/customers/refresh-customer-token.js @@ -0,0 +1,35 @@ +import { setRefreshToken, getRefreshToken } from '../../utils/refresh-token' +import { setToken, getToken } from '../../utils/token' + +import config from '../../config' + +export const refreshCustomerToken = async () => { + const refreshToken = getRefreshToken() + const token = getToken() + + const response = await fetch(`${config.apiUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }) + + switch (response.status) { + case 200: { + const { refresh_token, access_token } = await response.json() + setToken(access_token) + setRefreshToken(refresh_token) + + return access_token + } + default: + throw new Error('Cannot refresh customer token') + } +} diff --git a/src/components/ErrorBoundary/index.js b/src/components/ErrorBoundary/index.js new file mode 100644 index 0000000..cb33acb --- /dev/null +++ b/src/components/ErrorBoundary/index.js @@ -0,0 +1,30 @@ +import React from 'react' +import { toast } from 'react-toastify' + +export class ErrorBoundary extends React.Component { + state = { + error: false, + } + + static getDerivedStateFromError() { + return { + error: true, + } + } + + componentDidCatch(error, info) { + toast.error(`Error: ${error.message}`) + console.error('Error boundary error', error, info) + } + + render() { + return this.state.error ? ( +

+ We are sorry! There was an error which we were not able to recover from. + Please refresh the page +

+ ) : ( + this.props.children + ) + } +} diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js index f7b2d7b..7708ae0 100644 --- a/src/components/Layout/index.js +++ b/src/components/Layout/index.js @@ -1,61 +1,37 @@ import React, { Fragment } from 'react' -import { withRouter } from 'react-router-dom' import { connect } from 'react-redux' -import * as customerActions from '../../store/customer/actions' import * as routes from '../../routes' -import { removeToken } from '../../utils/token' -import { removeCustomer } from '../../utils/customer' import { Wrapper, Header, HeaderSection, HeaderLink } from './styled' -const Layout = ({ logout, isAuthenticated, history, children }) => { - const handleLogout = () => { - logout() - removeToken() - removeCustomer() - history.push(routes.HOMEPAGE) - } - - return ( - -
- - All Products - - - My Cart| - {isAuthenticated ? ( - <> - My Account| - - Logout - - - ) : ( - <> - Log In | - Sign Up - - )} - -
- {children} -
- ) -} +const Layout = ({ isAuthenticated, children }) => ( + +
+ + All Products + + + My Cart| + {isAuthenticated ? ( + <> + My Account| + Logout + + ) : ( + <> + Log In | + Sign Up + + )} + +
+ {children} +
+) const mapStateToProps = state => ({ isAuthenticated: Object.keys(state.customer).length !== 0, }) -const mapDispatchToProps = { - logout: customerActions.logout, -} - -export default withRouter( - connect( - mapStateToProps, - mapDispatchToProps - )(Layout) -) +export default connect(mapStateToProps)(Layout) diff --git a/src/config.js b/src/config.js index 6dc65fe..7f3014f 100644 --- a/src/config.js +++ b/src/config.js @@ -1,5 +1,7 @@ export default { - clientId: '1639340def6563ae1342dca16e5e9711e696ffe45f1c21fe4fba8e272a03f51a', - scope: 'market:335', - apiUrl: 'https://the-amber-brand-12.commercelayer.io', + clientId: '1add790e25ab4f3b0724b593a09e1d1008fcce751d4c2b2f337353051f439eda', + clientSecret: + 'eac6464b9c4840222b1258732cb51c5db35ab472be4ba305b24813c8c0076839', + scope: 'market:710', + apiUrl: 'https://the-brown-brand-23.commercelayer.io', } diff --git a/src/globalStyles.js b/src/globalStyles.js index c5922bc..cecf064 100644 --- a/src/globalStyles.js +++ b/src/globalStyles.js @@ -1,4 +1,5 @@ import 'sanitize.css' +import 'react-toastify/dist/ReactToastify.css' import { createGlobalStyle } from 'styled-components' import theme from './common/theme' diff --git a/src/pages/LogIn/index.js b/src/pages/LogIn/index.js index 1179e9f..cb0c863 100644 --- a/src/pages/LogIn/index.js +++ b/src/pages/LogIn/index.js @@ -1,6 +1,7 @@ import React, { useState } from 'react' import { Formik } from 'formik' import { connect } from 'react-redux' +import { toast } from 'react-toastify' import Layout from '../../components/Layout' import { H1 } from '../../components/Typography' @@ -8,10 +9,7 @@ import { Form, GlobalFormError } from '../../components/Form' import { Input } from '../../components/Input' import Button from '../../components/Button' import * as customerActions from '../../store/customer/actions' -import * as routes from '../../routes' - -import { getCustomerToken } from '../../api/customers/get-customer-token' -import { getCustomer } from '../../api/customers/get-customer' +import { AsyncValidationError } from '../../utils/errors' import { schema } from './schema' const initialValues = { @@ -20,22 +18,29 @@ const initialValues = { } const LogInPage = ({ login, history }) => { - const [globalError, setGlobalError] = useState('') + const [formAsyncError, setFormAsyncError] = useState('') const handleSubmit = async ({ email, password }, { setSubmitting }) => { try { setSubmitting(true) - const { ownerId } = await getCustomerToken({ + + await login({ username: email, password, + push: history.push, }) - const customer = await getCustomer(ownerId) - login(customer) - history.push(routes.ACCOUNT) } catch (error) { - setGlobalError(error.message) + if (error instanceof AsyncValidationError) { + setFormAsyncError(error.message) + } else { + toast.error( + `There was an error while logging in, please try again later!` + ) + // This would be nice place to log errors to some external service + } + } finally { + setSubmitting(false) } - setSubmitting(false) } return ( @@ -48,8 +53,8 @@ const LogInPage = ({ login, history }) => { > {({ isSubmitting }) => (
- {Boolean(globalError) && ( - {globalError} + {Boolean(formAsyncError) && ( + {formAsyncError} )} diff --git a/src/pages/Logout/index.js b/src/pages/Logout/index.js new file mode 100644 index 0000000..8ac2e06 --- /dev/null +++ b/src/pages/Logout/index.js @@ -0,0 +1,18 @@ +import React, { useEffect } from 'react' +import { connect } from 'react-redux' +import { logout } from '../../store/customer/actions' + +const LogoutPage = ({ logoutAction, history }) => { + useEffect(() => + logoutAction({ + push: history.push, + }) + ) + + return Logging out +} + +export const Logout = connect( + null, + { logoutAction: logout } +)(LogoutPage) diff --git a/src/pages/SignUp/index.js b/src/pages/SignUp/index.js index 1e11b9e..c1cc804 100644 --- a/src/pages/SignUp/index.js +++ b/src/pages/SignUp/index.js @@ -8,10 +8,8 @@ import { Form, GlobalFormError } from '../../components/Form' import { Input } from '../../components/Input' import Button from '../../components/Button' import * as customerActions from '../../store/customer/actions' -import * as routes from '../../routes' import { createCustomer } from '../../api/customers/create-customer' -import { getCustomer } from '../../api/customers/get-customer' import { schema } from './schema' const initialValues = { @@ -24,13 +22,18 @@ const initialValues = { const SignUpPage = ({ login, history }) => { const [globalError, setGlobalError] = useState('') - const handleSubmit = async (values, { setSubmitting }) => { + const handleSubmit = async ( + { email, password, firstName }, + { setSubmitting } + ) => { try { setSubmitting(true) - const { ownerId } = await createCustomer(values) - const customer = await getCustomer(ownerId) - login(customer) - history.push(routes.ACCOUNT) + await createCustomer({ email, password, firstName }) + await login({ + username: email, + password, + push: history.push, + }) } catch (error) { setGlobalError(error.message) } diff --git a/src/routes.js b/src/routes.js index 04c3830..b20390a 100644 --- a/src/routes.js +++ b/src/routes.js @@ -9,3 +9,4 @@ export const CART = '/cart' export const SIGN_UP = '/signup' export const LOGIN = '/login' export const ACCOUNT = '/account' +export const LOGOUT = '/logout' diff --git a/src/store/customer/actions.js b/src/store/customer/actions.js index 77c2fc2..5c635de 100644 --- a/src/store/customer/actions.js +++ b/src/store/customer/actions.js @@ -1,11 +1,51 @@ -export const LOGIN = 'customer/LOGIN' +import { getCustomerToken } from '../../api/customers/get-customer-token' +import { getCustomer } from '../../api/customers/get-customer' +import * as routes from '../../routes' + +import { setToken, removeToken } from '../../utils/token' +import { setRefreshToken, removeRefreshToken } from '../../utils/refresh-token' +import { removeCustomer } from '../../utils/customer' + +export const LOGIN_INIT = 'customer/LOGIN_INIT' +export const LOGIN_FAIL = 'customer/LOGIN_FAIL' +export const LOGIN_SUCCESS = 'customer/LOGIN_SUCCESS' + export const LOGOUT = 'customer/LOGOUT' -export const login = customer => ({ - type: LOGIN, - payload: { customer }, -}) +export const login = ({ username, password, push }) => async dispatch => { + dispatch({ + type: LOGIN_INIT, + payload: { username, password }, + }) + + const { ownerId, access_token, refresh_token } = await getCustomerToken({ + username, + password, + }) + + setToken(access_token) + setRefreshToken(refresh_token) + + const customer = await getCustomer(ownerId) + + dispatch({ + type: LOGIN_SUCCESS, + payload: { + customer, + }, + }) + + push(routes.ACCOUNT) +} + +export const logout = ({ push }) => dispatch => { + removeToken() + removeRefreshToken() + removeCustomer() + + push(routes.HOMEPAGE) -export const logout = () => ({ - type: LOGOUT, -}) + dispatch({ + type: LOGOUT, + }) +} diff --git a/src/store/customer/reducer.js b/src/store/customer/reducer.js index 7f87ea4..7fe50c4 100644 --- a/src/store/customer/reducer.js +++ b/src/store/customer/reducer.js @@ -1,10 +1,10 @@ -import { LOGIN, LOGOUT } from './actions' +import { LOGOUT, LOGIN_SUCCESS } from './actions' const initialState = {} const reducer = (state = initialState, action) => { switch (action.type) { - case LOGIN: + case LOGIN_SUCCESS: return action.payload.customer case LOGOUT: diff --git a/src/store/index.js b/src/store/index.js index 1b37dc8..659897b 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,5 +1,5 @@ -import { createStore, combineReducers } from 'redux' - +import { createStore, combineReducers, applyMiddleware, compose } from 'redux' +import thunk from 'redux-thunk' import cart from './cart/reducer' import customer from './customer/reducer' @@ -8,11 +8,9 @@ const reducer = combineReducers({ customer, }) +// this variable will be set if you have redux-dev-tools extension installed in your browser +// https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose + export const configureStore = (preloadedState = {}) => - createStore( - reducer, - preloadedState, - // this variable will be set if you have redux-dev-tools extension installed in your browser - // https://github.com/zalmoxisus/redux-devtools-extension#11-basic-store - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() - ) + createStore(reducer, preloadedState, composeEnhancers(applyMiddleware(thunk))) diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..c642e2c --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,13 @@ +/* eslint-disable max-classes-per-file */ + +// Workaround because Babel cannot extend default Error +// https://stackoverflow.com/questions/31089801/extending-error-in-javascript-with-es6-syntax-babel +class CustomError { + constructor(message) { + this.name = 'CustomError' + this.message = message + } +} +CustomError.prototype = Object.create(Error.prototype) + +export class AsyncValidationError extends CustomError {} diff --git a/src/utils/refresh-token.js b/src/utils/refresh-token.js new file mode 100644 index 0000000..a798122 --- /dev/null +++ b/src/utils/refresh-token.js @@ -0,0 +1,11 @@ +export const getRefreshToken = () => { + return window.localStorage.getItem('refreshtoken') +} + +export const setRefreshToken = refreshToken => { + window.localStorage.setItem('refreshtoken', refreshToken) +} + +export const removeRefreshToken = () => { + window.localStorage.removeItem('refreshtoken') +} diff --git a/yarn.lock b/yarn.lock index 2145834..97c6bc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2198,6 +2198,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + clean-css@4.2.x: version "4.2.1" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" @@ -2998,6 +3003,13 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" @@ -7796,6 +7808,11 @@ react-is@^16.8.2: version "16.8.6" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-redux@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-6.0.1.tgz#0d423e2c1cb10ada87293d47e7de7c329623ba4d" @@ -7887,6 +7904,26 @@ react-scripts@2.1.8: optionalDependencies: fsevents "1.2.4" +react-toastify@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-5.1.0.tgz#aefe1f084bf4733f083423f013d24242cb63c482" + integrity sha512-0kVAAE7VO609EeXLVaFHDTc6Bnd/OUAb7rrRAwMsHeaThKEhH+WEQEPftTjuA4rP59K0QhCnWu4Ds2hXAcFxaw== + dependencies: + "@babel/runtime" "^7.4.2" + classnames "^2.2.6" + prop-types "^15.7.2" + react-transition-group "^2.6.1" + +react-transition-group@^2.6.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.8.4: version "16.8.4" resolved "https://registry.yarnpkg.com/react/-/react-16.8.4.tgz#fdf7bd9ae53f03a9c4cd1a371432c206be1c4768" @@ -7996,6 +8033,11 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + redux@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5"