Merge pull request #426 from bigcapitalhq/big-163-user-email-verification-after-signing-up

feat: User email verification after signing-up.
This commit is contained in:
Ahmed Bouhuolia
2024-05-06 17:46:26 +02:00
committed by GitHub
38 changed files with 1193 additions and 54 deletions

View File

@@ -48,6 +48,9 @@ SIGNUP_DISABLED=false
SIGNUP_ALLOWED_DOMAINS=
SIGNUP_ALLOWED_EMAILS=
# Sign-up Email Confirmation
SIGNUP_EMAIL_CONFIRMATION=false
# API rate limit (points,duration,block duration).
API_RATE_LIMIT=120,60,600

View File

@@ -9,6 +9,8 @@ import { DATATYPES_LENGTH } from '@/data/DataTypes';
import LoginThrottlerMiddleware from '@/api/middleware/LoginThrottlerMiddleware';
import AuthenticationApplication from '@/services/Authentication/AuthApplication';
import JWTAuth from '@/api/middleware/jwtAuth';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
@Service()
export default class AuthenticationController extends BaseController {
@Inject()
@@ -28,6 +30,20 @@ export default class AuthenticationController extends BaseController {
asyncMiddleware(this.login.bind(this)),
this.handlerErrors
);
router.use('/register/verify/resend', JWTAuth);
router.use('/register/verify/resend', AttachCurrentTenantUser);
router.post(
'/register/verify/resend',
asyncMiddleware(this.registerVerifyResendMail.bind(this)),
this.handlerErrors
);
router.post(
'/register/verify',
this.signupVerifySchema,
this.validationResult,
asyncMiddleware(this.registerVerify.bind(this)),
this.handlerErrors
);
router.post(
'/register',
this.registerSchema,
@@ -99,6 +115,17 @@ export default class AuthenticationController extends BaseController {
];
}
private get signupVerifySchema(): ValidationChain[] {
return [
check('email')
.exists()
.isString()
.isEmail()
.isLength({ max: DATATYPES_LENGTH.STRING }),
check('token').exists().isString(),
];
}
/**
* Reset password schema.
* @returns {ValidationChain[]}
@@ -166,6 +193,58 @@ export default class AuthenticationController extends BaseController {
}
}
/**
* Verifies the provider user's email after signin-up.
* @param {Request} req
* @param {Response}| res
* @param {Function} next
* @returns {Response|void}
*/
private async registerVerify(req: Request, res: Response, next: Function) {
const signUpVerifyDTO: { email: string; token: string } =
this.matchedBodyData(req);
try {
const user = await this.authApplication.signUpConfirm(
signUpVerifyDTO.email,
signUpVerifyDTO.token
);
return res.status(200).send({
type: 'success',
message: 'The given user has verified successfully',
user,
});
} catch (error) {
next(error);
}
}
/**
* Resends the confirmation email to the user.
* @param {Request} req
* @param {Response}| res
* @param {Function} next
*/
private async registerVerifyResendMail(
req: Request,
res: Response,
next: Function
) {
const { user } = req;
try {
const data = await this.authApplication.signUpConfirmResend(user.id);
return res.status(200).send({
type: 'success',
message: 'The given user has verified successfully',
data,
});
} catch (error) {
next(error);
}
}
/**
* Send reset password handler
* @param {Request} req

View File

@@ -153,6 +153,13 @@ module.exports = {
),
},
/**
* Sign-up email confirmation
*/
signupConfirmation: {
enabled: parseBoolean<boolean>(process.env.SIGNUP_EMAIL_CONFIRMATION, false),
},
/**
* Puppeteer remote browserless connection.
*/

View File

@@ -66,16 +66,27 @@ export interface IAuthResetedPasswordEventPayload {
password: string;
}
export interface IAuthSendingResetPassword {
user: ISystemUser,
user: ISystemUser;
token: string;
}
export interface IAuthSendedResetPassword {
user: ISystemUser,
user: ISystemUser;
token: string;
}
export interface IAuthGetMetaPOJO {
export interface IAuthGetMetaPOJO {
signupDisabled: boolean;
}
}
export interface IAuthSignUpVerifingEventPayload {
email: string;
verifyToken: string;
userId: number;
}
export interface IAuthSignUpVerifiedEventPayload {
email: string;
verifyToken: string;
userId: number;
}

View File

@@ -91,6 +91,7 @@ import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/s
import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize';
import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete';
import { SubscribeFreeOnSignupCommunity } from '@/services/Subscription/events/SubscribeFreeOnSignupCommunity';
import { SendVerfiyMailOnSignUp } from '@/services/Authentication/events/SendVerfiyMailOnSignUp';
export default () => {
@@ -222,6 +223,7 @@ export const susbcribers = () => {
DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete,
SubscribeFreeOnSignupCommunity
SubscribeFreeOnSignupCommunity,
SendVerfiyMailOnSignUp
];
};

View File

@@ -12,6 +12,7 @@ import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleRe
import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob';
import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob';
import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob';
import { SendVerifyMailJob } from '@/services/Authentication/jobs/SendVerifyMailJob';
export default ({ agenda }: { agenda: Agenda }) => {
new ResetPasswordMailJob(agenda);
@@ -27,6 +28,7 @@ export default ({ agenda }: { agenda: Agenda }) => {
new PaymentReceiveMailNotificationJob(agenda);
new PlaidFetchTransactionsJob(agenda);
new ImportDeleteExpiredFilesJobs(agenda);
new SendVerifyMailJob(agenda);
agenda.start().then(() => {
agenda.every('1 hours', 'delete-expired-imported-files', {});

View File

@@ -1,4 +1,4 @@
import { Service, Inject, Container } from 'typedi';
import { Service, Inject } from 'typedi';
import {
IRegisterDTO,
ISystemUser,
@@ -9,6 +9,9 @@ import { AuthSigninService } from './AuthSignin';
import { AuthSignupService } from './AuthSignup';
import { AuthSendResetPassword } from './AuthSendResetPassword';
import { GetAuthMeta } from './GetAuthMeta';
import { AuthSignupConfirmService } from './AuthSignupConfirm';
import { SystemUser } from '@/system/models';
import { AuthSignupConfirmResend } from './AuthSignupResend';
@Service()
export default class AuthenticationApplication {
@@ -18,6 +21,12 @@ export default class AuthenticationApplication {
@Inject()
private authSignupService: AuthSignupService;
@Inject()
private authSignupConfirmService: AuthSignupConfirmService;
@Inject()
private authSignUpConfirmResendService: AuthSignupConfirmResend;
@Inject()
private authResetPasswordService: AuthSendResetPassword;
@@ -44,6 +53,28 @@ export default class AuthenticationApplication {
return this.authSignupService.signUp(signupDTO);
}
/**
* Verfying the provided user's email after signin-up.
* @param {string} email
* @param {string} token
* @returns {Promise<SystemUser>}
*/
public async signUpConfirm(
email: string,
token: string
): Promise<SystemUser> {
return this.authSignupConfirmService.signUpConfirm(email, token);
}
/**
* Resends the confirmation email of the given system user.
* @param {number} userId - System user id.
* @returns {Promise<void>}
*/
public async signUpConfirmResend(userId: number) {
return this.authSignUpConfirmResendService.signUpConfirmResend(userId);
}
/**
* Generates and retrieve password reset token for the given user email.
* @param {string} email

View File

@@ -1,5 +1,6 @@
import { isEmpty, omit } from 'lodash';
import { defaultTo, isEmpty, omit } from 'lodash';
import moment from 'moment';
import crypto from 'crypto';
import { ServiceError } from '@/exceptions';
import {
IAuthSignedUpEventPayload,
@@ -42,6 +43,13 @@ export class AuthSignupService {
const hashedPassword = await hashPassword(signupDTO.password);
const verifyTokenCrypto = crypto.randomBytes(64).toString('hex');
const verifiedEnabed = defaultTo(config.signupConfirmation.enabled, false);
const verifyToken = verifiedEnabed ? verifyTokenCrypto : '';
const verified = !verifiedEnabed;
const inviteAcceptedAt = moment().format('YYYY-MM-DD');
// Triggers signin up event.
await this.eventPublisher.emitAsync(events.auth.signingUp, {
signupDTO,
@@ -50,10 +58,12 @@ export class AuthSignupService {
const tenant = await this.tenantsManager.createTenant();
const registeredUser = await systemUserRepository.create({
...omit(signupDTO, 'country'),
verifyToken,
verified,
active: true,
password: hashedPassword,
tenantId: tenant.id,
inviteAcceptedAt: moment().format('YYYY-MM-DD'),
inviteAcceptedAt,
});
// Triggers signed up event.
await this.eventPublisher.emitAsync(events.auth.signUp, {

View File

@@ -0,0 +1,57 @@
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import { SystemUser } from '@/system/models';
import { ERRORS } from './_constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IAuthSignUpVerifiedEventPayload,
IAuthSignUpVerifingEventPayload,
} from '@/interfaces';
@Service()
export class AuthSignupConfirmService {
@Inject()
private eventPublisher: EventPublisher;
/**
* Verifies the provided user's email after signing-up.
* @throws {ServiceErrors}
* @param {IRegisterDTO} signupDTO
* @returns {Promise<ISystemUser>}
*/
public async signUpConfirm(
email: string,
verifyToken: string
): Promise<SystemUser> {
const foundUser = await SystemUser.query().findOne({ email, verifyToken });
if (!foundUser) {
throw new ServiceError(ERRORS.SIGNUP_CONFIRM_TOKEN_INVALID);
}
const userId = foundUser.id;
// Triggers `signUpConfirming` event.
await this.eventPublisher.emitAsync(events.auth.signUpConfirming, {
email,
verifyToken,
userId,
} as IAuthSignUpVerifingEventPayload);
const updatedUser = await SystemUser.query().patchAndFetchById(
foundUser.id,
{
verified: true,
verifyToken: '',
}
);
// Triggers `signUpConfirmed` event.
await this.eventPublisher.emitAsync(events.auth.signUpConfirmed, {
email,
verifyToken,
userId,
} as IAuthSignUpVerifiedEventPayload);
return updatedUser as SystemUser;
}
}

View File

@@ -0,0 +1,34 @@
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import { SystemUser } from '@/system/models';
import { ERRORS } from './_constants';
@Service()
export class AuthSignupConfirmResend {
@Inject('agenda')
private agenda: any;
/**
* Resends the email confirmation of the given user.
* @param {number} userId - User ID.
* @returns {Promise<void>}
*/
public async signUpConfirmResend(userId: number) {
const user = await SystemUser.query().findById(userId).throwIfNotFound();
// Throw error if the user is already verified.
if (user.verified) {
throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED);
}
// Throw error if the verification token is not exist.
if (!user.verifyToken) {
throw new ServiceError(ERRORS.USER_ALREADY_VERIFIED);
}
const payload = {
email: user.email,
token: user.verifyToken,
fullName: user.firstName,
};
await this.agenda.now('send-signup-verify-mail', payload);
}
}

View File

@@ -33,4 +33,33 @@ export default class AuthenticationMailMesssages {
})
.send();
}
/**
* Sends signup verification mail.
* @param {string} email - Email address
* @param {string} fullName - User name.
* @param {string} token - Verification token.
* @returns {Promise<void>}
*/
public async sendSignupVerificationMail(
email: string,
fullName: string,
token: string
) {
const verifyUrl = `${config.baseURL}/auth/email_confirmation?token=${token}&email=${email}`;
await new Mail()
.setSubject('Bigcapital - Verify your email')
.setView('mail/SignupVerifyEmail.html')
.setTo(email)
.setAttachments([
{
filename: 'bigcapital.png',
path: `${global.__views_dir}/images/bigcapital.png`,
cid: 'bigcapital_logo',
},
])
.setData({ verifyUrl, fullName })
.send();
}
}

View File

@@ -9,4 +9,6 @@ export const ERRORS = {
EMAIL_EXISTS: 'EMAIL_EXISTS',
SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED',
SIGNUP_CONFIRM_TOKEN_INVALID: 'SIGNUP_CONFIRM_TOKEN_INVALID',
USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED',
};

View File

@@ -0,0 +1,30 @@
import { IAuthSignedUpEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { Inject } from 'typedi';
export class SendVerfiyMailOnSignUp {
@Inject('agenda')
private agenda: any;
/**
* Attaches events with handles.
*/
public attach(bus) {
bus.subscribe(events.auth.signUp, this.handleSendVerifyMailOnSignup);
}
/**
*
* @param {ITaxRateEditedPayload} payload -
*/
private handleSendVerifyMailOnSignup = async ({
user,
}: IAuthSignedUpEventPayload) => {
const payload = {
email: user.email,
token: user.verifyToken,
fullName: user.firstName,
};
await this.agenda.now('send-signup-verify-mail', payload);
};
}

View File

@@ -0,0 +1,35 @@
import { Container } from 'typedi';
import AuthenticationMailMesssages from '@/services/Authentication/AuthenticationMailMessages';
export class SendVerifyMailJob {
/**
* Constructor method.
* @param {Agenda} agenda
*/
constructor(agenda) {
agenda.define(
'send-signup-verify-mail',
{ priority: 'high' },
this.handler.bind(this)
);
}
/**
* Handle send welcome mail job.
* @param {Job} job
* @param {Function} done
*/
public async handler(job, done: Function): Promise<void> {
const { data } = job.attrs;
const { email, fullName, token } = data;
const authService = Container.get(AuthenticationMailMesssages);
try {
await authService.sendSignupVerificationMail(email, fullName, token);
done();
} catch (error) {
console.log(error);
done(error);
}
}
}

View File

@@ -9,6 +9,9 @@ export default {
signUp: 'onSignUp',
signingUp: 'onSigningUp',
signUpConfirming: 'signUpConfirming',
signUpConfirmed: 'signUpConfirmed',
sendingResetPassword: 'onSendingResetPassword',
sendResetPassword: 'onSendResetPassword',

View File

@@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.table('users', (table) => {
table.string('verify_token');
table.boolean('verified').defaultTo(false);
})
.then(() => {
return knex('USERS').update({ verified: true });
});
};
exports.down = (knex) => {};

View File

@@ -4,6 +4,12 @@ import SystemModel from '@/system/models/SystemModel';
import SoftDeleteQueryBuilder from '@/collection/SoftDeleteQueryBuilder';
export default class SystemUser extends SystemModel {
firstName!: string;
lastName!: string;
verified!: boolean;
inviteAcceptedAt!: Date | null;
deletedAt!: Date | null;
/**
* Table name.
*/
@@ -29,23 +35,33 @@ export default class SystemUser extends SystemModel {
* Virtual attributes.
*/
static get virtualAttributes() {
return ['fullName', 'isDeleted', 'isInviteAccepted'];
return ['fullName', 'isDeleted', 'isInviteAccepted', 'isVerified'];
}
/**
*
* Detarmines whether the user is deleted.
* @returns {boolean}
*/
get isDeleted() {
return !!this.deletedAt;
}
/**
*
* Detarmines whether the sent invite is accepted.
* @returns {boolean}
*/
get isInviteAccepted() {
return !!this.inviteAcceptedAt;
}
/**
* Detarmines whether the user's email is verified.
* @returns {boolean}
*/
get isVerified() {
return !!this.verified;
}
/**
* Full name attribute.
*/

View File

@@ -1,6 +1,6 @@
import bcrypt from 'bcryptjs';
import moment from 'moment';
import _ from 'lodash';
import _, { isEmpty } from 'lodash';
import path from 'path';
import * as R from 'ramda';
@@ -329,7 +329,7 @@ const booleanValuesRepresentingTrue: string[] = ['true', '1'];
const booleanValuesRepresentingFalse: string[] = ['false', '0'];
const normalizeValue = (value: any): string =>
value.toString().trim().toLowerCase();
value?.toString().trim().toLowerCase();
const booleanValues: string[] = [
...booleanValuesRepresentingTrue,
@@ -338,7 +338,7 @@ const booleanValues: string[] = [
export const parseBoolean = <T>(value: any, defaultValue: T): T | boolean => {
const normalizedValue = normalizeValue(value);
if (booleanValues.indexOf(normalizedValue) === -1) {
if (isEmpty(value) || booleanValues.indexOf(normalizedValue) === -1) {
return defaultValue;
}
return booleanValuesRepresentingTrue.indexOf(normalizedValue) !== -1;

View File

@@ -0,0 +1,424 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bigcapital | Reset your password</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: bold;
line-height: 1.4;
margin: 0;
margin-bottom: 0;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #3498db;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #2d95fd;
}
.btn-primary a {
background-color: #1968F0;
border-color: #1968F0;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.mb4{
margin-bottom: 4rem;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
margin: 20px 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #004dd0 !important;
}
.btn-primary a:hover {
background-color: #004dd0 !important;
border-color: #004dd0 !important;
}
}
[data-icon="bigcapital"] path {
fill: #004dd0;
}
[data-icon='bigcapital'] .path-1,
[data-icon='bigcapital'] .path-13 {
fill: #2d95fd;
}
</style>
</head>
<body class="">
<span class="preheader">Verify your email.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<p class="align-center">
<img src="cid:bigcapital_logo" />
</p>
<hr />
<p class="align-center">
<h2>Verify your email</h2>
</p>
<p class="mgb-1x">Hi <strong>{{ fullName }}<strong>,</p>
<p class="mgb-2-5x">To continue setting up your Bigcapital account, please verify that this is your email address.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{ verifyUrl }}" target="_blank">Verify email address</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>If this was a mistake, just ignore this email and nothing will happen.</p>
<p class="email-note">This is an automatically generated email please do not reply to this email.</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block powered-by">
Powered by <a href="https://Bigcapital.ly">Bigcapital.ly</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@@ -9,13 +9,24 @@ import 'moment/locale/ar-ly';
import 'moment/locale/es-us';
import AppIntlLoader from './AppIntlLoader';
import PrivateRoute from '@/components/Guards/PrivateRoute';
import { EnsureAuthenticated } from '@/components/Guards/EnsureAuthenticated';
import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors';
import DashboardPrivatePages from '@/components/Dashboard/PrivatePages';
import { Authentication } from '@/containers/Authentication/Authentication';
import LazyLoader from '@/components/LazyLoader';
import { SplashScreen, DashboardThemeProvider } from '../components';
import { queryConfig } from '../hooks/query/base';
import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified';
import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated';
import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified';
const EmailConfirmation = LazyLoader({
loader: () => import('@/containers/Authentication/EmailConfirmation'),
});
const RegisterVerify = LazyLoader({
loader: () => import('@/containers/Authentication/RegisterVerify'),
});
/**
* App inner.
@@ -26,9 +37,30 @@ function AppInsider({ history }) {
<DashboardThemeProvider>
<Router history={history}>
<Switch>
<Route path={'/auth'} component={Authentication} />
<Route path={'/auth/register/verify'}>
<EnsureAuthenticated>
<EnsureUserEmailNotVerified>
<RegisterVerify />
</EnsureUserEmailNotVerified>
</EnsureAuthenticated>
</Route>
<Route path={'/auth/email_confirmation'}>
<EmailConfirmation />
</Route>
<Route path={'/auth'}>
<EnsureAuthNotAuthenticated>
<Authentication />
</EnsureAuthNotAuthenticated>
</Route>
<Route path={'/'}>
<PrivateRoute component={DashboardPrivatePages} />
<EnsureAuthenticated>
<EnsureUserEmailVerified>
<DashboardPrivatePages />
</EnsureUserEmailVerified>
</EnsureAuthenticated>
</Route>
</Switch>
</Router>

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import React from 'react';
import React, { useEffect } from 'react';
import {
useAuthenticatedAccount,
useCurrentOrganization,
@@ -116,6 +116,14 @@ export function useApplicationBoot() {
isBooted.current = true;
},
);
// Reset the loading states once the hook unmount.
useEffect(
() => () => {
isAuthUserLoading && !isBooted.current && stopLoading();
isOrgLoading && !isBooted.current && stopLoading();
},
[isAuthUserLoading, isOrgLoading, stopLoading],
);
return {
isLoading: isOrgLoading || isAuthUserLoading,

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
interface EnsureAuthNotAuthenticatedProps {
children: React.ReactNode;
redirectTo?: string;
}
export function EnsureAuthNotAuthenticated({
children,
redirectTo = '/',
}: EnsureAuthNotAuthenticatedProps) {
const isAuthenticated = useIsAuthenticated();
return !isAuthenticated ? (
<>{children}</>
) : (
<Redirect to={{ pathname: redirectTo }} />
);
}

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
interface EnsureAuthenticatedProps {
children: React.ReactNode;
redirectTo?: string;
}
export function EnsureAuthenticated({
children,
redirectTo = '/auth/login',
}: EnsureAuthenticatedProps) {
const isAuthenticated = useIsAuthenticated();
return isAuthenticated ? (
<>{children}</>
) : (
<Redirect to={{ pathname: redirectTo }} />
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useAuthUserVerified } from '@/hooks/state';
interface EnsureUserEmailNotVerifiedProps {
children: React.ReactNode;
redirectTo?: string;
}
/**
* Higher Order Component to ensure that the user's email is not verified.
* If is verified, redirects to the inner setup page.
*/
export function EnsureUserEmailNotVerified({
children,
redirectTo = '/',
}: EnsureUserEmailNotVerifiedProps) {
const isAuthVerified = useAuthUserVerified();
if (isAuthVerified) {
return <Redirect to={{ pathname: redirectTo }} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useAuthUserVerified } from '@/hooks/state';
interface EnsureUserEmailVerifiedProps {
children: React.ReactNode;
redirectTo?: string;
}
/**
* Higher Order Component to ensure that the user's email is verified.
* If not verified, redirects to the email verification page.
*/
export function EnsureUserEmailVerified({
children,
redirectTo = '/auth/register/verify',
}: EnsureUserEmailVerifiedProps) {
const isAuthVerified = useAuthUserVerified();
if (!isAuthVerified) {
return <Redirect to={{ pathname: redirectTo }} />;
}
return <>{children}</>;
}

View File

@@ -1,19 +0,0 @@
// @ts-nocheck
import React from 'react';
import BodyClassName from 'react-body-classname';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
export default function PrivateRoute({ component: Component, ...rest }) {
const isAuthenticated = useIsAuthenticated();
return (
<BodyClassName className={''}>
{isAuthenticated ? (
<Component />
) : (
<Redirect to={{ pathname: '/auth/login' }} />
)}
</BodyClassName>
);
}

View File

@@ -0,0 +1,34 @@
// @ts-nocheck
import styled from 'styled-components';
import { Icon, FormattedMessage as T } from '@/components';
interface AuthContainerProps {
children: React.ReactNode;
}
export function AuthContainer({ children }: AuthContainerProps) {
return (
<AuthPage>
<AuthInsider>
<AuthLogo>
<Icon icon="bigcapital" height={37} width={214} />
</AuthLogo>
{children}
</AuthInsider>
</AuthPage>
);
}
const AuthPage = styled.div``;
const AuthInsider = styled.div`
width: 384px;
margin: 0 auto;
margin-bottom: 40px;
padding-top: 80px;
`;
const AuthLogo = styled.div`
text-align: center;
margin-bottom: 40px;
`;

View File

@@ -1,24 +1,16 @@
// @ts-nocheck
import React from 'react';
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
import { Route, Switch, useLocation } from 'react-router-dom';
import BodyClassName from 'react-body-classname';
import styled from 'styled-components';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import authenticationRoutes from '@/routes/authentication';
import { Icon, FormattedMessage as T } from '@/components';
import { useIsAuthenticated } from '@/hooks/state';
import { AuthMetaBootProvider } from './AuthMetaBoot';
import '@/style/pages/Authentication/Auth.scss';
export function Authentication() {
const to = { pathname: '/' };
const isAuthenticated = useIsAuthenticated();
if (isAuthenticated) {
return <Redirect to={to} />;
}
return (
<BodyClassName className={'authentication'}>
<AuthPage>

View File

@@ -0,0 +1,46 @@
// @ts-nocheck
import { useEffect, useMemo } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { useAuthSignUpVerify } from '@/hooks/query';
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
function useQuery() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
export default function EmailConfirmation() {
const { mutateAsync: authSignupVerify } = useAuthSignUpVerify();
const history = useHistory();
const query = useQuery();
const token = query.get('token');
const email = query.get('email');
useEffect(() => {
if (!token || !email) {
history.push('/auth/login');
}
}, [history, token, email]);
useEffect(() => {
authSignupVerify({ token, email })
.then(() => {
AppToaster.show({
message: 'Your email has been verified, Congrats!',
intent: Intent.SUCCESS,
});
history.push('/');
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong',
intent: Intent.DANGER,
});
history.push('/');
});
}, [token, email, authSignupVerify, history]);
return null;
}

View File

@@ -0,0 +1,18 @@
.root {
text-align: center;
}
.title{
font-size: 18px;
font-weight: 600;
margin-bottom: 0.5rem;
color: #252A31;
}
.description{
margin-bottom: 1rem;
font-size: 15px;
line-height: 1.45;
color: #404854;
}

View File

@@ -0,0 +1,70 @@
// @ts-nocheck
import { Button, Intent } from '@blueprintjs/core';
import AuthInsider from './AuthInsider';
import { AuthInsiderCard } from './_components';
import styles from './RegisterVerify.module.scss';
import { AppToaster, Stack } from '@/components';
import { useAuthActions } from '@/hooks/state';
import { useAuthSignUpVerifyResendMail } from '@/hooks/query';
import { AuthContainer } from './AuthContainer';
export default function RegisterVerify() {
const { setLogout } = useAuthActions();
const { mutateAsync: resendSignUpVerifyMail, isLoading } =
useAuthSignUpVerifyResendMail();
const handleResendMailBtnClick = () => {
resendSignUpVerifyMail()
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The verification mail has sent successfully.',
});
})
.catch(() => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
const handleSignOutBtnClick = () => {
setLogout();
};
return (
<AuthContainer>
<AuthInsider>
<AuthInsiderCard className={styles.root}>
<h2 className={styles.title}>Please verify your email</h2>
<p className={styles.description}>
We sent an email to <strong>asdahmed@gmail.com</strong> Click the
link inside to get started.
</p>
<Stack spacing={4}>
<Button
large
fill
loading={isLoading}
intent={Intent.NONE}
onClick={handleResendMailBtnClick}
>
Resend email
</Button>
<Button
large
fill
minimal
intent={Intent.DANGER}
onClick={handleSignOutBtnClick}
>
Not my email
</Button>
</Stack>
</AuthInsiderCard>
</AuthInsider>
</AuthContainer>
);
}

View File

@@ -78,7 +78,7 @@ export const useAuthResetPassword = (props) => {
*/
export const useAuthMetadata = (props) => {
return useRequestQuery(
[t.AUTH_METADATA_PAGE,],
[t.AUTH_METADATA_PAGE],
{
method: 'get',
url: `auth/meta`,
@@ -88,5 +88,35 @@ export const useAuthMetadata = (props) => {
defaultData: {},
...props,
},
);
);
};
/**
*
*/
export const useAuthSignUpVerifyResendMail = (props) => {
const apiRequest = useApiRequest();
return useMutation(
() => apiRequest.post('auth/register/verify/resend'),
props,
);
};
interface AuthSignUpVerifyValues {
token: string;
email: string;
}
/**
*
*/
export const useAuthSignUpVerify = (props) => {
const apiRequest = useApiRequest();
return useMutation(
(values: AuthSignUpVerifyValues) =>
apiRequest.post('auth/register/verify', values),
props,
);
};

View File

@@ -5,6 +5,7 @@ import { useQueryTenant, useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest';
import { useSetFeatureDashboardMeta } from '../state/feature';
import t from './types';
import { useSetAuthEmailConfirmed } from '../state';
// Common invalidate queries.
const commonInvalidateQueries = (queryClient) => {
@@ -130,6 +131,8 @@ export function useUser(id, props) {
}
export function useAuthenticatedAccount(props) {
const setEmailConfirmed = useSetAuthEmailConfirmed();
return useRequestQuery(
['AuthenticatedAccount'],
{
@@ -139,6 +142,9 @@ export function useAuthenticatedAccount(props) {
{
select: (response) => response.data.data,
defaultData: {},
onSuccess: (data) => {
setEmailConfirmed(data.is_verified);
},
...props,
},
);
@@ -166,4 +172,3 @@ export const useDashboardMeta = (props) => {
}, [state.isSuccess, state.data, setFeatureDashboardMeta]);
return state;
};

View File

@@ -2,7 +2,10 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback } from 'react';
import { isAuthenticated } from '@/store/authentication/authentication.reducer';
import { setLogin } from '@/store/authentication/authentication.actions';
import {
setEmailConfirmed,
setLogin,
} from '@/store/authentication/authentication.actions';
import { useQueryClient } from 'react-query';
import { removeCookie } from '@/utils';
@@ -64,3 +67,22 @@ export const useAuthUser = () => {
export const useAuthOrganizationId = () => {
return useSelector((state) => state.authentication.organizationId);
};
/**
* Retrieves the user's email verification status.
*/
export const useAuthUserVerified = () => {
return useSelector((state) => state.authentication.verified);
};
/**
* Sets the user's email verification status.
*/
export const useSetAuthEmailConfirmed = () => {
const dispatch = useDispatch();
return useCallback(
(verified?: boolean = true) => dispatch(setEmailConfirmed(verified)),
[dispatch],
);
};

View File

@@ -28,10 +28,16 @@ export default [
loader: () => import('@/containers/Authentication/InviteAccept'),
}),
},
{
path: `${BASE_URL}/register/email_confirmation`,
component: LazyLoader({
loader: () => import('@/containers/Authentication/EmailConfirmation'),
}),
},
{
path: `${BASE_URL}/register`,
component: LazyLoader({
loader: () => import('@/containers/Authentication/Register'),
}),
}
},
];

View File

@@ -3,4 +3,8 @@ import t from '@/store/types';
export const setLogin = () => ({ type: t.LOGIN_SUCCESS });
export const setLogout = () => ({ type: t.LOGOUT });
export const setStoreReset = () => ({ type: t.RESET });
export const setStoreReset = () => ({ type: t.RESET });
export const setEmailConfirmed = (verified?: boolean) => ({
type: t.SET_EMAIL_VERIFIED,
action: { verified },
});

View File

@@ -1,8 +1,9 @@
// @ts-nocheck
import { createReducer } from '@reduxjs/toolkit';
import { PayloadAction, createReducer } from '@reduxjs/toolkit';
import { persistReducer } from 'redux-persist';
import purgeStoredState from 'redux-persist/es/purgeStoredState';
import storage from 'redux-persist/lib/storage';
import { isUndefined } from 'lodash';
import { getCookie } from '@/utils';
import t from '@/store/types';
@@ -13,6 +14,7 @@ const initialState = {
tenantId: getCookie('tenant_id'),
userId: getCookie('authenticated_user_id'),
locale: getCookie('locale'),
verified: true, // Let's be optimistic and assume the user's email is confirmed.
errors: [],
};
@@ -32,6 +34,15 @@ const reducerInstance = createReducer(initialState, {
state.errors = [];
},
[t.SET_EMAIL_VERIFIED]: (
state,
payload: PayloadAction<{ verified?: boolean }>,
) => {
state.verified = !isUndefined(payload.action.verified)
? payload.action.verified
: true;
},
[t.RESET]: (state) => {
purgeStoredState(CONFIG);
},

View File

@@ -7,4 +7,5 @@ export default {
LOGOUT: 'LOGOUT',
LOGIN_CLEAR_ERRORS: 'LOGIN_CLEAR_ERRORS',
RESET: 'RESET',
SET_EMAIL_VERIFIED: 'SET_EMAIL_VERIFIED'
};