Skip to content

Commit

Permalink
refactor: Extract Invitation routes to InvitationController (no-chang…
Browse files Browse the repository at this point in the history
…elog) (n8n-io#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>
  • Loading branch information
RicardoE105 and netroy authored Nov 16, 2023
1 parent e2ffd39 commit 8e0ae3c
Show file tree
Hide file tree
Showing 17 changed files with 713 additions and 624 deletions.
15 changes: 9 additions & 6 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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),
Expand All @@ -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()) {
Expand Down
166 changes: 166 additions & 0 deletions packages/cli/src/controllers/invitation.controller.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading

0 comments on commit 8e0ae3c

Please sign in to comment.