refactor: Authentication service.

This commit is contained in:
Ahmed Bouhuolia
2020-08-31 22:15:44 +02:00
parent ca251a2d28
commit abefba22ee
35 changed files with 880 additions and 395 deletions

View File

@@ -67,5 +67,6 @@ module.exports = {
},
easySMSGateway: {
api_key: 'b0JDZW56RnV6aEthb0RGPXVEcUI'
}
},
jwtSecret: 'b0JDZW56RnV6aEthb0RGPXVEcUI',
};

View File

@@ -0,0 +1,11 @@
export default class ServiceError {
errorType: string;
message: string;
constructor(errorType: string, message?: string) {
this.errorType = errorType;
this.message = message || null;
}
}

View File

@@ -0,0 +1,15 @@
import ServiceError from './ServiceError';
export default class ServiceErrors {
errors: ServiceError[];
constructor(errors: ServiceError[]) {
this.errors = errors;
}
hasType(errorType: string) {
return this.errors
.filter((error: ServiceError) => error.errorType === errorType);
}
}

View File

@@ -1,5 +1,9 @@
import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan';
import ServiceError from './ServiceError';
import ServiceErrors from './ServiceErrors';
export {
NotAllowedChangeSubscriptionPlan,
ServiceError,
ServiceErrors,
};

View File

@@ -1,327 +0,0 @@
import express from 'express';
import { check, validationResult } from 'express-validator';
import path from 'path';
import fs from 'fs';
import Mustache from 'mustache';
import jwt from 'jsonwebtoken';
import { pick } from 'lodash';
import uniqid from 'uniqid';
import moment from 'moment';
import Logger from '@/services/Logger';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import SystemUser from '@/system/models/SystemUser';
import mail from '@/services/mail';
import { hashPassword } from '@/utils';
import dbManager from '@/database/manager';
import Tenant from '@/system/models/Tenant';
import TenantUser from '@/models/TenantUser';
import TenantsManager from '@/system/TenantsManager';
import TenantModel from '@/models/TenantModel';
import PasswordReset from '@/system/models/PasswordReset';
export default {
/**
* Constructor method.
*/
router() {
const router = express.Router();
router.post('/login',
this.login.validation,
asyncMiddleware(this.login.handler));
router.post('/register',
this.register.validation,
asyncMiddleware(this.register.handler));
router.post('/send_reset_password',
this.sendResetPassword.validation,
asyncMiddleware(this.sendResetPassword.handler));
router.post('/reset/:token',
this.resetPassword.validation,
asyncMiddleware(this.resetPassword.handler));
return router;
},
/**
* User login authentication request.
*/
login: {
validation: [
check('crediential').exists().isEmail(),
check('password').exists().isLength({ min: 5 }),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
const { JWT_SECRET_KEY } = process.env;
Logger.log('info', 'Someone trying to login.', { form });
const user = await SystemUser.query()
.withGraphFetched('tenant')
.where('email', form.crediential)
.orWhere('phone_number', form.crediential)
.first();
if (!user) {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (!user.verifyPassword(form.password)) {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (!user.active) {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_INACTIVE', code: 110 }],
});
}
const lastLoginAt = moment().format('YYYY/MM/DD HH:mm:ss');
const tenantDb = TenantsManager.knexInstance(user.tenant.organizationId);
TenantModel.knexBinded = tenantDb;
const updateTenantUser = TenantUser.tenant().query()
.where('id', user.id)
.update({ last_login_at: lastLoginAt });
const updateSystemUser = SystemUser.query()
.where('id', user.id)
.update({ last_login_at: lastLoginAt });
await Promise.all([updateTenantUser, updateSystemUser]);
const token = jwt.sign(
{ email: user.email, _id: user.id },
JWT_SECRET_KEY,
{ expiresIn: '1d' },
);
Logger.log('info', 'Logging success.', { form });
return res.status(200).send({ token, user });
},
},
/**
* Registers a new organization.
*/
register: {
validation: [
check('organization_name').exists().trim().escape(),
check('first_name').exists().trim().escape(),
check('last_name').exists().trim().escape(),
check('email').exists().trim().escape(),
check('phone_number').exists().trim().escape(),
check('password').exists().trim().escape(),
check('country').exists().trim().escape(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
Logger.log('info', 'Someone trying to register.', { form });
const user = await SystemUser.query()
.where('email', form.email)
.orWhere('phone_number', form.phone_number)
.first();
const errorReasons = [];
if (user && user.phoneNumber === form.phone_number) {
errorReasons.push({ type: 'PHONE_NUMBER_EXISTS', code: 100 });
}
if (user && user.email === form.email) {
errorReasons.push({ type: 'EMAIL_EXISTS', code: 200 });
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
const organizationId = uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
});
const hashedPassword = await hashPassword(form.password);
const userInsert = {
...pick(form, ['first_name', 'last_name', 'email', 'phone_number']),
active: true,
};
const registeredUser = await SystemUser.query().insert({
...userInsert,
password: hashedPassword,
tenant_id: tenantOrganization.id,
});
await dbManager.createDb(`bigcapital_tenant_${organizationId}`);
const tenantDb = TenantsManager.knexInstance(organizationId);
await tenantDb.migrate.latest();
TenantModel.knexBinded = tenantDb;
await TenantUser.bindKnex(tenantDb).query().insert({
...userInsert,
invite_accepted_at: moment().format('YYYY/MM/DD HH:mm:ss'),
});
Logger.log('info', 'New tenant has been created.', { organizationId });
const filePath = path.join(global.rootPath, 'views/mail/Welcome.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, { ...form });
const mailOptions = {
to: userInsert.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Welcome to Bigcapital',
html: rendered,
};
mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.log('error', 'Failed send welcome mail', { error, form });
return;
}
Logger.log('info', 'User has been sent welcome email successfuly.', { form });
});
return res.status(200).send({
organization_id: organizationId,
});
},
},
/**
* Send reset password link via email or SMS.
*/
sendResetPassword: {
validation: [
check('email').exists().isEmail(),
],
// eslint-disable-next-line consistent-return
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const form = { ...req.body };
Logger.log('info', 'User trying to send reset password.', { form });
const user = await SystemUser.query().where('email', form.email).first();
if (!user) {
return res.status(400).send({
errors: [{ type: 'EMAIL.NOT.REGISTERED', code: 200 }],
});
}
// Delete all stored tokens of reset password that associate to the give email.
await PasswordReset.query()
.where('email', form.email)
.delete();
const token = uniqid();
const passwordReset = await PasswordReset.query()
.insert({ email: form.email, token });
const filePath = path.join(global.rootPath, 'views/mail/ResetPassword.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
url: `${req.protocol}://${req.hostname}/reset/${passwordReset.token}`,
first_name: user.firstName,
last_name: user.lastName,
// contact_us_email: config.contactUsMail,
});
const mailOptions = {
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Bigcapital - Password Reset',
html: rendered,
};
mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.log('error', 'Failed send reset password mail', { error, form });
return;
}
Logger.log('info', 'User has been sent reset password email successfuly.', { form });
});
res.status(200).send({ email: passwordReset.email });
},
},
/**
* Reset password.
*/
resetPassword: {
validation: [
check('password').exists().isLength({ min: 5 }).custom((value, { req }) => {
if (value !== req.body.confirm_password) {
throw new Error("Passwords don't match");
} else {
return value;
}
}),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
Logger.log('info', 'User trying to reset password.');
const { token } = req.params;
const { password } = req.body;
const tokenModel = await PasswordReset.query()
.where('token', token)
// .where('created_at', '>=', Date.now() - 3600000)
.first();
if (!tokenModel) {
return res.boom.badRequest(null, {
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
});
}
const user = await SystemUser.query()
.where('email', tokenModel.email).first();
if (!user) {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
});
}
const hashedPassword = await hashPassword(password);
await SystemUser.query()
.where('email', tokenModel.email)
.update({
password: hashedPassword,
});
// Delete the reset password token.
await PasswordReset.query().where('token', token).delete();
Logger.log('info', 'User password has been reset successfully.');
return res.status(200).send({});
},
},
};

