mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
feat: User invitation system.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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({});
|
||||
},
|
||||
|
||||
186
server/src/http/controllers/InviteUsers.js
Normal file
186
server/src/http/controllers/InviteUsers.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
|
||||
13
server/src/http/middleware/ConfiguredMiddleware.js
Normal file
13
server/src/http/middleware/ConfiguredMiddleware.js
Normal 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();
|
||||
};
|
||||
Reference in New Issue
Block a user