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:
Alex Grozav 2023-01-05 17:10:08 +02:00 committed by GitHub
parent 11a46a4cbc
commit 2327563c44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 419 additions and 247 deletions

View file

@ -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,
}), }),
]); ]);
} }

View file

@ -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 {

View file

@ -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'],

View file

@ -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 = {

View file

@ -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 };
} }
} }

View file

@ -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;
} }

View file

@ -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,

View file

@ -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 };
}), }),
); );

View file

@ -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<

View file

@ -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();
}); });

View file

@ -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',

View file

@ -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);
}
}, },
}, },
}); });

View file

@ -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',

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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,

View file

@ -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;
} }

View file

@ -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>

View file

@ -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;
} }

View file

@ -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 wont 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 wont 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}",

View file

@ -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);

View file

@ -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>