| | import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types'; |
| | import { Logger } from '@n8n/backend-common'; |
| | import type { User } from '@n8n/db'; |
| | import { UserRepository } from '@n8n/db'; |
| | import { Post, GlobalScope, RestController, Body, Param } from '@n8n/decorators'; |
| | import { Response } from 'express'; |
| |
|
| | import { AuthService } from '@/auth/auth.service'; |
| | import config from '@/config'; |
| | import { RESPONSE_ERROR_MESSAGES } from '@/constants'; |
| | import { BadRequestError } from '@/errors/response-errors/bad-request.error'; |
| | import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; |
| | import { EventService } from '@/events/event.service'; |
| | import { ExternalHooks } from '@/external-hooks'; |
| | import { License } from '@/license'; |
| | import { PostHogClient } from '@/posthog'; |
| | import { AuthenticatedRequest, AuthlessRequest } from '@/requests'; |
| | import { PasswordUtility } from '@/services/password.utility'; |
| | import { UserService } from '@/services/user.service'; |
| | import { isSamlLicensedAndEnabled } from '@/sso.ee/saml/saml-helpers'; |
| |
|
| | @RestController('/invitations') |
| | export class InvitationController { |
| | constructor( |
| | private readonly logger: Logger, |
| | private readonly externalHooks: ExternalHooks, |
| | private readonly authService: AuthService, |
| | private readonly userService: UserService, |
| | private readonly license: License, |
| | private readonly passwordUtility: PasswordUtility, |
| | private readonly userRepository: UserRepository, |
| | private readonly postHog: PostHogClient, |
| | private readonly eventService: EventService, |
| | ) {} |
| |
|
| | |
| | |
| | |
| |
|
| | @Post('/', { rateLimit: { limit: 10 } }) |
| | @GlobalScope('user:create') |
| | async inviteUser( |
| | req: AuthenticatedRequest, |
| | _res: Response, |
| | @Body invitations: InviteUsersRequestDto, |
| | ) { |
| | if (invitations.length === 0) return []; |
| |
|
| | const isWithinUsersLimit = this.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 ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); |
| | } |
| |
|
| | if (!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'); |
| | } |
| |
|
| | const attributes = invitations.map(({ email, role }) => { |
| | if (role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { |
| | throw new ForbiddenError( |
| | 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', |
| | ); |
| | } |
| | return { email, role }; |
| | }); |
| |
|
| | const { usersInvited, usersCreated } = await this.userService.inviteUsers(req.user, attributes); |
| |
|
| | await this.externalHooks.run('user.invited', [usersCreated]); |
| |
|
| | return usersInvited; |
| | } |
| |
|
| | |
| | |
| | |
| | @Post('/:id/accept', { skipAuth: true }) |
| | async acceptInvitation( |
| | req: AuthlessRequest, |
| | res: Response, |
| | @Body payload: AcceptInvitationRequestDto, |
| | @Param('id') inviteeId: string, |
| | ) { |
| | const { inviterId, firstName, lastName, password } = payload; |
| |
|
| | const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); |
| |
|
| | 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 this.passwordUtility.hash(password); |
| |
|
| | const updatedUser = await this.userRepository.save(invitee, { transaction: false }); |
| |
|
| | this.authService.issueCookie(res, updatedUser, req.browserId); |
| |
|
| | this.eventService.emit('user-signed-up', { |
| | user: updatedUser, |
| | userType: 'email', |
| | wasDisabledLdapUser: 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 await this.userService.toPublic(updatedUser, { |
| | posthog: this.postHog, |
| | withScopes: true, |
| | }); |
| | } |
| | } |
| |
|