mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
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:
parent
e2ffd397fc
commit
8e0ae3cf8c
|
@ -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()) {
|
||||||
|
|
166
packages/cli/src/controllers/invitation.controller.ts
Normal file
166
packages/cli/src/controllers/invitation.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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> = {};
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'],
|
||||||
];
|
];
|
||||||
|
|
335
packages/cli/test/integration/invitations.api.test.ts
Normal file
335
packages/cli/test/integration/invitations.api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
25
packages/editor-ui/src/api/invitation.ts
Normal file
25
packages/editor-ui/src/api/invitation.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 },
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue