feat: User invitation system.

This commit is contained in:
Ahmed Bouhuolia
2020-04-23 20:09:07 +02:00
parent 1e13aa16ac
commit 11e3d4c1a9
23 changed files with 970 additions and 50 deletions

View File

@@ -15,6 +15,7 @@
"license": "ISC",
"dependencies": {
"@hapi/boom": "^7.4.3",
"app-root-path": "^3.0.0",
"bcryptjs": "^2.4.3",
"bookshelf": "^0.15.1",
"bookshelf-cascade-delete": "^2.0.1",
@@ -26,6 +27,7 @@
"errorhandler": "^1.5.1",
"express": "^4.17.1",
"express-boom": "^3.0.0",
"express-fileupload": "^1.1.7-alpha.3",
"express-oauth-server": "^2.0.0",
"express-validator": "^6.2.0",
"helmet": "^3.21.0",

View File

@@ -2,10 +2,13 @@ import express from 'express';
import helmet from 'helmet';
import boom from 'express-boom';
import i18n from 'i18n';
import rootPath from 'app-root-path';
import fileUpload from 'express-fileupload';
import '../config';
import '@/database/objection';
import routes from '@/http';
global.rootPath = rootPath.path;
const app = express();
@@ -29,6 +32,10 @@ app.set('port', process.env.PORT || 3000);
app.use(helmet());
app.use(boom());
app.use(express.json());
app.use(fileUpload({
createParentPath: true,
// safeFileNames: true,
}));
routes(app);

View File

@@ -6,7 +6,6 @@ exports.up = function (knex) {
table.string('last_name');
table.string('email').unique();
table.string('phone_number').unique();
table.string('password');
table.boolean('active');
table.integer('role_id').unique();
table.string('language');

View File

@@ -14,6 +14,7 @@ exports.up = function (knex) {
table.text('note').nullable();
table.integer('category_id').unsigned();
table.integer('user_id').unsigned();
table.string('attachment_file');
table.timestamps();
});
};

View File

@@ -9,6 +9,7 @@ exports.up = function(knex) {
table.date('date');
table.boolean('status').defaultTo(false);
table.string('description');
table.string('attachment_file');
table.integer('user_id').unsigned();
table.timestamps();
});

View File

