mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat: Add user management invite links without SMTP set up (#5084)
* feat: update n8n-users-list to no longer use preset list of actions * feat: prepared users settings for invite links feature * refactor: Return invite link URLs when inviting users (#5079) * refactor: Return invite link URLs when inviting users * test: Refactor and add tests to mailer * feat: Add FE inviteAcceptUrl integration (#5085) * feat: update n8n-users-list to no longer use preset list of actions * feat: prepared users settings for invite links feature * feat: add integration with new inviteAcceptUrl changes * feat: Add inviteAcceptUrl to user list for pending users Co-authored-by: Alex Grozav <alex@grozav.com> * fix conflicts * fix lint issue * test: Make sure inviteAcceptUrl is defined * feat: update smtp setup suggestion * feat: add invite link summary when inviting multiple users * refactor: Add telemetry flag for when email is sent * fix: add email_sent correctly to telemetry event * feat: move SMTP info-tip to invite modal Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
parent
11a46a4cbc
commit
2327563c44
|
@ -436,6 +436,7 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
user: User;
|
user: User;
|
||||||
target_user_id: string[];
|
target_user_id: string[];
|
||||||
public_api: boolean;
|
public_api: boolean;
|
||||||
|
email_sent: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
eventBus.sendAuditEvent({
|
eventBus.sendAuditEvent({
|
||||||
|
@ -449,6 +450,7 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
user_id: userInviteData.user.id,
|
user_id: userInviteData.user.id,
|
||||||
target_user_id: userInviteData.target_user_id,
|
target_user_id: userInviteData.target_user_id,
|
||||||
public_api: userInviteData.public_api,
|
public_api: userInviteData.public_api,
|
||||||
|
email_sent: userInviteData.email_sent,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ export interface PublicUser {
|
||||||
passwordResetToken?: string;
|
passwordResetToken?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
inviteAcceptUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface N8nApp {
|
export interface N8nApp {
|
||||||
|
|
|
@ -99,6 +99,10 @@ export function getInstanceBaseUrl(): string {
|
||||||
return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl;
|
return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateUserInviteUrl(inviterId: string, inviteeId: string): string {
|
||||||
|
return `${getInstanceBaseUrl()}/signup?inviterId=${inviterId}&inviteeId=${inviteeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Enforce at model level
|
// TODO: Enforce at model level
|
||||||
export function validatePassword(password?: string): string {
|
export function validatePassword(password?: string): string {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
@ -156,6 +160,13 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
|
||||||
return sanitizedUser;
|
return sanitizedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addInviteLinktoUser(user: PublicUser, inviterId: string): PublicUser {
|
||||||
|
if (user.isPending) {
|
||||||
|
user.inviteAcceptUrl = generateUserInviteUrl(inviterId, user.id);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserById(userId: string): Promise<User> {
|
export async function getUserById(userId: string): Promise<User> {
|
||||||
const user = await Db.collections.User.findOneOrFail(userId, {
|
const user = await Db.collections.User.findOneOrFail(userId, {
|
||||||
relations: ['globalRole'],
|
relations: ['globalRole'],
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export interface UserManagementMailerImplementation {
|
export interface UserManagementMailerImplementation {
|
||||||
|
init: () => Promise<void>;
|
||||||
sendMail: (mailData: MailData) => Promise<SendEmailResult>;
|
sendMail: (mailData: MailData) => Promise<SendEmailResult>;
|
||||||
verifyConnection: () => Promise<void>;
|
verifyConnection: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -20,8 +21,7 @@ export type PasswordResetData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SendEmailResult = {
|
export type SendEmailResult = {
|
||||||
success: boolean;
|
emailSent: boolean;
|
||||||
error?: Error;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailData = {
|
export type MailData = {
|
||||||
|
|
|
@ -5,9 +5,9 @@ import config from '@/config';
|
||||||
import { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces';
|
import { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces';
|
||||||
|
|
||||||
export class NodeMailer implements UserManagementMailerImplementation {
|
export class NodeMailer implements UserManagementMailerImplementation {
|
||||||
private transport: Transporter;
|
private transport?: Transporter;
|
||||||
|
|
||||||
constructor() {
|
async init(): Promise<void> {
|
||||||
this.transport = createTransport({
|
this.transport = createTransport({
|
||||||
host: config.getEnv('userManagement.emails.smtp.host'),
|
host: config.getEnv('userManagement.emails.smtp.host'),
|
||||||
port: config.getEnv('userManagement.emails.smtp.port'),
|
port: config.getEnv('userManagement.emails.smtp.port'),
|
||||||
|
@ -20,12 +20,15 @@ export class NodeMailer implements UserManagementMailerImplementation {
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyConnection(): Promise<void> {
|
async verifyConnection(): Promise<void> {
|
||||||
|
if (!this.transport) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
const host = config.getEnv('userManagement.emails.smtp.host');
|
const host = config.getEnv('userManagement.emails.smtp.host');
|
||||||
const user = config.getEnv('userManagement.emails.smtp.auth.user');
|
const user = config.getEnv('userManagement.emails.smtp.auth.user');
|
||||||
const pass = config.getEnv('userManagement.emails.smtp.auth.pass');
|
const pass = config.getEnv('userManagement.emails.smtp.auth.pass');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transport.verify();
|
await this.transport?.verify();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message: string[] = [];
|
const message: string[] = [];
|
||||||
if (!host) message.push('SMTP host not defined (N8N_SMTP_HOST).');
|
if (!host) message.push('SMTP host not defined (N8N_SMTP_HOST).');
|
||||||
|
@ -36,6 +39,9 @@ export class NodeMailer implements UserManagementMailerImplementation {
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMail(mailData: MailData): Promise<SendEmailResult> {
|
async sendMail(mailData: MailData): Promise<SendEmailResult> {
|
||||||
|
if (!this.transport) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
let sender = config.getEnv('userManagement.emails.smtp.sender');
|
let sender = config.getEnv('userManagement.emails.smtp.sender');
|
||||||
const user = config.getEnv('userManagement.emails.smtp.auth.user');
|
const user = config.getEnv('userManagement.emails.smtp.auth.user');
|
||||||
|
|
||||||
|
@ -44,7 +50,7 @@ export class NodeMailer implements UserManagementMailerImplementation {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transport.sendMail({
|
await this.transport?.sendMail({
|
||||||
from: sender,
|
from: sender,
|
||||||
to: mailData.emailRecipients,
|
to: mailData.emailRecipients,
|
||||||
subject: mailData.subject,
|
subject: mailData.subject,
|
||||||
|
@ -57,12 +63,9 @@ export class NodeMailer implements UserManagementMailerImplementation {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorReporter.error(error);
|
ErrorReporter.error(error);
|
||||||
Logger.error('Failed to send email', { recipients: mailData.emailRecipients, error });
|
Logger.error('Failed to send email', { recipients: mailData.emailRecipients, error });
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { emailSent: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,57 +44,52 @@ export class UserManagementMailer {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Other implementations can be used in the future.
|
// Other implementations can be used in the future.
|
||||||
if (config.getEnv('userManagement.emails.mode') === 'smtp') {
|
if (
|
||||||
|
config.getEnv('userManagement.emails.mode') === 'smtp' &&
|
||||||
|
config.getEnv('userManagement.emails.smtp.host') !== ''
|
||||||
|
) {
|
||||||
this.mailer = new NodeMailer();
|
this.mailer = new NodeMailer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyConnection(): Promise<void> {
|
async verifyConnection(): Promise<void> {
|
||||||
if (!this.mailer) return Promise.reject();
|
if (!this.mailer) throw new Error('No mailer configured.');
|
||||||
|
|
||||||
return this.mailer.verifyConnection();
|
return this.mailer.verifyConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
async invite(inviteEmailData: InviteEmailData): Promise<SendEmailResult> {
|
async invite(inviteEmailData: InviteEmailData): Promise<SendEmailResult> {
|
||||||
if (!this.mailer) return Promise.reject();
|
|
||||||
|
|
||||||
const template = await getTemplate('invite');
|
const template = await getTemplate('invite');
|
||||||
const result = await this.mailer.sendMail({
|
const result = await this.mailer?.sendMail({
|
||||||
emailRecipients: inviteEmailData.email,
|
emailRecipients: inviteEmailData.email,
|
||||||
subject: 'You have been invited to n8n',
|
subject: 'You have been invited to n8n',
|
||||||
body: template(inviteEmailData),
|
body: template(inviteEmailData),
|
||||||
});
|
});
|
||||||
|
|
||||||
// If mailer does not exist it means mail has been disabled.
|
// If mailer does not exist it means mail has been disabled.
|
||||||
return result ?? { success: true };
|
// No error, just say no email was sent.
|
||||||
|
return result ?? { emailSent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
async passwordReset(passwordResetData: PasswordResetData): Promise<SendEmailResult> {
|
async passwordReset(passwordResetData: PasswordResetData): Promise<SendEmailResult> {
|
||||||
if (!this.mailer) return Promise.reject();
|
|
||||||
|
|
||||||
const template = await getTemplate('passwordReset');
|
const template = await getTemplate('passwordReset');
|
||||||
const result = await this.mailer.sendMail({
|
const result = await this.mailer?.sendMail({
|
||||||
emailRecipients: passwordResetData.email,
|
emailRecipients: passwordResetData.email,
|
||||||
subject: 'n8n password reset',
|
subject: 'n8n password reset',
|
||||||
body: template(passwordResetData),
|
body: template(passwordResetData),
|
||||||
});
|
});
|
||||||
|
|
||||||
// If mailer does not exist it means mail has been disabled.
|
// If mailer does not exist it means mail has been disabled.
|
||||||
return result ?? { success: true };
|
// No error, just say no email was sent.
|
||||||
|
return result ?? { emailSent: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mailerInstance: UserManagementMailer | undefined;
|
let mailerInstance: UserManagementMailer | undefined;
|
||||||
|
|
||||||
export async function getInstance(): Promise<UserManagementMailer> {
|
export function getInstance(): UserManagementMailer {
|
||||||
if (mailerInstance === undefined) {
|
if (mailerInstance === undefined) {
|
||||||
mailerInstance = new UserManagementMailer();
|
mailerInstance = new UserManagementMailer();
|
||||||
try {
|
|
||||||
await mailerInstance.verifyConnection();
|
|
||||||
} catch (error) {
|
|
||||||
mailerInstance = undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return mailerInstance;
|
return mailerInstance;
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||||
url.searchParams.append('token', resetPasswordToken);
|
url.searchParams.append('token', resetPasswordToken);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mailer = await UserManagementMailer.getInstance();
|
const mailer = UserManagementMailer.getInstance();
|
||||||
await mailer.passwordReset({
|
await mailer.passwordReset({
|
||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
|
|
|
@ -14,6 +14,8 @@ import { UserRequest } from '@/requests';
|
||||||
import * as UserManagementMailer from '../email/UserManagementMailer';
|
import * as UserManagementMailer from '../email/UserManagementMailer';
|
||||||
import { N8nApp, PublicUser } from '../Interfaces';
|
import { N8nApp, PublicUser } from '../Interfaces';
|
||||||
import {
|
import {
|
||||||
|
addInviteLinktoUser,
|
||||||
|
generateUserInviteUrl,
|
||||||
getInstanceBaseUrl,
|
getInstanceBaseUrl,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
isEmailSetUp,
|
isEmailSetUp,
|
||||||
|
@ -34,25 +36,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
this.app.post(
|
this.app.post(
|
||||||
`/${this.restEndpoint}/users`,
|
`/${this.restEndpoint}/users`,
|
||||||
ResponseHelper.send(async (req: UserRequest.Invite) => {
|
ResponseHelper.send(async (req: UserRequest.Invite) => {
|
||||||
if (config.getEnv('userManagement.emails.mode') === '') {
|
const mailer = UserManagementMailer.getInstance();
|
||||||
Logger.debug(
|
|
||||||
'Request to send email invite(s) to user(s) failed because emailing was not set up',
|
|
||||||
);
|
|
||||||
throw new ResponseHelper.InternalServerError(
|
|
||||||
'Email sending must be set up in order to request a password reset email',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mailer: UserManagementMailer.UserManagementMailer | undefined;
|
|
||||||
try {
|
|
||||||
mailer = await UserManagementMailer.getInstance();
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new ResponseHelper.InternalServerError(
|
|
||||||
`There is a problem with your SMTP setup! ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this should be checked in the middleware rather than here
|
// TODO: this should be checked in the middleware rather than here
|
||||||
if (isUserManagementDisabled()) {
|
if (isUserManagementDisabled()) {
|
||||||
|
@ -143,19 +127,13 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
void InternalHooksManager.getInstance().onUserInvite({
|
|
||||||
user: req.user,
|
|
||||||
target_user_id: Object.values(createUsers) as string[],
|
|
||||||
public_api: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorReporter.error(error);
|
ErrorReporter.error(error);
|
||||||
Logger.error('Failed to create user shells', { userShells: createUsers });
|
Logger.error('Failed to create user shells', { userShells: createUsers });
|
||||||
throw new ResponseHelper.InternalServerError('An error occurred during user creation');
|
throw new ResponseHelper.InternalServerError('An error occurred during user creation');
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('Created user shell(s) successfully', { userId: req.user.id });
|
Logger.debug('Created user shell(s) successfully', { userId: req.user.id });
|
||||||
Logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
|
Logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
|
||||||
userShells: createUsers,
|
userShells: createUsers,
|
||||||
});
|
});
|
||||||
|
@ -168,27 +146,48 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
|
|
||||||
const emailingResults = await Promise.all(
|
const emailingResults = await Promise.all(
|
||||||
usersPendingSetup.map(async ([email, id]) => {
|
usersPendingSetup.map(async ([email, id]) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
if (!id) {
|
||||||
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`;
|
// This should never happen since those are removed from the list before reaching this point
|
||||||
const result = await mailer?.invite({
|
throw new ResponseHelper.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 mailer.invite({
|
||||||
email,
|
email,
|
||||||
inviteAcceptUrl,
|
inviteAcceptUrl,
|
||||||
domain: baseUrl,
|
domain: baseUrl,
|
||||||
});
|
});
|
||||||
const resp: { user: { id: string | null; email: string }; error?: string } = {
|
if (result.emailSent) {
|
||||||
user: {
|
resp.user.emailSent = true;
|
||||||
id,
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (result?.success) {
|
|
||||||
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
user_id: id!,
|
user_id: id,
|
||||||
message_type: 'New user invite',
|
message_type: 'New user invite',
|
||||||
public_api: false,
|
public_api: false,
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
void InternalHooksManager.getInstance().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 InternalHooksManager.getInstance().onEmailFailed({
|
void InternalHooksManager.getInstance().onEmailFailed({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
message_type: 'New user invite',
|
message_type: 'New user invite',
|
||||||
|
@ -200,7 +199,8 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
domain: baseUrl,
|
domain: baseUrl,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
resp.error = 'Email could not be sent';
|
resp.error = error.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
}),
|
}),
|
||||||
|
@ -361,10 +361,13 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
|
|
||||||
this.app.get(
|
this.app.get(
|
||||||
`/${this.restEndpoint}/users`,
|
`/${this.restEndpoint}/users`,
|
||||||
ResponseHelper.send(async () => {
|
ResponseHelper.send(async (req: UserRequest.List) => {
|
||||||
const users = await Db.collections.User.find({ relations: ['globalRole'] });
|
const users = await Db.collections.User.find({ relations: ['globalRole'] });
|
||||||
|
|
||||||
return users.map((user): PublicUser => sanitizeUser(user, ['personalizationAnswers']));
|
return users.map(
|
||||||
|
(user): PublicUser =>
|
||||||
|
addInviteLinktoUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -563,22 +566,27 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
const baseUrl = getInstanceBaseUrl();
|
const baseUrl = getInstanceBaseUrl();
|
||||||
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`;
|
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`;
|
||||||
|
|
||||||
let mailer: UserManagementMailer.UserManagementMailer | undefined;
|
const mailer = UserManagementMailer.getInstance();
|
||||||
try {
|
try {
|
||||||
mailer = await UserManagementMailer.getInstance();
|
const result = await mailer.invite({
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new ResponseHelper.InternalServerError(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await mailer?.invite({
|
|
||||||
email: reinvitee.email,
|
email: reinvitee.email,
|
||||||
inviteAcceptUrl,
|
inviteAcceptUrl,
|
||||||
domain: baseUrl,
|
domain: baseUrl,
|
||||||
});
|
});
|
||||||
|
if (result.emailSent) {
|
||||||
|
void InternalHooksManager.getInstance().onUserReinvite({
|
||||||
|
user: req.user,
|
||||||
|
target_user_id: reinvitee.id,
|
||||||
|
public_api: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result?.success) {
|
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
||||||
|
user_id: reinvitee.id,
|
||||||
|
message_type: 'Resend invite',
|
||||||
|
public_api: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
void InternalHooksManager.getInstance().onEmailFailed({
|
void InternalHooksManager.getInstance().onEmailFailed({
|
||||||
user: reinvitee,
|
user: reinvitee,
|
||||||
message_type: 'Resend invite',
|
message_type: 'Resend invite',
|
||||||
|
@ -591,19 +599,6 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
});
|
});
|
||||||
throw new ResponseHelper.InternalServerError(`Failed to send email to ${reinvitee.email}`);
|
throw new ResponseHelper.InternalServerError(`Failed to send email to ${reinvitee.email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
void InternalHooksManager.getInstance().onUserReinvite({
|
|
||||||
user: reinvitee,
|
|
||||||
target_user_id: reinvitee.id,
|
|
||||||
public_api: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
|
||||||
user_id: reinvitee.id,
|
|
||||||
message_type: 'Resend invite',
|
|
||||||
public_api: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
2
packages/cli/src/requests.d.ts
vendored
2
packages/cli/src/requests.d.ts
vendored
|
@ -197,6 +197,8 @@ export declare namespace PasswordResetRequest {
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export declare namespace UserRequest {
|
export declare namespace UserRequest {
|
||||||
|
export type List = AuthenticatedRequest;
|
||||||
|
|
||||||
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
|
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
|
||||||
|
|
||||||
export type ResolveSignUp = AuthlessRequest<
|
export type ResolveSignUp = AuthlessRequest<
|
||||||
|
|
|
@ -65,7 +65,8 @@ beforeEach(async () => {
|
||||||
|
|
||||||
config.set('userManagement.disabled', false);
|
config.set('userManagement.disabled', false);
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
config.set('userManagement.emails.mode', '');
|
config.set('userManagement.emails.mode', 'smtp');
|
||||||
|
config.set('userManagement.emails.smtp.host', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -432,26 +433,28 @@ test('POST /users/:id should fail with already accepted invite', async () => {
|
||||||
expect(storedMember.password).not.toBe(newMemberData.password);
|
expect(storedMember.password).not.toBe(newMemberData.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail if emailing is not set up', async () => {
|
test('POST /users should succeed if emailing is not set up', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
const response = await authAgent(owner)
|
const response = await authAgent(owner)
|
||||||
.post('/users')
|
.post('/users')
|
||||||
.send([{ email: randomEmail() }]);
|
.send([{ email: randomEmail() }]);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.data[0].user.inviteAcceptUrl).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should fail if user management is disabled', async () => {
|
test('POST /users should fail if user management is disabled', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
config.set('userManagement.disabled', true);
|
config.set('userManagement.disabled', true);
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||||
|
|
||||||
const response = await authAgent(owner)
|
const response = await authAgent(owner)
|
||||||
.post('/users')
|
.post('/users')
|
||||||
.send([{ email: randomEmail() }]);
|
.send([{ email: randomEmail() }]);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(500);
|
expect(response.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /users should email invites and create user shells but ignore existing', async () => {
|
test('POST /users should email invites and create user shells but ignore existing', async () => {
|
||||||
|
@ -567,16 +570,34 @@ test('POST /users/:id/reinvite should send reinvite, but fail if user already ac
|
||||||
expect(reinviteMemberResponse.statusCode).toBe(400);
|
expect(reinviteMemberResponse.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('UserManagementMailer expect NodeMailer.verifyConnection have been called', async () => {
|
test('UserManagementMailer expect NodeMailer.verifyConnection not be called when SMTP not set up', async () => {
|
||||||
jest.spyOn(NodeMailer.prototype, 'verifyConnection').mockImplementation(async () => {});
|
const mockVerifyConnection = jest.spyOn(NodeMailer.prototype, 'verifyConnection');
|
||||||
|
mockVerifyConnection.mockImplementation(async () => {});
|
||||||
|
|
||||||
// NodeMailer.verifyConnection called 1 time
|
|
||||||
const userManagementMailer = UserManagementMailer.getInstance();
|
const userManagementMailer = UserManagementMailer.getInstance();
|
||||||
// NodeMailer.verifyConnection called 2 time
|
// NodeMailer.verifyConnection gets called only explicitly
|
||||||
(await userManagementMailer).verifyConnection();
|
expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow();
|
||||||
|
|
||||||
expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(2);
|
expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
// @ts-ignore
|
mockVerifyConnection.mockRestore();
|
||||||
NodeMailer.prototype.verifyConnection.mockRestore();
|
});
|
||||||
|
|
||||||
|
test('UserManagementMailer expect NodeMailer.verifyConnection to be called when SMTP set up', async () => {
|
||||||
|
const mockVerifyConnection = jest.spyOn(NodeMailer.prototype, 'verifyConnection');
|
||||||
|
mockVerifyConnection.mockImplementation(async () => {});
|
||||||
|
const mockInit = jest.spyOn(NodeMailer.prototype, 'init');
|
||||||
|
mockInit.mockImplementation(async () => {});
|
||||||
|
|
||||||
|
// host needs to be set, otherwise smtp is skipped
|
||||||
|
config.set('userManagement.emails.smtp.host', 'host');
|
||||||
|
config.set('userManagement.emails.mode', 'smtp');
|
||||||
|
|
||||||
|
const userManagementMailer = new UserManagementMailer.UserManagementMailer();
|
||||||
|
// NodeMailer.verifyConnection gets called only explicitly
|
||||||
|
expect(async () => await userManagementMailer.verifyConnection()).not.toThrow();
|
||||||
|
|
||||||
|
// expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
mockVerifyConnection.mockRestore();
|
||||||
|
mockInit.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import N8nUsersList from './UsersList.vue';
|
import N8nUsersList from './UsersList.vue';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import type { StoryFn } from '@storybook/vue';
|
import type { StoryFn } from '@storybook/vue';
|
||||||
|
import { IUser } from '@/types';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Modules/UsersList',
|
title: 'Modules/UsersList',
|
||||||
|
@ -21,12 +22,24 @@ const Template: StoryFn = (args, { argTypes }) => ({
|
||||||
components: {
|
components: {
|
||||||
N8nUsersList,
|
N8nUsersList,
|
||||||
},
|
},
|
||||||
template: '<n8n-users-list v-bind="$props" @reinvite="onReinvite" @delete="onDelete" />',
|
template:
|
||||||
|
'<n8n-users-list v-bind="$props" :actions="actions" @reinvite="onReinvite" @delete="onDelete" />',
|
||||||
methods,
|
methods,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UsersList = Template.bind({});
|
export const UsersList = Template.bind({});
|
||||||
UsersList.args = {
|
UsersList.args = {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Resend Invite',
|
||||||
|
value: 'reinvite',
|
||||||
|
guard: (user: IUser) => !user.firstName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete User',
|
||||||
|
value: 'delete',
|
||||||
|
},
|
||||||
|
],
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|
|
@ -25,20 +25,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { IUser } from '../../types';
|
import { IUser, IUserListAction } from '../../types';
|
||||||
import N8nActionToggle from '../N8nActionToggle';
|
import N8nActionToggle from '../N8nActionToggle';
|
||||||
import N8nBadge from '../N8nBadge';
|
import N8nBadge from '../N8nBadge';
|
||||||
import N8nUserInfo from '../N8nUserInfo';
|
import N8nUserInfo from '../N8nUserInfo';
|
||||||
import Locale from '../../mixins/locale';
|
import Locale from '../../mixins/locale';
|
||||||
import mixins from 'vue-typed-mixins';
|
import mixins from 'vue-typed-mixins';
|
||||||
import { t } from '../../locale';
|
|
||||||
import { PropType } from 'vue';
|
import { PropType } from 'vue';
|
||||||
|
|
||||||
export interface IUserListAction {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default mixins(Locale).extend({
|
export default mixins(Locale).extend({
|
||||||
name: 'n8n-users-list',
|
name: 'n8n-users-list',
|
||||||
components: {
|
components: {
|
||||||
|
@ -61,17 +55,9 @@ export default mixins(Locale).extend({
|
||||||
currentUserId: {
|
currentUserId: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
deleteLabel: {
|
|
||||||
type: String,
|
|
||||||
default: () => t('nds.usersList.deleteUser'),
|
|
||||||
},
|
|
||||||
reinviteLabel: {
|
|
||||||
type: String,
|
|
||||||
default: () => t('nds.usersList.reinviteUser'),
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
type: Array as PropType<string[]>,
|
type: Array as PropType<IUserListAction[]>,
|
||||||
default: () => ['delete', 'reinvite'],
|
default: () => [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -115,37 +101,16 @@ export default mixins(Locale).extend({
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getActions(user: IUser): IUserListAction[] {
|
getActions(user: IUser): IUserListAction[] {
|
||||||
const actions = [];
|
|
||||||
const DELETE: IUserListAction = {
|
|
||||||
label: this.deleteLabel,
|
|
||||||
value: 'delete',
|
|
||||||
};
|
|
||||||
|
|
||||||
const REINVITE: IUserListAction = {
|
|
||||||
label: this.reinviteLabel,
|
|
||||||
value: 'reinvite',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user.isOwner) {
|
if (user.isOwner) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.firstName) {
|
const defaultGuard = () => true;
|
||||||
if (this.actions.includes('reinvite')) {
|
|
||||||
actions.push(REINVITE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.actions.includes('delete')) {
|
return this.actions.filter((action) => (action.guard || defaultGuard)(user));
|
||||||
actions.push(DELETE);
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
},
|
},
|
||||||
onUserAction(user: IUser, action: string): void {
|
onUserAction(user: IUser, action: string): void {
|
||||||
if (action === 'delete' || action === 'reinvite') {
|
|
||||||
this.$emit(action, user.id);
|
this.$emit(action, user.id);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,8 +3,6 @@ export default {
|
||||||
'nds.userInfo.you': '(you)',
|
'nds.userInfo.you': '(you)',
|
||||||
'nds.userSelect.selectUser': 'Select User',
|
'nds.userSelect.selectUser': 'Select User',
|
||||||
'nds.userSelect.noMatchingUsers': 'No matching users',
|
'nds.userSelect.noMatchingUsers': 'No matching users',
|
||||||
'nds.usersList.deleteUser': 'Delete User',
|
|
||||||
'nds.usersList.reinviteUser': 'Resend invite',
|
|
||||||
'notice.showMore': 'Show more',
|
'notice.showMore': 'Show more',
|
||||||
'notice.showLess': 'Show less',
|
'notice.showLess': 'Show less',
|
||||||
'formInput.validator.fieldRequired': 'This field is required',
|
'formInput.validator.fieldRequired': 'This field is required',
|
||||||
|
|
|
@ -6,4 +6,17 @@ export interface IUser {
|
||||||
email?: string;
|
email?: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
isPendingUser: boolean;
|
isPendingUser: boolean;
|
||||||
|
inviteAcceptUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUserListAction {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
guard?: (user: IUser) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUserListAction {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
guard?: (user: IUser) => boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -641,6 +641,7 @@ export interface IUser extends IUserResponse {
|
||||||
isDefaultUser: boolean;
|
isDefaultUser: boolean;
|
||||||
isPendingUser: boolean;
|
isPendingUser: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
inviteAcceptUrl?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}
|
}
|
||||||
|
@ -1297,6 +1298,8 @@ export interface IInviteResponse {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
inviteAcceptUrl: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,6 +120,13 @@ export async function reinvite(context: IRestApiContext, { id }: { id: string })
|
||||||
await makeRestApiRequest(context, 'POST', `/users/${id}/reinvite`);
|
await makeRestApiRequest(context, 'POST', `/users/${id}/reinvite`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInviteLink(
|
||||||
|
context: IRestApiContext,
|
||||||
|
{ id }: { id: string },
|
||||||
|
): Promise<{ link: string }> {
|
||||||
|
return await makeRestApiRequest(context, 'GET', `/users/${id}/invite-link`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitPersonalizationSurvey(
|
export async function submitPersonalizationSurvey(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: IPersonalizationLatestVersion,
|
params: IPersonalizationLatestVersion,
|
||||||
|
|
|
@ -3,13 +3,19 @@
|
||||||
<div v-if="!isSharingEnabled">
|
<div v-if="!isSharingEnabled">
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
:heading="
|
:heading="
|
||||||
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.title)
|
$locale.baseText(
|
||||||
|
uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.title,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
:description="
|
:description="
|
||||||
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.description)
|
$locale.baseText(
|
||||||
|
uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.description,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
:buttonText="
|
:buttonText="
|
||||||
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.button)
|
$locale.baseText(
|
||||||
|
uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.button,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
@click="goToUpgrade"
|
@click="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
|
@ -62,9 +68,9 @@
|
||||||
</template>
|
</template>
|
||||||
</n8n-user-select>
|
</n8n-user-select>
|
||||||
<n8n-users-list
|
<n8n-users-list
|
||||||
|
:actions="usersListActions"
|
||||||
:users="sharedWithList"
|
:users="sharedWithList"
|
||||||
:currentUserId="usersStore.currentUser.id"
|
:currentUserId="usersStore.currentUser.id"
|
||||||
:delete-label="$locale.baseText('credentialEdit.credentialSharing.list.delete')"
|
|
||||||
:readonly="!credentialPermissions.updateSharing"
|
:readonly="!credentialPermissions.updateSharing"
|
||||||
@delete="onRemoveSharee"
|
@delete="onRemoveSharee"
|
||||||
/>
|
/>
|
||||||
|
@ -73,7 +79,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { IUser, UIState } from '@/Interface';
|
import { IUser, IUserListAction, UIState } from '@/Interface';
|
||||||
import mixins from 'vue-typed-mixins';
|
import mixins from 'vue-typed-mixins';
|
||||||
import { showMessage } from '@/mixins/showMessage';
|
import { showMessage } from '@/mixins/showMessage';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
|
@ -96,12 +102,17 @@ export default mixins(showMessage).extend({
|
||||||
],
|
],
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useCredentialsStore, useUsersStore, useUsageStore, useUIStore, useSettingsStore),
|
...mapStores(useCredentialsStore, useUsersStore, useUsageStore, useUIStore, useSettingsStore),
|
||||||
|
usersListActions(): IUserListAction[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: this.$locale.baseText('credentialEdit.credentialSharing.list.delete'),
|
||||||
|
value: 'delete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
isDefaultUser(): boolean {
|
isDefaultUser(): boolean {
|
||||||
return this.usersStore.isDefaultUser;
|
return this.usersStore.isDefaultUser;
|
||||||
},
|
},
|
||||||
contextBasedTranslationKeys(): UIState['contextBasedTranslationKeys'] {
|
|
||||||
return this.uiStore.contextBasedTranslationKeys;
|
|
||||||
},
|
|
||||||
isSharingEnabled(): boolean {
|
isSharingEnabled(): boolean {
|
||||||
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
|
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
|
||||||
},
|
},
|
||||||
|
@ -168,7 +179,7 @@ export default mixins(showMessage).extend({
|
||||||
this.modalBus.$emit('close');
|
this.modalBus.$emit('close');
|
||||||
},
|
},
|
||||||
goToUpgrade() {
|
goToUpgrade() {
|
||||||
let linkUrl = this.$locale.baseText(this.contextBasedTranslationKeys.upgradeLinkUrl);
|
let linkUrl = this.$locale.baseText(this.uiStore.contextBasedTranslationKeys.upgradeLinkUrl);
|
||||||
if (linkUrl.includes('subscription')) {
|
if (linkUrl.includes('subscription')) {
|
||||||
linkUrl = this.usageStore.viewPlansUrl;
|
linkUrl = this.usageStore.viewPlansUrl;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,21 +2,54 @@
|
||||||
<Modal
|
<Modal
|
||||||
:name="INVITE_USER_MODAL_KEY"
|
:name="INVITE_USER_MODAL_KEY"
|
||||||
@enter="onSubmit"
|
@enter="onSubmit"
|
||||||
:title="$locale.baseText('settings.users.inviteNewUsers')"
|
:title="
|
||||||
|
$locale.baseText(
|
||||||
|
showInviteUrls ? 'settings.users.copyInviteUrls' : 'settings.users.inviteNewUsers',
|
||||||
|
)
|
||||||
|
"
|
||||||
:center="true"
|
:center="true"
|
||||||
width="460px"
|
width="460px"
|
||||||
:eventBus="modalBus"
|
:eventBus="modalBus"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<div v-if="showInviteUrls">
|
||||||
|
<n8n-users-list :users="invitedUsers">
|
||||||
|
<template #actions="{ user }">
|
||||||
|
<n8n-tooltip>
|
||||||
|
<template #content>
|
||||||
|
{{ $locale.baseText('settings.users.inviteLink.copy') }}
|
||||||
|
</template>
|
||||||
|
<n8n-icon-button
|
||||||
|
icon="link"
|
||||||
|
type="tertiary"
|
||||||
|
@click="onCopyInviteLink(user)"
|
||||||
|
></n8n-icon-button>
|
||||||
|
</n8n-tooltip>
|
||||||
|
</template>
|
||||||
|
</n8n-users-list>
|
||||||
|
</div>
|
||||||
<n8n-form-inputs
|
<n8n-form-inputs
|
||||||
|
v-else
|
||||||
:inputs="config"
|
:inputs="config"
|
||||||
:eventBus="formBus"
|
:eventBus="formBus"
|
||||||
:columnView="true"
|
:columnView="true"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
/>
|
/>
|
||||||
|
<n8n-info-tip v-if="!settingsStore.isSmtpSetup" class="mt-s">
|
||||||
|
<i18n path="settings.users.setupSMTPInfo">
|
||||||
|
<template #link>
|
||||||
|
<a
|
||||||
|
href="https://docs.n8n.io/reference/user-management.html#step-one-smtp"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ $locale.baseText('settings.users.setupSMTPInfo.link') }}
|
||||||
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
</i18n>
|
||||||
|
</n8n-info-tip>
|
||||||
|
</template>
|
||||||
|
<template v-if="!showInviteUrls" #footer>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="!enabledButton"
|
:disabled="!enabledButton"
|
||||||
|
@ -32,13 +65,15 @@
|
||||||
import mixins from 'vue-typed-mixins';
|
import mixins from 'vue-typed-mixins';
|
||||||
|
|
||||||
import { showMessage } from '@/mixins/showMessage';
|
import { showMessage } from '@/mixins/showMessage';
|
||||||
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import { IFormInputs, IInviteResponse } from '@/Interface';
|
import { IFormInputs, IInviteResponse, IUser } from '@/Interface';
|
||||||
import { VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
import { VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
||||||
import { ROLE } from '@/utils';
|
import { ROLE } from '@/utils';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUsersStore } from '@/stores/users';
|
import { useUsersStore } from '@/stores/users';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
|
||||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||||
|
|
||||||
|
@ -53,7 +88,7 @@ function getEmail(email: string): string {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default mixins(showMessage).extend({
|
export default mixins(showMessage, copyPaste).extend({
|
||||||
components: { Modal },
|
components: { Modal },
|
||||||
name: 'InviteUsersModal',
|
name: 'InviteUsersModal',
|
||||||
props: {
|
props: {
|
||||||
|
@ -67,6 +102,7 @@ export default mixins(showMessage).extend({
|
||||||
formBus: new Vue(),
|
formBus: new Vue(),
|
||||||
modalBus: new Vue(),
|
modalBus: new Vue(),
|
||||||
emails: '',
|
emails: '',
|
||||||
|
showInviteUrls: null as IInviteResponse[] | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
INVITE_USER_MODAL_KEY,
|
INVITE_USER_MODAL_KEY,
|
||||||
};
|
};
|
||||||
|
@ -108,22 +144,35 @@ export default mixins(showMessage).extend({
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useUsersStore),
|
...mapStores(useUsersStore, useSettingsStore),
|
||||||
emailsCount(): number {
|
emailsCount(): number {
|
||||||
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
|
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
|
||||||
},
|
},
|
||||||
buttonLabel(): string {
|
buttonLabel(): string {
|
||||||
if (this.emailsCount > 1) {
|
if (this.emailsCount > 1) {
|
||||||
return this.$locale.baseText('settings.users.inviteXUser', {
|
return this.$locale.baseText(
|
||||||
|
`settings.users.inviteXUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
|
||||||
|
{
|
||||||
interpolate: { count: this.emailsCount.toString() },
|
interpolate: { count: this.emailsCount.toString() },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.$locale.baseText('settings.users.inviteUser');
|
return this.$locale.baseText(
|
||||||
|
`settings.users.inviteUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
enabledButton(): boolean {
|
enabledButton(): boolean {
|
||||||
return this.emailsCount >= 1;
|
return this.emailsCount >= 1;
|
||||||
},
|
},
|
||||||
|
invitedUsers(): IUser[] {
|
||||||
|
console.log(this.usersStore.allUsers, this.showInviteUrls);
|
||||||
|
return this.showInviteUrls
|
||||||
|
? this.usersStore.allUsers.filter((user) =>
|
||||||
|
this.showInviteUrls!.find((invite) => invite.user.id === user.id),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
validateEmails(value: string | number | boolean | null | undefined) {
|
validateEmails(value: string | number | boolean | null | undefined) {
|
||||||
|
@ -165,56 +214,106 @@ export default mixins(showMessage).extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
const invited: IInviteResponse[] = await this.usersStore.inviteUsers(emails);
|
const invited: IInviteResponse[] = await this.usersStore.inviteUsers(emails);
|
||||||
const invitedEmails = invited.reduce(
|
const erroredInvites = invited.filter((invite) => invite.error);
|
||||||
(accu, { user, error }) => {
|
const successfulEmailInvites = invited.filter(
|
||||||
if (error) {
|
(invite) => !invite.error && invite.user.emailSent,
|
||||||
accu.error.push(user.email);
|
);
|
||||||
} else {
|
const successfulUrlInvites = invited.filter(
|
||||||
accu.success.push(user.email);
|
(invite) => !invite.error && !invite.user.emailSent,
|
||||||
}
|
|
||||||
return accu;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
success: [] as string[],
|
|
||||||
error: [] as string[],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invitedEmails.success.length) {
|
if (successfulEmailInvites.length) {
|
||||||
this.$showMessage({
|
this.$showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: this.$locale.baseText(
|
title: this.$locale.baseText(
|
||||||
invitedEmails.success.length > 1
|
successfulEmailInvites.length > 1
|
||||||
? 'settings.users.usersInvited'
|
? 'settings.users.usersInvited'
|
||||||
: 'settings.users.userInvited',
|
: 'settings.users.userInvited',
|
||||||
),
|
),
|
||||||
message: this.$locale.baseText('settings.users.emailInvitesSent', {
|
message: this.$locale.baseText('settings.users.emailInvitesSent', {
|
||||||
interpolate: { emails: invitedEmails.success.join(', ') },
|
interpolate: {
|
||||||
|
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invitedEmails.error.length) {
|
if (successfulUrlInvites.length) {
|
||||||
|
if (successfulUrlInvites.length === 1) {
|
||||||
|
this.copyToClipboard(successfulUrlInvites[0].user.inviteAcceptUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: this.$locale.baseText(
|
||||||
|
successfulUrlInvites.length > 1
|
||||||
|
? 'settings.users.multipleInviteUrlsCreated'
|
||||||
|
: 'settings.users.inviteUrlCreated',
|
||||||
|
),
|
||||||
|
message: this.$locale.baseText(
|
||||||
|
successfulUrlInvites.length > 1
|
||||||
|
? 'settings.users.multipleInviteUrlsCreated.message'
|
||||||
|
: 'settings.users.inviteUrlCreated.message',
|
||||||
|
{
|
||||||
|
interpolate: {
|
||||||
|
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (erroredInvites.length) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.$showMessage({
|
this.$showMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: this.$locale.baseText('settings.users.usersEmailedError'),
|
title: this.$locale.baseText('settings.users.usersEmailedError'),
|
||||||
message: this.$locale.baseText('settings.users.emailInvitesSentError', {
|
message: this.$locale.baseText('settings.users.emailInvitesSentError', {
|
||||||
interpolate: { emails: invitedEmails.error.join(', ') },
|
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}, 0); // notifications stack on top of each other otherwise
|
}, 0); // notifications stack on top of each other otherwise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (successfulUrlInvites.length > 1) {
|
||||||
|
this.showInviteUrls = successfulUrlInvites;
|
||||||
|
} else {
|
||||||
this.modalBus.$emit('close');
|
this.modalBus.$emit('close');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$showError(error, this.$locale.baseText('settings.users.usersInvitedError'));
|
this.$showError(error, this.$locale.baseText('settings.users.usersInvitedError'));
|
||||||
}
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
|
||||||
|
this.$showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: this.$locale.baseText(
|
||||||
|
successfulUrlInvites.length > 1
|
||||||
|
? 'settings.users.multipleInviteUrlsCreated'
|
||||||
|
: 'settings.users.inviteUrlCreated',
|
||||||
|
),
|
||||||
|
message: this.$locale.baseText(
|
||||||
|
successfulUrlInvites.length > 1
|
||||||
|
? 'settings.users.multipleInviteUrlsCreated.message'
|
||||||
|
: 'settings.users.inviteUrlCreated.message',
|
||||||
|
{
|
||||||
|
interpolate: {
|
||||||
|
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
onSubmitClick() {
|
onSubmitClick() {
|
||||||
this.formBus.$emit('submit');
|
this.formBus.$emit('submit');
|
||||||
},
|
},
|
||||||
|
onCopyInviteLink(user: IUser) {
|
||||||
|
if (user.inviteAcceptUrl && this.showInviteUrls) {
|
||||||
|
this.copyToClipboard(user.inviteAcceptUrl);
|
||||||
|
this.showCopyInviteLinkToast([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<n8n-text>
|
<n8n-text>
|
||||||
{{
|
{{
|
||||||
$locale.baseText(
|
$locale.baseText(
|
||||||
contextBasedTranslationKeys.workflows.sharing.unavailable.description.modal,
|
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.description.modal,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
|
@ -50,7 +50,6 @@
|
||||||
:currentUserId="currentUser.id"
|
:currentUserId="currentUser.id"
|
||||||
:delete-label="$locale.baseText('workflows.shareModal.list.delete')"
|
:delete-label="$locale.baseText('workflows.shareModal.list.delete')"
|
||||||
:readonly="!workflowPermissions.updateSharing"
|
:readonly="!workflowPermissions.updateSharing"
|
||||||
@delete="onRemoveSharee"
|
|
||||||
>
|
>
|
||||||
<template #actions="{ user }">
|
<template #actions="{ user }">
|
||||||
<n8n-select
|
<n8n-select
|
||||||
|
@ -71,7 +70,9 @@
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<n8n-text>
|
<n8n-text>
|
||||||
<i18n
|
<i18n
|
||||||
:path="contextBasedTranslationKeys.workflows.sharing.unavailable.description"
|
:path="
|
||||||
|
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.description
|
||||||
|
"
|
||||||
tag="span"
|
tag="span"
|
||||||
>
|
>
|
||||||
<template #action />
|
<template #action />
|
||||||
|
@ -85,7 +86,11 @@
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div v-if="!isSharingEnabled" :class="$style.actionButtons">
|
<div v-if="!isSharingEnabled" :class="$style.actionButtons">
|
||||||
<n8n-button @click="goToUpgrade">
|
<n8n-button @click="goToUpgrade">
|
||||||
{{ $locale.baseText(contextBasedTranslationKeys.workflows.sharing.unavailable.button) }}
|
{{
|
||||||
|
$locale.baseText(
|
||||||
|
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.button,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</n8n-button>
|
</n8n-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="isDefaultUser" :class="$style.actionButtons">
|
<div v-else-if="isDefaultUser" :class="$style.actionButtons">
|
||||||
|
@ -112,10 +117,12 @@
|
||||||
</n8n-button>
|
</n8n-button>
|
||||||
|
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<n8n-link :to="contextBasedTranslationKeys.workflows.sharing.unavailable.linkUrl">
|
<n8n-link :to="uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.linkUrl">
|
||||||
<n8n-button :loading="loading" size="medium">
|
<n8n-button :loading="loading" size="medium">
|
||||||
{{
|
{{
|
||||||
$locale.baseText(contextBasedTranslationKeys.workflows.sharing.unavailable.button)
|
$locale.baseText(
|
||||||
|
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.button,
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</n8n-button>
|
</n8n-button>
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
|
@ -192,8 +199,8 @@ export default mixins(showMessage).extend({
|
||||||
modalTitle(): string {
|
modalTitle(): string {
|
||||||
return this.$locale.baseText(
|
return this.$locale.baseText(
|
||||||
this.isSharingEnabled
|
this.isSharingEnabled
|
||||||
? this.contextBasedTranslationKeys.workflows.sharing.title
|
? this.uiStore.contextBasedTranslationKeys.workflows.sharing.title
|
||||||
: this.contextBasedTranslationKeys.workflows.sharing.unavailable.title,
|
: this.uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.title,
|
||||||
{
|
{
|
||||||
interpolate: { name: this.workflow.name },
|
interpolate: { name: this.workflow.name },
|
||||||
},
|
},
|
||||||
|
@ -235,9 +242,6 @@ export default mixins(showMessage).extend({
|
||||||
workflowOwnerName(): string {
|
workflowOwnerName(): string {
|
||||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
|
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
|
||||||
},
|
},
|
||||||
contextBasedTranslationKeys(): UIState['contextBasedTranslationKeys'] {
|
|
||||||
return this.uiStore.contextBasedTranslationKeys;
|
|
||||||
},
|
|
||||||
isDirty(): boolean {
|
isDirty(): boolean {
|
||||||
const previousSharedWith = this.workflow.sharedWith || [];
|
const previousSharedWith = this.workflow.sharedWith || [];
|
||||||
|
|
||||||
|
@ -433,7 +437,7 @@ export default mixins(showMessage).extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
goToUpgrade() {
|
goToUpgrade() {
|
||||||
let linkUrl = this.$locale.baseText(this.contextBasedTranslationKeys.upgradeLinkUrl);
|
let linkUrl = this.$locale.baseText(this.uiStore.contextBasedTranslationKeys.upgradeLinkUrl);
|
||||||
if (linkUrl.includes('subscription')) {
|
if (linkUrl.includes('subscription')) {
|
||||||
linkUrl = this.usageStore.viewPlansUrl;
|
linkUrl = this.usageStore.viewPlansUrl;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1078,24 +1078,35 @@
|
||||||
"settings.users.deleteConfirmationMessage": "Type “delete all data” to confirm",
|
"settings.users.deleteConfirmationMessage": "Type “delete all data” to confirm",
|
||||||
"settings.users.deleteConfirmationText": "delete all data",
|
"settings.users.deleteConfirmationText": "delete all data",
|
||||||
"settings.users.deleteUser": "Delete {user}",
|
"settings.users.deleteUser": "Delete {user}",
|
||||||
|
"settings.users.actions.delete": "Delete User",
|
||||||
|
"settings.users.actions.reinvite": "Resend Invite",
|
||||||
|
"settings.users.actions.copyInviteLink": "Copy Invite Link",
|
||||||
"settings.users.deleteWorkflowsAndCredentials": "Delete their workflows and credentials",
|
"settings.users.deleteWorkflowsAndCredentials": "Delete their workflows and credentials",
|
||||||
"settings.users.emailInvitesSent": "An invite email was sent to {emails}",
|
"settings.users.emailInvitesSent": "An invite email was sent to {emails}",
|
||||||
"settings.users.emailInvitesSentError": "Could not invite {emails}",
|
"settings.users.emailInvitesSentError": "Could not invite {emails}",
|
||||||
"settings.users.emailSentTo": "Email sent to {email}",
|
"settings.users.emailSentTo": "Email sent to {email}",
|
||||||
"settings.users.invalidEmailError": "{email} is not a valid email",
|
"settings.users.invalidEmailError": "{email} is not a valid email",
|
||||||
|
"settings.users.inviteLink.copy": "Copy Invite Link",
|
||||||
|
"settings.users.inviteLink.error": "Could not retrieve invite link",
|
||||||
"settings.users.invite": "Invite",
|
"settings.users.invite": "Invite",
|
||||||
"settings.users.inviteNewUsers": "Invite new users",
|
"settings.users.inviteNewUsers": "Invite new users",
|
||||||
|
"settings.users.copyInviteUrls": "You can now send the invitation links directly to your users",
|
||||||
"settings.users.inviteResent": "Invite resent",
|
"settings.users.inviteResent": "Invite resent",
|
||||||
"settings.users.inviteUser": "Invite user",
|
"settings.users.inviteUser": "Invite user",
|
||||||
|
"settings.users.inviteUser.inviteUrl": "Create invite link",
|
||||||
"settings.users.inviteXUser": "Invite {count} users",
|
"settings.users.inviteXUser": "Invite {count} users",
|
||||||
|
"settings.users.inviteXUser.inviteUrl": "Create {count} invite links",
|
||||||
|
"settings.users.inviteUrlCreated": "Invite link copied to clipboard",
|
||||||
|
"settings.users.inviteUrlCreated.message": "Send the invite link to your invitee for activation",
|
||||||
|
"settings.users.multipleInviteUrlsCreated": "Invite links created",
|
||||||
|
"settings.users.multipleInviteUrlsCreated.message": "Send the invite links to your invitees for activation",
|
||||||
"settings.users.newEmailsToInvite": "New User Email Addresses",
|
"settings.users.newEmailsToInvite": "New User Email Addresses",
|
||||||
"settings.users.noUsersToInvite": "No users to invite",
|
"settings.users.noUsersToInvite": "No users to invite",
|
||||||
"settings.users.setupMyAccount": "Set up my owner account",
|
"settings.users.setupMyAccount": "Set up my owner account",
|
||||||
"settings.users.setupSMTPToInviteUsers": "Set up SMTP to invite users. {action}",
|
|
||||||
"settings.users.setupSMTPToInviteUsers.instructions": "Instructions",
|
|
||||||
"settings.users.setupToInviteUsers": "To invite users, set up your own account",
|
"settings.users.setupToInviteUsers": "To invite users, set up your own account",
|
||||||
"settings.users.setupToInviteUsersInfo": "Invited users won’t be able to see workflows and credentials of other users unless you upgrade. <a href=\"https://docs.n8n.io/reference/user-management.html\" target=\"_blank\">More info</a> <br /> <br />",
|
"settings.users.setupToInviteUsersInfo": "Invited users won’t be able to see workflows and credentials of other users unless you upgrade. <a href=\"https://docs.n8n.io/reference/user-management.html\" target=\"_blank\">More info</a>",
|
||||||
"settings.users.setupSMTPInfo": "You will need details of an <a href=\"https://docs.n8n.io/reference/user-management.html#step-one-smtp\" target=\"_blank\">SMTP server</a> to complete the setup.",
|
"settings.users.setupSMTPInfo": "You can also send invite emails if you provide a valid {link} setup.",
|
||||||
|
"settings.users.setupSMTPInfo.link": "SMTP server",
|
||||||
"settings.users.smtpToAddUsersWarning": "Set up SMTP before adding users (so that n8n can send them invitation emails). <a target=\"_blank\" href=\"https://docs.n8n.io/reference/user-management.html#step-one-smtp\">Instructions</a>",
|
"settings.users.smtpToAddUsersWarning": "Set up SMTP before adding users (so that n8n can send them invitation emails). <a target=\"_blank\" href=\"https://docs.n8n.io/reference/user-management.html#step-one-smtp\">Instructions</a>",
|
||||||
"settings.users.transferWorkflowsAndCredentials": "Transfer their workflows and credentials to another user",
|
"settings.users.transferWorkflowsAndCredentials": "Transfer their workflows and credentials to another user",
|
||||||
"settings.users.transferredToUser": "Data transferred to {user}",
|
"settings.users.transferredToUser": "Data transferred to {user}",
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {
|
||||||
changePassword,
|
changePassword,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
|
getInviteLink,
|
||||||
getUsers,
|
getUsers,
|
||||||
inviteUsers,
|
inviteUsers,
|
||||||
login,
|
login,
|
||||||
|
@ -255,6 +256,10 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
await reinvite(rootStore.getRestApiContext, params);
|
await reinvite(rootStore.getRestApiContext, params);
|
||||||
},
|
},
|
||||||
|
async getUserInviteLink(params: { id: string }): Promise<{ link: string }> {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
return await getInviteLink(rootStore.getRestApiContext, params);
|
||||||
|
},
|
||||||
async submitPersonalizationSurvey(results: IPersonalizationLatestVersion): Promise<void> {
|
async submitPersonalizationSurvey(results: IPersonalizationLatestVersion): Promise<void> {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
await submitPersonalizationSurvey(rootStore.getRestApiContext, results);
|
await submitPersonalizationSurvey(rootStore.getRestApiContext, results);
|
||||||
|
|
|
@ -3,28 +3,12 @@
|
||||||
<div>
|
<div>
|
||||||
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.users') }}</n8n-heading>
|
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.users') }}</n8n-heading>
|
||||||
<div :class="$style.buttonContainer" v-if="!usersStore.showUMSetupWarning">
|
<div :class="$style.buttonContainer" v-if="!usersStore.showUMSetupWarning">
|
||||||
<n8n-tooltip :disabled="settingsStore.isSmtpSetup" placement="bottom">
|
|
||||||
<template #content>
|
|
||||||
<i18n path="settings.users.setupSMTPToInviteUsers" tag="span">
|
|
||||||
<template #action>
|
|
||||||
<a
|
|
||||||
href="https://docs.n8n.io/reference/user-management.html#step-one-smtp"
|
|
||||||
target="_blank"
|
|
||||||
v-text="$locale.baseText('settings.users.setupSMTPToInviteUsers.instructions')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</i18n>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:label="$locale.baseText('settings.users.invite')"
|
:label="$locale.baseText('settings.users.invite')"
|
||||||
@click="onInvite"
|
@click="onInvite"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="!settingsStore.isSmtpSetup"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n8n-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="usersStore.showUMSetupWarning" :class="$style.setupInfoContainer">
|
<div v-if="usersStore.showUMSetupWarning" :class="$style.setupInfoContainer">
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
|
@ -32,27 +16,25 @@
|
||||||
:buttonText="$locale.baseText('settings.users.setupMyAccount')"
|
:buttonText="$locale.baseText('settings.users.setupMyAccount')"
|
||||||
:description="`${
|
:description="`${
|
||||||
isSharingEnabled ? '' : $locale.baseText('settings.users.setupToInviteUsersInfo')
|
isSharingEnabled ? '' : $locale.baseText('settings.users.setupToInviteUsersInfo')
|
||||||
}${$locale.baseText('settings.users.setupSMTPInfo')}`"
|
}`"
|
||||||
@click="redirectToSetup"
|
@click="redirectToSetup"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.usersContainer" v-else>
|
<div :class="$style.usersContainer" v-else>
|
||||||
<PageAlert
|
|
||||||
v-if="!settingsStore.isSmtpSetup"
|
|
||||||
:message="$locale.baseText('settings.users.smtpToAddUsersWarning')"
|
|
||||||
:popupClass="$style.alert"
|
|
||||||
/>
|
|
||||||
<n8n-users-list
|
<n8n-users-list
|
||||||
|
:actions="usersListActions"
|
||||||
:users="usersStore.allUsers"
|
:users="usersStore.allUsers"
|
||||||
:currentUserId="usersStore.currentUserId"
|
:currentUserId="usersStore.currentUserId"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@reinvite="onReinvite"
|
@reinvite="onReinvite"
|
||||||
|
@copyInviteLink="onCopyInviteLink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<feature-coming-soon
|
<feature-coming-soon
|
||||||
v-for="fakeDoorFeature in fakeDoorFeatures"
|
v-for="fakeDoorFeature in fakeDoorFeatures"
|
||||||
:key="fakeDoorFeature.id"
|
:key="fakeDoorFeature.id"
|
||||||
:featureId="fakeDoorFeature.id"
|
:featureId="fakeDoorFeature.id"
|
||||||
|
class="pb-3xl"
|
||||||
showTitle
|
showTitle
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,15 +45,16 @@ import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/consta
|
||||||
|
|
||||||
import PageAlert from '../components/PageAlert.vue';
|
import PageAlert from '../components/PageAlert.vue';
|
||||||
import FeatureComingSoon from '@/components/FeatureComingSoon.vue';
|
import FeatureComingSoon from '@/components/FeatureComingSoon.vue';
|
||||||
import { IFakeDoor, IUser } from '@/Interface';
|
import { IFakeDoor, IUser, IUserListAction } from '@/Interface';
|
||||||
import mixins from 'vue-typed-mixins';
|
import mixins from 'vue-typed-mixins';
|
||||||
import { showMessage } from '@/mixins/showMessage';
|
import { showMessage } from '@/mixins/showMessage';
|
||||||
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUIStore } from '@/stores/ui';
|
import { useUIStore } from '@/stores/ui';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import { useUsersStore } from '@/stores/users';
|
import { useUsersStore } from '@/stores/users';
|
||||||
|
|
||||||
export default mixins(showMessage).extend({
|
export default mixins(showMessage, copyPaste).extend({
|
||||||
name: 'SettingsUsersView',
|
name: 'SettingsUsersView',
|
||||||
components: {
|
components: {
|
||||||
PageAlert,
|
PageAlert,
|
||||||
|
@ -87,6 +70,24 @@ export default mixins(showMessage).extend({
|
||||||
isSharingEnabled() {
|
isSharingEnabled() {
|
||||||
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
|
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
|
||||||
},
|
},
|
||||||
|
usersListActions(): IUserListAction[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: this.$locale.baseText('settings.users.actions.copyInviteLink'),
|
||||||
|
value: 'copyInviteLink',
|
||||||
|
guard: (user) => !user.firstName && !!user.inviteAcceptUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$locale.baseText('settings.users.actions.reinvite'),
|
||||||
|
value: 'reinvite',
|
||||||
|
guard: (user) => !user.firstName && this.settingsStore.isSmtpSetup,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: this.$locale.baseText('settings.users.actions.delete'),
|
||||||
|
value: 'delete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
fakeDoorFeatures(): IFakeDoor[] {
|
fakeDoorFeatures(): IFakeDoor[] {
|
||||||
return this.uiStore.getFakeDoorByLocation('settings/users');
|
return this.uiStore.getFakeDoorByLocation('settings/users');
|
||||||
},
|
},
|
||||||
|
@ -122,6 +123,18 @@ export default mixins(showMessage).extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async onCopyInviteLink(userId: string) {
|
||||||
|
const user = this.usersStore.getUserById(userId) as IUser | null;
|
||||||
|
if (user?.inviteAcceptUrl) {
|
||||||
|
this.copyToClipboard(user.inviteAcceptUrl);
|
||||||
|
|
||||||
|
this.$showToast({
|
||||||
|
type: 'success',
|
||||||
|
title: this.$locale.baseText('settings.users.inviteUrlCreated'),
|
||||||
|
message: this.$locale.baseText('settings.users.inviteUrlCreated.message'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue