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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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 { MfaService } from './Mfa/mfa.service';
import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers';
import type { FrontendService } from './services/frontend.service'; import type { FrontendService } from './services/frontend.service';
import { JwtService } from './services/jwt.service';
import { RoleService } from './services/role.service'; import { RoleService } from './services/role.service';
import { UserService } from './services/user.service'; import { UserService } from './services/user.service';
import { OrchestrationController } from './controllers/orchestration.controller'; import { OrchestrationController } from './controllers/orchestration.controller';
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee'; import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
import { InvitationController } from './controllers/invitation.controller';
const exec = promisify(callbackExec); const exec = promisify(callbackExec);
@ -259,7 +259,6 @@ export class Server extends AbstractServer {
const internalHooks = Container.get(InternalHooks); const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer); const mailer = Container.get(UserManagementMailer);
const userService = Container.get(UserService); const userService = Container.get(UserService);
const jwtService = Container.get(JwtService);
const postHog = this.postHog; const postHog = this.postHog;
const mfaService = Container.get(MfaService); const mfaService = Container.get(MfaService);
@ -283,18 +282,14 @@ export class Server extends AbstractServer {
Container.get(TagsController), Container.get(TagsController),
new TranslationController(config, this.credentialTypes), new TranslationController(config, this.credentialTypes),
new UsersController( new UsersController(
config,
logger, logger,
externalHooks, externalHooks,
internalHooks, internalHooks,
Container.get(SharedCredentialsRepository), Container.get(SharedCredentialsRepository),
Container.get(SharedWorkflowRepository), Container.get(SharedWorkflowRepository),
activeWorkflowRunner, activeWorkflowRunner,
mailer,
jwtService,
Container.get(RoleService), Container.get(RoleService),
userService, userService,
postHog,
), ),
Container.get(SamlController), Container.get(SamlController),
Container.get(SourceControlController), Container.get(SourceControlController),
@ -303,6 +298,14 @@ export class Server extends AbstractServer {
Container.get(OrchestrationController), Container.get(OrchestrationController),
Container.get(WorkflowHistoryController), Container.get(WorkflowHistoryController),
Container.get(BinaryDataController), Container.get(BinaryDataController),
new InvitationController(
config,
logger,
internalHooks,
externalHooks,
Container.get(UserService),
postHog,
),
]; ];
if (isLdapEnabled()) { 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 type { FindManyOptions } from 'typeorm';
import { In, Not } from 'typeorm'; import { In, Not } from 'typeorm';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators'; import { Authorized, Delete, Get, RestController, Patch } from '@/decorators';
import { import { BadRequestError, NotFoundError } from '@/ResponseHelper';
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import {
BadRequestError,
InternalServerError,
NotFoundError,
UnauthorizedError,
} from '@/ResponseHelper';
import { Response } from 'express';
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests'; import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { UserManagementMailer } from '@/UserManagement/email';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { Config } from '@/config';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import { PostHogClient } from '@/posthog';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { plainToInstance } from 'class-transformer'; 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 { RoleService } from '@/services/role.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares'; import { listQueryMiddleware } from '@/middlewares';
@ -45,277 +22,16 @@ import { Logger } from '@/Logger';
@RestController('/users') @RestController('/users')
export class UsersController { export class UsersController {
constructor( constructor(
private readonly config: Config,
private readonly logger: Logger, private readonly logger: Logger,
private readonly externalHooks: IExternalHooksClass, private readonly externalHooks: IExternalHooksClass,
private readonly internalHooks: IInternalHooksClass, private readonly internalHooks: IInternalHooksClass,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly activeWorkflowRunner: ActiveWorkflowRunner, private readonly activeWorkflowRunner: ActiveWorkflowRunner,
private readonly mailer: UserManagementMailer,
private readonly jwtService: JwtService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly userService: UserService, 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) { private async toFindManyOptions(listQueryOptions?: ListQuery.Options) {
const findManyOptions: FindManyOptions<User> = {}; const findManyOptions: FindManyOptions<User> = {};

View file

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

View file

@ -295,6 +295,11 @@ export declare namespace PasswordResetRequest {
export declare namespace UserRequest { export declare namespace UserRequest {
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; 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< 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 type { EntityManager, FindManyOptions, FindOneOptions, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import type { IUserSettings } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-workflow';
import { UserRepository } from '@db/repositories/user.repository'; 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 { PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
import { type JwtPayload, JwtService } from './jwt.service'; import { type JwtPayload, JwtService } from './jwt.service';
import { TokenExpiredError } from 'jsonwebtoken'; import { TokenExpiredError } from 'jsonwebtoken';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { createPasswordSha } from '@/auth/jwt'; 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() @Service()
export class UserService { export class UserService {
@ -18,6 +24,8 @@ export class UserService {
private readonly logger: Logger, private readonly logger: Logger,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly mailer: UserManagementMailer,
private readonly roleService: RoleService,
) {} ) {}
async findOne(options: FindOneOptions<User>) { async findOne(options: FindOneOptions<User>) {
@ -169,4 +177,114 @@ export class UserService {
return Promise.race([fetchPromise, timeoutPromise]); 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 };
}
} }

View file

@ -4,7 +4,9 @@ import { getGlobalMemberRole } from './shared/db/roles';
import { createUser } from './shared/db/users'; import { createUser } from './shared/db/users';
describe('Auth Middleware', () => { describe('Auth Middleware', () => {
const testServer = utils.setupTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] }); const testServer = utils.setupTestServer({
endpointGroups: ['me', 'auth', 'owner', 'users', 'invitations'],
});
/** Routes requiring a valid `n8n-auth` cookie for a user, either owner or member. */ /** Routes requiring a valid `n8n-auth` cookie for a user, either owner or member. */
const ROUTES_REQUIRING_AUTHENTICATION: Readonly<Array<[string, string]>> = [ const ROUTES_REQUIRING_AUTHENTICATION: Readonly<Array<[string, string]>> = [
@ -17,7 +19,7 @@ describe('Auth Middleware', () => {
/** Routes requiring a valid `n8n-auth` cookie for an owner. */ /** Routes requiring a valid `n8n-auth` cookie for an owner. */
const ROUTES_REQUIRING_AUTHORIZATION: Readonly<Array<[string, string]>> = [ const ROUTES_REQUIRING_AUTHORIZATION: Readonly<Array<[string, string]>> = [
['POST', '/users'], ['POST', '/invitations'],
['DELETE', '/users/123'], ['DELETE', '/users/123'],
['POST', '/owner/setup'], ['POST', '/owner/setup'],
]; ];

View file

@ -0,0 +1,335 @@
import validator from 'validator';
import type { SuperAgentTest } from 'supertest';
import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User';
import { compareHash } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
import Container from 'typedi';
import { UserRepository } from '@db/repositories/user.repository';
import { mockInstance } from '../shared/mocking';
import {
randomEmail,
randomInvalidPassword,
randomName,
randomValidPassword,
} from './shared/random';
import * as testDb from './shared/testDb';
import * as utils from './shared/utils/';
import { getAllRoles } from './shared/db/roles';
import { createMember, createOwner, createUser, createUserShell } from './shared/db/users';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
let credentialOwnerRole: Role;
let globalMemberRole: Role;
let workflowOwnerRole: Role;
let owner: User;
let member: User;
let authOwnerAgent: SuperAgentTest;
let authlessAgent: SuperAgentTest;
mockInstance(InternalHooks);
const externalHooks = mockInstance(ExternalHooks);
const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = utils.setupTestServer({ endpointGroups: ['invitations'] });
type UserInvitationResponse = {
user: Pick<User, 'id' | 'email'> & { inviteAcceptUrl: string; emailSent: boolean };
error?: string;
};
beforeAll(async () => {
const [_, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole, fetchedCredentialOwnerRole] =
await getAllRoles();
credentialOwnerRole = fetchedCredentialOwnerRole;
globalMemberRole = fetchedGlobalMemberRole;
workflowOwnerRole = fetchedWorkflowOwnerRole;
});
beforeEach(async () => {
jest.resetAllMocks();
await testDb.truncate(['User', 'SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials']);
owner = await createOwner();
member = await createMember();
authOwnerAgent = testServer.authAgentFor(owner);
authlessAgent = testServer.authlessAgent;
});
const assertInviteUserSuccessResponse = (data: UserInvitationResponse) => {
expect(validator.isUUID(data.user.id)).toBe(true);
expect(data.user.inviteAcceptUrl).toBeUndefined();
expect(data.user.email).toBeDefined();
expect(data.user.emailSent).toBe(true);
};
const assertInviteUserErrorResponse = (data: UserInvitationResponse) => {
expect(validator.isUUID(data.user.id)).toBe(true);
expect(data.user.inviteAcceptUrl).toBeDefined();
expect(data.user.email).toBeDefined();
expect(data.user.emailSent).toBe(false);
expect(data.error).toBeDefined();
};
const assertInvitedUsersOnDb = (user: User) => {
expect(user.firstName).toBeNull();
expect(user.lastName).toBeNull();
expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeNull();
expect(user.isPending).toBe(true);
};
describe('POST /invitations/:id/accept', () => {
test('should fill out a user shell', async () => {
const memberShell = await createUserShell(globalMemberRole);
const memberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authlessAgent
.post(
`/invitations/${memberShell.id}/
accept`,
)
.send(memberData);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
password,
globalRole,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBe(memberData.firstName);
expect(lastName).toBe(memberData.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
const member = await Container.get(UserRepository).findOneByOrFail({ id: memberShell.id });
expect(member.firstName).toBe(memberData.firstName);
expect(member.lastName).toBe(memberData.lastName);
expect(member.password).not.toBe(memberData.password);
});
test('should fail with invalid inputs', async () => {
const memberShellEmail = randomEmail();
const memberShell = await Container.get(UserRepository).save({
email: memberShellEmail,
globalRole: globalMemberRole,
});
const invalidPayloads = [
{
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomInvalidPassword(),
},
];
for (const invalidPayload of invalidPayloads) {
const response = await authlessAgent
.post(`/invitations/${memberShell.id}/accept`)
.send(invalidPayload);
expect(response.statusCode).toBe(400);
const storedUser = await Container.get(UserRepository).findOneOrFail({
where: { email: memberShellEmail },
});
expect(storedUser.firstName).toBeNull();
expect(storedUser.lastName).toBeNull();
expect(storedUser.password).toBeNull();
}
});
test('should fail with already accepted invite', async () => {
const member = await createUser({ globalRole: globalMemberRole });
const newMemberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authlessAgent
.post(`/invitations/${member.id}/accept`)
.send(newMemberData);
expect(response.statusCode).toBe(400);
const storedMember = await Container.get(UserRepository).findOneOrFail({
where: { email: member.email },
});
expect(storedMember.firstName).not.toBe(newMemberData.firstName);
expect(storedMember.lastName).not.toBe(newMemberData.lastName);
const comparisonResult = await compareHash(member.password, storedMember.password);
expect(comparisonResult).toBe(false);
expect(storedMember.password).not.toBe(newMemberData.password);
});
});
describe('POST /invitations', () => {
test('should fail with invalid inputs', async () => {
const invalidPayloads = [
randomEmail(),
[randomEmail()],
{},
[{ name: randomName() }],
[{ email: randomName() }],
];
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/invitations').send(invalidPayload);
expect(response.statusCode).toBe(400);
const users = await Container.get(UserRepository).find();
expect(users.length).toBe(2); // DB unaffected
}),
);
});
test('should ignore an empty payload', async () => {
const response = await authOwnerAgent.post('/invitations').send([]);
const { data } = response.body;
expect(response.statusCode).toBe(200);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(0);
const users = await Container.get(UserRepository).find();
expect(users.length).toBe(2);
});
test('should succeed if emailing is not set up', async () => {
mailer.invite.mockResolvedValueOnce({ emailSent: false });
const usersToInvite = randomEmail();
const response = await authOwnerAgent.post('/invitations').send([{ email: usersToInvite }]);
expect(response.statusCode).toBe(200);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBe(1);
const { user } = response.body.data[0];
expect(user.inviteAcceptUrl).toBeDefined();
const inviteUrl = new URL(user.inviteAcceptUrl);
expect(inviteUrl.searchParams.get('inviterId')).toBe(owner.id);
expect(inviteUrl.searchParams.get('inviteeId')).toBe(user.id);
});
test('should email invites and create user shells but ignore existing', async () => {
const internalHooks = Container.get(InternalHooks);
mailer.invite.mockImplementation(async () => ({ emailSent: true }));
const memberShell = await createUserShell(globalMemberRole);
const newUser = randomEmail();
const shellUsers = [memberShell.email];
const usersToInvite = [newUser, ...shellUsers];
const usersToCreate = [newUser];
const existingUsers = [member.email];
const testEmails = [...usersToInvite, ...existingUsers];
const payload = testEmails.map((email) => ({ email }));
const response = await authOwnerAgent.post('/invitations').send(payload);
expect(response.statusCode).toBe(200);
expect(internalHooks.onUserTransactionalEmail).toHaveBeenCalledTimes(usersToInvite.length);
expect(externalHooks.run).toHaveBeenCalledTimes(1);
const [hookName, hookData] = externalHooks.run.mock.calls[0];
expect(hookName).toBe('user.invited');
expect(hookData?.[0]).toStrictEqual(usersToCreate);
for (const invitationResponse of response.body.data as UserInvitationResponse[]) {
const storedUser = await Container.get(UserRepository).findOneByOrFail({
id: invitationResponse.user.id,
});
assertInviteUserSuccessResponse(invitationResponse);
assertInvitedUsersOnDb(storedUser);
}
for (const [onUserTransactionalEmailParameter] of internalHooks.onUserTransactionalEmail.mock
.calls) {
expect(onUserTransactionalEmailParameter.user_id).toBeDefined();
expect(onUserTransactionalEmailParameter.message_type).toBe('New user invite');
expect(onUserTransactionalEmailParameter.public_api).toBe(false);
}
});
test('should return error when invite method throws an error', async () => {
const error = 'failed to send email';
mailer.invite.mockImplementation(async () => {
throw new Error(error);
});
const newUser = randomEmail();
const usersToCreate = [newUser];
const payload = usersToCreate.map((email) => ({ email }));
const response = await authOwnerAgent.post('/invitations').send(payload);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBe(1);
expect(response.statusCode).toBe(200);
const invitationResponse = response.body.data[0];
assertInviteUserErrorResponse(invitationResponse);
});
});

View file

@ -29,7 +29,8 @@ type EndpointGroup =
| 'metrics' | 'metrics'
| 'executions' | 'executions'
| 'workflowHistory' | 'workflowHistory'
| 'binaryData'; | 'binaryData'
| 'invitations';
export interface SetupProps { export interface SetupProps {
applyAuth?: boolean; applyAuth?: boolean;

View file

@ -11,7 +11,6 @@ import type { User } from '@db/entities/User';
import { issueJWT } from '@/auth/jwt'; import { issueJWT } from '@/auth/jwt';
import { registerController } from '@/decorators'; import { registerController } from '@/decorators';
import { rawBodyReader, bodyParser, setupAuthMiddlewares } from '@/middlewares'; import { rawBodyReader, bodyParser, setupAuthMiddlewares } from '@/middlewares';
import { InternalHooks } from '@/InternalHooks';
import { PostHogClient } from '@/posthog'; import { PostHogClient } from '@/posthog';
import { License } from '@/License'; import { License } from '@/License';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
@ -20,6 +19,7 @@ import { mockInstance } from '../../../shared/mocking';
import * as testDb from '../../shared/testDb'; import * as testDb from '../../shared/testDb';
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import type { SetupProps, TestServer } from '../types'; import type { SetupProps, TestServer } from '../types';
import { InternalHooks } from '@/InternalHooks';
/** /**
* Plugin to prefix a path segment into a request URL pathname. * Plugin to prefix a path segment into a request URL pathname.
@ -234,33 +234,44 @@ export const setupTestServer = ({
'@db/repositories/sharedWorkflow.repository' '@db/repositories/sharedWorkflow.repository'
); );
const { ActiveWorkflowRunner } = await import('@/ActiveWorkflowRunner'); const { ActiveWorkflowRunner } = await import('@/ActiveWorkflowRunner');
const { ExternalHooks } = await import('@/ExternalHooks');
const { JwtService } = await import('@/services/jwt.service');
const { RoleService } = await import('@/services/role.service');
const { UserService: US } = await import('@/services/user.service'); const { UserService: US } = await import('@/services/user.service');
const { UserManagementMailer } = await import( const { ExternalHooks: EH } = await import('@/ExternalHooks');
'@/UserManagement/email/UserManagementMailer' const { RoleService: RS } = await import('@/services/role.service');
);
const { UsersController } = await import('@/controllers/users.controller'); const { UsersController } = await import('@/controllers/users.controller');
registerController( registerController(
app, app,
config, config,
new UsersController( new UsersController(
config,
logger, logger,
Container.get(ExternalHooks), Container.get(EH),
Container.get(InternalHooks), Container.get(InternalHooks),
Container.get(SharedCredentialsRepository), Container.get(SharedCredentialsRepository),
Container.get(SharedWorkflowRepository), Container.get(SharedWorkflowRepository),
Container.get(ActiveWorkflowRunner), Container.get(ActiveWorkflowRunner),
Container.get(UserManagementMailer), Container.get(RS),
Container.get(JwtService),
Container.get(RoleService),
Container.get(US), Container.get(US),
), ),
); );
break; break;
case 'invitations':
const { InvitationController } = await import('@/controllers/invitation.controller');
const { ExternalHooks: EHS } = await import('@/ExternalHooks');
const { UserService: USE } = await import('@/services/user.service');
registerController(
app,
config,
new InvitationController(
config,
logger,
Container.get(InternalHooks),
Container.get(EHS),
Container.get(USE),
),
);
break;
case 'tags': case 'tags':
const { TagsController } = await import('@/controllers/tags.controller'); const { TagsController } = await import('@/controllers/tags.controller');
registerController(app, config, Container.get(TagsController)); registerController(app, config, Container.get(TagsController));

View file

@ -1,12 +1,9 @@
import validator from 'validator';
import type { SuperAgentTest } from 'supertest'; import type { SuperAgentTest } from 'supertest';
import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { compareHash } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
import Container from 'typedi'; import Container from 'typedi';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
@ -17,21 +14,14 @@ import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.reposi
import { mockInstance } from '../shared/mocking'; import { mockInstance } from '../shared/mocking';
import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { import { randomCredentialPayload, randomName } from './shared/random';
randomCredentialPayload,
randomEmail,
randomInvalidPassword,
randomName,
randomValidPassword,
} from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
import { saveCredential } from './shared/db/credentials'; import { saveCredential } from './shared/db/credentials';
import { getAllRoles } from './shared/db/roles'; import { getAllRoles } from './shared/db/roles';
import { createMember, createOwner, createUser, createUserShell } from './shared/db/users'; import { createMember, createOwner, createUser } from './shared/db/users';
import { createWorkflow } from './shared/db/workflows'; import { createWorkflow } from './shared/db/workflows';
import type { PublicUser } from '@/Interfaces'; import type { PublicUser } from '@/Interfaces';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
const { any } = expect; const { any } = expect;
@ -46,8 +36,6 @@ let authOwnerAgent: SuperAgentTest;
let authlessAgent: SuperAgentTest; let authlessAgent: SuperAgentTest;
mockInstance(InternalHooks); mockInstance(InternalHooks);
const externalHooks = mockInstance(ExternalHooks);
const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = utils.setupTestServer({ endpointGroups: ['users'] }); const testServer = utils.setupTestServer({ endpointGroups: ['users'] });
@ -88,29 +76,6 @@ const validatePublicUser = (user: PublicUser) => {
expect(user.globalRole).toBeDefined(); expect(user.globalRole).toBeDefined();
}; };
const assertInviteUserSuccessResponse = (data: UserInvitationResponse) => {
expect(validator.isUUID(data.user.id)).toBe(true);
expect(data.user.inviteAcceptUrl).toBeUndefined();
expect(data.user.email).toBeDefined();
expect(data.user.emailSent).toBe(true);
};
const assertInviteUserErrorResponse = (data: UserInvitationResponse) => {
expect(validator.isUUID(data.user.id)).toBe(true);
expect(data.user.inviteAcceptUrl).toBeDefined();
expect(data.user.email).toBeDefined();
expect(data.user.emailSent).toBe(false);
expect(data.error).toBeDefined();
};
const assertInvitedUsersOnDb = (user: User) => {
expect(user.firstName).toBeNull();
expect(user.lastName).toBeNull();
expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeNull();
expect(user.isPending).toBe(true);
};
describe('GET /users', () => { describe('GET /users', () => {
test('should return all users', async () => { test('should return all users', async () => {
const response = await authOwnerAgent.get('/users').expect(200); const response = await authOwnerAgent.get('/users').expect(200);
@ -414,244 +379,3 @@ describe('DELETE /users/:id', () => {
expect(deletedUser).toBeNull(); expect(deletedUser).toBeNull();
}); });
}); });
describe('POST /users/:id', () => {
test('should fill out a user shell', async () => {
const memberShell = await createUserShell(globalMemberRole);
const memberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authlessAgent.post(`/users/${memberShell.id}`).send(memberData);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
password,
globalRole,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBe(memberData.firstName);
expect(lastName).toBe(memberData.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
const member = await Container.get(UserRepository).findOneByOrFail({ id: memberShell.id });
expect(member.firstName).toBe(memberData.firstName);
expect(member.lastName).toBe(memberData.lastName);
expect(member.password).not.toBe(memberData.password);
});
test('should fail with invalid inputs', async () => {
const memberShellEmail = randomEmail();
const memberShell = await Container.get(UserRepository).save({
email: memberShellEmail,
globalRole: globalMemberRole,
});
const invalidPayloads = [
{
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomInvalidPassword(),
},
];
for (const invalidPayload of invalidPayloads) {
const response = await authlessAgent.post(`/users/${memberShell.id}`).send(invalidPayload);
expect(response.statusCode).toBe(400);
const storedUser = await Container.get(UserRepository).findOneOrFail({
where: { email: memberShellEmail },
});
expect(storedUser.firstName).toBeNull();
expect(storedUser.lastName).toBeNull();
expect(storedUser.password).toBeNull();
}
});
test('should fail with already accepted invite', async () => {
const member = await createUser({ globalRole: globalMemberRole });
const newMemberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authlessAgent.post(`/users/${member.id}`).send(newMemberData);
expect(response.statusCode).toBe(400);
const storedMember = await Container.get(UserRepository).findOneOrFail({
where: { email: member.email },
});
expect(storedMember.firstName).not.toBe(newMemberData.firstName);
expect(storedMember.lastName).not.toBe(newMemberData.lastName);
const comparisonResult = await compareHash(member.password, storedMember.password);
expect(comparisonResult).toBe(false);
expect(storedMember.password).not.toBe(newMemberData.password);
});
});
describe('POST /users', () => {
test('should fail with invalid inputs', async () => {
const invalidPayloads = [
randomEmail(),
[randomEmail()],
{},
[{ name: randomName() }],
[{ email: randomName() }],
];
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/users').send(invalidPayload);
expect(response.statusCode).toBe(400);
const users = await Container.get(UserRepository).find();
expect(users.length).toBe(2); // DB unaffected
}),
);
});
test('should ignore an empty payload', async () => {
const response = await authOwnerAgent.post('/users').send([]);
const { data } = response.body;
expect(response.statusCode).toBe(200);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(0);
const users = await Container.get(UserRepository).find();
expect(users.length).toBe(2);
});
test('should succeed if emailing is not set up', async () => {
mailer.invite.mockResolvedValueOnce({ emailSent: false });
const usersToInvite = randomEmail();
const response = await authOwnerAgent.post('/users').send([{ email: usersToInvite }]);
expect(response.statusCode).toBe(200);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBe(1);
const { user } = response.body.data[0];
expect(user.inviteAcceptUrl).toBeDefined();
const inviteUrl = new URL(user.inviteAcceptUrl);
expect(inviteUrl.searchParams.get('inviterId')).toBe(owner.id);
expect(inviteUrl.searchParams.get('inviteeId')).toBe(user.id);
});
test('should email invites and create user shells but ignore existing', async () => {
const internalHooks = Container.get(InternalHooks);
mailer.invite.mockImplementation(async () => ({ emailSent: true }));
const memberShell = await createUserShell(globalMemberRole);
const newUser = randomEmail();
const shellUsers = [memberShell.email];
const usersToInvite = [newUser, ...shellUsers];
const usersToCreate = [newUser];
const existingUsers = [member.email];
const testEmails = [...usersToInvite, ...existingUsers];
const payload = testEmails.map((email) => ({ email }));
const response = await authOwnerAgent.post('/users').send(payload);
expect(response.statusCode).toBe(200);
expect(internalHooks.onUserTransactionalEmail).toHaveBeenCalledTimes(usersToInvite.length);
expect(externalHooks.run).toHaveBeenCalledTimes(1);
const [hookName, hookData] = externalHooks.run.mock.calls[0];
expect(hookName).toBe('user.invited');
expect(hookData?.[0]).toStrictEqual(usersToCreate);
for (const invitationResponse of response.body.data as UserInvitationResponse[]) {
const storedUser = await Container.get(UserRepository).findOneByOrFail({
id: invitationResponse.user.id,
});
assertInviteUserSuccessResponse(invitationResponse);
assertInvitedUsersOnDb(storedUser);
}
for (const [onUserTransactionalEmailParameter] of internalHooks.onUserTransactionalEmail.mock
.calls) {
expect(onUserTransactionalEmailParameter.user_id).toBeDefined();
expect(onUserTransactionalEmailParameter.message_type).toBe('New user invite');
expect(onUserTransactionalEmailParameter.public_api).toBe(false);
}
});
test('should return error when invite method throws an error', async () => {
const error = 'failed to send email';
mailer.invite.mockImplementation(async () => {
throw new Error(error);
});
const newUser = randomEmail();
const usersToCreate = [newUser];
const payload = usersToCreate.map((email) => ({ email }));
const response = await authOwnerAgent.post('/users').send(payload);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBe(1);
expect(response.statusCode).toBe(200);
const invitationResponse = response.body.data[0];
assertInviteUserErrorResponse(invitationResponse);
});
});

View file

@ -6,12 +6,14 @@ import { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { RoleService } from '@/services/role.service';
describe('UserService', () => { describe('UserService', () => {
config.set('userManagement.jwtSecret', 'random-secret'); config.set('userManagement.jwtSecret', 'random-secret');
mockInstance(Logger); mockInstance(Logger);
const repository = mockInstance(UserRepository); const repository = mockInstance(UserRepository);
mockInstance(RoleService);
const service = Container.get(UserService); const service = Container.get(UserService);
const testUser = Object.assign(new User(), { const testUser = Object.assign(new User(), {
id: '1234', id: '1234',

View file

@ -0,0 +1,25 @@
import type { CurrentUserResponse, IInviteResponse, IRestApiContext } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils';
type AcceptInvitationParams = {
inviterId: string;
inviteeId: string;
firstName: string;
lastName: string;
password: string;
};
export async function inviteUsers(context: IRestApiContext, params: Array<{ email: string }>) {
return makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
}
export async function acceptInvitation(context: IRestApiContext, params: AcceptInvitationParams) {
const { inviteeId, ...props } = params;
return makeRestApiRequest<CurrentUserResponse>(
context,
'POST',
`/invitations/${params.inviteeId}/accept`,
props as unknown as IDataObject,
);
}

View file

@ -1,6 +1,5 @@
import type { import type {
CurrentUserResponse, CurrentUserResponse,
IInviteResponse,
IPersonalizationLatestVersion, IPersonalizationLatestVersion,
IRestApiContext, IRestApiContext,
IUserResponse, IUserResponse,
@ -124,17 +123,6 @@ export async function getUsers(context: IRestApiContext): Promise<IUserResponse[
return makeRestApiRequest(context, 'GET', '/users'); return makeRestApiRequest(context, 'GET', '/users');
} }
export async function inviteUsers(
context: IRestApiContext,
params: Array<{ email: string }>,
): Promise<IInviteResponse[]> {
return makeRestApiRequest(context, 'POST', '/users', params as unknown as IDataObject);
}
export async function reinvite(context: IRestApiContext, { id }: { id: string }): Promise<void> {
await makeRestApiRequest(context, 'POST', `/users/${id}/reinvite`);
}
export async function getInviteLink( export async function getInviteLink(
context: IRestApiContext, context: IRestApiContext,
{ id }: { id: string }, { id }: { id: string },

View file

@ -1,16 +1,13 @@
import { import {
changePassword, changePassword,
deleteUser, deleteUser,
getInviteLink,
getPasswordResetLink, getPasswordResetLink,
getUsers, getUsers,
inviteUsers,
login, login,
loginCurrentUser, loginCurrentUser,
logout, logout,
sendForgotPasswordEmail, sendForgotPasswordEmail,
setupOwner, setupOwner,
signup,
submitPersonalizationSurvey, submitPersonalizationSurvey,
updateCurrentUser, updateCurrentUser,
updateCurrentUserPassword, updateCurrentUserPassword,
@ -40,6 +37,7 @@ import { useUIStore } from './ui.store';
import { useCloudPlanStore } from './cloudPlan.store'; import { useCloudPlanStore } from './cloudPlan.store';
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
import { inviteUsers, acceptInvitation } from '@/api/invitation';
const isDefaultUser = (user: IUserResponse | null) => const isDefaultUser = (user: IUserResponse | null) =>
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner);
@ -233,7 +231,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
const rootStore = useRootStore(); const rootStore = useRootStore();
return validateSignupToken(rootStore.getRestApiContext, params); return validateSignupToken(rootStore.getRestApiContext, params);
}, },
async signup(params: { async acceptInvitation(params: {
inviteeId: string; inviteeId: string;
inviterId: string; inviterId: string;
firstName: string; firstName: string;
@ -241,7 +239,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
password: string; password: string;
}): Promise<void> { }): Promise<void> {
const rootStore = useRootStore(); const rootStore = useRootStore();
const user = await signup(rootStore.getRestApiContext, params); const user = await acceptInvitation(rootStore.getRestApiContext, params);
if (user) { if (user) {
this.addUsers([user]); this.addUsers([user]);
this.currentUserId = user.id; this.currentUserId = user.id;
@ -336,10 +334,6 @@ export const useUsersStore = defineStore(STORES.USERS, {
throw Error(invitationResponse[0].error); throw Error(invitationResponse[0].error);
} }
}, },
async getUserInviteLink(params: { id: string }): Promise<{ link: string }> {
const rootStore = useRootStore();
return getInviteLink(rootStore.getRestApiContext, params);
},
async getUserPasswordResetLink(params: { id: string }): Promise<{ link: string }> { async getUserPasswordResetLink(params: { id: string }): Promise<{ link: string }> {
const rootStore = useRootStore(); const rootStore = useRootStore();
return getPasswordResetLink(rootStore.getRestApiContext, params); return getPasswordResetLink(rootStore.getRestApiContext, params);

View file

@ -98,11 +98,11 @@ async function request(config: {
} }
} }
export async function makeRestApiRequest( export async function makeRestApiRequest<T>(
context: IRestApiContext, context: IRestApiContext,
method: Method, method: Method,
endpoint: string, endpoint: string,
data?: IDataObject, data?: any,
) { ) {
const response = await request({ const response = await request({
method, method,
@ -113,7 +113,7 @@ export async function makeRestApiRequest(
}); });
// @ts-ignore all cli rest api endpoints return data wrapped in `data` key // @ts-ignore all cli rest api endpoints return data wrapped in `data` key
return response.data; return response.data as T;
} }
export async function get( export async function get(

View file

@ -83,14 +83,8 @@ export default defineComponent({
}; };
}, },
async mounted() { async mounted() {
const inviterId = const inviterId = this.getQueryParameter('inviterId');
!this.$route.query.inviterId || typeof this.$route.query.inviterId !== 'string' const inviteeId = this.getQueryParameter('inviteeId');
? null
: this.$route.query.inviterId;
const inviteeId =
!this.$route.query.inviteeId || typeof this.$route.query.inviteeId !== 'string'
? null
: this.$route.query.inviteeId;
try { try {
if (!inviterId || !inviteeId) { if (!inviterId || !inviteeId) {
throw new Error(this.$locale.baseText('auth.signup.missingTokenError')); throw new Error(this.$locale.baseText('auth.signup.missingTokenError'));
@ -129,7 +123,7 @@ export default defineComponent({
try { try {
this.loading = true; this.loading = true;
await this.usersStore.signup({ await this.usersStore.acceptInvitation({
...values, ...values,
inviterId: this.inviterId, inviterId: this.inviterId,
inviteeId: this.inviteeId, inviteeId: this.inviteeId,
@ -153,6 +147,11 @@ export default defineComponent({
} }
this.loading = false; this.loading = false;
}, },
getQueryParameter(key: 'inviterId' | 'inviteeId'): string | null {
return !this.$route.query[key] || typeof this.$route.query[key] !== 'string'
? null
: (this.$route.query[key] as string);
},
}, },
}); });
</script> </script>