diff --git a/client/src/containers/Alerts/Users/UserActivateAlert.js b/client/src/containers/Alerts/Users/UserActivateAlert.js
index e9d98a6e8..28737e586 100644
--- a/client/src/containers/Alerts/Users/UserActivateAlert.js
+++ b/client/src/containers/Alerts/Users/UserActivateAlert.js
@@ -1,6 +1,69 @@
+import React from 'react';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { Alert, Intent } from '@blueprintjs/core';
+import { AppToaster } from 'components';
+import { useActivateUser } from 'hooks/query';
+import withAlertStoreConnect from 'containers/Alert/withAlertStoreConnect';
+import withAlertActions from 'containers/Alert/withAlertActions';
+import { compose } from 'utils';
-function UserActivateAlert() {
-
-}
\ No newline at end of file
+/**
+ * User inactivate alert.
+ */
+function UserActivateAlert({
+ // #ownProps
+ name,
+
+ // #withAlertStoreConnect
+ isOpen,
+ payload: { userId },
+
+ // #withAlertActions
+ closeAlert,
+}) {
+ const { formatMessage } = useIntl();
+
+ const { mutateAsync: userActivateMutate } = useActivateUser();
+
+ const handleConfirmActivate = () => {
+ userActivateMutate(userId)
+ .then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_user_has_been_activated_successfully',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ closeAlert(name);
+ })
+ .catch((error) => {
+ closeAlert(name);
+ });
+ };
+
+ const handleCancel = () => {
+ closeAlert(name);
+ };
+
+ return (
+ }
+ confirmButtonText={}
+ intent={Intent.WARNING}
+ isOpen={isOpen}
+ onCancel={handleCancel}
+ onConfirm={handleConfirmActivate}
+ >
+
+
+
+
+ );
+}
+
+export default compose(
+ withAlertStoreConnect(),
+ withAlertActions,
+)(UserActivateAlert);
diff --git a/client/src/containers/Alerts/Users/UserDeleteAlert.js b/client/src/containers/Alerts/Users/UserDeleteAlert.js
index 13ccbb1da..5c6f8471b 100644
--- a/client/src/containers/Alerts/Users/UserDeleteAlert.js
+++ b/client/src/containers/Alerts/Users/UserDeleteAlert.js
@@ -40,6 +40,7 @@ function UserDeleteAlert({
}),
intent: Intent.SUCCESS,
});
+ closeAlert(name);
})
.catch(({ response: { data: { errors } } }) => {
if (errors.find(e => e.type === 'CANNOT_DELETE_LAST_USER')) {
diff --git a/client/src/containers/Alerts/Users/UserInactivateAlert.js b/client/src/containers/Alerts/Users/UserInactivateAlert.js
index 335452283..b6894ae98 100644
--- a/client/src/containers/Alerts/Users/UserInactivateAlert.js
+++ b/client/src/containers/Alerts/Users/UserInactivateAlert.js
@@ -36,9 +36,16 @@ function UserInactivateAlert({
}),
intent: Intent.SUCCESS,
});
+ closeAlert(name);
})
- .catch((error) => {
-
+ .catch(({ response: { data: { errors } } }) => {
+ if (errors.find(e => e.type === 'CANNOT.TOGGLE.ACTIVATE.AUTHORIZED.USER')) {
+ AppToaster.show({
+ message: 'You could not activate/inactivate the same authorized user.',
+ intent: Intent.DANGER,
+ });
+ }
+ closeAlert(name);
});
};
diff --git a/client/src/containers/Preferences/Users/UsersAlerts.js b/client/src/containers/Preferences/Users/UsersAlerts.js
index 73717281f..689004d2e 100644
--- a/client/src/containers/Preferences/Users/UsersAlerts.js
+++ b/client/src/containers/Preferences/Users/UsersAlerts.js
@@ -1,14 +1,14 @@
import React from 'react';
import UserDeleteAlert from 'containers/Alerts/Users/UserDeleteAlert';
import UserInactivateAlert from 'containers/Alerts/Users/UserInactivateAlert';
-// import UserActivateAlert from 'containers/Alerts/UserActivateAlert';
+import UserActivateAlert from 'containers/Alerts/Users/UserActivateAlert';
export default function UsersAlerts() {
return (
<>
- {/* */}
+
>
);
}
diff --git a/client/src/containers/Preferences/Users/UsersDataTable.js b/client/src/containers/Preferences/Users/UsersDataTable.js
index 3efcbf238..15bcf9053 100644
--- a/client/src/containers/Preferences/Users/UsersDataTable.js
+++ b/client/src/containers/Preferences/Users/UsersDataTable.js
@@ -2,6 +2,8 @@ import React, { useCallback } from 'react';
import { compose } from 'utils';
import { DataTable } from 'components';
+import { useResendInvitation } from 'hooks/query';
+import AppToaster from 'components/AppToaster';
import TableSkeletonRows from 'components/Datatable/TableSkeletonRows';
@@ -10,6 +12,7 @@ import withAlertActions from 'containers/Alert/withAlertActions';
import { ActionsMenu, useUsersListColumns } from './components';
import { useUsersListContext } from './UsersProvider';
+import { Intent } from '@blueprintjs/core';
/**
* Users datatable.
@@ -49,6 +52,26 @@ function UsersDataTable({
},
[openAlert]
);
+
+ const { mutateAsync: resendInviation } = useResendInvitation();
+
+ const handleResendInvitation = useCallback(
+ (user) => {
+ resendInviation(user.id).then(() => {
+ AppToaster.show({
+ message: 'User invitation has been re-sent to the user.',
+ intent: Intent.SUCCESS
+ });
+ }).catch(({ response: { data: { errors } } }) => {
+ if (errors.find(e => e.type === 'USER_RECENTLY_INVITED')) {
+ AppToaster.show({
+ message: 'This person was recently invited. No need to invite them again just yet.',
+ intent: Intent.DANGER
+ });
+ }
+ });
+ }
+ )
// Users list columns.
const columns = useUsersListColumns();
@@ -67,9 +90,10 @@ function UsersDataTable({
ContextMenu={ActionsMenu}
payload={{
onEdit: handleEditUserAction,
- onActivate: handleInactivateUser,
- onInactivate: handleActivateuser,
- onDelete: handleDeleteUser
+ onActivate: handleActivateuser,
+ onInactivate: handleInactivateUser,
+ onDelete: handleDeleteUser,
+ onResendInvitation: handleResendInvitation
}}
/>
);
diff --git a/client/src/containers/Preferences/Users/components.js b/client/src/containers/Preferences/Users/components.js
index e4235c077..0118c569f 100644
--- a/client/src/containers/Preferences/Users/components.js
+++ b/client/src/containers/Preferences/Users/components.js
@@ -25,12 +25,7 @@ function AvatarCell(row) {
*/
export function ActionsMenu({
row: { original },
- payload: {
- onEdit,
- onInactivate,
- onActivate,
- onDelete
- }
+ payload: { onEdit, onInactivate, onActivate, onDelete, onResendInvitation },
}) {
const { formatMessage } = useIntl();
@@ -44,9 +39,26 @@ export function ActionsMenu({
/>
+ {original.active ? (
+ }
+ />
+ ) : (
+ }
+ />
+ )}
+
+
+
}
/>
@@ -64,7 +76,7 @@ export function ActionsMenu({
* Status accessor.
*/
function StatusAccessor(user) {
- return !user.invite_accepted_at ? (
+ return !user.is_invite_accepted ? (
@@ -93,6 +105,10 @@ function ActionsCell(props) {
);
}
+function FullNameAccessor(user) {
+ return user.is_invite_accepted ? user.full_name : user.email;
+}
+
export const useUsersListColumns = () => {
const { formatMessage } = useIntl();
@@ -107,7 +123,7 @@ export const useUsersListColumns = () => {
{
id: 'full_name',
Header: formatMessage({ id: 'full_name' }),
- accessor: 'full_name',
+ accessor: FullNameAccessor,
width: 150,
},
{
diff --git a/client/src/hooks/query/invite.js b/client/src/hooks/query/invite.js
index aab936bf5..b1b7960ff 100644
--- a/client/src/hooks/query/invite.js
+++ b/client/src/hooks/query/invite.js
@@ -27,4 +27,14 @@ export const useInviteMetaByToken = (token, props) => {
...props
}
);
+}
+
+
+export const useResendInvitation = (props) => {
+ const apiRequest = useApiRequest();
+
+ return useMutation(
+ (userId) => apiRequest.post(`invite/resend/${userId}`),
+ props
+ )
}
\ No newline at end of file
diff --git a/client/src/hooks/query/users.js b/client/src/hooks/query/users.js
index 0298e8c0c..cf513d371 100644
--- a/client/src/hooks/query/users.js
+++ b/client/src/hooks/query/users.js
@@ -47,10 +47,30 @@ export function useInactivateUser(props) {
const queryClient = useQueryClient();
return useMutation(
- ([id, values]) => apiRequest.post(`users/${id}/inactivate`, values),
+ (userId) => apiRequest.put(`users/${userId}/inactivate`),
{
- onSuccess: (res, [id, values]) => {
- queryClient.invalidateQueries([t.USER, id]);
+ onSuccess: (res, userId) => {
+ queryClient.invalidateQueries([t.USER, userId]);
+
+ // Common invalidate queries.
+ commonInvalidateQueries(queryClient);
+ },
+ ...props,
+ },
+ );
+}
+
+
+
+export function useActivateUser(props) {
+ const apiRequest = useApiRequest();
+ const queryClient = useQueryClient();
+
+ return useMutation(
+ (userId) => apiRequest.put(`users/${userId}/activate`),
+ {
+ onSuccess: (res, userId) => {
+ queryClient.invalidateQueries([t.USER, userId]);
// Common invalidate queries.
commonInvalidateQueries(queryClient);
diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js
index a87f81eba..455f809c3 100644
--- a/client/src/lang/en/index.js
+++ b/client/src/lang/en/index.js
@@ -318,6 +318,7 @@ export default {
edit_user: 'Edit User',
edit_invite: 'Edit Invite',
inactivate_user: 'Inactivate User',
+ activate_user: 'Activate User',
delete_user: 'Delete User',
full_name: 'Full Name',
the_user_has_been_inactivated_successfully:
diff --git a/server/src/api/controllers/Authentication.ts b/server/src/api/controllers/Authentication.ts
index 9e8d77824..7e344c333 100644
--- a/server/src/api/controllers/Authentication.ts
+++ b/server/src/api/controllers/Authentication.ts
@@ -7,13 +7,13 @@ import BaseController from 'api/controllers/BaseController';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import AuthenticationService from 'services/Authentication';
import { ILoginDTO, ISystemUser, IRegisterDTO } from 'interfaces';
-import { ServiceError, ServiceErrors } from "exceptions";
+import { ServiceError, ServiceErrors } from 'exceptions';
import { DATATYPES_LENGTH } from 'data/DataTypes';
import LoginThrottlerMiddleware from 'api/middleware/LoginThrottlerMiddleware';
import config from 'config';
@Service()
-export default class AuthenticationController extends BaseController{
+export default class AuthenticationController extends BaseController {
@Inject()
authService: AuthenticationService;
@@ -116,19 +116,21 @@ export default class AuthenticationController extends BaseController{
* Country validator.
*/
countryValidator(value, { req }) {
- const { countries: { whitelist, blacklist } } = config.registration;
+ const {
+ countries: { whitelist, blacklist },
+ } = config.registration;
const foundCountry = countries.findOne('countryCode', value);
if (!foundCountry) {
throw new Error('The country code is invalid.');
}
if (
- // Focus with me! In case whitelist is not empty and the given coutry is not
+ // Focus with me! In case whitelist is not empty and the given coutry is not
// in whitelist throw the error.
- //
- // Or in case the blacklist is not empty and the given country exists
+ //
+ // Or in case the blacklist is not empty and the given country exists
// in the blacklist throw the goddamn error.
- (whitelist.length > 0 && whitelist.indexOf(value) === -1) ||
+ (whitelist.length > 0 && whitelist.indexOf(value) === -1) ||
(blacklist.length > 0 && blacklist.indexOf(value) !== -1)
) {
throw new Error('The country code is not supported yet.');
@@ -153,7 +155,9 @@ export default class AuthenticationController extends BaseController{
*/
get resetPasswordSchema(): ValidationChain[] {
return [
- check('password').exists().isLength({ min: 5 })
+ check('password')
+ .exists()
+ .isLength({ min: 5 })
.custom((value, { req }) => {
if (value !== req.body.confirm_password) {
throw new Error("Passwords don't match");
@@ -168,15 +172,13 @@ export default class AuthenticationController extends BaseController{
* Send reset password validation schema.
*/
get sendResetPasswordSchema(): ValidationChain[] {
- return [
- check('email').exists().isEmail().trim().escape(),
- ];
+ return [check('email').exists().isEmail().trim().escape()];
}
/**
* Handle user login.
- * @param {Request} req
- * @param {Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async login(req: Request, res: Response, next: Function): Response {
const userDTO: ILoginDTO = this.matchedBodyData(req);
@@ -194,14 +196,16 @@ export default class AuthenticationController extends BaseController{
/**
* Organization register handler.
- * @param {Request} req
- * @param {Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async register(req: Request, res: Response, next: Function) {
const registerDTO: IRegisterDTO = this.matchedBodyData(req);
try {
- const registeredUser: ISystemUser = await this.authService.register(registerDTO);
+ const registeredUser: ISystemUser = await this.authService.register(
+ registerDTO
+ );
return res.status(200).send({
type: 'success',
@@ -215,8 +219,8 @@ export default class AuthenticationController extends BaseController{
/**
* Send reset password handler
- * @param {Request} req
- * @param {Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async sendResetPassword(req: Request, res: Response, next: Function) {
const { email } = this.matchedBodyData(req);
@@ -226,11 +230,10 @@ export default class AuthenticationController extends BaseController{
return res.status(200).send({
code: 'SEND_RESET_PASSWORD_SUCCESS',
- message: 'The reset password message has been sent successfully.'
+ message: 'The reset password message has been sent successfully.',
});
- } catch(error) {
+ } catch (error) {
if (error instanceof ServiceError) {
-
}
next(error);
}
@@ -238,8 +241,8 @@ export default class AuthenticationController extends BaseController{
/**
* Reset password handler
- * @param {Request} req
- * @param {Response} res
+ * @param {Request} req
+ * @param {Response} res
*/
async resetPassword(req: Request, res: Response, next: Function) {
const { token } = req.params;
@@ -250,9 +253,9 @@ export default class AuthenticationController extends BaseController{
return res.status(200).send({
type: 'RESET_PASSWORD_SUCCESS',
- message: 'The password has been reset successfully.'
- })
- } catch(error) {
+ message: 'The password has been reset successfully.',
+ });
+ } catch (error) {
next(error);
}
}
@@ -262,7 +265,9 @@ export default class AuthenticationController extends BaseController{
*/
handlerErrors(error, req: Request, res: Response, next: Function) {
if (error instanceof ServiceError) {
- if (['INVALID_DETAILS', 'invalid_password'].indexOf(error.errorType) !== -1) {
+ if (
+ ['INVALID_DETAILS', 'invalid_password'].indexOf(error.errorType) !== -1
+ ) {
return res.boom.badRequest(null, {
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
});
@@ -272,7 +277,10 @@ export default class AuthenticationController extends BaseController{
errors: [{ type: 'USER_INACTIVE', code: 200 }],
});
}
- if (error.errorType === 'TOKEN_INVALID' || error.errorType === 'TOKEN_EXPIRED') {
+ if (
+ error.errorType === 'TOKEN_INVALID' ||
+ error.errorType === 'TOKEN_EXPIRED'
+ ) {
return res.boom.badRequest(null, {
errors: [{ type: 'TOKEN_INVALID', code: 300 }],
});
@@ -303,4 +311,4 @@ export default class AuthenticationController extends BaseController{
}
next(error);
}
-};
+}
diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts
index 1cbcd2eb4..1df27843b 100644
--- a/server/src/api/controllers/Expenses.ts
+++ b/server/src/api/controllers/Expenses.ts
@@ -30,12 +30,6 @@ export default class ExpensesController extends BaseController {
asyncMiddleware(this.newExpense.bind(this)),
this.catchServiceErrors
);
- router.post(
- '/publish',
- [...this.bulkSelectSchema],
- this.bulkPublishExpenses.bind(this),
- this.catchServiceErrors
- );
router.post(
'/:id/publish',
[...this.expenseParamSchema],
@@ -57,13 +51,6 @@ export default class ExpensesController extends BaseController {
asyncMiddleware(this.deleteExpense.bind(this)),
this.catchServiceErrors
);
- router.delete(
- '/',
- [...this.bulkSelectSchema],
- this.validationResult,
- asyncMiddleware(this.bulkDeleteExpenses.bind(this)),
- this.catchServiceErrors
- );
router.get(
'/',
[...this.expensesListSchema],
@@ -250,63 +237,6 @@ export default class ExpensesController extends BaseController {
}
}
- /**
- * Deletes the expenses in bulk.
- * @param {Request} req
- * @param {Response} res
- * @param {NextFunction} next
- */
- async bulkDeleteExpenses(req: Request, res: Response, next: NextFunction) {
- const { tenantId, user } = req;
- const { ids: expensesIds } = req.query;
-
- try {
- await this.expensesService.deleteBulkExpenses(
- tenantId,
- expensesIds,
- user
- );
- return res.status(200).send({
- ids: expensesIds,
- message: 'The expenses have been deleted successfully.',
- });
- } catch (error) {
- next(error);
- }
- }
-
- /**
- * Publishes the given expenses in bulk.
- * @param {Request} req
- * @param {Response} res
- * @param {NextFunction} next
- */
- async bulkPublishExpenses(req: Request, res: Response, next: NextFunction) {
- const { tenantId, user } = req;
- const { ids: expensesIds } = req.query;
-
- try {
- const {
- meta: { alreadyPublished, published, total },
- } = await this.expensesService.publishBulkExpenses(
- tenantId,
- expensesIds,
- user
- );
- return res.status(200).send({
- ids: expensesIds,
- message: 'The expenses have been published successfully.',
- meta: {
- alreadyPublished,
- published,
- total,
- },
- });
- } catch (error) {
- next(error);
- }
- }
-
/**
* Retrieve expneses list.
* @param {Request} req
diff --git a/server/src/api/controllers/InviteUsers.ts b/server/src/api/controllers/InviteUsers.ts
index 1a78e41c1..0b22269bd 100644
--- a/server/src/api/controllers/InviteUsers.ts
+++ b/server/src/api/controllers/InviteUsers.ts
@@ -1,5 +1,5 @@
import { Service, Inject } from 'typedi';
-import { Router, Request, Response } from 'express';
+import { Router, Request, Response, NextFunction } from 'express';
import { check, body, param } from 'express-validator';
import { IInviteUserInput } from 'interfaces';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
@@ -25,6 +25,15 @@ export default class InviteUsersController extends BaseController {
asyncMiddleware(this.sendInvite.bind(this)),
this.handleServicesError
);
+ router.post(
+ '/resend/:userId',
+ [
+ param('userId').exists().isNumeric().toInt()
+ ],
+ this.validationResult,
+ this.asyncMiddleware(this.resendInvite.bind(this)),
+ this.handleServicesError
+ );
return router;
}
@@ -67,9 +76,9 @@ export default class InviteUsersController extends BaseController {
/**
* Invite a user to the authorized user organization.
- * @param {Request} req -
- * @param {Response} res -
- * @param {NextFunction} next -
+ * @param {Request} req - Request object.
+ * @param {Response} res - Response object.
+ * @param {NextFunction} next - Next function.
*/
async sendInvite(req: Request, res: Response, next: Function) {
const { email } = req.body;
@@ -90,7 +99,29 @@ export default class InviteUsersController extends BaseController {
} catch (error) {
next(error);
}
- return res.status(200).send();
+ }
+
+ /**
+ * Resend the user invite.
+ * @param {Request} req - Request object.
+ * @param {Response} res - Response object.
+ * @param {NextFunction} next - Next function.
+ */
+ async resendInvite(req: Request, res: Response, next: NextFunction) {
+ const { tenantId, user } = req;
+ const { userId } = req.params;
+
+ try {
+ await this.inviteUsersService.resendInvite(tenantId, userId, user);
+
+ return res.status(200).send({
+ type: 'success',
+ code: 'INVITE.RESEND.SUCCESSFULLY',
+ message: 'The invite has been sent to the given email.',
+ });
+ } catch (error) {
+ next(error);
+ }
}
/**
@@ -151,38 +182,59 @@ export default class InviteUsersController extends BaseController {
if (error instanceof ServiceError) {
if (error.errorType === 'EMAIL_EXISTS') {
return res.status(400).send({
- errors: [{
- type: 'EMAIL.ALREADY.EXISTS',
- code: 100,
- message: 'Email already exists in the users.'
- }],
+ errors: [
+ {
+ type: 'EMAIL.ALREADY.EXISTS',
+ code: 100,
+ message: 'Email already exists in the users.',
+ },
+ ],
});
}
if (error.errorType === 'EMAIL_ALREADY_INVITED') {
return res.status(400).send({
- errors: [{
- type: 'EMAIL.ALREADY.INVITED',
- code: 200,
- message: 'Email already invited.',
- }],
+ errors: [
+ {
+ type: 'EMAIL.ALREADY.INVITED',
+ code: 200,
+ message: 'Email already invited.',
+ },
+ ],
});
}
if (error.errorType === 'INVITE_TOKEN_INVALID') {
return res.status(400).send({
- errors: [{
- type: 'INVITE.TOKEN.INVALID',
- code: 300,
- message: 'Invite token is invalid, please try another one.',
- }],
+ errors: [
+ {
+ type: 'INVITE.TOKEN.INVALID',
+ code: 300,
+ message: 'Invite token is invalid, please try another one.',
+ },
+ ],
});
}
if (error.errorType === 'PHONE_NUMBER_EXISTS') {
return res.status(400).send({
- errors: [{
- type: 'PHONE_NUMBER.EXISTS',
- code: 400,
- message: 'Phone number is already invited, please try another unique one.'
- }],
+ errors: [
+ {
+ type: 'PHONE_NUMBER.EXISTS',
+ code: 400,
+ message:
+ 'Phone number is already invited, please try another unique one.',
+ },
+ ],
+ });
+ }
+ if (error.errorType === 'USER_RECENTLY_INVITED') {
+ return res.status(400).send({
+ errors: [
+ {
+ type: 'USER_RECENTLY_INVITED',
+ code: 500,
+ message:
+ 'This person was recently invited. No need to invite them again just yet.',
+ },
+ ],
});
}
}
diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts
index c3bbe9946..0d1a11a1f 100644
--- a/server/src/interfaces/Expenses.ts
+++ b/server/src/interfaces/Expenses.ts
@@ -84,24 +84,6 @@ export interface IExpensesService {
authorizedUser: ISystemUser
): Promise;
- deleteBulkExpenses(
- tenantId: number,
- expensesIds: number[],
- authorizedUser: ISystemUser
- ): Promise;
-
- publishBulkExpenses(
- tenantId: number,
- expensesIds: number[],
- authorizedUser: ISystemUser
- ): Promise<{
- meta: {
- alreadyPublished: number;
- published: number;
- total: number,
- },
- }>;
-
getExpensesList(
tenantId: number,
expensesFilter: IExpensesFilter
diff --git a/server/src/interfaces/User.ts b/server/src/interfaces/User.ts
index 162d1b879..7df463af5 100644
--- a/server/src/interfaces/User.ts
+++ b/server/src/interfaces/User.ts
@@ -1,45 +1,67 @@
import { Model } from 'objection';
export interface ISystemUser extends Model {
- id: number,
- firstName: string,
- lastName: string,
- active: boolean,
- password: string,
- email: string,
- phoneNumber: string,
+ id: number;
+ firstName: string;
+ lastName: string;
+ active: boolean;
+ password: string;
+ email: string;
+ phoneNumber: string;
- roleId: number,
- tenantId: number,
+ roleId: number;
+ tenantId: number;
- inviteAcceptAt: Date,
- lastLoginAt: Date,
- deletedAt: Date,
+ inviteAcceptAt: Date;
+ lastLoginAt: Date;
+ deletedAt: Date;
- createdAt: Date,
- updatedAt: Date,
+ createdAt: Date;
+ updatedAt: Date;
}
export interface ISystemUserDTO {
- firstName: string,
- lastName: string,
- password: string,
- phoneNumber: string,
- active: boolean,
- email: string,
+ firstName: string;
+ lastName: string;
+ password: string;
+ phoneNumber: string;
+ active: boolean;
+ email: string;
}
export interface IInviteUserInput {
- firstName: string,
- lastName: string,
- phoneNumber: string,
- password: string,
-};
+ firstName: string;
+ lastName: string;
+ phoneNumber: string;
+ password: string;
+}
export interface IUserInvite {
- id: number,
- email: string,
- token: string,
- tenantId: number,
- createdAt?: Date,
-}
\ No newline at end of file
+ id: number;
+ email: string;
+ token: string;
+ tenantId: number;
+ userId: number;
+ createdAt?: Date;
+}
+
+export interface IInviteUserService {
+ acceptInvite(token: string, inviteUserInput: IInviteUserInput): Promise;
+ resendInvite(
+ tenantId: number,
+ userId: number,
+ authorizedUser: ISystemUser
+ ): Promise<{
+ invite: IUserInvite;
+ }>;
+ sendInvite(
+ tenantId: number,
+ email: string,
+ authorizedUser: ISystemUser
+ ): Promise<{
+ invite: IUserInvite;
+ }>;
+ checkInvite(
+ token: string
+ ): Promise<{ inviteToken: IUserInvite; orgName: object }>;
+}
diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts
index f7d6856bc..9770f767b 100644
--- a/server/src/services/Authentication/index.ts
+++ b/server/src/services/Authentication/index.ts
@@ -177,7 +177,8 @@ export default class AuthenticationService implements IAuthenticationService {
...omit(registerDTO, 'country'),
active: true,
password: hashedPassword,
- tenant_id: tenant.id,
+ tenantId: tenant.id,
+ inviteAcceptedAt: moment().format('YYYY-MM-DD'),
});
// Triggers `onRegister` event.
this.eventDispatcher.dispatch(events.auth.register, {
diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts
index 659cdbc35..13c239952 100644
--- a/server/src/services/Expenses/ExpensesService.ts
+++ b/server/src/services/Expenses/ExpensesService.ts
@@ -570,48 +570,6 @@ export default class ExpensesService implements IExpensesService {
});
}
- /**
- * Deletes the given expenses in bulk.
- * @param {number} tenantId
- * @param {number[]} expensesIds
- * @param {ISystemUser} authorizedUser
- */
- public async deleteBulkExpenses(
- tenantId: number,
- expensesIds: number[],
- authorizedUser: ISystemUser
- ) {
- const {
- expenseRepository,
- expenseEntryRepository,
- } = this.tenancy.repositories(tenantId);
-
- // Retrieve olds expenses.
- const oldExpenses = await this.getExpensesOrThrowError(
- tenantId,
- expensesIds
- );
-
- this.logger.info('[expense] trying to delete the given expenses.', {
- tenantId,
- expensesIds,
- });
- await expenseEntryRepository.deleteWhereIn('expenseId', expensesIds);
- await expenseRepository.deleteWhereIdIn(expensesIds);
-
- this.logger.info('[expense] the given expenses deleted successfully.', {
- tenantId,
- expensesIds,
- });
- // Triggers `onExpenseBulkDeleted` event.
- this.eventDispatcher.dispatch(events.expenses.onBulkDeleted, {
- tenantId,
- expensesIds,
- oldExpenses,
- authorizedUser,
- });
- }
-
/**
* Filters the not published expenses.
* @param {IExpense[]} expenses -
@@ -629,74 +587,6 @@ export default class ExpensesService implements IExpensesService {
return expenses.filter((expense) => expense.publishedAt);
}
- /**
- * Deletes the given expenses in bulk.
- * @param {number} tenantId
- * @param {number[]} expensesIds
- * @param {ISystemUser} authorizedUser
- */
- public async publishBulkExpenses(
- tenantId: number,
- expensesIds: number[],
- authorizedUser: ISystemUser
- ): Promise<{
- meta: {
- alreadyPublished: number;
- published: number;
- total: number,
- },
- }> {
- const oldExpenses = await this.getExpensesOrThrowError(
- tenantId,
- expensesIds
- );
- const { expenseRepository } = this.tenancy.repositories(tenantId);
-
- // Filters the not published expenses.
- const notPublishedExpenses = this.getNonePublishedExpenses(oldExpenses);
-
- // Filters the published expenses.
- const publishedExpenses = this.getPublishedExpenses(oldExpenses);
-
- // Mappes the not-published expenses to get id.
- const notPublishedExpensesIds = map(notPublishedExpenses, 'id');
-
- if (notPublishedExpensesIds.length > 0) {
- this.logger.info('[expense] trying to publish the given expenses.', {
- tenantId,
- expensesIds,
- });
- await expenseRepository.whereIdInPublish(notPublishedExpensesIds);
-
- this.logger.info(
- '[expense] the given expenses ids published successfully.',
- { tenantId, expensesIds }
- );
- }
- // Retrieve the new expenses after modification.
- const expenses = await expenseRepository.findWhereIn(
- 'id',
- expensesIds,
- 'categories'
- );
- // Triggers `onExpenseBulkDeleted` event.
- this.eventDispatcher.dispatch(events.expenses.onBulkPublished, {
- tenantId,
- expensesIds,
- oldExpenses,
- expenses,
- authorizedUser,
- });
-
- return {
- meta: {
- alreadyPublished: publishedExpenses.length,
- published: notPublishedExpenses.length,
- total: oldExpenses.length,
- },
- };
- }
-
/**
* Retrieve expenses datatable lsit.
* @param {number} tenantId
diff --git a/server/src/services/InviteUsers/constants.ts b/server/src/services/InviteUsers/constants.ts
new file mode 100644
index 000000000..1e0516917
--- /dev/null
+++ b/server/src/services/InviteUsers/constants.ts
@@ -0,0 +1,11 @@
+
+
+export const ERRORS = {
+ EMAIL_ALREADY_INVITED: 'EMAIL_ALREADY_INVITED',
+ INVITE_TOKEN_INVALID: 'INVITE_TOKEN_INVALID',
+ PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS',
+ USER_NOT_FOUND: 'USER_NOT_FOUND',
+ EMAIL_EXISTS: 'EMAIL_EXISTS',
+ EMAIL_NOT_EXISTS: 'EMAIL_NOT_EXISTS',
+ USER_RECENTLY_INVITED: 'USER_RECENTLY_INVITED',
+};
\ No newline at end of file
diff --git a/server/src/services/InviteUsers/index.ts b/server/src/services/InviteUsers/index.ts
index d903f6334..6fbf79d23 100644
--- a/server/src/services/InviteUsers/index.ts
+++ b/server/src/services/InviteUsers/index.ts
@@ -12,17 +12,17 @@ import { hashPassword } from 'utils';
import TenancyService from 'services/Tenancy/TenancyService';
import InviteUsersMailMessages from 'services/InviteUsers/InviteUsersMailMessages';
import events from 'subscribers/events';
-import { ISystemUser, IInviteUserInput, IUserInvite } from 'interfaces';
+import {
+ ISystemUser,
+ IInviteUserInput,
+ IUserInvite,
+ IInviteUserService,
+} from 'interfaces';
import TenantsManagerService from 'services/Tenancy/TenantsManager';
+import { ERRORS } from './constants';
-const ERRORS = {
- EMAIL_ALREADY_INVITED: 'EMAIL_ALREADY_INVITED',
- INVITE_TOKEN_INVALID: 'INVITE_TOKEN_INVALID',
- PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS',
- EMAIL_EXISTS: 'EMAIL_EXISTS'
-};
@Service()
-export default class InviteUserService {
+export default class InviteUserService implements IInviteUserService {
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
@@ -41,60 +41,6 @@ export default class InviteUserService {
@Inject()
tenantsManager: TenantsManagerService;
- /**
- * Accept the received invite.
- * @param {string} token
- * @param {IInviteUserInput} inviteUserInput
- * @throws {ServiceErrors}
- * @returns {Promise}
- */
- async acceptInvite(
- token: string,
- inviteUserInput: IInviteUserInput
- ): Promise {
- const { systemUserRepository } = this.sysRepositories;
-
- // Retrieve the invite token or throw not found error.
- const inviteToken = await this.getInviteOrThrowError(token);
-
- // Validates the user phone number.
- await this.validateUserPhoneNumber(inviteUserInput);
-
- this.logger.info('[aceept_invite] trying to hash the user password.');
- const hashedPassword = await hashPassword(inviteUserInput.password);
-
- this.logger.info('[accept_invite] trying to update user details.');
- const user = await systemUserRepository.findOneByEmail(inviteToken.email);
-
- // Sets the invited user details after invite accepting.
- const systemUserOper = systemUserRepository.create(
- {
- ...inviteUserInput,
- email: inviteToken.email,
- tenantId: inviteToken.tenantId,
- active: 1,
- inviteAcceptedAt: moment().format('YYYY-MM-DD'),
- password: hashedPassword,
- },
- );
-
- this.logger.info('[accept_invite] trying to delete the given token.');
- const deleteInviteTokenOper = Invite.query()
- .where('token', inviteToken.token)
- .delete();
-
- // Await all async operations.
- const [systemUser] = await Promise.all([
- systemUserOper,
- deleteInviteTokenOper,
- ]);
- // Triggers `onUserAcceptInvite` event.
- this.eventDispatcher.dispatch(events.inviteUser.acceptInvite, {
- inviteToken,
- user: systemUser,
- });
- }
-
/**
* Sends invite mail to the given email from the given tenant and user.
* @param {number} tenantId -
@@ -110,27 +56,120 @@ export default class InviteUserService {
): Promise<{
invite: IUserInvite;
}> {
- // Throw error in case user email exists.
- await this.throwErrorIfUserEmailExists(email);
+ const { systemUserRepository } = this.sysRepositories;
- // Throws service error in case the user already invited.
- await this.throwErrorIfUserInvited(email);
+ // Validates the given email not exists on the storage.
+ await this.validateUserEmailNotExists(email);
- this.logger.info('[send_invite] trying to store invite token.');
+ this.logger.info('[invite] trying to store user with email and tenant.', {
+ email,
+ });
+ const user = await systemUserRepository.create({
+ email,
+ tenantId,
+ active: 1,
+ });
+
+ this.logger.info('[invite] trying to store invite token.', { email });
const invite = await Invite.query().insert({
email,
- tenant_id: authorizedUser.tenantId,
+ tenantId: authorizedUser.tenantId,
+ userId: user.id,
token: uniqid(),
});
- this.logger.info(
- '[send_invite] trying to store user with email and tenant.'
- );
// Triggers `onUserSendInvite` event.
this.eventDispatcher.dispatch(events.inviteUser.sendInvite, {
invite,
authorizedUser,
tenantId,
+ user,
+ });
+ return { invite };
+ }
+
+ /**
+ * Accept the received invite.
+ * @param {string} token
+ * @param {IInviteUserInput} inviteUserInput
+ * @throws {ServiceErrors}
+ * @returns {Promise}
+ */
+ public async acceptInvite(
+ token: string,
+ inviteUserInput: IInviteUserInput
+ ): Promise {
+ const { systemUserRepository } = this.sysRepositories;
+
+ // Retrieve the invite token or throw not found error.
+ const inviteToken = await this.getInviteTokenOrThrowError(token);
+
+ // Validates the user phone number.
+ await this.validateUserPhoneNumberNotExists(inviteUserInput.phoneNumber);
+
+ this.logger.info('[invite] trying to hash the user password.');
+ const hashedPassword = await hashPassword(inviteUserInput.password);
+
+ this.logger.info('[invite] trying to update user details.');
+ const user = await systemUserRepository.findOneByEmail(inviteToken.email);
+
+ // Sets the invited user details after invite accepting.
+ const systemUser = await systemUserRepository.update(
+ {
+ ...inviteUserInput,
+ inviteAcceptedAt: moment().format('YYYY-MM-DD'),
+ password: hashedPassword,
+ },
+ { id: inviteToken.userId }
+ );
+ // Clear invite token by the given user id.
+ await this.clearInviteTokensByUserId(inviteToken.userId);
+
+ // Triggers `onUserAcceptInvite` event.
+ this.eventDispatcher.dispatch(events.inviteUser.acceptInvite, {
+ inviteToken,
+ user: systemUser,
+ });
+ }
+
+ /**
+ * Re-send user invite.
+ * @param tenantId
+ * @param {string} email
+ * @return {Promise<{ invite: IUserInvite }>}
+ */
+ public async resendInvite(
+ tenantId: number,
+ userId: number,
+ authorizedUser: ISystemUser
+ ): Promise<{
+ invite: IUserInvite;
+ }> {
+ // Retrieve the user by id or throw not found service error.
+ const user = this.getUserByIdOrThrowError(userId);
+
+ // Validate invite user active
+ await this.validateInviteUserNotActive(tenantId, userId);
+
+ // Clear all invite tokens of the given user id.
+ await this.clearInviteTokensByUserId(userId);
+
+ this.logger.info('[invite] trying to store invite token.', {
+ userId,
+ tenantId,
+ });
+ const invite = await Invite.query().insert({
+ email: user.email,
+ tenantId,
+ userId,
+ token: uniqid(),
+ });
+ // Triggers `onUserSendInvite` event.
+ this.eventDispatcher.dispatch(events.inviteUser.sendInvite, {
+ invite,
+ authorizedUser,
+ tenantId,
+ user,
});
return { invite };
}
@@ -143,7 +182,7 @@ export default class InviteUserService {
public async checkInvite(
token: string
): Promise<{ inviteToken: IUserInvite; orgName: object }> {
- const inviteToken = await this.getInviteOrThrowError(token);
+ const inviteToken = await this.getInviteTokenOrThrowError(token);
// Find the tenant that associated to the given token.
const tenant = await Tenant.query().findById(inviteToken.tenantId);
@@ -166,13 +205,48 @@ export default class InviteUserService {
return { inviteToken, orgName };
}
+ /**
+ * Validate the given user has no active invite token.
+ * @param {number} tenantId
+ * @param {number} userId - User id.
+ */
+ private async validateInviteUserNotActive(tenantId: number, userId: number) {
+ // Retrieve the invite token or throw not found error.
+ const inviteTokens = await Invite.query()
+ .modify('notExpired')
+ .where('user_id', userId);
+
+ // Throw the error if the one invite tokens is still active.
+ if (inviteTokens.length > 0) {
+ this.logger.info('[invite] email is already invited.', {
+ userId,
+ tenantId,
+ });
+ throw new ServiceError(ERRORS.USER_RECENTLY_INVITED);
+ }
+ }
+
+ /**
+ * Retrieve the given user by id or throw not found service error.
+ * @param {number} userId - User id.
+ */
+ private async getUserByIdOrThrowError(userId: number) {
+ const { systemUserRepository } = this.sysRepositories;
+ const user = await systemUserRepository.findOneById(userId);
+
+ // Throw if the user not found.
+ if (!user) {
+ throw new ServiceError(ERRORS.USER_NOT_FOUND);
+ }
+ return user;
+ }
+
/**
* Throws error in case the given user email not exists on the storage.
* @param {string} email
+ * @throws {ServiceError}
*/
- private async throwErrorIfUserEmailExists(
- email: string
- ): Promise {
+ private async validateUserEmailNotExists(email: string): Promise {
const { systemUserRepository } = this.sysRepositories;
const foundUser = await systemUserRepository.findOneByEmail(email);
@@ -181,31 +255,21 @@ export default class InviteUserService {
}
}
- /**
- * Throws service error if the user already invited.
- * @param {string} email -
- */
- private async throwErrorIfUserInvited(
- email: string,
- ): Promise {
- const inviteToken = await Invite.query().findOne('email', email);
-
- if (inviteToken) {
- throw new ServiceError(ERRORS.EMAIL_ALREADY_INVITED);
- }
- }
-
/**
* Retrieve invite model from the given token or throw error.
* @param {string} token - Then given token string.
* @throws {ServiceError}
* @returns {Invite}
*/
- private async getInviteOrThrowError(token: string): Promise {
- const inviteToken = await Invite.query().findOne('token', token);
+ private async getInviteTokenOrThrowError(
+ token: string
+ ): Promise {
+ const inviteToken = await Invite.query()
+ .modify('notExpired')
+ .findOne('token', token);
if (!inviteToken) {
- this.logger.info('[aceept_invite] the invite token is invalid.');
+ this.logger.info('[invite] the invite token is invalid.');
throw new ServiceError(ERRORS.INVITE_TOKEN_INVALID);
}
return inviteToken;
@@ -215,15 +279,24 @@ export default class InviteUserService {
* Validate the given user email and phone number uniquine.
* @param {IInviteUserInput} inviteUserInput
*/
- private async validateUserPhoneNumber(
- inviteUserInput: IInviteUserInput
+ private async validateUserPhoneNumberNotExists(
+ phoneNumber: string
): Promise {
const { systemUserRepository } = this.sysRepositories;
const foundUser = await systemUserRepository.findOneByPhoneNumber(
- inviteUserInput.phoneNumber
+ phoneNumber
);
if (foundUser) {
throw new ServiceError(ERRORS.PHONE_NUMBER_EXISTS);
}
}
+
+ /**
+ * Clear invite tokens of the given user id.
+ * @param {number} userId - User id.
+ */
+ private async clearInviteTokensByUserId(userId: number) {
+ this.logger.info('[invite] trying to delete the given token.');
+ await Invite.query().where('user_id', userId).delete();
+ }
}
diff --git a/server/src/services/Users/UsersService.ts b/server/src/services/Users/UsersService.ts
index 22e623689..0d3bf4eef 100644
--- a/server/src/services/Users/UsersService.ts
+++ b/server/src/services/Users/UsersService.ts
@@ -113,7 +113,7 @@ export default class UsersService {
// Throw serivce error if the user is already activated.
this.throwErrorIfUserActive(user);
- await systemUserRepository.activateUser(userId);
+ await systemUserRepository.activateById(userId);
}
/**
diff --git a/server/src/system/migrations/20200422225247_create_user_invites_table.js b/server/src/system/migrations/20200422225247_create_user_invites_table.js
index abb723b20..42028ccc7 100644
--- a/server/src/system/migrations/20200422225247_create_user_invites_table.js
+++ b/server/src/system/migrations/20200422225247_create_user_invites_table.js
@@ -5,6 +5,7 @@ exports.up = function(knex) {
table.string('email').index();
table.string('token').unique().index();
table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants');
+ table.integer('user_id').unsigned().index().references('id').inTable('users');
table.datetime('created_at');
});
};
diff --git a/server/src/system/models/Invite.js b/server/src/system/models/Invite.js
index bb226f88b..2de707735 100644
--- a/server/src/system/models/Invite.js
+++ b/server/src/system/models/Invite.js
@@ -1,4 +1,5 @@
import SystemModel from 'system/models/SystemModel';
+import moment from 'moment';
export default class UserInvite extends SystemModel {
/**
@@ -14,4 +15,16 @@ export default class UserInvite extends SystemModel {
get timestamps() {
return ['createdAt'];
}
+
+ /**
+ * Model modifiers.
+ */
+ static get modifiers() {
+ return {
+ notExpired(query) {
+ const comp = moment().subtract(24, 'hours').toMySqlDateTime();
+ query.where('created_at', '>=', comp);
+ }
+ }
+ }
}
diff --git a/server/src/system/models/SystemUser.js b/server/src/system/models/SystemUser.js
index a1f5bed74..a314240e2 100644
--- a/server/src/system/models/SystemUser.js
+++ b/server/src/system/models/SystemUser.js
@@ -29,7 +29,21 @@ export default class SystemUser extends SystemModel {
* Virtual attributes.
*/
static get virtualAttributes() {
- return ['fullName'];
+ return ['fullName', 'isDeleted', 'isInviteAccepted'];
+ }
+
+ /**
+ *
+ */
+ get isDeleted() {
+ return !!this.deletedAt;
+ }
+
+ /**
+ *
+ */
+ get isInviteAccepted() {
+ return !!this.inviteAcceptedAt;
}
/**