@@ -6,6 +6,7 @@ import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import JWTAuth from '@/http/middleware/jwtAuth';
import JournalPoster from '@/services/Accounting/JournalPoster';
import JournalEntry from '@/services/Accounting/JournalEntry';
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
import {
mapViewRolesToConditionals,
mapFilterRolesToDynamicFilter,
@@ -25,6 +26,7 @@ export default {
router() {
const router = express.Router();
router.use(JWTAuth);
router.use(TenancyMiddleware);
router.get('/manual-journals/:id',
this.getManualJournal.validation,
@@ -185,6 +187,7 @@ export default {
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.account_id').isNumeric().toInt(),
check('entries.*.note').optional(),
check('attachment').optional(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
@@ -243,9 +246,28 @@ export default {
if (journalNumber.length > 0) {
errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 });
}
const { attachment } = req.files;
const supportedMimes = ['image/png', 'image/jpeg'];
if (attachment && supportedMimes.indexOf(attachment.mimeType) === -1) {
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 400 });
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
if (attachment) {
const publicPath = 'storage/app/public/';
try {
await attachment.mv(`${publicPath}${req.organizationId}/${attachment.md5}.png`);
} catch (error) {
return res.status(400).send({
errors: [{ type: 'ATTACHMENT.UPLOAD.FAILED', code: 600 }],
});
}
}
// Save manual journal transaction.
const manualJournal = await ManualJournal.query().insert({
reference: form.reference,
@@ -256,6 +278,7 @@ export default {
description: form.description,
status: form.status,
user_id: user.id,
attachment_file: (attachment) ? `${attachment.md5}.png` : null,
});
const journalPoster = new JournalPoster();
@@ -279,7 +302,6 @@ export default {
journalPoster.credit(jouranlEntry);
}
});
// Saves the journal entries and accounts balance changes.
await Promise.all([
journalPoster.saveEntries(),

View File

@@ -17,6 +17,7 @@ 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 {
/**
@@ -187,44 +188,47 @@ export default {
code: 'validation_error', ...validationErrors,
});
}
const { email } = req.body;
const user = await User.where('email', email).fetch();
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(422).send();
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.where({ email }).destroy({ require: false });
await PasswordReset.query()
.where('email', form.email)
.delete();
const passwordReset = PasswordReset.forge({
email,
token: '123123',
});
await passwordReset.save();
const token = uniqid();
const passwordReset = await PasswordReset.query()
.insert({ email: form.email, token });
const filePath = path.join(__dirname, '../../views/mail/ResetPassword.html');
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.attributes.token}`,
first_name: user.attributes.first_name,
last_name: user.attributes.last_name,
contact_us_email: process.env.CONTACT_US_EMAIL,
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.attributes.email,
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: 'Ratteb Password Reset',
subject: 'Bigcapital - Password Reset',
html: rendered,
};
// eslint-disable-next-line consistent-return
mail.sendMail(mailOptions, (error) => {
if (error) {
return res.status(400).send();
Logger.log('error', 'Failed send reset password mail', { error, form });
}
res.status(200).send({ data: { email: passwordReset.attributes.email } });
Logger.log('info', 'User has been sent reset password email successfuly.', { form });
});
res.status(200).send({ email: passwordReset.email });
},
},
@@ -246,7 +250,7 @@ export default {
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'VALIDATION_ERROR', ...validationErrors,
code: 'validation_error', ...validationErrors,
});
}
const { token } = req.params;
@@ -262,9 +266,9 @@ export default {
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
});
}
const user = await User.where({
email: tokenModel.email,
});
const user = await SystemUser.query()
.where('email', tokenModel.email).first();
if (!user) {
return res.boom.badRequest(null, {
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
@@ -272,10 +276,14 @@ export default {
}
const hashedPassword = await hashPassword(password);
user.password = hashedPassword;
await user.save();
await SystemUser.query()
.where('email', tokenModel.email)
.update({
password: hashedPassword,
});
await PasswordReset.where('email', user.get('email')).destroy({ require: false });
// Delete the reset password token.
await PasswordReset.query().where('token', token).delete();
return res.status(200).send({});
},

View File

@@ -0,0 +1,186 @@
import express from 'express';
import uniqid from 'uniqid';
import {
check,
body,
query,
validationResult,
} from 'express-validator';
import path from 'path';
import fs from 'fs';
import Mustache from 'mustache';
import mail from '@/services/mail';
import { hashPassword } from '@/utils';
import SystemUser from '@/system/models/SystemUser';
import Invite from '@/system/models/Invite';
import TenantUser from '@/models/TenantUser';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import Tenant from '@/system/models/Tenant';
import TenantsManager from '@/system/TenantsManager';
import jwtAuth from '@/http/middleware/jwtAuth';
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
import TenantModel from '@/models/TenantModel';
import Logger from '@/services/Logger';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.use('/send', jwtAuth);
router.use('/send', TenancyMiddleware);
router.post('/send',
this.invite.validation,
asyncMiddleware(this.invite.handler));
router.post('/accept',
this.accept.validation,
asyncMiddleware(this.accept.handler));
return router;
},
/**
* Invite a user to the authorized user organization.
*/
invite: {
validation: [
body('email').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 };
const foundUser = await SystemUser.query()
.where('email', form.email).first();
const { user } = req;
if (foundUser) {
return res.status(400).send({
errors: [{ type: 'USER.EMAIL.ALREADY.REGISTERED', code: 100 }],
});
}
const token = uniqid();
const invite = await Invite.query().insert({
email: form.email,
tenant_id: user.tenantId,
token,
});
const { Option } = req.models;
const organizationOptions = await Option.query()
.where('key', 'organization_name');
const filePath = path.join(global.rootPath, 'views/mail/UserInvite.html');
const template = fs.readFileSync(filePath, 'utf8');
const rendered = Mustache.render(template, {
url: `${req.protocol}://${req.hostname}/invite/accept/${invite.token}`,
fullName: `${user.firstName} ${user.lastName}`,
email: user.email,
organizationName: organizationOptions.getMeta('organization_meta'),
});
const mailOptions = {
to: user.email,
from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`,
subject: `${user.firstName} ${user.lastName} has invited you to join a Bigcapital`,
html: rendered,
};
mail.sendMail(mailOptions, (error) => {
if (error) {
Logger.log('error', 'Failed send user invite mail', { error, form });
}
Logger.log('info', 'User has been sent invite user email successfuly.', { form });
});
return res.status(200).send();
}
},
/**
* Acceprt the inviation.
*/
accept: {
validation: [
check('first_name').exists().trim().escape(),
check('last_name').exists().trim().escape(),
check('phone_number').exists().trim().escape(),
check('language').exists().isIn(['ar', 'en']),
check('password').exists().trim().escape(),
query('token').exists().trim().escape(),
],
async handler(req, res) {
const inviteToken = await Invite.query()
.where('token', req.query.token).first();
if (!inviteToken) {
return res.status(404).send({
errors: [{ type: 'INVITE.TOKEN.NOT.FOUND', code: 300 }],
});
}
const form = { ...req.body };
const systemUser = await SystemUser.query()
.where('phone_number', form.phone_number)
.first();
const errorReasons = [];
// Validate there is already registered phone number.
if (systemUser && systemUser.phoneNumber === form.phone_number) {
errorReasons.push({
type: 'PHONE_MUMNER.ALREADY.EXISTS', code: 400,
});
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
// Find the tenant that associated to the given token.
const tenant = await Tenant.query()
.where('id', inviteToken.tenantId).first();
const tenantDb = TenantsManager.knexInstance(tenant.organizationId);
const hashedPassword = await hashPassword(form.password);
const userForm = {
first_name: form.first_name,
last_name: form.last_name,
email: form.email,
phone_number: form.phone_number,
language: form.language,
active: 1,
password: hashedPassword,
};
TenantModel.knexBinded = tenantDb;
const foundTenantUser = await TenantUser.query()
.where('phone_number', form.phone_number).first();
if (foundTenantUser) {
return res.status(400).send({
errors: [{ type: 'PHONE_NUMBER.ALREADY.EXISTS', code: 400 }],
});
}
const insertUserOper = TenantUser.bindKnex(tenantDb)
.query().insert({ ...userForm });
const insertSysUserOper = SystemUser.query().insert({ ...userForm });
await Promise.all([
insertUserOper,
insertSysUserOper,
]);
await Invite.query()
.where('token', req.query.token).delete();
return res.status(200).send();
}
}
}

View File

@@ -1,8 +1,10 @@
import express from 'express';
import { check, query, validationResult } from 'express-validator';
import { difference } from 'lodash';
import fs from 'fs';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import jwtAuth from '@/http/middleware/jwtAuth';
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
import {
mapViewRolesToConditionals,
mapFilterRolesToDynamicFilter,
@@ -13,7 +15,10 @@ import {
DynamicFilterViews,
DynamicFilterFilterRoles,
} from '@/lib/DynamicFilter';
import Logger from '@/services/Logger';
import ConfiguredMiddleware from '@/http/middleware/ConfiguredMiddleware';
const fsPromises = fs.promises;
export default {
/**
@@ -23,6 +28,8 @@ export default {
const router = express.Router();
router.use(jwtAuth);
router.use(TenancyMiddleware);
router.use(ConfiguredMiddleware);
router.post('/:id',
this.editItem.validation,
@@ -68,6 +75,7 @@ export default {
check('custom_fields.*.value').exists(),
check('note').optional(),
check('attachment').optional(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
@@ -118,6 +126,13 @@ export default {
errorReasons.push({ type: 'FIELD_KEY_NOT_FOUND', code: 150, fields: notFoundFields });
}
}
const { attachment } = req.files;
const attachmentsMimes = ['image/png', 'image/jpeg'];
// Validate the attachment.
if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) {
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 });
}
const [
costAccount,
sellAccount,
@@ -142,6 +157,11 @@ export default {
if (errorReasons.length > 0) {
return res.boom.badRequest(null, { errors: errorReasons });
}
if (attachment) {
const publicPath = 'storage/app/public/';
await attachment.mv(`${publicPath}${req.organizationId}/${attachment.md5}.png`);
}
const item = await Item.query().insertAndFetch({
name: form.name,
type: form.type,
@@ -151,6 +171,7 @@ export default {
cost_account_id: form.cost_account_id,
currency_code: form.currency_code,
note: form.note,
attachment_file: (attachment) ? `${attachment.md5}.png` : null,
});
return res.status(200).send({ id: item.id });
},
@@ -173,6 +194,8 @@ export default {
check('sell_account_id').exists().isInt(),
check('category_id').optional().isInt(),
check('note').optional(),
check('attachment').optional(),
check('')
],
async handler(req, res) {
const validationErrors = validationResult(req);
@@ -215,9 +238,34 @@ export default {
if (!itemCategory && form.category_id) {
errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 });
}
const { attachment } = req.files;
const attachmentsMimes = ['image/png', 'image/jpeg'];
// Validate the attachment.
if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) {
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 });
}
if (errorReasons.length > 0) {
return res.boom.badRequest(null, { errors: errorReasons });
}
if (attachment) {
const publicPath = 'storage/app/public/';
const tenantPath = `${publicPath}${req.organizationId}`;
try {
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
} catch (error) {
Logger.log('error', 'Delete item attachment file delete failed.', { error });
}
try {
await attachment.mv(`${tenantPath}/${attachment.md5}.png`);
} catch (error) {
return res.status(400).send({
errors: [{ type: 'ATTACHMENT.UPLOAD.FAILED', code: 600 }],
});
}
}
const updatedItem = await Item.query().findById(id).patch({
name: form.name,
@@ -229,6 +277,7 @@ export default {
cost_account_id: form.cost_account_id,
category_id: form.category_id,
note: form.note,
attachment_file: (attachment) ? item.attachmentFile : null,
});
return res.status(200).send({ id: updatedItem.id });
},
@@ -252,6 +301,16 @@ export default {
// Delete the fucking the given item id.
await Item.query().findById(item.id).delete();
if (item.attachmentFile) {
const publicPath = 'storage/app/public/';
const tenantPath = `${publicPath}${req.organizationId}`;
try {
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
} catch (error) {
Logger.log('error', 'Delete item attachment file delete failed.', { error });
}
}
return res.status(200).send();
},
},

View File

@@ -1,5 +1,6 @@
// import OAuth2 from '@/http/controllers/OAuth2';
import Authentication from '@/http/controllers/Authentication';
import InviteUsers from '@/http/controllers/InviteUsers';
// import Users from '@/http/controllers/Users';
// import Roles from '@/http/controllers/Roles';
import Items from '@/http/controllers/Items';
@@ -28,6 +29,7 @@ import ExchangeRates from '@/http/controllers/ExchangeRates';
export default (app) => {
// app.use('/api/oauth2', OAuth2.router());
app.use('/api/auth', Authentication.router());
app.use('/api/invite', InviteUsers.router());
app.use('/api/currencies', Currencies.router());
// app.use('/api/users', Users.router());
// app.use('/api/roles', Roles.router());

View File

@@ -0,0 +1,13 @@
// eslint-disable-next-line consistent-return
export default async (req, res, next) => {
const { Option } = req.models;
const option = await Option.query().where('key', 'app_configured');
if (option.getMeta('app_configured', false)) {
return res.res(400).send({
errors: [{ type: 'TENANT.NOT.CONFIGURED', code: 700 }],
});
}
next();
};

View File

@@ -1,5 +1,4 @@
export default class DynamicFilterAbstructor {
constructor() {
this.filterRoles = [];
@@ -10,9 +9,21 @@ export default class DynamicFilterAbstructor {
this.tableName = tableName;
}
/**
* @interface
*/
// eslint-disable-next-line class-methods-use-this
buildLogicExpression() {}
/**
* @interface
*/
// eslint-disable-next-line class-methods-use-this
validateFilterRoles() {}
/**
* @interface
*/
// eslint-disable-next-line class-methods-use-this
buildQuery() {}
}

View File

@@ -249,7 +249,6 @@ export default class MetableCollection {
this.metadata.push(meta);
}
toArray() {
return this.metadata;
}

View File

@@ -1,9 +1,9 @@
import { mixin } from 'objection';
import BaseModel from '@/models/Model';
import TenantModel from '@/models/TenantModel';
import MetableCollection from '@/lib/Metable/MetableCollection';
import definedOptions from '@/data/options';
export default class Option extends mixin(BaseModel, [mixin]) {
export default class Option extends mixin(TenantModel, [mixin]) {
/**
* Table name.
*/

View File

@@ -1,10 +0,0 @@
import TenantModel from '@/models/TenantModel';
export default class PasswordResets extends TenantModel {
/**
* Table name
*/
static get tableName() {
return 'password_resets';
}
}

View File

@@ -1,13 +1,24 @@
import nodemailer from 'nodemailer';
import config from '@/../config/config';
// create reusable transporter object using the default SMTP transport
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT),
secure: process.env.MAIL_SECURE === 'true', // true for 465, false for other ports
host: config.mail.host,
port: config.mail.port,
secure: config.mail.secure, // true for 465, false for other ports
auth: {
user: process.env.MAIL_USERNAME,
pass: process.env.MAIL_PASSWORD,
user: config.mail.username,
pass: config.mail.password,
},
});
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,
},
});

View File

@@ -1,7 +1,7 @@
exports.up = (knex) => knex.schema.createTable('password_resets', (table) => {
table.increments();
table.string('user_id');
table.string('email');
table.string('token');
table.timestamp('created_at');
});

View File

@@ -0,0 +1,14 @@
exports.up = function(knex) {
return knex.schema.createTable('user_invites', (table) => {
table.increments();
table.string('email');
table.string('token').unique();
table.integer('tenant_id').unsigned();
table.timestamps();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('user_invites');
};

View File

@@ -0,0 +1,10 @@
import SystemModel from '@/system/models/SystemModel';
export default class UserInvite extends SystemModel {
/**
* Table name.
*/
static get tableName() {
return 'user_invites';
}
}

View File

@@ -0,0 +1,10 @@
import SystemModel from '@/system/models/SystemModel';
export default class PasswordResets extends SystemModel {
/**
* Table name
*/
static get tableName() {
return 'password_resets';
}
}

View File

@@ -21,7 +21,7 @@ export default class SystemUser extends SystemModel {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
join: {
from: 'users.tenant_id',
from: 'users.tenantId',
to: 'tenants.id',
},
},

View File

@@ -0,0 +1,286 @@
<!DOCTYPE html>
<head>
<title>Moosher Reset Password</title>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<style>
body {
font-family: "Roboto",sans-serif;
font-size: 14px;
line-height: 1.7;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
background: #e0e8f3;
min-width: 320px;
color: #495463;
}
/*! Email Template Preview Purpose */
.email-wraper {
background: #e0e8f3;
font-size: 14px;
line-height: 22px;
font-weight: 400;
color: #5c6e82;
width: 100%
}
.email-wraper a {
color: #253992;
word-break: break-all
}
.email-wraper .link-block {
display: block
}
.email-ul {
margin: 5px 0;
padding: 0
}
.email-ul:not(:last-child) {
margin-bottom: 10px
}
.email-ul li {
list-style: disc;
list-style-position: inside
}
.email-ul-col2 {
display: flex;
flex-wrap: wrap
}
.email-ul-col2 li {
width: 50%;
padding-right: 10px
}
.email-body {
width: 96%;
max-width: 620px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e6effb;
border-bottom: 4px solid #007bff;
}
.email-success {
border-bottom-color: #00d285
}
.email-warning {
border-bottom-color: #ffc100
}
.email-btn {
background: #007bff;
border-radius: 4px;
color: #ffffff !important;
display: inline-block;
font-size: 13px;
font-weight: 600;
line-height: 44px;
text-align: center;
text-decoration: none;
text-transform: uppercase;
padding: 0 30px
}
.email-btn-sm {
line-height: 38px
}
.email-header,.email-footer {
width: 100%;
max-width: 620px;
margin: 0 auto
}
.email-logo {
height: 40px
}
.email-title {
font-size: 13px;
color: #253992;
padding-top: 12px
}
.email-heading {
font-size: 18px;
color: #253992;
font-weight: 600;
margin: 0
}
.email-heading-sm {
font-size: 16px
}
.email-heading-s2 {
font-size: 15px;
color: #000000;
font-weight: 600;
margin: 0;
text-transform: uppercase;
margin-bottom: 10px
}
.email-heading-s3 {
font-size: 18px;
color: #1babfe;
font-weight: 400;
margin-bottom: 8px
}
.email-heading-success {
color: #00d285
}
.email-heading-warning {
color: #ffc100
}
.email-note {
margin: 0;
font-size: 13px;
line-height: 22px;
color: #6e81a9
}
.email-copyright-text {
font-size: 13px
}
.email-social li {
display: inline-block;
padding: 4px
}
.email-social li a {
display: inline-block;
height: 30px;
width: 30px;
border-radius: 50%;
background: #ffffff
}
.email-social li a img {
width: 30px
}
@media (max-width: 480px) {
.email-preview-page .card {
border-radius:0;
margin-left: -20px;
margin-right: -20px
}
.email-ul-col2 li {
width: 100%
}
}
.pdb-4x {
padding-bottom: 40px
}
.pdt-4x {
padding-top: 40px
}
.pdb-2-5x {
padding-bottom: 25px
}
.pd-3x {
padding: 30px
}
.pdb-1-5x {
padding-bottom: 15px
}
.pdb-2x {
padding-bottom: 20px
}
.pdl-2x {
padding-left: 20px
}
.pdt-2-5x {
padding-top: 25px
}
.text-center{
text-align: center;
}
.pdt-0{
padding-top: 0;
}
.pt-0{
padding-top: 0;
}
.mgb-2-5x {
margin-bottom: 25px
}
.mt-0{
margin-top: 0;
}
</style>
</head>
<body>
<table class="email-wraper">
<tbody>
<tr>
<td class="pdt-4x pdb-4x">
<table class="email-header">
<tbody>
<tr>
<td class="text-center pdb-2-5x">
<a href="#">
<img src="https://www.moosher.ly/logo-email.png" alt="Moosher" />
</a>
</td>
</tr>
</tbody>
</table>
<table class="email-body">
<tbody>
<tr>
<td class="text-center pd-3x pdb-1-5x">
<h2 class="email-heading">Reset Your Password</h2>
</td>
</tr>
<tr>
<td class="text-center pd-3x pt-0 pdb-2x">
<p class="mgb-1x">Hi {{ first_name }} {{ last_name }},</p>
<p class="mgb-2-5x">Click On The link blow to reset your password.</p>
<a href="{{ url }}" class="email-btn" target="_blank">Reset Password</a>
</td>
</tr>
<tr>
<td class="text-center pd-3x pdt-0 pdb-4x">
<p>If you did not make this request, please contact us or ignore this message.</p>
<p class="email-note">This is an automatically generated email please do not reply to
this email. If you face any issues, please contact us at {{ contact_us_email }}</p>
</td>
</tr>
</tbody>
</table>
<table class="email-footer">
<tbody>
<tr>
<td class="text-center pdt-2-5x pdl-2x pdr-2x">
<p class="email-copyright-text mt-0">Copyright © 2019 Moosher.</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,289 @@
<!DOCTYPE html>
<head>
<title>Moosher Reset Password</title>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<style>
body {
font-family: "Roboto",sans-serif;
font-size: 14px;
line-height: 1.7;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
background: #e0e8f3;
min-width: 320px;
color: #495463;
}
/*! Email Template Preview Purpose */
.email-wraper {
background: #e0e8f3;
font-size: 14px;
line-height: 22px;
font-weight: 400;
color: #5c6e82;
width: 100%
}
.email-wraper a {
color: #253992;
word-break: break-all
}
.email-wraper .link-block {
display: block
}
.email-ul {
margin: 5px 0;
padding: 0
}
.email-ul:not(:last-child) {
margin-bottom: 10px
}
.email-ul li {
list-style: disc;
list-style-position: inside
}
.email-ul-col2 {
display: flex;
flex-wrap: wrap
}
.email-ul-col2 li {
width: 50%;
padding-right: 10px
}
.email-body {
width: 96%;
max-width: 620px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e6effb;
border-bottom: 4px solid #007bff;
}
.email-success {
border-bottom-color: #00d285
}
.email-warning {
border-bottom-color: #ffc100
}
.email-btn {
background: #007bff;
border-radius: 4px;
color: #ffffff !important;
display: inline-block;
font-size: 13px;
font-weight: 600;
line-height: 44px;
text-align: center;
text-decoration: none;
text-transform: uppercase;
padding: 0 30px
}
.email-btn-sm {
line-height: 38px
}
.email-header,.email-footer {
width: 100%;
max-width: 620px;
margin: 0 auto
}
.email-logo {
height: 40px
}
.email-title {
font-size: 13px;
color: #253992;
padding-top: 12px
}
.email-heading {
font-size: 18px;
color: #253992;
font-weight: 600;
margin: 0
}
.email-heading-sm {
font-size: 16px
}
.email-heading-s2 {
font-size: 15px;
color: #000000;
font-weight: 600;
margin: 0;
text-transform: uppercase;
margin-bottom: 10px
}
.email-heading-s3 {
font-size: 18px;
color: #1babfe;
font-weight: 400;
margin-bottom: 8px
}
.email-heading-success {
color: #00d285
}
.email-heading-warning {
color: #ffc100
}
.email-note {
margin: 0;
font-size: 13px;
line-height: 22px;
color: #6e81a9
}
.email-copyright-text {
font-size: 13px
}
.email-social li {
display: inline-block;
padding: 4px
}
.email-social li a {
display: inline-block;
height: 30px;
width: 30px;
border-radius: 50%;
background: #ffffff
}
.email-social li a img {
width: 30px
}
@media (max-width: 480px) {
.email-preview-page .card {
border-radius:0;
margin-left: -20px;
margin-right: -20px
}
.email-ul-col2 li {
width: 100%
}
}
.pdb-4x {
padding-bottom: 40px
}
.pdt-4x {
padding-top: 40px
}
.pdb-2-5x {
padding-bottom: 25px
}
.pd-3x {
padding: 30px
}
.pdb-1-5x {
padding-bottom: 15px
}
.pdb-2x {
padding-bottom: 20px
}
.pdl-2x {
padding-left: 20px
}
.pdt-2-5x {
padding-top: 25px
}
.text-center{
text-align: center;
}
.pdt-0{
padding-top: 0;
}
.pt-0{
padding-top: 0;
}
.mgb-2-5x {
margin-bottom: 25px
}
.mt-0{
margin-top: 0;
}
</style>
</head>
<body>
<table class="email-wraper">
<tbody>
<tr>
<td class="pdt-4x pdb-4x">
<table class="email-header">
<tbody>
<tr>
<td class="text-center pdb-2-5x">
<a href="#">
<img src="https://www.moosher.ly/logo-email.png" alt="Moosher" />
</a>
</td>
</tr>
</tbody>
</table>
<table class="email-body">
<tbody>
<tr>
<td class="text-center pd-3x pdb-1-5x">
<h2 class="email-heading">Join {{ organizationName }} on Bigcapital</h2>
</td>
</tr>
<tr>
<td class="text-center pd-3x pt-0 pdb-2x">
<p class="mgb-2-5x">
{{ fullName }} ({{ email }}) has invited you to join the Bigcapital {{ organizationName }}.
Join now to start collaborating!
</p>
<a href="{{ url }}" class="email-btn" target="_blank">Reset Password</a>
</td>
</tr>
<tr>
<td class="text-center pd-3x pdt-0 pdb-4x">
<p>If you did not make this request, please contact us or ignore this message.</p>
<p class="email-note">This is an automatically generated email please do not reply to
this email. If you face any issues, please contact us at {{ contact_us_email }}</p>
</td>
</tr>
</tbody>
</table>
<table class="email-footer">
<tbody>
<tr>
<td class="text-center pdt-2-5x pdl-2x pdr-2x">
<p class="email-copyright-text mt-0">Copyright © 2019 Moosher.</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>