refactor: Extract Invitation routes to InvitationController (no-changelog) (#7726)

This PR:

- Creates `InvitationController`
- Moves `POST /users` to `POST /invitations` and move related test to
`invitations.api.tests`
- Moves `POST /users/:id` to `POST /invitations/:id/accept` and move
related test to `invitations.api.tests`
- Adjusts FE to use new endpoints
- Moves all the invitation logic to the `UserService`

---------

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ricardo Espinoza
2023-11-16 12:39:43 -05:00
committed by GitHub
parent e2ffd397fc
commit 8e0ae3cf8c
17 changed files with 713 additions and 624 deletions

View File

@@ -129,11 +129,11 @@ import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { MfaService } from './Mfa/mfa.service';
import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers';
import type { FrontendService } from './services/frontend.service';
import { JwtService } from './services/jwt.service';
import { RoleService } from './services/role.service';
import { UserService } from './services/user.service';
import { OrchestrationController } from './controllers/orchestration.controller';
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
import { InvitationController } from './controllers/invitation.controller';
const exec = promisify(callbackExec);
@@ -259,7 +259,6 @@ export class Server extends AbstractServer {
const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer);
const userService = Container.get(UserService);
const jwtService = Container.get(JwtService);
const postHog = this.postHog;
const mfaService = Container.get(MfaService);
@@ -283,18 +282,14 @@ export class Server extends AbstractServer {
Container.get(TagsController),
new TranslationController(config, this.credentialTypes),
new UsersController(
config,
logger,
externalHooks,
internalHooks,
Container.get(SharedCredentialsRepository),
Container.get(SharedWorkflowRepository),
activeWorkflowRunner,
mailer,
jwtService,
Container.get(RoleService),
userService,
postHog,
),
Container.get(SamlController),
Container.get(SourceControlController),
@@ -303,6 +298,14 @@ export class Server extends AbstractServer {
Container.get(OrchestrationController),
Container.get(WorkflowHistoryController),
Container.get(BinaryDataController),
new InvitationController(
config,
logger,
internalHooks,
externalHooks,
Container.get(UserService),
postHog,
),
];
if (isLdapEnabled()) {

View File

@@ -0,0 +1,166 @@
import { In } from 'typeorm';
import Container, { Service } from 'typedi';
import { Authorized, NoAuthRequired, Post, RestController } from '@/decorators';
import { BadRequestError, UnauthorizedError } from '@/ResponseHelper';
import { issueCookie } from '@/auth/jwt';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Response } from 'express';
import { UserRequest } from '@/requests';
import { Config } from '@/config';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import { License } from '@/License';
import { UserService } from '@/services/user.service';
import { Logger } from '@/Logger';
import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers';
import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper';
import { PostHogClient } from '@/posthog';
import type { User } from '@/databases/entities/User';
import validator from 'validator';
@Service()
@RestController('/invitations')
export class InvitationController {
constructor(
private readonly config: Config,
private readonly logger: Logger,
private readonly internalHooks: IInternalHooksClass,
private readonly externalHooks: IExternalHooksClass,
private readonly userService: UserService,
private readonly postHog?: PostHogClient,
) {}
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
@Authorized(['global', 'owner'])
@Post('/')
async inviteUser(req: UserRequest.Invite) {
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
if (isSamlLicensedAndEnabled()) {
this.logger.debug(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
throw new BadRequestError(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
}
if (!isWithinUsersLimit) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);
throw new BadRequestError('You must set up your own account before inviting others');
}
if (!Array.isArray(req.body)) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
this.logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
});
const emails = req.body.map((e) => e.email);
const { usersInvited, usersCreated } = await this.userService.inviteMembers(req.user, emails);
await this.externalHooks.run('user.invited', [usersCreated]);
return usersInvited;
}
/**
* Fill out user shell with first name, last name, and password.
*/
@NoAuthRequired()
@Post('/:id/accept')
async acceptInvitation(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
this.logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new BadRequestError('Invalid payload');
}
const validPassword = validatePassword(password);
const users = await this.userService.findMany({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) {
this.logger.debug(
'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database',
{
inviterId,
inviteeId,
},
);
throw new BadRequestError('Invalid payload or URL');
}
const invitee = users.find((user) => user.id === inviteeId) as User;
if (invitee.password) {
this.logger.debug(
'Request to fill out a user shell failed because the invite had already been accepted',
{ inviteeId },
);
throw new BadRequestError('This invite has been accepted already');
}
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await this.userService.save(invitee);
await issueCookie(res, updatedUser);
void this.internalHooks.onUserSignup(updatedUser, {
user_type: 'email',
was_disabled_ldap_user: false,
});
const publicInvitee = await this.userService.toPublic(invitee);
await this.externalHooks.run('user.profile.update', [invitee.email, publicInvitee]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return this.userService.toPublic(updatedUser, { posthog: this.postHog });
}
}

View File

@@ -1,41 +1,18 @@
import validator from 'validator';
import type { FindManyOptions } from 'typeorm';
import { In, Not } from 'typeorm';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators';
import {
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import {
BadRequestError,
InternalServerError,
NotFoundError,
UnauthorizedError,
} from '@/ResponseHelper';
import { Response } from 'express';
import { Authorized, Delete, Get, RestController, Patch } from '@/decorators';
import { BadRequestError, NotFoundError } from '@/ResponseHelper';
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { UserManagementMailer } from '@/UserManagement/email';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { Config } from '@/config';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import { PostHogClient } from '@/posthog';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { plainToInstance } from 'class-transformer';
import { License } from '@/License';
import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { JwtService } from '@/services/jwt.service';
import { RoleService } from '@/services/role.service';
import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
@@ -45,277 +22,16 @@ import { Logger } from '@/Logger';
@RestController('/users')
export class UsersController {
constructor(
private readonly config: Config,
private readonly logger: Logger,
private readonly externalHooks: IExternalHooksClass,
private readonly internalHooks: IInternalHooksClass,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly activeWorkflowRunner: ActiveWorkflowRunner,
private readonly mailer: UserManagementMailer,
private readonly jwtService: JwtService,
private readonly roleService: RoleService,
private readonly userService: UserService,
private readonly postHog?: PostHogClient,
) {}
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
@Post('/')
async sendEmailInvites(req: UserRequest.Invite) {
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
if (isSamlLicensedAndEnabled()) {
this.logger.debug(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
throw new BadRequestError(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
}
if (!isWithinUsersLimit) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);
throw new BadRequestError('You must set up your own account before inviting others');
}
if (!Array.isArray(req.body)) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
const createUsers: { [key: string]: string | null } = {};
// Validate payload
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
this.logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
createUsers[invite.email.toLowerCase()] = null;
});
const role = await this.roleService.findGlobalMemberRole();
if (!role) {
this.logger.error(
'Request to send email invite(s) to user(s) failed because no global member role was found in database',
);
throw new InternalServerError('Members role not found in database - inconsistent state');
}
// remove/exclude existing users from creation
const existingUsers = await this.userService.findMany({
where: { email: In(Object.keys(createUsers)) },
relations: ['globalRole'],
});
existingUsers.forEach((user) => {
if (user.password) {
delete createUsers[user.email];
return;
}
createUsers[user.email] = user.id;
});
const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null);
const total = usersToSetUp.length;
this.logger.debug(total > 1 ? `Creating ${total} user shells...` : 'Creating 1 user shell...');
try {
await this.userService.getManager().transaction(async (transactionManager) =>
Promise.all(
usersToSetUp.map(async (email) => {
const newUser = Object.assign(new User(), {
email,
globalRole: role,
});
const savedUser = await transactionManager.save<User>(newUser);
createUsers[savedUser.email] = savedUser.id;
return savedUser;
}),
),
);
} catch (error) {
ErrorReporter.error(error);
this.logger.error('Failed to create user shells', { userShells: createUsers });
throw new InternalServerError('An error occurred during user creation');
}
this.logger.debug('Created user shell(s) successfully', { userId: req.user.id });
this.logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
userShells: createUsers,
});
const baseUrl = getInstanceBaseUrl();
const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email);
// send invite email to new or not yet setup users
const emailingResults = await Promise.all(
usersPendingSetup.map(async ([email, id]) => {
if (!id) {
// This should never happen since those are removed from the list before reaching this point
throw new InternalServerError('User ID is missing for user with email address');
}
const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id);
const resp: {
user: { id: string | null; email: string; inviteAcceptUrl?: string; emailSent: boolean };
error?: string;
} = {
user: {
id,
email,
inviteAcceptUrl,
emailSent: false,
},
};
try {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
resp.user.emailSent = true;
delete resp.user.inviteAcceptUrl;
void this.internalHooks.onUserTransactionalEmail({
user_id: id,
message_type: 'New user invite',
public_api: false,
});
}
void this.internalHooks.onUserInvite({
user: req.user,
target_user_id: Object.values(createUsers) as string[],
public_api: false,
email_sent: result.emailSent,
});
} catch (error) {
if (error instanceof Error) {
void this.internalHooks.onEmailFailed({
user: req.user,
message_type: 'New user invite',
public_api: false,
});
this.logger.error('Failed to send email', {
userId: req.user.id,
inviteAcceptUrl,
domain: baseUrl,
email,
});
resp.error = error.message;
}
}
return resp;
}),
);
await this.externalHooks.run('user.invited', [usersToSetUp]);
this.logger.debug(
usersPendingSetup.length > 1
? `Sent ${usersPendingSetup.length} invite emails successfully`
: 'Sent 1 invite email successfully',
{ userShells: createUsers },
);
return emailingResults;
}
/**
* Fill out user shell with first name, last name, and password.
*/
@NoAuthRequired()
@Post('/:id')
async updateUser(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
this.logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new BadRequestError('Invalid payload');
}
const validPassword = validatePassword(password);
const users = await this.userService.findMany({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) {
this.logger.debug(
'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database',
{
inviterId,
inviteeId,
},
);
throw new BadRequestError('Invalid payload or URL');
}
const invitee = users.find((user) => user.id === inviteeId) as User;
if (invitee.password) {
this.logger.debug(
'Request to fill out a user shell failed because the invite had already been accepted',
{ inviteeId },
);
throw new BadRequestError('This invite has been accepted already');
}
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await this.userService.save(invitee);
await issueCookie(res, updatedUser);
void this.internalHooks.onUserSignup(updatedUser, {
user_type: 'email',
was_disabled_ldap_user: false,
});
const publicInvitee = await this.userService.toPublic(invitee);
await this.externalHooks.run('user.profile.update', [invitee.email, publicInvitee]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return this.userService.toPublic(updatedUser, { posthog: this.postHog });
}
private async toFindManyOptions(listQueryOptions?: ListQuery.Options) {
const findManyOptions: FindManyOptions<User> = {};

View File

@@ -60,10 +60,10 @@ const staticAssets = globSync(['**/*.html', '**/*.svg', '**/*.png', '**/*.ico'],
});
// TODO: delete this
const isPostUsersId = (req: Request, restEndpoint: string): boolean =>
const isPostInvitationAccept = (req: Request, restEndpoint: string): boolean =>
req.method === 'POST' &&
new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url) &&
!req.url.includes('reinvite');
new RegExp(`/${restEndpoint}/invitations/[\\w\\d-]*`).test(req.url) &&
req.url.includes('accept');
const isAuthExcluded = (url: string, ignoredEndpoints: Readonly<string[]>): boolean =>
!!ignoredEndpoints
@@ -89,7 +89,7 @@ export const setupAuthMiddlewares = (
canSkipAuth(req.method, req.path) ||
isAuthExcluded(req.url, ignoredEndpoints) ||
req.url.startsWith(`/${restEndpoint}/settings`) ||
isPostUsersId(req, restEndpoint)
isPostInvitationAccept(req, restEndpoint)
) {
return next();
}

