From 8e0ae3cf8ca942b5833dc38db04baa5101df891a Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 16 Nov 2023 12:39:43 -0500 Subject: [PATCH] refactor: Extract Invitation routes to InvitationController (no-changelog) (#7726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: कारतोफ्फेलस्क्रिप्ट™ --- packages/cli/src/Server.ts | 15 +- .../src/controllers/invitation.controller.ts | 166 +++++++++ .../cli/src/controllers/users.controller.ts | 288 +-------------- packages/cli/src/middlewares/auth.ts | 8 +- packages/cli/src/requests.ts | 5 + packages/cli/src/services/user.service.ts | 122 ++++++- packages/cli/test/integration/auth.mw.test.ts | 6 +- .../test/integration/invitations.api.test.ts | 335 ++++++++++++++++++ packages/cli/test/integration/shared/types.ts | 3 +- .../integration/shared/utils/testServer.ts | 35 +- .../cli/test/integration/users.api.test.ts | 280 +-------------- .../test/unit/services/user.service.test.ts | 2 + packages/editor-ui/src/api/invitation.ts | 25 ++ packages/editor-ui/src/api/users.ts | 12 - packages/editor-ui/src/stores/users.store.ts | 12 +- packages/editor-ui/src/utils/apiUtils.ts | 6 +- packages/editor-ui/src/views/SignupView.vue | 17 +- 17 files changed, 713 insertions(+), 624 deletions(-) create mode 100644 packages/cli/src/controllers/invitation.controller.ts create mode 100644 packages/cli/test/integration/invitations.api.test.ts create mode 100644 packages/editor-ui/src/api/invitation.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c00fb4558b..3dc9431a15 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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()) { diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts new file mode 100644 index 0000000000..ce94cf4edc --- /dev/null +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -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 }); + } +} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 114ffbff39..fb9b0edce6 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -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(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 = {}; diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index b97f935351..195b174b74 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -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): 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(); } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 9131cc2fa3..d05e60766c 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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< {}, {}, diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index becec7a5fa..3b3a649731 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -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) { @@ -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(); + + 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(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 }; + } } diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 933f33a31b..8cc77968a1 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -4,7 +4,9 @@ import { getGlobalMemberRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; 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. */ const ROUTES_REQUIRING_AUTHENTICATION: Readonly> = [ @@ -17,7 +19,7 @@ describe('Auth Middleware', () => { /** Routes requiring a valid `n8n-auth` cookie for an owner. */ const ROUTES_REQUIRING_AUTHORIZATION: Readonly> = [ - ['POST', '/users'], + ['POST', '/invitations'], ['DELETE', '/users/123'], ['POST', '/owner/setup'], ]; diff --git a/packages/cli/test/integration/invitations.api.test.ts b/packages/cli/test/integration/invitations.api.test.ts new file mode 100644 index 0000000000..6fec335501 --- /dev/null +++ b/packages/cli/test/integration/invitations.api.test.ts @@ -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 & { 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); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 0021cfc99c..ffc47b78f7 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -29,7 +29,8 @@ type EndpointGroup = | 'metrics' | 'executions' | 'workflowHistory' - | 'binaryData'; + | 'binaryData' + | 'invitations'; export interface SetupProps { applyAuth?: boolean; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 15dba72c0e..e5c3b2ad08 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -11,7 +11,6 @@ import type { User } from '@db/entities/User'; import { issueJWT } from '@/auth/jwt'; import { registerController } from '@/decorators'; import { rawBodyReader, bodyParser, setupAuthMiddlewares } from '@/middlewares'; -import { InternalHooks } from '@/InternalHooks'; import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { Logger } from '@/Logger'; @@ -20,6 +19,7 @@ import { mockInstance } from '../../../shared/mocking'; import * as testDb from '../../shared/testDb'; import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import type { SetupProps, TestServer } from '../types'; +import { InternalHooks } from '@/InternalHooks'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -234,33 +234,44 @@ export const setupTestServer = ({ '@db/repositories/sharedWorkflow.repository' ); 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 { UserManagementMailer } = await import( - '@/UserManagement/email/UserManagementMailer' - ); + const { ExternalHooks: EH } = await import('@/ExternalHooks'); + const { RoleService: RS } = await import('@/services/role.service'); const { UsersController } = await import('@/controllers/users.controller'); registerController( app, config, new UsersController( - config, logger, - Container.get(ExternalHooks), + Container.get(EH), Container.get(InternalHooks), Container.get(SharedCredentialsRepository), Container.get(SharedWorkflowRepository), Container.get(ActiveWorkflowRunner), - Container.get(UserManagementMailer), - Container.get(JwtService), - Container.get(RoleService), + Container.get(RS), Container.get(US), ), ); 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': const { TagsController } = await import('@/controllers/tags.controller'); registerController(app, config, Container.get(TagsController)); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index f197bdec8c..65af440ab0 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1,12 +1,9 @@ -import validator from 'validator'; import type { SuperAgentTest } from 'supertest'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import { compareHash } from '@/UserManagement/UserManagementHelper'; -import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer'; import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; @@ -17,21 +14,14 @@ import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.reposi import { mockInstance } from '../shared/mocking'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; -import { - randomCredentialPayload, - randomEmail, - randomInvalidPassword, - randomName, - randomValidPassword, -} from './shared/random'; +import { randomCredentialPayload, randomName } from './shared/random'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; import { saveCredential } from './shared/db/credentials'; 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 type { PublicUser } from '@/Interfaces'; -import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; const { any } = expect; @@ -46,8 +36,6 @@ let authOwnerAgent: SuperAgentTest; let authlessAgent: SuperAgentTest; mockInstance(InternalHooks); -const externalHooks = mockInstance(ExternalHooks); -const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true }); const testServer = utils.setupTestServer({ endpointGroups: ['users'] }); @@ -88,29 +76,6 @@ const validatePublicUser = (user: PublicUser) => { 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', () => { test('should return all users', async () => { const response = await authOwnerAgent.get('/users').expect(200); @@ -414,244 +379,3 @@ describe('DELETE /users/:id', () => { 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); - }); -}); diff --git a/packages/cli/test/unit/services/user.service.test.ts b/packages/cli/test/unit/services/user.service.test.ts index 02148a3a13..6c1110ff39 100644 --- a/packages/cli/test/unit/services/user.service.test.ts +++ b/packages/cli/test/unit/services/user.service.test.ts @@ -6,12 +6,14 @@ import { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { UserService } from '@/services/user.service'; import { mockInstance } from '../../shared/mocking'; +import { RoleService } from '@/services/role.service'; describe('UserService', () => { config.set('userManagement.jwtSecret', 'random-secret'); mockInstance(Logger); const repository = mockInstance(UserRepository); + mockInstance(RoleService); const service = Container.get(UserService); const testUser = Object.assign(new User(), { id: '1234', diff --git a/packages/editor-ui/src/api/invitation.ts b/packages/editor-ui/src/api/invitation.ts new file mode 100644 index 0000000000..2eabe1ab4f --- /dev/null +++ b/packages/editor-ui/src/api/invitation.ts @@ -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(context, 'POST', '/invitations', params); +} + +export async function acceptInvitation(context: IRestApiContext, params: AcceptInvitationParams) { + const { inviteeId, ...props } = params; + return makeRestApiRequest( + context, + 'POST', + `/invitations/${params.inviteeId}/accept`, + props as unknown as IDataObject, + ); +} diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 5ce56d26dd..2363304469 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -1,6 +1,5 @@ import type { CurrentUserResponse, - IInviteResponse, IPersonalizationLatestVersion, IRestApiContext, IUserResponse, @@ -124,17 +123,6 @@ export async function getUsers(context: IRestApiContext): Promise, -): Promise { - return makeRestApiRequest(context, 'POST', '/users', params as unknown as IDataObject); -} - -export async function reinvite(context: IRestApiContext, { id }: { id: string }): Promise { - await makeRestApiRequest(context, 'POST', `/users/${id}/reinvite`); -} - export async function getInviteLink( context: IRestApiContext, { id }: { id: string }, diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index b8745401fd..dc37492b83 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -1,16 +1,13 @@ import { changePassword, deleteUser, - getInviteLink, getPasswordResetLink, getUsers, - inviteUsers, login, loginCurrentUser, logout, sendForgotPasswordEmail, setupOwner, - signup, submitPersonalizationSurvey, updateCurrentUser, updateCurrentUserPassword, @@ -40,6 +37,7 @@ import { useUIStore } from './ui.store'; import { useCloudPlanStore } from './cloudPlan.store'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; +import { inviteUsers, acceptInvitation } from '@/api/invitation'; const isDefaultUser = (user: IUserResponse | null) => Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); @@ -233,7 +231,7 @@ export const useUsersStore = defineStore(STORES.USERS, { const rootStore = useRootStore(); return validateSignupToken(rootStore.getRestApiContext, params); }, - async signup(params: { + async acceptInvitation(params: { inviteeId: string; inviterId: string; firstName: string; @@ -241,7 +239,7 @@ export const useUsersStore = defineStore(STORES.USERS, { password: string; }): Promise { const rootStore = useRootStore(); - const user = await signup(rootStore.getRestApiContext, params); + const user = await acceptInvitation(rootStore.getRestApiContext, params); if (user) { this.addUsers([user]); this.currentUserId = user.id; @@ -336,10 +334,6 @@ export const useUsersStore = defineStore(STORES.USERS, { 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 }> { const rootStore = useRootStore(); return getPasswordResetLink(rootStore.getRestApiContext, params); diff --git a/packages/editor-ui/src/utils/apiUtils.ts b/packages/editor-ui/src/utils/apiUtils.ts index caa3005242..6705d6b578 100644 --- a/packages/editor-ui/src/utils/apiUtils.ts +++ b/packages/editor-ui/src/utils/apiUtils.ts @@ -98,11 +98,11 @@ async function request(config: { } } -export async function makeRestApiRequest( +export async function makeRestApiRequest( context: IRestApiContext, method: Method, endpoint: string, - data?: IDataObject, + data?: any, ) { const response = await request({ method, @@ -113,7 +113,7 @@ export async function makeRestApiRequest( }); // @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( diff --git a/packages/editor-ui/src/views/SignupView.vue b/packages/editor-ui/src/views/SignupView.vue index 0434ed1c81..3d2cd8885c 100644 --- a/packages/editor-ui/src/views/SignupView.vue +++ b/packages/editor-ui/src/views/SignupView.vue @@ -83,14 +83,8 @@ export default defineComponent({ }; }, async mounted() { - const inviterId = - !this.$route.query.inviterId || typeof this.$route.query.inviterId !== 'string' - ? null - : this.$route.query.inviterId; - const inviteeId = - !this.$route.query.inviteeId || typeof this.$route.query.inviteeId !== 'string' - ? null - : this.$route.query.inviteeId; + const inviterId = this.getQueryParameter('inviterId'); + const inviteeId = this.getQueryParameter('inviteeId'); try { if (!inviterId || !inviteeId) { throw new Error(this.$locale.baseText('auth.signup.missingTokenError')); @@ -129,7 +123,7 @@ export default defineComponent({ try { this.loading = true; - await this.usersStore.signup({ + await this.usersStore.acceptInvitation({ ...values, inviterId: this.inviterId, inviteeId: this.inviteeId, @@ -153,6 +147,11 @@ export default defineComponent({ } 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); + }, }, });