View File

@@ -0,0 +1,225 @@
import { Request, Response, Router } from 'express';
import { check, validationResult, matchedData, ValidationChain } from 'express-validator';
import { Service, Inject } from 'typedi';
import { camelCase, mapKeys } from 'lodash';
import validateMiddleware from '@/http/middleware/validateMiddleware';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import prettierMiddleware from '@/http/middleware/prettierMiddleware';
import AuthenticationService from '@/services/Authentication';
import { IUserOTD, ISystemUser, IRegisterOTD } from '@/interfaces';
import { ServiceError, ServiceErrors } from "@/exceptions";
import { IRegisterDTO } from 'src/interfaces';
@Service()
export default class AuthenticationController {
@Inject()
authService: AuthenticationService;
/**
* Constructor method.
*/
router() {
const router = Router();
router.post(
'/login',
this.loginSchema,
validateMiddleware,
asyncMiddleware(this.login.bind(this))
);
router.post(
'/register',
this.registerSchema,
validateMiddleware,
asyncMiddleware(this.register.bind(this))
);
router.post(
'/send_reset_password',
this.sendResetPasswordSchema,
validateMiddleware,
asyncMiddleware(this.sendResetPassword.bind(this))
);
router.post(
'/reset/:token',
this.resetPasswordSchema,
validateMiddleware,
asyncMiddleware(this.resetPassword.bind(this))
);
return router;
}
/**
* Login schema.
*/
get loginSchema(): ValidationChain[] {
return [
check('crediential').exists().isEmail(),
check('password').exists().isLength({ min: 5 }),
];
}
/**
* Register schema.
*/
get registerSchema(): ValidationChain[] {
return [
check('organization_name').exists().trim().escape(),
check('first_name').exists().trim().escape(),
check('last_name').exists().trim().escape(),
check('email').exists().trim().escape(),
check('phone_number').exists().trim().escape(),
check('password').exists().trim().escape(),
check('country').exists().trim().escape(),
];
}
/**
* Reset password schema.
*/
get resetPasswordSchema(): ValidationChain[] {
return [
check('password').exists().isLength({ min: 5 }).custom((value, { req }) => {
if (value !== req.body.confirm_password) {
throw new Error("Passwords don't match");
} else {
return value;
}
}),
]
}
get sendResetPasswordSchema(): ValidationChain[] {
return [
check('email').exists().isEmail().trim().escape(),
];
}
/**
* Handle user login.
* @param {Request} req
* @param {Response} res
*/
async login(req: Request, res: Response, next: Function): Response {
const userDTO: IUserOTD = mapKeys(matchedData(req, {
locations: ['body'],
includeOptionals: true,
}), (v, k) => camelCase(k));
try {
const { token, user } = await this.authService.signIn(
userDTO.crediential,
userDTO.password
);
return res.status(200).send({ token, user });
} catch (error) {
if (error instanceof ServiceError) {
if (error.errorType === 'invalid_details') {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
}
if (error.errorType === 'user_inactive') {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 200 }],
});
}
}
next();
}
}
/**
* Organization register handler.
* @param {Request} req
* @param {Response} res
*/
async register(req: Request, res: Response, next: Function) {
const registerDTO: IRegisterDTO = mapKeys(matchedData(req, {
locations: ['body'],
includeOptionals: true,
}), (v, k) => camelCase(k));
try {
const registeredUser = await this.authService.register(registerDTO);
return res.status(200).send({
code: 'REGISTER.SUCCESS',
message: 'Register organization has been success.',
});
} catch (error) {
if (error instanceof ServiceErrors) {
const errorReasons = [];
if (error.hasType('phone_number_exists')) {
errorReasons.push({ type: 'PHONE_NUMBER_EXISTS', code: 100 });
}
if (error.hasType('email_exists')) {
errorReasons.push({ type: 'EMAIL.EXISTS', code: 200 });
}
if (errorReasons.length > 0) {
return res.status(200).send({ errors: errorReasons });
}
}
next();
}
}
/**
* Send reset password handler
* @param {Request} req
* @param {Response} res
*/
async sendResetPassword(req: Request, res: Response, next: Function) {
const { email } = req.body;
try {
await this.authService.sendResetPassword(email);
return res.status(200).send({
code: 'SEND_RESET_PASSWORD_SUCCESS',
});
} catch(error) {
if (error instanceof ServiceError) {
if (error.errorType === 'email_not_found') {
return res.status(400).send({
errors: [{ type: 'EMAIL.NOT.REGISTERED', code: 200 }],
});
}
}
next();
}
}
/**
* Reset password handler
* @param {Request} req
* @param {Response} res
*/
async resetPassword(req: Request, res: Response) {
const { token } = req.params;
const { password } = req.body;
try {
await this.authService.resetPassword(token, password);
return res.status(200).send({
type: 'RESET_PASSWORD_SUCCESS',
})
} catch(error) {
console.log(error);
if (error instanceof ServiceError) {
if (error.errorType === 'token_invalid') {
return res.boom.badRequest(null, {
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
});
}
if (error.errorType === 'user_not_found') {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
});
}
}
}
}
};