View File

@@ -295,6 +295,11 @@ export declare namespace PasswordResetRequest {
export declare namespace UserRequest {
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
export type InviteResponse = {
user: { id: string; email: string; inviteAcceptUrl?: string; emailSent: boolean };
error?: string;
};
export type ResolveSignUp = AuthlessRequest<
{},
{},

View File

@@ -1,16 +1,22 @@
import { Service } from 'typedi';
import Container, { Service } from 'typedi';
import type { EntityManager, FindManyOptions, FindOneOptions, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm';
import { User } from '@db/entities/User';
import type { IUserSettings } from 'n8n-workflow';
import { UserRepository } from '@db/repositories/user.repository';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import { generateUserInviteUrl, getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog';
import { type JwtPayload, JwtService } from './jwt.service';
import { TokenExpiredError } from 'jsonwebtoken';
import { Logger } from '@/Logger';
import { createPasswordSha } from '@/auth/jwt';
import { UserManagementMailer } from '@/UserManagement/email';
import { InternalHooks } from '@/InternalHooks';
import { RoleService } from '@/services/role.service';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { InternalServerError } from '@/ResponseHelper';
import type { UserRequest } from '@/requests';
@Service()
export class UserService {
@@ -18,6 +24,8 @@ export class UserService {
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly mailer: UserManagementMailer,
private readonly roleService: RoleService,
) {}
async findOne(options: FindOneOptions<User>) {
@@ -169,4 +177,114 @@ export class UserService {
return Promise.race([fetchPromise, timeoutPromise]);
}
private async sendEmails(owner: User, toInviteUsers: { [key: string]: string }) {
const domain = getInstanceBaseUrl();
return Promise.all(
Object.entries(toInviteUsers).map(async ([email, id]) => {
const inviteAcceptUrl = generateUserInviteUrl(owner.id, id);
const invitedUser: UserRequest.InviteResponse = {
user: {
id,
email,
inviteAcceptUrl,
emailSent: false,
},
error: '',
};
try {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain,
});
if (result.emailSent) {
invitedUser.user.emailSent = true;
delete invitedUser.user?.inviteAcceptUrl;
void Container.get(InternalHooks).onUserTransactionalEmail({
user_id: id,
message_type: 'New user invite',
public_api: false,
});
}
void Container.get(InternalHooks).onUserInvite({
user: owner,
target_user_id: Object.values(toInviteUsers),
public_api: false,
email_sent: result.emailSent,
});
} catch (e) {
if (e instanceof Error) {
void Container.get(InternalHooks).onEmailFailed({
user: owner,
message_type: 'New user invite',
public_api: false,
});
this.logger.error('Failed to send email', {
userId: owner.id,
inviteAcceptUrl,
domain,
email,
});
invitedUser.error = e.message;
}
}
return invitedUser;
}),
);
}
public async inviteMembers(owner: User, emails: string[]) {
const memberRole = await this.roleService.findGlobalMemberRole();
const existingUsers = await this.findMany({
where: { email: In(emails) },
relations: ['globalRole'],
select: ['email', 'password', 'id'],
});
const existUsersEmails = existingUsers.map((user) => user.email);
const toCreateUsers = emails.filter((email) => !existUsersEmails.includes(email));
const pendingUsersToInvite = existingUsers.filter((email) => email.isPending);
const createdUsers = new Map<string, string>();
this.logger.debug(
toCreateUsers.length > 1
? `Creating ${toCreateUsers.length} user shells...`
: 'Creating 1 user shell...',
);
try {
await this.getManager().transaction(async (transactionManager) =>
Promise.all(
toCreateUsers.map(async (email) => {
const newUser = Object.assign(new User(), {
email,
globalRole: memberRole,
});
const savedUser = await transactionManager.save<User>(newUser);
createdUsers.set(email, savedUser.id);
return savedUser;
}),
),
);
} catch (error) {
ErrorReporter.error(error);
this.logger.error('Failed to create user shells', { userShells: createdUsers });
throw new InternalServerError('An error occurred during user creation');
}
pendingUsersToInvite.forEach(({ email, id }) => createdUsers.set(email, id));
const usersInvited = await this.sendEmails(owner, Object.fromEntries(createdUsers));
return { usersInvited, usersCreated: toCreateUsers };
}
}