Repository: cornflourblue/react-signup-verification-boilerplate
Branch: master
Commit: 9e95d5127a17
Files: 37
Total size: 73.7 KB
Directory structure:
gitextract_o8cqunop/
├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── src/
│ ├── _components/
│ │ ├── Alert.jsx
│ │ ├── Nav.jsx
│ │ ├── PrivateRoute.jsx
│ │ └── index.js
│ ├── _helpers/
│ │ ├── fake-backend.js
│ │ ├── fetch-wrapper.js
│ │ ├── history.js
│ │ ├── index.js
│ │ └── role.js
│ ├── _services/
│ │ ├── account.service.js
│ │ ├── alert.service.js
│ │ └── index.js
│ ├── account/
│ │ ├── ForgotPassword.jsx
│ │ ├── Index.jsx
│ │ ├── Login.jsx
│ │ ├── Register.jsx
│ │ ├── ResetPassword.jsx
│ │ └── VerifyEmail.jsx
│ ├── admin/
│ │ ├── Index.jsx
│ │ ├── Overview.jsx
│ │ └── users/
│ │ ├── AddEdit.jsx
│ │ ├── Index.jsx
│ │ └── List.jsx
│ ├── app/
│ │ └── Index.jsx
│ ├── home/
│ │ └── Index.jsx
│ ├── index.html
│ ├── index.jsx
│ ├── profile/
│ │ ├── Details.jsx
│ │ ├── Index.jsx
│ │ └── Update.jsx
│ └── styles.less
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": [
"@babel/preset-react",
"@babel/preset-env"
]
}
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
typings
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# dist folder
dist
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019 Jason Watmore
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# react-signup-verification-boilerplate
React - Email Sign Up with Verification, Authentication & Forgot Password
For documentation and a live demo see https://jasonwatmore.com/post/2020/04/22/react-email-sign-up-with-verification-authentication-forgot-password
================================================
FILE: package.json
================================================
{
"name": "react-signup-verification-boilerplate",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "https://github.com/cornflourblue/react-signup-verification-boilerplate.git"
},
"license": "MIT",
"scripts": {
"build": "webpack --mode production",
"start": "webpack-dev-server --open"
},
"dependencies": {
"formik": "^2.1.4",
"history": "^4.10.1",
"prop-types": "^15.7.2",
"query-string": "^6.11.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-router-dom": "^5.0.0",
"rxjs": "^6.3.3",
"yup": "^0.28.1"
},
"devDependencies": {
"@babel/core": "^7.4.3",
"@babel/preset-env": "^7.4.3",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.5",
"css-loader": "^3.4.2",
"html-webpack-plugin": "^3.2.0",
"less": "^3.11.0",
"less-loader": "^5.0.0",
"path": "^0.12.7",
"style-loader": "^1.1.3",
"webpack": "^4.29.6",
"webpack-cli": "^3.3.0",
"webpack-dev-server": "^3.2.1"
}
}
================================================
FILE: src/_components/Alert.jsx
================================================
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { alertService, AlertType } from '@/_services';
import { history } from '@/_helpers';
const propTypes = {
id: PropTypes.string,
fade: PropTypes.bool
};
const defaultProps = {
id: 'default-alert',
fade: true
};
function Alert({ id, fade }) {
const [alerts, setAlerts] = useState([]);
useEffect(() => {
// subscribe to new alert notifications
const subscription = alertService.onAlert(id)
.subscribe(alert => {
// clear alerts when an empty alert is received
if (!alert.message) {
setAlerts(alerts => {
// filter out alerts without 'keepAfterRouteChange' flag
const filteredAlerts = alerts.filter(x => x.keepAfterRouteChange);
// remove 'keepAfterRouteChange' flag on the rest
filteredAlerts.forEach(x => delete x.keepAfterRouteChange);
return filteredAlerts;
});
} else {
// add alert to array
setAlerts(alerts => ([...alerts, alert]));
// auto close alert if required
if (alert.autoClose) {
setTimeout(() => removeAlert(alert), 3000);
}
}
});
// clear alerts on location change
const historyUnlisten = history.listen(({ pathname }) => {
// don't clear if pathname has trailing slash because this will be auto redirected again
if (pathname.endsWith('/')) return;
alertService.clear(id);
});
// clean up function that runs when the component unmounts
return () => {
// unsubscribe & unlisten to avoid memory leaks
subscription.unsubscribe();
historyUnlisten();
};
}, []);
function removeAlert(alert) {
if (fade) {
// fade out alert
const alertWithFade = { ...alert, fade: true };
setAlerts(alerts => alerts.map(x => x === alert ? alertWithFade : x));
// remove alert after faded out
setTimeout(() => {
setAlerts(alerts => alerts.filter(x => x !== alertWithFade));
}, 250);
} else {
// remove alert
setAlerts(alerts => alerts.filter(x => x !== alert));
}
}
function cssClasses(alert) {
if (!alert) return;
const classes = ['alert', 'alert-dismissable'];
const alertTypeClass = {
[AlertType.Success]: 'alert alert-success',
[AlertType.Error]: 'alert alert-danger',
[AlertType.Info]: 'alert alert-info',
[AlertType.Warning]: 'alert alert-warning'
}
classes.push(alertTypeClass[alert.type]);
if (alert.fade) {
classes.push('fade');
}
return classes.join(' ');
}
if (!alerts.length) return null;
return (
{alerts.map((alert, index) =>
)}
);
}
Alert.propTypes = propTypes;
Alert.defaultProps = defaultProps;
export { Alert };
================================================
FILE: src/_components/Nav.jsx
================================================
import React, { useState, useEffect } from 'react';
import { NavLink, Route } from 'react-router-dom';
import { Role } from '@/_helpers';
import { accountService } from '@/_services';
function Nav() {
const [user, setUser] = useState({});
useEffect(() => {
const subscription = accountService.user.subscribe(x => setUser(x));
return subscription.unsubscribe;
}, []);
// only show nav when logged in
if (!user) return null;
return (
);
}
function AdminNav({ match }) {
const { path } = match;
return (
);
}
export { Nav };
================================================
FILE: src/_components/PrivateRoute.jsx
================================================
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import { accountService } from '@/_services';
function PrivateRoute({ component: Component, roles, ...rest }) {
return (
{
const user = accountService.userValue;
if (!user) {
// not logged in so redirect to login page with the return url
return
}
// check if route is restricted by role
if (roles && roles.indexOf(user.role) === -1) {
// role not authorized so redirect to home page
return
}
// authorized so return component
return
}} />
);
}
export { PrivateRoute };
================================================
FILE: src/_components/index.js
================================================
export * from './Alert';
export * from './Nav';
export * from './PrivateRoute';
================================================
FILE: src/_helpers/fake-backend.js
================================================
import { Role } from './'
import { alertService } from '@/_services';
// array in local storage for registered users
const usersKey = 'react-signup-verification-boilerplate-users';
let users = JSON.parse(localStorage.getItem(usersKey)) || [];
export function configureFakeBackend() {
let realFetch = window.fetch;
window.fetch = function (url, opts) {
return new Promise((resolve, reject) => {
// wrap in timeout to simulate server api call
setTimeout(handleRoute, 500);
function handleRoute() {
const { method } = opts;
switch (true) {
case url.endsWith('/accounts/authenticate') && method === 'POST':
return authenticate();
case url.endsWith('/accounts/refresh-token') && method === 'POST':
return refreshToken();
case url.endsWith('/accounts/revoke-token') && method === 'POST':
return revokeToken();
case url.endsWith('/accounts/register') && method === 'POST':
return register();
case url.endsWith('/accounts/verify-email') && method === 'POST':
return verifyEmail();
case url.endsWith('/accounts/forgot-password') && method === 'POST':
return forgotPassword();
case url.endsWith('/accounts/validate-reset-token') && method === 'POST':
return validateResetToken();
case url.endsWith('/accounts/reset-password') && method === 'POST':
return resetPassword();
case url.endsWith('/accounts') && method === 'GET':
return getUsers();
case url.match(/\/accounts\/\d+$/) && method === 'GET':
return getUserById();
case url.endsWith('/accounts') && method === 'POST':
return createUser();
case url.match(/\/accounts\/\d+$/) && method === 'PUT':
return updateUser();
case url.match(/\/accounts\/\d+$/) && method === 'DELETE':
return deleteUser();
default:
// pass through any requests not handled above
return realFetch(url, opts)
.then(response => resolve(response))
.catch(error => reject(error));
}
}
// route functions
function authenticate() {
const { email, password } = body();
const user = users.find(x => x.email === email && x.password === password && x.isVerified);
if (!user) return error('Email or password is incorrect');
// add refresh token to user
user.refreshTokens.push(generateRefreshToken());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok({
id: user.id,
email: user.email,
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
jwtToken: generateJwtToken(user)
});
}
function refreshToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) return unauthorized();
const user = users.find(x => x.refreshTokens.includes(refreshToken));
if (!user) return unauthorized();
// replace old refresh token with a new one and save
user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
user.refreshTokens.push(generateRefreshToken());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok({
id: user.id,
email: user.email,
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
jwtToken: generateJwtToken(user)
})
}
function revokeToken() {
if (!isAuthenticated()) return unauthorized();
const refreshToken = getRefreshToken();
const user = users.find(x => x.refreshTokens.includes(refreshToken));
// revoke token and save
user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function register() {
const user = body();
if (users.find(x => x.email === user.email)) {
// display email already registered "email" in alert
setTimeout(() => {
alertService.info(`
Email Already Registered
Your email ${user.email} is already registered.
If you don't know your password please visit the forgot password page.
NOTE: The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.
`, { autoClose: false });
}, 1000);
// always return ok() response to prevent email enumeration
return ok();
}
// assign user id and a few other properties then save
user.id = newUserId();
if (user.id === 1) {
// first registered user is an admin
user.role = Role.Admin;
} else {
user.role = Role.User;
}
user.dateCreated = new Date().toISOString();
user.verificationToken = new Date().getTime().toString();
user.isVerified = false;
user.refreshTokens = [];
delete user.confirmPassword;
users.push(user);
localStorage.setItem(usersKey, JSON.stringify(users));
// display verification email in alert
setTimeout(() => {
const verifyUrl = `${location.origin}/account/verify-email?token=${user.verificationToken}`;
alertService.info(`
Verification Email
Thanks for registering!
Please click the below link to verify your email address:
${verifyUrl}
NOTE: The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.
`, { autoClose: false });
}, 1000);
return ok();
}
function verifyEmail() {
const { token } = body();
const user = users.find(x => !!x.verificationToken && x.verificationToken === token);
if (!user) return error('Verification failed');
// set is verified flag to true if token is valid
user.isVerified = true;
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function forgotPassword() {
const { email } = body();
const user = users.find(x => x.email === email);
// always return ok() response to prevent email enumeration
if (!user) return ok();
// create reset token that expires after 24 hours
user.resetToken = new Date().getTime().toString();
user.resetTokenExpires = new Date(Date.now() + 24*60*60*1000).toISOString();
localStorage.setItem(usersKey, JSON.stringify(users));
// display password reset email in alert
setTimeout(() => {
const resetUrl = `${location.origin}/account/reset-password?token=${user.resetToken}`;
alertService.info(`
Reset Password Email
Please click the below link to reset your password, the link will be valid for 1 day:
${resetUrl}
NOTE: The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.
`, { autoClose: false });
}, 1000);
return ok();
}
function validateResetToken() {
const { token } = body();
const user = users.find(x =>
!!x.resetToken && x.resetToken === token &&
new Date() < new Date(x.resetTokenExpires)
);
if (!user) return error('Invalid token');
return ok();
}
function resetPassword() {
const { token, password } = body();
const user = users.find(x =>
!!x.resetToken && x.resetToken === token &&
new Date() < new Date(x.resetTokenExpires)
);
if (!user) return error('Invalid token');
// update password and remove reset token
user.password = password;
user.isVerified = true;
delete user.resetToken;
delete user.resetTokenExpires;
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function getUsers() {
if (!isAuthorized(Role.Admin)) return unauthorized();
return ok(users);
}
function getUserById() {
if (!isAuthenticated()) return unauthorized();
let user = users.find(x => x.id === idFromUrl());
// users can get own profile and admins can get all profiles
if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
return unauthorized();
}
return ok(user);
}
function createUser() {
if (!isAuthorized(Role.Admin)) return unauthorized();
const user = body();
if (users.find(x => x.email === user.email)) {
return error(`Email ${user.email} is already registered`);
}
// assign user id and a few other properties then save
user.id = newUserId();
user.dateCreated = new Date().toISOString();
user.isVerified = true;
user.refreshTokens = [];
delete user.confirmPassword;
users.push(user);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function updateUser() {
if (!isAuthenticated()) return unauthorized();
let params = body();
let user = users.find(x => x.id === idFromUrl());
// users can update own profile and admins can update all profiles
if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
return unauthorized();
}
// only update password if included
if (!params.password) {
delete params.password;
}
// don't save confirm password
delete params.confirmPassword;
// update and save user
Object.assign(user, params);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok({
id: user.id,
email: user.email,
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
role: user.role
});
}
function deleteUser() {
if (!isAuthenticated()) return unauthorized();
let user = users.find(x => x.id === idFromUrl());
// users can delete own account and admins can delete any account
if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
return unauthorized();
}
// delete user then save
users = users.filter(x => x.id !== idFromUrl());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
// helper functions
function ok(body) {
resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) });
}
function unauthorized() {
resolve({ status: 401, text: () => Promise.resolve(JSON.stringify({ message: 'Unauthorized' })) });
}
function error(message) {
resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) });
}
function isAuthenticated() {
return !!currentUser();
}
function isAuthorized(role) {
const user = currentUser();
if (!user) return false;
return user.role === role;
}
function idFromUrl() {
const urlParts = url.split('/');
return parseInt(urlParts[urlParts.length - 1]);
}
function body() {
return opts.body && JSON.parse(opts.body);
}
function newUserId() {
return users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
}
function generateJwtToken(user) {
// create token that expires in 15 minutes
const tokenPayload = {
exp: Math.round(new Date(Date.now() + 15*60*1000).getTime() / 1000),
id: user.id
}
return `fake-jwt-token.${btoa(JSON.stringify(tokenPayload))}`;
}
function currentUser() {
// check if jwt token is in auth header
const authHeader = opts.headers['Authorization'] || '';
if (!authHeader.startsWith('Bearer fake-jwt-token')) return;
// check if token is expired
const jwtToken = JSON.parse(atob(authHeader.split('.')[1]));
const tokenExpired = Date.now() > (jwtToken.exp * 1000);
if (tokenExpired) return;
const user = users.find(x => x.id === jwtToken.id);
return user;
}
function generateRefreshToken() {
const token = new Date().getTime().toString();
// add token cookie that expires in 7 days
const expires = new Date(Date.now() + 7*24*60*60*1000).toUTCString();
document.cookie = `fakeRefreshToken=${token}; expires=${expires}; path=/`;
return token;
}
function getRefreshToken() {
// get refresh token from cookie
return (document.cookie.split(';').find(x => x.includes('fakeRefreshToken')) || '=').split('=')[1];
}
});
}
}
================================================
FILE: src/_helpers/fetch-wrapper.js
================================================
import config from 'config';
import { accountService } from '@/_services';
export const fetchWrapper = {
get,
post,
put,
delete: _delete
}
function get(url) {
const requestOptions = {
method: 'GET',
headers: authHeader(url)
};
return fetch(url, requestOptions).then(handleResponse);
}
function post(url, body) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader(url) },
credentials: 'include',
body: JSON.stringify(body)
};
return fetch(url, requestOptions).then(handleResponse);
}
function put(url, body) {
const requestOptions = {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader(url) },
body: JSON.stringify(body)
};
return fetch(url, requestOptions).then(handleResponse);
}
// prefixed with underscored because delete is a reserved word in javascript
function _delete(url) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(url)
};
return fetch(url, requestOptions).then(handleResponse);
}
// helper functions
function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const user = accountService.userValue;
const isLoggedIn = user && user.jwtToken;
const isApiUrl = url.startsWith(config.apiUrl);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${user.jwtToken}` };
} else {
return {};
}
}
function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].includes(response.status) && accountService.userValue) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
accountService.logout();
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}
================================================
FILE: src/_helpers/history.js
================================================
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();
================================================
FILE: src/_helpers/index.js
================================================
export * from './fake-backend';
export * from './fetch-wrapper';
export * from './history';
export * from './role';
================================================
FILE: src/_helpers/role.js
================================================
export const Role = {
Admin: 'Admin',
User: 'User'
}
================================================
FILE: src/_services/account.service.js
================================================
import { BehaviorSubject } from 'rxjs';
import config from 'config';
import { fetchWrapper, history } from '@/_helpers';
const userSubject = new BehaviorSubject(null);
const baseUrl = `${config.apiUrl}/accounts`;
export const accountService = {
login,
logout,
refreshToken,
register,
verifyEmail,
forgotPassword,
validateResetToken,
resetPassword,
getAll,
getById,
create,
update,
delete: _delete,
user: userSubject.asObservable(),
get userValue () { return userSubject.value }
};
function login(email, password) {
return fetchWrapper.post(`${baseUrl}/authenticate`, { email, password })
.then(user => {
// publish user to subscribers and start timer to refresh token
userSubject.next(user);
startRefreshTokenTimer();
return user;
});
}
function logout() {
// revoke token, stop refresh timer, publish null to user subscribers and redirect to login page
fetchWrapper.post(`${baseUrl}/revoke-token`, {});
stopRefreshTokenTimer();
userSubject.next(null);
history.push('/account/login');
}
function refreshToken() {
return fetchWrapper.post(`${baseUrl}/refresh-token`, {})
.then(user => {
// publish user to subscribers and start timer to refresh token
userSubject.next(user);
startRefreshTokenTimer();
return user;
});
}
function register(params) {
return fetchWrapper.post(`${baseUrl}/register`, params);
}
function verifyEmail(token) {
return fetchWrapper.post(`${baseUrl}/verify-email`, { token });
}
function forgotPassword(email) {
return fetchWrapper.post(`${baseUrl}/forgot-password`, { email });
}
function validateResetToken(token) {
return fetchWrapper.post(`${baseUrl}/validate-reset-token`, { token });
}
function resetPassword({ token, password, confirmPassword }) {
return fetchWrapper.post(`${baseUrl}/reset-password`, { token, password, confirmPassword });
}
function getAll() {
return fetchWrapper.get(baseUrl);
}
function getById(id) {
return fetchWrapper.get(`${baseUrl}/${id}`);
}
function create(params) {
return fetchWrapper.post(baseUrl, params);
}
function update(id, params) {
return fetchWrapper.put(`${baseUrl}/${id}`, params)
.then(user => {
// update stored user if the logged in user updated their own record
if (user.id === userSubject.value.id) {
// publish updated user to subscribers
user = { ...userSubject.value, ...user };
userSubject.next(user);
}
return user;
});
}
// prefixed with underscore because 'delete' is a reserved word in javascript
function _delete(id) {
return fetchWrapper.delete(`${baseUrl}/${id}`)
.then(x => {
// auto logout if the logged in user deleted their own record
if (id === userSubject.value.id) {
logout();
}
return x;
});
}
// helper functions
let refreshTokenTimeout;
function startRefreshTokenTimer() {
// parse json object from base64 encoded jwt token
const jwtToken = JSON.parse(atob(userSubject.value.jwtToken.split('.')[1]));
// set a timeout to refresh the token a minute before it expires
const expires = new Date(jwtToken.exp * 1000);
const timeout = expires.getTime() - Date.now() - (60 * 1000);
refreshTokenTimeout = setTimeout(refreshToken, timeout);
}
function stopRefreshTokenTimer() {
clearTimeout(refreshTokenTimeout);
}
================================================
FILE: src/_services/alert.service.js
================================================
import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';
const alertSubject = new Subject();
const defaultId = 'default-alert';
export const alertService = {
onAlert,
success,
error,
info,
warn,
alert,
clear
};
export const AlertType = {
Success: 'Success',
Error: 'Error',
Info: 'Info',
Warning: 'Warning'
}
// enable subscribing to alerts observable
function onAlert(id = defaultId) {
return alertSubject.asObservable().pipe(filter(x => x && x.id === id));
}
// convenience methods
function success(message, options) {
alert({ ...options, type: AlertType.Success, message });
}
function error(message, options) {
alert({ ...options, type: AlertType.Error, message });
}
function info(message, options) {
alert({ ...options, type: AlertType.Info, message });
}
function warn(message, options) {
alert({ ...options, type: AlertType.Warning, message });
}
// core alert method
function alert(alert) {
alert.id = alert.id || defaultId;
alert.autoClose = (alert.autoClose === undefined ? true : alert.autoClose);
alertSubject.next(alert);
}
// clear alerts
function clear(id = defaultId) {
alertSubject.next({ id });
}
================================================
FILE: src/_services/index.js
================================================
export * from './account.service';
export * from './alert.service';
================================================
FILE: src/account/ForgotPassword.jsx
================================================
import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function ForgotPassword() {
const initialValues = {
email: ''
};
const validationSchema = Yup.object().shape({
email: Yup.string()
.email('Email is invalid')
.required('Email is required')
});
function onSubmit({ email }, { setSubmitting }) {
alertService.clear();
accountService.forgotPassword(email)
.then(() => alertService.success('Please check your email for password reset instructions'))
.catch(error => alertService.error(error))
.finally(() => setSubmitting(false));
}
return (
{({ errors, touched, isSubmitting }) => (
)}
)
}
export { ForgotPassword };
================================================
FILE: src/account/Index.jsx
================================================
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { accountService } from '@/_services';
import { Login } from './Login';
import { Register } from './Register';
import { VerifyEmail } from './VerifyEmail';
import { ForgotPassword } from './ForgotPassword';
import { ResetPassword } from './ResetPassword';
function Account({ history, match }) {
const { path } = match;
useEffect(() => {
// redirect to home if already logged in
if (accountService.userValue) {
history.push('/');
}
}, []);
return (
);
}
export { Account };
================================================
FILE: src/account/Login.jsx
================================================
import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function Login({ history, location }) {
const initialValues = {
email: '',
password: ''
};
const validationSchema = Yup.object().shape({
email: Yup.string()
.email('Email is invalid')
.required('Email is required'),
password: Yup.string().required('Password is required')
});
function onSubmit({ email, password }, { setSubmitting }) {
alertService.clear();
accountService.login(email, password)
.then(() => {
const { from } = location.state || { from: { pathname: "/" } };
history.push(from);
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
return (
{({ errors, touched, isSubmitting }) => (
)}
)
}
export { Login };
================================================
FILE: src/account/Register.jsx
================================================
import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function Register({ history }) {
const initialValues = {
title: '',
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false
};
const validationSchema = Yup.object().shape({
title: Yup.string()
.required('Title is required'),
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
email: Yup.string()
.email('Email is invalid')
.required('Email is required'),
password: Yup.string()
.min(6, 'Password must be at least 6 characters')
.required('Password is required'),
confirmPassword: Yup.string()
.oneOf([Yup.ref('password'), null], 'Passwords must match')
.required('Confirm Password is required'),
acceptTerms: Yup.bool()
.oneOf([true], 'Accept Terms & Conditions is required')
});
function onSubmit(fields, { setStatus, setSubmitting }) {
setStatus();
accountService.register(fields)
.then(() => {
alertService.success('Registration successful, please check your email for verification instructions', { keepAfterRouteChange: true });
history.push('login');
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
return (
{({ errors, touched, isSubmitting }) => (
)}
)
}
export { Register };
================================================
FILE: src/account/ResetPassword.jsx
================================================
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import queryString from 'query-string';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function ResetPassword({ history }) {
const TokenStatus = {
Validating: 'Validating',
Valid: 'Valid',
Invalid: 'Invalid'
}
const [token, setToken] = useState(null);
const [tokenStatus, setTokenStatus] = useState(TokenStatus.Validating);
useEffect(() => {
const { token } = queryString.parse(location.search);
// remove token from url to prevent http referer leakage
history.replace(location.pathname);
accountService.validateResetToken(token)
.then(() => {
setToken(token);
setTokenStatus(TokenStatus.Valid);
})
.catch(() => {
setTokenStatus(TokenStatus.Invalid);
});
}, []);
function getForm() {
const initialValues = {
password: '',
confirmPassword: ''
};
const validationSchema = Yup.object().shape({
password: Yup.string()
.min(6, 'Password must be at least 6 characters')
.required('Password is required'),
confirmPassword: Yup.string()
.oneOf([Yup.ref('password'), null], 'Passwords must match')
.required('Confirm Password is required'),
});
function onSubmit({ password, confirmPassword }, { setSubmitting }) {
alertService.clear();
accountService.resetPassword({ token, password, confirmPassword })
.then(() => {
alertService.success('Password reset successful, you can now login', { keepAfterRouteChange: true });
history.push('login');
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
return (
{({ errors, touched, isSubmitting }) => (
)}
);
}
function getBody() {
switch (tokenStatus) {
case TokenStatus.Valid:
return getForm();
case TokenStatus.Invalid:
return Token validation failed, if the token has expired you can get a new one at the forgot password page.
;
case TokenStatus.Validating:
return Validating token...
;
}
}
return (
Reset Password
{getBody()}
)
}
export { ResetPassword };
================================================
FILE: src/account/VerifyEmail.jsx
================================================
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import queryString from 'query-string';
import { accountService, alertService } from '@/_services';
function VerifyEmail({ history }) {
const EmailStatus = {
Verifying: 'Verifying',
Failed: 'Failed'
}
const [emailStatus, setEmailStatus] = useState(EmailStatus.Verifying);
useEffect(() => {
const { token } = queryString.parse(location.search);
// remove token from url to prevent http referer leakage
history.replace(location.pathname);
accountService.verifyEmail(token)
.then(() => {
alertService.success('Verification successful, you can now login', { keepAfterRouteChange: true });
history.push('login');
})
.catch(() => {
setEmailStatus(EmailStatus.Failed);
});
}, []);
function getBody() {
switch (emailStatus) {
case EmailStatus.Verifying:
return Verifying...
;
case EmailStatus.Failed:
return Verification failed, you can also verify your account using the forgot password page.
;
}
}
return (
)
}
export { VerifyEmail };
================================================
FILE: src/admin/Index.jsx
================================================
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Overview } from './Overview';
import { Users } from './users';
function Admin({ match }) {
const { path } = match;
return (
);
}
export { Admin };
================================================
FILE: src/admin/Overview.jsx
================================================
import React from 'react';
import { Link } from 'react-router-dom';
function Overview({ match }) {
const { path } = match;
return (
Admin
This section can only be accessed by administrators.
Manage Users
);
}
export { Overview };
================================================
FILE: src/admin/users/AddEdit.jsx
================================================
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function AddEdit({ history, match }) {
const { id } = match.params;
const isAddMode = !id;
const initialValues = {
title: '',
firstName: '',
lastName: '',
email: '',
role: '',
password: '',
confirmPassword: ''
};
const validationSchema = Yup.object().shape({
title: Yup.string()
.required('Title is required'),
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
email: Yup.string()
.email('Email is invalid')
.required('Email is required'),
role: Yup.string()
.required('Role is required'),
password: Yup.string()
.concat(isAddMode ? Yup.string().required('Password is required') : null)
.min(6, 'Password must be at least 6 characters'),
confirmPassword: Yup.string()
.when('password', (password, schema) => {
if (password) return schema.required('Confirm Password is required');
})
.oneOf([Yup.ref('password')], 'Passwords must match')
});
function onSubmit(fields, { setStatus, setSubmitting }) {
setStatus();
if (isAddMode) {
createUser(fields, setSubmitting);
} else {
updateUser(id, fields, setSubmitting);
}
}
function createUser(fields, setSubmitting) {
accountService.create(fields)
.then(() => {
alertService.success('User added successfully', { keepAfterRouteChange: true });
history.push('.');
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
function updateUser(id, fields, setSubmitting) {
accountService.update(id, fields)
.then(() => {
alertService.success('Update successful', { keepAfterRouteChange: true });
history.push('..');
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
return (
{({ errors, touched, isSubmitting, setFieldValue }) => {
useEffect(() => {
if (!isAddMode) {
// get user and set form fields
accountService.getById(id).then(user => {
const fields = ['title', 'firstName', 'lastName', 'email', 'role'];
fields.forEach(field => setFieldValue(field, user[field], false));
});
}
}, []);
return (
);
}}
);
}
export { AddEdit };
================================================
FILE: src/admin/users/Index.jsx
================================================
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { List } from './List';
import { AddEdit } from './AddEdit';
function Users({ match }) {
const { path } = match;
return (
);
}
export { Users };
================================================
FILE: src/admin/users/List.jsx
================================================
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { accountService } from '@/_services';
function List({ match }) {
const { path } = match;
const [users, setUsers] = useState(null);
useEffect(() => {
accountService.getAll().then(x => setUsers(x));
}, []);
function deleteUser(id) {
setUsers(users.map(x => {
if (x.id === id) { x.isDeleting = true; }
return x;
}));
accountService.delete(id).then(() => {
setUsers(users => users.filter(x => x.id !== id));
});
}
return (
Users
All users from secure (admin only) api end point:
Add User
| Name |
Email |
Role |
|
{users && users.map(user =>
| {user.title} {user.firstName} {user.lastName} |
{user.email} |
{user.role} |
Edit
|
)}
{!users &&
|
|
}
);
}
export { List };
================================================
FILE: src/app/Index.jsx
================================================
import React, { useState, useEffect } from 'react';
import { Route, Switch, Redirect, useLocation } from 'react-router-dom';
import { Role } from '@/_helpers';
import { accountService } from '@/_services';
import { Nav, PrivateRoute, Alert } from '@/_components';
import { Home } from '@/home';
import { Profile } from '@/profile';
import { Admin } from '@/admin';
import { Account } from '@/account';
function App() {
const { pathname } = useLocation();
const [user, setUser] = useState({});
useEffect(() => {
const subscription = accountService.user.subscribe(x => setUser(x));
return subscription.unsubscribe;
}, []);
return (
);
}
export { App };
================================================
FILE: src/home/Index.jsx
================================================
import React from 'react';
import { accountService } from '@/_services';
function Home() {
const user = accountService.userValue;
return (
Hi {user.firstName}!
You're logged in with React & JWT!!
);
}
export { Home };
================================================
FILE: src/index.html
================================================
React - Email Sign Up with Verification, Authentication & Forgot Password
================================================
FILE: src/index.jsx
================================================
import React from 'react';
import { Router } from 'react-router-dom';
import { render } from 'react-dom';
import { history } from './_helpers';
import { accountService } from './_services';
import { App } from './app';
import './styles.less';
// setup fake backend
import { configureFakeBackend } from './_helpers';
configureFakeBackend();
// attempt silent token refresh before startup
accountService.refreshToken().finally(startApp);
function startApp() {
render(
,
document.getElementById('app')
);
}
================================================
FILE: src/profile/Details.jsx
================================================
import React from 'react';
import { Link } from 'react-router-dom';
import { accountService } from '@/_services';
function Details({ match }) {
const { path } = match;
const user = accountService.userValue;
return (
My Profile
Name: {user.title} {user.firstName} {user.lastName}
Email: {user.email}
Update Profile
);
}
export { Details };
================================================
FILE: src/profile/Index.jsx
================================================
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Details } from './Details';
import { Update } from './Update';
function Profile({ match }) {
const { path } = match;
return (
);
}
export { Profile };
================================================
FILE: src/profile/Update.jsx
================================================
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
import { accountService, alertService } from '@/_services';
function Update({ history }) {
const user = accountService.userValue;
const initialValues = {
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
password: '',
confirmPassword: ''
};
const validationSchema = Yup.object().shape({
title: Yup.string()
.required('Title is required'),
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
email: Yup.string()
.email('Email is invalid')
.required('Email is required'),
password: Yup.string()
.min(6, 'Password must be at least 6 characters'),
confirmPassword: Yup.string()
.when('password', (password, schema) => {
if (password) return schema.required('Confirm Password is required');
})
.oneOf([Yup.ref('password')], 'Passwords must match')
});
function onSubmit(fields, { setStatus, setSubmitting }) {
setStatus();
accountService.update(user.id, fields)
.then(() => {
alertService.success('Update successful', { keepAfterRouteChange: true });
history.push('.');
})
.catch(error => {
setSubmitting(false);
alertService.error(error);
});
}
const [isDeleting, setIsDeleting] = useState(false);
function onDelete() {
if (confirm('Are you sure?')) {
setIsDeleting(true);
accountService.delete(user.id)
.then(() => alertService.success('Account deleted successfully'));
}
}
return (
{({ errors, touched, isSubmitting }) => (
)}
)
}
export { Update };
================================================
FILE: src/styles.less
================================================
// global styles
a { cursor: pointer; }
.app-container {
min-height: 320px;
}
.admin-nav {
padding-top: 0;
padding-bottom: 0;
background-color: #e8e9ea;
border-bottom: 1px solid #ccc;
}
================================================
FILE: webpack.config.js
================================================
var HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
mode: 'development',
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader'
},
{
test: /\.less$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'less-loader' }
]
}
]
},
resolve: {
mainFiles: ['index', 'Index'],
extensions: ['.js', '.jsx'],
alias: {
'@': path.resolve(__dirname, 'src/'),
}
},
plugins: [new HtmlWebpackPlugin({
template: './src/index.html'
})],
devServer: {
historyApiFallback: true
},
externals: {
// global app config object
config: JSON.stringify({
apiUrl: 'http://localhost:4000'
})
}
}