View File

@@ -10,7 +10,6 @@ import path from 'path';
import fs from 'fs';
import Mustache from 'mustache';
import moment from 'moment';
import mail from '@/services/mail';
import { hashPassword } from '@/utils';
import SystemUser from '@/system/models/SystemUser';
import Invite from '@/system/models/Invite';

View File

@@ -31,7 +31,7 @@ import TenantDependencyInjection from '@/http/middleware/TenantDependencyInjecti
import SubscriptionMiddleware from '@/http/middleware/SubscriptionMiddleware';
export default (app) => {
app.use('/api/auth', Authentication.router());
app.use('/api/auth', Container.get(Authentication).router());
app.use('/api/invite', InviteUsers.router());
app.use('/api/vouchers', Container.get(VouchersController).router());
app.use('/api/subscription', Container.get(Subscription).router());

View File

@@ -0,0 +1,10 @@
export interface IRegisterDTO {
firstName: string,
lastName: string,
email: string,
password: string,
organizationName: string,
};

View File

@@ -0,0 +1,9 @@
export interface ISystemUser {
}
export interface ISystemUserDTO {
}

View File

@@ -28,6 +28,13 @@ import {
ISaleEstimate,
ISaleEstimateOTD,
} from './SaleEstimate';
import {
IRegisterDTO,
} from './Register';
import {
ISystemUser,
ISystemUserDTO,
} from './User';
export {
IBillPaymentEntry,
@@ -58,4 +65,8 @@ export {
IPaymentReceive,
IPaymentReceiveOTD,
IRegisterDTO,
ISystemUser,
ISystemUserDTO,
};

View File

@@ -1,8 +0,0 @@
export default class MailNotificationSubscribeEnd {
handler(job) {
}
}

View File

@@ -1,6 +1,27 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationSubscribeEnd {
/**
*
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
}
Logger.debug(`Send mail notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send mail notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,27 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationTrialEnd {
/**
*
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send mail notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send mail notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import Mustache from 'mustache';
import { Container } from 'typedi';
export default class ResetPasswordMailJob {
/**
*
* @param job
* @param done
*/
handler(job, done) {
const { user, token } = job.attrs.data;
const Logger = Container.get('logger');
const Mail = Container.get('mail');
const filePath = path.join(global.rootPath, 'views/mail/ResetPassword.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
url: `https://google.com/reset/${token}`,
first_name: user.firstName,
last_name: user.lastName,
// contact_us_email: config.contactUsMail,
});
const mailOptions = {
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Bigcapital - Password Reset',
html: rendered,
};
Mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.info('[send_reset_password] failed send reset password mail', { error, user });
done(error);
return;
}
Logger.info('[send_reset_password] user has been sent reset password email successfuly.', { user });
done();
});
res.status(200).send({ email: passwordReset.email });
}
}

View File

@@ -1,13 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationSubscribeEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, subscriptionSlug } = job.attrs.data;
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send SMS notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingSubscriptionPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send SMS notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send SMS notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -1,8 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationTrialEnd {
export default class MailNotificationSubscribeEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.debug(`Send notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.debug(`Send notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.error(`Send notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,8 @@
export default class UserInviteMailJob {
handler(job, done) {
}
}

View File

@@ -1,11 +1,38 @@
import fs from 'fs';
import Mustache from 'mustache';
import path from 'path';
import { Container } from 'typedi';
import MailerService from '../services/mailer';
export default class WelcomeEmailJob {
/**
*
* @param {Job} job
* @param {Function} done
*/
public async handler(job, done: Function): Promise<void> {
const { email, organizationName, firstName } = job.attrs.data;
const Logger = Container.get('logger');
const Mail = Container.get('mail');
console.log('✌Email Sequence Job triggered!');
done();
const filePath = path.join(global.rootPath, 'views/mail/Welcome.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
email, organizationName, firstName,
});
const mailOptions = {
to: email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Welcome to Bigcapital',
html: rendered,
};
Mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.error('Failed send welcome mail', { error, form });
done(error);
return;
}
Logger.info('User has been sent welcome email successfuly.', { form });
done();
});
}
}

View File

@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import LoggerInstance from '@/services/Logger';
import agendaFactory from '@/loaders/agenda';
import SmsClientLoader from '@/loaders/smsClient';
import mailInstance from '@/loaders/mail';
export default ({ mongoConnection, knex }) => {
try {
@@ -20,6 +21,9 @@ export default ({ mongoConnection, knex }) => {
Container.set('SMSClient', smsClientInstance);
LoggerInstance.info('SMS client has been injected into container');
Container.set('mail', mailInstance);
LoggerInstance.info('Mail instance has been injected into container');
return { agenda: agendaInstance };
} catch (e) {
LoggerInstance.error('Error on dependency injector loader: %o', e);

View File

@@ -0,0 +1,3 @@
// Here we import all events.
import '@/subscribers/authentication';

View File

@@ -1,9 +1,15 @@
import Agenda from 'agenda';
import WelcomeEmailJob from '@/Jobs/welcomeEmail';
import ResetPasswordMailJob from '@/Jobs/ResetPasswordMail';
import ComputeItemCost from '@/Jobs/ComputeItemCost';
import RewriteInvoicesJournalEntries from '@/jobs/writeInvoicesJEntries';
import SendVoucherViaPhoneJob from '@/jobs/SendVoucherPhone';
import SendVoucherViaEmailJob from '@/jobs/SendVoucherEmail';
import SendSMSNotificationSubscribeEnd from '@/jobs/SMSNotificationSubscribeEnd';
import SendSMSNotificationTrialEnd from '@/jobs/SMSNotificationTrialEnd';
import SendMailNotificationSubscribeEnd from '@/jobs/MailNotificationSubscribeEnd';
import SendMailNotificationTrialEnd from '@/jobs/MailNotificationTrialEnd';
import UserInviteMailJob from '@/jobs/UserInviteMail';
export default ({ agenda }: { agenda: Agenda }) => {
agenda.define(
@@ -11,6 +17,16 @@ export default ({ agenda }: { agenda: Agenda }) => {
{ priority: 'high' },
new WelcomeEmailJob().handler,
);
agenda.define(
'reset-password-mail',
{ priority: 'high' },
new ResetPasswordMailJob().handler,
);
agenda.define(
'user-invite-mail',
{ priority: 'high' },
new UserInviteMailJob().handler,
)
agenda.define(
'compute-item-cost',
{ priority: 'high', concurrency: 20 },
@@ -31,21 +47,25 @@ export default ({ agenda }: { agenda: Agenda }) => {
{ priority: 'high', concurrency: 1, },
new SendVoucherViaEmailJob().handler,
);
// agenda.define(
// 'send-sms-notification-subscribe-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-mail-notification-subscribe-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-sms-notification-trial-end',
// { priority: 'high', concurrency: 1, },
// );
// agenda.define(
// 'send-mail-notification-trial-end',
// { priority: 'high', concurrency: 1, },
// );
agenda.define(
'send-sms-notification-subscribe-end',
{ priority: 'nromal', concurrency: 1, },
new SendSMSNotificationSubscribeEnd().handler,
);
agenda.define(
'send-sms-notification-trial-end',
{ priority: 'normal', concurrency: 1, },
new SendSMSNotificationTrialEnd().handler,
);
agenda.define(
'send-mail-notification-subscribe-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationSubscribeEnd().handler
);
agenda.define(
'send-mail-notification-trial-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationTrialEnd().handler
);
agenda.start();
};

View File

@@ -12,14 +12,4 @@ const transporter = nodemailer.createTransport({
},
});
console.log({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.secure, // true for 465, false for other ports
auth: {
user: config.mail.username,
pass: config.mail.password,
},
});
export default transporter;
export default transporter;

View File

@@ -0,0 +1,252 @@
import { Service, Inject, Container } from "typedi";
import JWT from 'jsonwebtoken';
import uniqid from 'uniqid';
import { omit } from 'lodash';
import {
EventDispatcher
EventDispatcherInterface
} from '@/decorators/eventDispatcher';
import {
SystemUser,
PasswordReset,
Tenant,
} from '@/system/models';
import {
IRegisterDTO,
ITenant,
ISystemUser,
IPasswordReset,
} from '@/interfaces';
import TenantsManager from "@/system/TenantsManager";
import { hashPassword } from '@/utils';
import { ServiceError, ServiceErrors } from "@/exceptions";
import config from '@/../config/config';
import events from '@/subscribers/events';
@Service()
export default class AuthenticationService {
@Inject('logger')
logger: any;
@Inject()
tenantsManager: TenantsManager;
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
/**
* Signin and generates JWT token.
* @throws {ServiceError}
* @param {string} emailOrPhone - Email or phone number.
* @param {string} password - Password.
* @return {Promise<{user: IUser, token: string}>}
*/
async signIn(emailOrPhone: string, password: string): Promise<{user: IUser, token: string }> {
this.logger.info('[login] Someone trying to login.', { emailOrPhone, password });
const user = await SystemUser.query()
.where('email', emailOrPhone)
.orWhere('phone_number', emailOrPhone)
.withGraphFetched('tenant')
.first();
if (!user) {
this.logger.info('[login] invalid data');
throw new ServiceError('invalid_details');
}
this.logger.info('[login] check password validation.');
if (!user.verifyPassword(password)) {
throw new ServiceError('password_invalid');
}
if (!user.active) {
this.logger.info('[login] user inactive.');
throw new ServiceError('user_inactive');
}
this.logger.info('[login] generating JWT token.');
const token = this.generateToken(user);
this.logger.info('[login] Logging success.', { user, token });
this.eventDispatcher.dispatch(events.auth.login, {
emailOrPhone, password,
});
return { user, token };
}
/**
* Validates email and phone number uniqiness on the storage.
* @throws {ServiceErrors}
* @param {IRegisterDTO} registerDTO - Register data object.
*/
private async validateEmailAndPhoneUniqiness(registerDTO: IRegisterDTO) {
const user: ISystemUser = await SystemUser.query()
.where('email', registerDTO.email)
.orWhere('phone_number', registerDTO.phoneNumber)
.first();
const errorReasons: ServiceErrors[] = [];
if (user && user.phoneNumber === registerDTO.phoneNumber) {
this.logger.info('[register] phone number exists on the storage.');
errorReasons.push(new ServiceError('phone_number_exists'));
}
if (user && user.email === registerDTO.email) {
this.logger.info('[register] email exists on the storage.');
errorReasons.push(new ServiceError('email_exists'));
}
if (errorReasons.length > 0) {
throw new ServiceErrors(errorReasons);
}
}
/**
* Registers a new tenant with user from user input.
* @throws {ServiceErrors}
* @param {IUserDTO} user
*/
async register(registerDTO: IRegisterDTO): Promise<ISystemUser> {
this.logger.info('[register] Someone trying to register.');
await this.validateEmailAndPhoneUniqiness(registerDTO);
this.logger.info('[register] Creating a new tenant org.')
const tenant = await this.newTenantOrganization();
this.logger.info('[register] Trying hashing the password.')
const hashedPassword = await hashPassword(registerDTO.password);
const registeredUser = await SystemUser.query().insert({
...omit(registerDTO, 'country', 'organizationName'),
active: true,
password: hashedPassword,
tenant_id: tenant.id,
});
this.eventDispatcher.dispatch(events.auth.register, { registerDTO });
return registeredUser;
}
/**
* Generates and insert new tenant organization id.
* @async
* @return {Promise<ITenant>}
*/
private async newTenantOrganization(): Promise<ITenant> {
const organizationId = uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
});
return tenantOrganization;
}
/**
* Initialize tenant database.
* @param {number} tenantId - The given tenant id.
* @return {void}
*/
async initializeTenant(tenantId: number): Promise<void> {
const dbManager = Container.get('dbManager');
const tenant = await Tenant.query().findById(tenantId);
this.logger.info('[tenant_init] Tenant DB creating.', { tenant });
await dbManager.createDb(`bigcapital_tenant_${tenant.organizationId}`);
const tenantDb = this.tenantsManager.knexInstance(tenant.organizationId);
this.logger.info('[tenant_init] Tenant DB migrating to latest version.', { tenant });
await tenantDb.migrate.latest();
}
/**
* Validate the given email existance on the storage.
* @throws {ServiceError}
* @param {string} email - email address.
*/
private async validateEmailExistance(email: string) {
const foundEmail = await SystemUser.query().findOne('email', email);
if (!foundEmail) {
this.logger.info('[send_reset_password] The given email not found.');
throw new ServiceError('email_not_found');
}
}
/**
* Generates and retrieve password reset token for the given user email.
* @param {string} email
* @return {<Promise<IPasswordReset>}
*/
async sendResetPassword(email: string): Promise<IPasswordReset> {
this.logger.info('[send_reset_password] Trying to send reset password.');
await this.validateEmailExistance(email);
// Delete all stored tokens of reset password that associate to the give email.
await PasswordReset.query().where('email', email).delete();
const token = uniqid();
const passwordReset = await PasswordReset.query().insert({ email, token });
const user = await SystemUser.query().findOne('email', email);
this.eventDispatcher.dispatch(events.auth.sendResetPassword, { user, token });
return passwordReset;
}
/**
* Resets a user password from given token.
* @param {string} token - Password reset token.
* @param {string} password - New Password.
* @return {Promise<void>}
*/
async resetPassword(token: string, password: string): Promise<void> {
const tokenModel = await PasswordReset.query().findOne('token', token)
if (!tokenModel) {
this.logger.info('[reset_password] token invalid.');
throw new ServiceError('token_invalid');
}
const user = await SystemUser.query().findOne('email', tokenModel.email)
if (!user) {
throw new ServiceError('user_not_found');
}
const hashedPassword = await hashPassword(password);
this.logger.info('[reset_password] saving a new hashed password.');
await SystemUser.query()
.where('email', tokenModel.email)
.update({
password: hashedPassword,
});
// Delete the reset password token.
await PasswordReset.query().where('email', user.email).delete();
this.eventDispatcher.dispatch(events.auth.sendResetPassword, { user, token, password });
this.logger.info('[reset_password] reset password success.');
}
/**
* Generates JWT token for the given user.
* @param {IUser} user
* @return {string} token
*/
generateToken(user: IUser): string {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
this.logger.silly(`Sign JWT for userId: ${user._id}`);
return JWT.sign(
{
_id: user._id, // We are gonna use this in the middleware 'isAuth'
exp: exp.getTime() / 1000,
},
config.jwtSecret,
);
}
}

View File

@@ -2,7 +2,6 @@ import fs from 'fs';
import path from 'path';
import Mustache from 'mustache';
import { Container } from 'typedi';
import mail from '@/services/mail';
export default class SubscriptionMailMessages {
/**
@@ -11,7 +10,8 @@ export default class SubscriptionMailMessages {
* @param {email} email
*/
public async sendMailVoucher(voucherCode: string, email: string) {
const logger = Container.get('logger');
const Logger = Container.get('logger');
const Mail = Container.get('mail');
const filePath = path.join(global.rootPath, 'views/mail/VoucherReceive.html');
const template = fs.readFileSync(filePath, 'utf8');
@@ -24,7 +24,7 @@ export default class SubscriptionMailMessages {
html: rendered,
};
return new Promise((resolve, reject) => {
mail.sendMail(mailOptions, (error) => {
Mail.sendMail(mailOptions, (error) => {
if (error) {
reject(error);
return;

View File

@@ -326,7 +326,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
/**
* Schedules compute sale invoice items cost based on each item
* cost method.
* @param {ISaleInvoice} saleInvoice
* @param {ISaleInvoice} saleInvoice
* @return {Promise}
*/
async scheduleComputeInvoiceItemsCost(
@@ -343,7 +343,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
.filter((entry: IItemEntry) => entry.item.type === 'inventory')
.map((entry: IItemEntry) => entry.itemId)
.uniq().value();
if (inventoryItemsIds.length === 0) {
await this.writeNonInventoryInvoiceJournals(tenantId, saleInvoice, override);
} else {

View File

@@ -0,0 +1,21 @@
import { Service } from "typedi";
@Service()
export default class SubscriptionMailMessages {
public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -0,0 +1,24 @@
import { Service, Inject } from 'typedi';
import SMSClient from '@/services/SMSClient';
@Service()
export default class SubscriptionSMSMessages {
@Inject('SMSClient')
smsClient: SMSClient;
public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -1,11 +1,19 @@
import { Service } from 'typedi';
import { Service, Inject } from 'typedi';
import { Plan, Tenant, Voucher } from '@/system/models';
import Subscription from '@/services/Subscription/Subscription';
import VocuherPaymentMethod from '@/services/Payment/VoucherPaymentMethod';
import PaymentContext from '@/services/Payment';
import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages';
import SubscriptionMailMessages from '@/services/Subscription/MailMessages';
@Service()
export default class SubscriptionService {
@Inject()
smsMessages: SubscriptionSMSMessages;
@Inject()
mailMessages: SubscriptionMailMessages;
/**
* Handles the payment process via voucher code and than subscribe to
* the given tenant.

View File

@@ -0,0 +1,41 @@
import { Container } from 'typedi';
import { pick } from 'lodash';
import { EventSubscriber, On } from 'event-dispatch';
import events from '@/subscribers/events';
@EventSubscriber()
export class AuthenticationSubscriber {
@On(events.auth.login)
public onLogin(payload) {
const { emailOrPhone, password } = payload;
}
@On(events.auth.register)
public onRegister(payload) {
const { registerDTO } = payload;
const agenda = Container.get('agenda');
// Send welcome mail to the user.
agenda.now('welcome-email', {
...pick(registerDTO, ['email', 'organizationName', 'firstName']),
});
}
@On(events.auth.resetPassword)
public onResetPassword(payload) {
}
@On(events.auth.sendResetPassword)
public onSendResetPassword (payload) {
const { user, token } = payload;
const agenda = Container.get('agenda');
// Send reset password mail.
agenda.now('reset-password-mail', { user, token })
}
}

View File

@@ -4,6 +4,7 @@ export default {
auth: {
login: 'onLogin',
register: 'onRegister',
sendResetPassword: 'onSendResetPassword',
resetPassword: 'onResetPassword',
},
}

View File

View File

@@ -1,5 +1,6 @@
import Knex from 'knex';
import { knexSnakeCaseMappers } from 'objection';
import { Service } from 'typedi';
import Tenant from '@/system/models/Tenant';
import config from '@/../config/config';
import TenantModel from '@/models/TenantModel';
@@ -17,13 +18,14 @@ import TenantUser from '@/models/TenantUser';
// tenantOrganizationId: String,
// }
@Service()
export default class TenantsManager {
constructor() {
this.knexCache = new Map();
}
static async getTenant(organizationId) {
async getTenant(organizationId) {
const tenant = await Tenant.query()
.where('organization_id', organizationId).first();
@@ -35,7 +37,7 @@ export default class TenantsManager {
* @param {Integer} uniqId
* @return {TenantWebsite}
*/
static async createTenant(uniqId) {
async createTenant(uniqId) {
const organizationId = uniqId || uniqid();
const tenantOrganization = await Tenant.query().insert({
organization_id: organizationId,
@@ -58,7 +60,7 @@ export default class TenantsManager {
* Drop tenant database of the given tenant website.
* @param {TenantWebsite} tenantWebsite
*/
static async dropTenant(tenantWebsite) {
async dropTenant(tenantWebsite) {
const tenantDbName = `bigcapital_tenant_${tenantWebsite.organizationId}`;
await dbManager.dropDb(tenantDbName);
@@ -69,7 +71,7 @@ export default class TenantsManager {
/**
* Creates a user that associate to the given tenant.
*/
static async createTenantUser(tenantWebsite, user) {
async createTenantUser(tenantWebsite, user) {
const userInsert = { ...user };
const systemUser = await SystemUser.query().insert({
@@ -92,7 +94,7 @@ export default class TenantsManager {
/**
* Retrieve all tenants metadata from system storage.
*/
static getAllTenants() {
getAllTenants() {
return Tenant.query();
}
@@ -100,7 +102,7 @@ export default class TenantsManager {
* Retrieve the given organization id knex configuration.
* @param {String} organizationId -
*/
static getTenantKnexConfig(organizationId) {
getTenantKnexConfig(organizationId) {
return {
client: config.tenant.db_client,
connection: {
@@ -120,7 +122,7 @@ export default class TenantsManager {
};
}
static knexInstance(organizationId) {
knexInstance(organizationId) {
const knexCache = new Map();
let knex = knexCache.get(organizationId);

View File

@@ -4,6 +4,8 @@ import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription';
import Voucher from './Subscriptions/Voucher';
import Tenant from './Tenant';
import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset';
export {
Plan,
@@ -11,4 +13,6 @@ export {
PlanSubscription,
Voucher,
Tenant,
SystemUser,
PasswordReset,
}

View File

@@ -364,15 +364,18 @@
<tr>
<td>
<p class="align-center">
<svg data-icon="bigcapital" class="bigcapital" width="190" height="37" viewBox="0 0 309.09 42.89"><desc>bigcapital</desc><path d="M56,3.16,61.33,8.5,31.94,37.9l-5.35-5.35Z" class="path-1" fill-rule="evenodd"></path><path d="M29.53,6.94l5.35,5.34L5.49,41.67.14,36.33l15.8-15.8Z" class="path-2" fill-rule="evenodd"></path><path d="M94.36,38.87H79.62v-31H94c6.33,0,10.22,3.15,10.22,8V16a7.22,7.22,0,0,1-4.07,6.69c3.58,1.37,5.8,3.45,5.8,7.61v.09C106,36,101.35,38.87,94.36,38.87Zm3.1-21.81c0-2-1.59-3.19-4.47-3.19H86.26v6.55h6.29c3,0,4.91-1,4.91-3.28Zm1.72,12.39c0-2.08-1.54-3.37-5-3.37H86.26V32.9h8.1c3,0,4.82-1.06,4.82-3.36Z" class="path-3" fill-rule="evenodd"></path><path d="M110.56,12.54v-6h7.08v6Zm.18,26.33V15.15h6.72V38.87Z" class="path-4" fill-rule="evenodd"></path><path d="M134,46a22.55,22.55,0,0,1-10.49-2.47l2.3-5a15.52,15.52,0,0,0,8,2.17c4.61,0,6.78-2.21,6.78-6.46V33.08c-2,2.39-4.16,3.85-7.75,3.85-5.53,0-10.53-4-10.53-11.07v-.09c0-7.08,5.09-11.06,10.53-11.06a9.63,9.63,0,0,1,7.66,3.54v-3.1h6.72V33.52C147.2,42.46,142.78,46,134,46Zm6.6-20.27a5.79,5.79,0,0,0-11.56,0v.09a5.42,5.42,0,0,0,5.76,5.49,5.49,5.49,0,0,0,5.8-5.49Z" class="path-5" fill-rule="evenodd"></path><path d="M164,39.41a12.11,12.11,0,0,1-12.35-12.26v-.09a12.18,12.18,0,0,1,12.44-12.35c4.47,0,7.25,1.5,9.47,4l-4.12,4.43a6.93,6.93,0,0,0-5.4-2.61c-3.36,0-5.75,3-5.75,6.46v.09c0,3.63,2.34,6.55,6,6.55,2.26,0,3.8-1,5.44-2.53l3.94,4A12,12,0,0,1,164,39.41Z" class="path-6" fill-rule="evenodd"></path><path d="M191.51,38.87V36.31a9.15,9.15,0,0,1-7.17,3c-4.47,0-8.15-2.57-8.15-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.72-4.34-5.09-4.34a17.57,17.57,0,0,0-6.55,1.28l-1.68-5.13a21,21,0,0,1,9.21-1.9c7.34,0,10.57,3.8,10.57,10.22V38.87Zm.13-9.55a10.3,10.3,0,0,0-4.29-.89c-2.88,0-4.65,1.15-4.65,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.15,0,5.27-1.73,5.27-4.16Z" class="path-7" fill-rule="evenodd"></path><path d="M217.49,39.32a9.1,9.1,0,0,1-7.39-3.54V46h-6.73V15.15h6.73v3.41a8.7,8.7,0,0,1,7.39-3.85c5.53,0,10.8,4.34,10.8,12.26v.09C228.29,35,223.11,39.32,217.49,39.32ZM221.56,27c0-3.94-2.66-6.55-5.8-6.55S210,23,210,27v.09c0,3.94,2.61,6.55,5.75,6.55s5.8-2.57,5.8-6.55Z" class="path-8" fill-rule="evenodd"></path><path d="M232.93,12.54v-6H240v6Zm.18,26.33V15.15h6.73V38.87Z" class="path-9" fill-rule="evenodd"></path><path d="M253.73,39.27c-4.11,0-6.9-1.63-6.9-7.12V20.91H244V15.15h2.83V9.09h6.73v6.06h5.57v5.76h-5.57V31c0,1.55.66,2.3,2.16,2.3A6.84,6.84,0,0,0,259,32.5v5.4A9.9,9.9,0,0,1,253.73,39.27Z" class="path-10" fill-rule="evenodd"></path><path d="M277.55,38.87V36.31a9.15,9.15,0,0,1-7.18,3c-4.46,0-8.14-2.57-8.14-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.73-4.34-5.09-4.34A17.57,17.57,0,0,0,266,21.92l-1.68-5.13a20.94,20.94,0,0,1,9.2-1.9c7.35,0,10.58,3.8,10.58,10.22V38.87Zm.13-9.55a10.31,10.31,0,0,0-4.3-.89c-2.87,0-4.64,1.15-4.64,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.14,0,5.27-1.73,5.27-4.16Z" class="path-11" fill-rule="evenodd"></path><path d="M289.72,38.87V6.57h6.72v32.3Z" class="path-12" fill-rule="evenodd"></path><path d="M302.06,38.87V31.79h7.17v7.08Z" class="path-13" fill-rule="evenodd"></path></svg>
<svg data-icon="bigcapital" class="bigcapital" width="190" height="37" viewBox="0 0 309.09 42.89">
<desc>bigcapital</desc>
<path d="M56,3.16,61.33,8.5,31.94,37.9l-5.35-5.35Z" class="path-1" fill-rule="evenodd"></path><path d="M29.53,6.94l5.35,5.34L5.49,41.67.14,36.33l15.8-15.8Z" class="path-2" fill-rule="evenodd"></path><path d="M94.36,38.87H79.62v-31H94c6.33,0,10.22,3.15,10.22,8V16a7.22,7.22,0,0,1-4.07,6.69c3.58,1.37,5.8,3.45,5.8,7.61v.09C106,36,101.35,38.87,94.36,38.87Zm3.1-21.81c0-2-1.59-3.19-4.47-3.19H86.26v6.55h6.29c3,0,4.91-1,4.91-3.28Zm1.72,12.39c0-2.08-1.54-3.37-5-3.37H86.26V32.9h8.1c3,0,4.82-1.06,4.82-3.36Z" class="path-3" fill-rule="evenodd"></path><path d="M110.56,12.54v-6h7.08v6Zm.18,26.33V15.15h6.72V38.87Z" class="path-4" fill-rule="evenodd"></path><path d="M134,46a22.55,22.55,0,0,1-10.49-2.47l2.3-5a15.52,15.52,0,0,0,8,2.17c4.61,0,6.78-2.21,6.78-6.46V33.08c-2,2.39-4.16,3.85-7.75,3.85-5.53,0-10.53-4-10.53-11.07v-.09c0-7.08,5.09-11.06,10.53-11.06a9.63,9.63,0,0,1,7.66,3.54v-3.1h6.72V33.52C147.2,42.46,142.78,46,134,46Zm6.6-20.27a5.79,5.79,0,0,0-11.56,0v.09a5.42,5.42,0,0,0,5.76,5.49,5.49,5.49,0,0,0,5.8-5.49Z" class="path-5" fill-rule="evenodd"></path><path d="M164,39.41a12.11,12.11,0,0,1-12.35-12.26v-.09a12.18,12.18,0,0,1,12.44-12.35c4.47,0,7.25,1.5,9.47,4l-4.12,4.43a6.93,6.93,0,0,0-5.4-2.61c-3.36,0-5.75,3-5.75,6.46v.09c0,3.63,2.34,6.55,6,6.55,2.26,0,3.8-1,5.44-2.53l3.94,4A12,12,0,0,1,164,39.41Z" class="path-6" fill-rule="evenodd"></path><path d="M191.51,38.87V36.31a9.15,9.15,0,0,1-7.17,3c-4.47,0-8.15-2.57-8.15-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.72-4.34-5.09-4.34a17.57,17.57,0,0,0-6.55,1.28l-1.68-5.13a21,21,0,0,1,9.21-1.9c7.34,0,10.57,3.8,10.57,10.22V38.87Zm.13-9.55a10.3,10.3,0,0,0-4.29-.89c-2.88,0-4.65,1.15-4.65,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.15,0,5.27-1.73,5.27-4.16Z" class="path-7" fill-rule="evenodd"></path><path d="M217.49,39.32a9.1,9.1,0,0,1-7.39-3.54V46h-6.73V15.15h6.73v3.41a8.7,8.7,0,0,1,7.39-3.85c5.53,0,10.8,4.34,10.8,12.26v.09C228.29,35,223.11,39.32,217.49,39.32ZM221.56,27c0-3.94-2.66-6.55-5.8-6.55S210,23,210,27v.09c0,3.94,2.61,6.55,5.75,6.55s5.8-2.57,5.8-6.55Z" class="path-8" fill-rule="evenodd"></path><path d="M232.93,12.54v-6H240v6Zm.18,26.33V15.15h6.73V38.87Z" class="path-9" fill-rule="evenodd"></path><path d="M253.73,39.27c-4.11,0-6.9-1.63-6.9-7.12V20.91H244V15.15h2.83V9.09h6.73v6.06h5.57v5.76h-5.57V31c0,1.55.66,2.3,2.16,2.3A6.84,6.84,0,0,0,259,32.5v5.4A9.9,9.9,0,0,1,253.73,39.27Z" class="path-10" fill-rule="evenodd"></path><path d="M277.55,38.87V36.31a9.15,9.15,0,0,1-7.18,3c-4.46,0-8.14-2.57-8.14-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.73-4.34-5.09-4.34A17.57,17.57,0,0,0,266,21.92l-1.68-5.13a20.94,20.94,0,0,1,9.2-1.9c7.35,0,10.58,3.8,10.58,10.22V38.87Zm.13-9.55a10.31,10.31,0,0,0-4.3-.89c-2.87,0-4.64,1.15-4.64,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.14,0,5.27-1.73,5.27-4.16Z" class="path-11" fill-rule="evenodd"></path><path d="M289.72,38.87V6.57h6.72v32.3Z" class="path-12" fill-rule="evenodd"></path><path d="M302.06,38.87V31.79h7.17v7.08Z" class="path-13" fill-rule="evenodd"></path>
</svg>
</p>
<hr />
<p class="align-center">
<h3>Hi {{ first_name }}, Welcome to Bigcapital</h3>
<h3>Hi {{ firstName }}, Welcome to Bigcapital</h3>
</p>
<p class="mgb-1x">Youve joined the new Bigcapital workspace {{ organization_name }}.</p>
<p class="mgb-1x">Youve joined the new Bigcapital workspace {{ organizationName }}.</p>
<p class="mgb-2-5x">If you need any help to get started please don't hesitate to contact us to help you via phone number or email.</p>
</td>
</tr>