mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
feat(core): Prompt user to confirm password when changing email and mfa is disabled (#19408)
Co-authored-by: Marc Littlemore <MarcL@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ccee1acf05
commit
f0388aae7e
@@ -236,7 +236,10 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
|||||||
INSTANCE_OWNER.email,
|
INSTANCE_OWNER.email,
|
||||||
updatedPersonalData.newPassword,
|
updatedPersonalData.newPassword,
|
||||||
);
|
);
|
||||||
personalSettingsPage.actions.updateEmail(updatedPersonalData.newEmail);
|
personalSettingsPage.actions.updateEmail(
|
||||||
|
updatedPersonalData.newEmail,
|
||||||
|
updatedPersonalData.newPassword,
|
||||||
|
);
|
||||||
successToast().should('contain', 'Personal details updated');
|
successToast().should('contain', 'Personal details updated');
|
||||||
personalSettingsPage.actions.loginWithNewData(
|
personalSettingsPage.actions.loginWithNewData(
|
||||||
updatedPersonalData.newEmail,
|
updatedPersonalData.newEmail,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class PersonalSettingsPage extends BasePage {
|
|||||||
firstNameInput: () => cy.getByTestId('firstName').find('input').first(),
|
firstNameInput: () => cy.getByTestId('firstName').find('input').first(),
|
||||||
lastNameInput: () => cy.getByTestId('lastName').find('input').first(),
|
lastNameInput: () => cy.getByTestId('lastName').find('input').first(),
|
||||||
emailInputContainer: () => cy.getByTestId('email'),
|
emailInputContainer: () => cy.getByTestId('email'),
|
||||||
|
currentPasswordConfirmationInput: () => cy.getByTestId('currentPassword').find('input'),
|
||||||
emailInput: () => cy.getByTestId('email').find('input').first(),
|
emailInput: () => cy.getByTestId('email').find('input').first(),
|
||||||
changePasswordLink: () => cy.getByTestId('change-password-link').first(),
|
changePasswordLink: () => cy.getByTestId('change-password-link').first(),
|
||||||
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
||||||
@@ -66,8 +67,14 @@ export class PersonalSettingsPage extends BasePage {
|
|||||||
.find('div[class^="_errorInput"]')
|
.find('div[class^="_errorInput"]')
|
||||||
.should('exist');
|
.should('exist');
|
||||||
},
|
},
|
||||||
updateEmail: (newEmail: string) => {
|
/**
|
||||||
|
* @param currentPassword only required if MFA is disabled
|
||||||
|
*/
|
||||||
|
updateEmail: (newEmail: string, currentPassword?: string) => {
|
||||||
this.getters.emailInput().type('{selectall}').type(newEmail).type('{enter}');
|
this.getters.emailInput().type('{selectall}').type(newEmail).type('{enter}');
|
||||||
|
if (currentPassword) {
|
||||||
|
this.getters.currentPasswordConfirmationInput().type(currentPassword).type('{enter}');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tryToSetInvalidEmail: (newEmail: string) => {
|
tryToSetInvalidEmail: (newEmail: string) => {
|
||||||
this.actions.updateEmail(newEmail);
|
this.actions.updateEmail(newEmail);
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ export class UserUpdateRequestDto extends Z.class({
|
|||||||
firstName: nameSchema().optional(),
|
firstName: nameSchema().optional(),
|
||||||
lastName: nameSchema().optional(),
|
lastName: nameSchema().optional(),
|
||||||
mfaCode: z.string().optional(),
|
mfaCode: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* The current password is required when changing the email address and MFA is disabled.
|
||||||
|
*/
|
||||||
|
currentPassword: z.string().optional(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe('MeController', () => {
|
|||||||
const user = mock<User>({
|
const user = mock<User>({
|
||||||
id: '123',
|
id: '123',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
|
email: 'current@email.com',
|
||||||
authIdentities: [],
|
authIdentities: [],
|
||||||
role: GLOBAL_OWNER_ROLE,
|
role: GLOBAL_OWNER_ROLE,
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
@@ -103,7 +104,7 @@ describe('MeController', () => {
|
|||||||
controller.updateCurrentUser(
|
controller.updateCurrentUser(
|
||||||
req,
|
req,
|
||||||
mock(),
|
mock(),
|
||||||
mock({ email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }),
|
mock({ email: user.email, firstName: 'John', lastName: 'Potato' }),
|
||||||
),
|
),
|
||||||
).rejects.toThrowError(new BadRequestError('Invalid email address'));
|
).rejects.toThrowError(new BadRequestError('Invalid email address'));
|
||||||
});
|
});
|
||||||
@@ -191,6 +192,133 @@ describe('MeController', () => {
|
|||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when mfa is disabled and email is being changed', () => {
|
||||||
|
const oldPasswordPlain = 'old_password';
|
||||||
|
const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; // Hashed 'old_password'
|
||||||
|
|
||||||
|
it('should throw BadRequestError if currentPassword is missing', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
id: '123',
|
||||||
|
email: 'michel-old@email.com',
|
||||||
|
password: passwordHash,
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.updateCurrentUser(
|
||||||
|
req,
|
||||||
|
mock(),
|
||||||
|
new UserUpdateRequestDto({
|
||||||
|
email: 'michel-new@email.com',
|
||||||
|
firstName: 'Michel',
|
||||||
|
lastName: 'n8n',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrowError(new BadRequestError('Current password is required to change email'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestError if currentPassword is not a string', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
id: '123',
|
||||||
|
email: 'michel-old@email.com',
|
||||||
|
password: passwordHash,
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.updateCurrentUser(req, mock(), {
|
||||||
|
email: 'michel-new@email.com',
|
||||||
|
firstName: 'Michel',
|
||||||
|
lastName: 'n8n',
|
||||||
|
currentPassword: 123 as any,
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrowError(new BadRequestError('Current password is required to change email'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestError if currentPassword is incorrect', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
email: 'michel-old@email.com',
|
||||||
|
password: passwordHash,
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.updateCurrentUser(
|
||||||
|
req,
|
||||||
|
mock(),
|
||||||
|
mock({
|
||||||
|
email: 'michel-new@email.com',
|
||||||
|
firstName: 'Michel',
|
||||||
|
lastName: 'n8n',
|
||||||
|
currentPassword: 'wrong-password',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrowError(
|
||||||
|
new BadRequestError(
|
||||||
|
'Unable to update profile. Please check your credentials and try again.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the user email if currentPassword is correct', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
email: 'michel-old@email.com',
|
||||||
|
password: passwordHash,
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
|
const res = mock<Response>();
|
||||||
|
userRepository.findOneByOrFail.mockResolvedValue(user);
|
||||||
|
userRepository.findOneOrFail.mockResolvedValue(user);
|
||||||
|
jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token');
|
||||||
|
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
|
||||||
|
|
||||||
|
const result = await controller.updateCurrentUser(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
mock({
|
||||||
|
email: 'michel-new@email.com',
|
||||||
|
firstName: 'Michel',
|
||||||
|
lastName: 'n8n',
|
||||||
|
currentPassword: oldPasswordPlain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userService.update).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not require currentPassword when email is not being changed', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
email: 'michel@email.com',
|
||||||
|
password: passwordHash,
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
|
const res = mock<Response>();
|
||||||
|
userRepository.findOneByOrFail.mockResolvedValue(user);
|
||||||
|
userRepository.findOneOrFail.mockResolvedValue(user);
|
||||||
|
jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token');
|
||||||
|
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
|
||||||
|
|
||||||
|
const result = await controller.updateCurrentUser(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
new UserUpdateRequestDto({
|
||||||
|
email: 'michel@email.com',
|
||||||
|
firstName: 'Michel',
|
||||||
|
lastName: 'n8n',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userService.update).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updatePassword', () => {
|
describe('updatePassword', () => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Body, Patch, Post, RestController } from '@n8n/decorators';
|
|||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
||||||
|
|
||||||
import { AuthService } from '@/auth/auth.service';
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
||||||
@@ -28,7 +30,6 @@ import {
|
|||||||
isOidcCurrentAuthenticationMethod,
|
isOidcCurrentAuthenticationMethod,
|
||||||
} from '@/sso.ee/sso-helpers';
|
} from '@/sso.ee/sso-helpers';
|
||||||
|
|
||||||
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
|
||||||
@RestController('/me')
|
@RestController('/me')
|
||||||
export class MeController {
|
export class MeController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -54,11 +55,11 @@ export class MeController {
|
|||||||
const {
|
const {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: currentEmail,
|
email: currentEmail,
|
||||||
mfaEnabled,
|
|
||||||
firstName: currentFirstName,
|
firstName: currentFirstName,
|
||||||
lastName: currentLastName,
|
lastName: currentLastName,
|
||||||
} = req.user;
|
} = req.user;
|
||||||
|
|
||||||
|
const { currentPassword, ...payloadWithoutPassword } = payload;
|
||||||
const { email, firstName, lastName } = payload;
|
const { email, firstName, lastName } = payload;
|
||||||
const isEmailBeingChanged = email !== currentEmail;
|
const isEmailBeingChanged = email !== currentEmail;
|
||||||
const isFirstNameChanged = firstName !== currentFirstName;
|
const isFirstNameChanged = firstName !== currentFirstName;
|
||||||
@@ -72,7 +73,7 @@ export class MeController {
|
|||||||
`Request to update user failed because ${getCurrentAuthenticationMethod()} user may not change their profile information`,
|
`Request to update user failed because ${getCurrentAuthenticationMethod()} user may not change their profile information`,
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
payload,
|
payload: payloadWithoutPassword,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
throw new BadRequestError(
|
throw new BadRequestError(
|
||||||
@@ -80,33 +81,16 @@ export class MeController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If SAML is enabled, we don't allow the user to change their email address
|
await this.validateChangingUserEmail(req.user, payload);
|
||||||
if (isSamlLicensedAndEnabled() && isEmailBeingChanged) {
|
|
||||||
this.logger.debug(
|
await this.externalHooks.run('user.profile.beforeUpdate', [
|
||||||
'Request to update user failed because SAML user may not change their email',
|
|
||||||
{
|
|
||||||
userId,
|
userId,
|
||||||
payload,
|
currentEmail,
|
||||||
},
|
payloadWithoutPassword,
|
||||||
);
|
]);
|
||||||
throw new BadRequestError('SAML user may not change their email');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mfaEnabled && isEmailBeingChanged) {
|
|
||||||
if (!payload.mfaCode) {
|
|
||||||
throw new BadRequestError('Two-factor code is required to change email');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
|
||||||
if (!isMfaCodeValid) {
|
|
||||||
throw new InvalidMfaCodeError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.externalHooks.run('user.profile.beforeUpdate', [userId, currentEmail, payload]);
|
|
||||||
|
|
||||||
const preUpdateUser = await this.userRepository.findOneByOrFail({ id: userId });
|
const preUpdateUser = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
await this.userService.update(userId, payload);
|
await this.userService.update(userId, payloadWithoutPassword);
|
||||||
const user = await this.userRepository.findOneOrFail({
|
const user = await this.userRepository.findOneOrFail({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
relations: ['role'],
|
relations: ['role'],
|
||||||
@@ -130,6 +114,60 @@ export class MeController {
|
|||||||
return publicUser;
|
return publicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateChangingUserEmail(currentUser: User, payload: UserUpdateRequestDto) {
|
||||||
|
if (!payload.email || payload.email === currentUser.email) {
|
||||||
|
// email is not being changed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword: providedCurrentPassword, ...payloadWithoutPassword } = payload;
|
||||||
|
const { id: userId, mfaEnabled } = currentUser;
|
||||||
|
|
||||||
|
// If SAML is enabled, we don't allow the user to change their email address
|
||||||
|
if (isSamlLicensedAndEnabled()) {
|
||||||
|
this.logger.debug(
|
||||||
|
'Request to update user failed because SAML user may not change their email',
|
||||||
|
{
|
||||||
|
userId: currentUser.id,
|
||||||
|
payload: payloadWithoutPassword,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new BadRequestError('SAML user may not change their email');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mfaEnabled) {
|
||||||
|
if (!payload.mfaCode) {
|
||||||
|
throw new BadRequestError('Two-factor code is required to change email');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||||
|
if (!isMfaCodeValid) {
|
||||||
|
throw new InvalidMfaCodeError();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentUser.password === null) {
|
||||||
|
this.logger.debug('User with no password changed their email', {
|
||||||
|
userId: currentUser.id,
|
||||||
|
payload: payloadWithoutPassword,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providedCurrentPassword || typeof providedCurrentPassword !== 'string') {
|
||||||
|
throw new BadRequestError('Current password is required to change email');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProvidedPasswordCorrect = await this.passwordUtility.compare(
|
||||||
|
providedCurrentPassword,
|
||||||
|
currentUser.password,
|
||||||
|
);
|
||||||
|
if (!isProvidedPasswordCorrect) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
'Unable to update profile. Please check your credentials and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the logged-in user's password.
|
* Update the logged-in user's password.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ownerPassword = randomValidPassword();
|
||||||
|
const memberPassword = randomValidPassword();
|
||||||
|
|
||||||
describe('Owner shell', () => {
|
describe('Owner shell', () => {
|
||||||
let ownerShell: User;
|
let ownerShell: User;
|
||||||
let authOwnerShellAgent: SuperAgentTest;
|
let authOwnerShellAgent: SuperAgentTest;
|
||||||
@@ -132,7 +135,6 @@ describe('Owner shell', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Member', () => {
|
describe('Member', () => {
|
||||||
const memberPassword = randomValidPassword();
|
|
||||||
let member: User;
|
let member: User;
|
||||||
let authMemberAgent: SuperAgentTest;
|
let authMemberAgent: SuperAgentTest;
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ describe('Member', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
|
for (const validPayload of getValidPatchMePayloads('member')) {
|
||||||
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
|
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
|
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
|
||||||
@@ -175,7 +177,7 @@ describe('Member', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH /me should fail with invalid inputs', async () => {
|
test('PATCH /me should fail with invalid inputs', async () => {
|
||||||
for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) {
|
for (const invalidPayload of getInvalidPatchMePayloads('member')) {
|
||||||
const response = await authMemberAgent.patch('/me').send(invalidPayload);
|
const response = await authMemberAgent.patch('/me').send(invalidPayload);
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
@@ -192,6 +194,39 @@ describe('Member', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PATCH /me should fail when changing email without currentPassword', async () => {
|
||||||
|
const payloadWithoutPassword = {
|
||||||
|
email: randomEmail(),
|
||||||
|
firstName: randomName(),
|
||||||
|
lastName: randomName(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent.patch('/me').send(payloadWithoutPassword);
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toContain('Current password is required to change email');
|
||||||
|
|
||||||
|
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
|
||||||
|
expect(storedMember.email).toBe(member.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCH /me should fail when changing email with wrong currentPassword', async () => {
|
||||||
|
const payloadWithWrongPassword = {
|
||||||
|
email: randomEmail(),
|
||||||
|
firstName: randomName(),
|
||||||
|
lastName: randomName(),
|
||||||
|
currentPassword: 'WrongPassword123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent.patch('/me').send(payloadWithWrongPassword);
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toContain(
|
||||||
|
'Unable to update profile. Please check your credentials and try again.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedMember = await Container.get(UserRepository).findOneByOrFail({});
|
||||||
|
expect(storedMember.email).toBe(member.email);
|
||||||
|
});
|
||||||
|
|
||||||
test('PATCH /me/password should succeed with valid inputs', async () => {
|
test('PATCH /me/password should succeed with valid inputs', async () => {
|
||||||
const validPayload = {
|
const validPayload = {
|
||||||
currentPassword: memberPassword,
|
currentPassword: memberPassword,
|
||||||
@@ -243,10 +278,13 @@ describe('Member', () => {
|
|||||||
|
|
||||||
describe('Owner', () => {
|
describe('Owner', () => {
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
|
const owner = await createUser({
|
||||||
|
role: GLOBAL_OWNER_ROLE,
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
const authOwnerAgent = testServer.authAgentFor(owner);
|
const authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
|
|
||||||
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
|
for (const validPayload of getValidPatchMePayloads('owner')) {
|
||||||
const response = await authOwnerAgent.patch('/me').send(validPayload);
|
const response = await authOwnerAgent.patch('/me').send(validPayload);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
@@ -314,6 +352,24 @@ const EMPTY_SURVEY: IPersonalizationSurveyAnswersV4 = {
|
|||||||
personalization_survey_n8n_version: '1.0.0',
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getValidPatchMePayloads(userType: 'owner' | 'member') {
|
||||||
|
return VALID_PATCH_ME_PAYLOADS.map((payload) => {
|
||||||
|
if (userType === 'owner') {
|
||||||
|
return { ...payload, currentPassword: ownerPassword };
|
||||||
|
}
|
||||||
|
return { ...payload, currentPassword: memberPassword };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvalidPatchMePayloads(userType: 'owner' | 'member') {
|
||||||
|
return INVALID_PATCH_ME_PAYLOADS.map((payload) => {
|
||||||
|
if (userType === 'owner') {
|
||||||
|
return { ...payload, currentPassword: ownerPassword };
|
||||||
|
}
|
||||||
|
return { ...payload, currentPassword: memberPassword };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_PATCH_ME_PAYLOADS = [
|
const VALID_PATCH_ME_PAYLOADS = [
|
||||||
{
|
{
|
||||||
email: randomEmail(),
|
email: randomEmail(),
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ describe('Instance owner', () => {
|
|||||||
await authOwnerAgent
|
await authOwnerAgent
|
||||||
.patch('/me')
|
.patch('/me')
|
||||||
.send({
|
.send({
|
||||||
email: randomEmail(),
|
email: owner.email,
|
||||||
firstName: randomName(),
|
firstName: randomName(),
|
||||||
lastName: randomName(),
|
lastName: randomName(),
|
||||||
password: randomValidPassword(),
|
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,6 +156,9 @@
|
|||||||
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
|
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
|
||||||
"auth.changePassword.reenterNewPassword": "Re-enter new password",
|
"auth.changePassword.reenterNewPassword": "Re-enter new password",
|
||||||
"auth.changePassword.tokenValidationError": "Invalid password-reset token",
|
"auth.changePassword.tokenValidationError": "Invalid password-reset token",
|
||||||
|
"auth.confirmPassword": "Confirm password",
|
||||||
|
"auth.confirmPassword.currentPassword": "Current password",
|
||||||
|
"auth.confirmPassword.confirmPasswordToChangeEmail": "Please confirm your password in order to change your email address.",
|
||||||
"auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
|
"auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
|
||||||
"auth.validation.missingParameters": "Missing token or user id",
|
"auth.validation.missingParameters": "Missing token or user id",
|
||||||
"auth.email": "Email",
|
"auth.email": "Email",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import ConfirmPasswordModal from '@/components/ConfirmPasswordModal/ConfirmPasswordModal.vue';
|
||||||
|
import type { createPinia } from 'pinia';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { CONFIRM_PASSWORD_MODAL_KEY } from '@/constants';
|
||||||
|
import { confirmPasswordEventBus } from './confirm-password.event-bus';
|
||||||
|
import { STORES } from '@n8n/stores';
|
||||||
|
|
||||||
|
const renderModal = createComponentRenderer(ConfirmPasswordModal);
|
||||||
|
|
||||||
|
const ModalStub = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<slot name="header" />
|
||||||
|
<slot name="title" />
|
||||||
|
<slot name="content" />
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[STORES.UI]: {
|
||||||
|
modalsById: {
|
||||||
|
[CONFIRM_PASSWORD_MODAL_KEY]: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modalStack: [CONFIRM_PASSWORD_MODAL_KEY],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const global = {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ConfirmPasswordModal', () => {
|
||||||
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createTestingPinia({ initialState });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render correctly', () => {
|
||||||
|
const wrapper = renderModal({ pinia });
|
||||||
|
|
||||||
|
expect(wrapper.html()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit password entered by the user when submitting form', async () => {
|
||||||
|
const eventBusSpy = vi.spyOn(confirmPasswordEventBus, 'emit');
|
||||||
|
|
||||||
|
const { getByTestId } = renderModal({
|
||||||
|
global,
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the onMounted hook to complete and form inputs to render
|
||||||
|
const input = await waitFor(() => getByTestId('currentPassword').querySelector('input')!);
|
||||||
|
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, 'testpassword123');
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('confirm-password-button'));
|
||||||
|
|
||||||
|
expect(eventBusSpy).toHaveBeenCalledWith('close', {
|
||||||
|
currentPassword: 'testpassword123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not submit form when password is empty', async () => {
|
||||||
|
const { getByTestId } = renderModal({
|
||||||
|
global,
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
const eventBusSpy = vi.spyOn(confirmPasswordEventBus, 'emit');
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('confirm-password-button'));
|
||||||
|
|
||||||
|
expect(eventBusSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { CONFIRM_PASSWORD_MODAL_KEY } from '../../constants';
|
||||||
|
import Modal from '@/components/Modal.vue';
|
||||||
|
import { createFormEventBus } from '@n8n/design-system/utils';
|
||||||
|
import type { IFormInputs, IFormInput, FormValues } from '@/Interface';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { confirmPasswordEventBus } from './confirm-password.event-bus';
|
||||||
|
|
||||||
|
const config = ref<IFormInputs | null>(null);
|
||||||
|
const formBus = createFormEventBus();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const onSubmit = (data: FormValues) => {
|
||||||
|
const currentPassword = (data as { currentPassword: string }).currentPassword;
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
confirmPasswordEventBus.emit('close', {
|
||||||
|
currentPassword,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmitClick = () => {
|
||||||
|
formBus.emit('submit');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const inputs: Record<string, IFormInput> = {
|
||||||
|
currentPassword: {
|
||||||
|
name: 'currentPassword',
|
||||||
|
properties: {
|
||||||
|
label: i18n.baseText('auth.confirmPassword.currentPassword'),
|
||||||
|
type: 'password',
|
||||||
|
required: true,
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
capitalize: true,
|
||||||
|
focusInitially: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const form: IFormInputs = [inputs.currentPassword];
|
||||||
|
|
||||||
|
config.value = form;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:name="CONFIRM_PASSWORD_MODAL_KEY"
|
||||||
|
:title="i18n.baseText('auth.confirmPassword')"
|
||||||
|
:center="true"
|
||||||
|
width="460px"
|
||||||
|
:event-bus="confirmPasswordEventBus"
|
||||||
|
@enter="onSubmitClick"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<n8n-text :class="$style.description" tag="p">{{
|
||||||
|
i18n.baseText('auth.confirmPassword.confirmPasswordToChangeEmail')
|
||||||
|
}}</n8n-text>
|
||||||
|
<n8n-form-inputs
|
||||||
|
v-if="config"
|
||||||
|
:inputs="config"
|
||||||
|
:event-bus="formBus"
|
||||||
|
:column-view="true"
|
||||||
|
@submit="onSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<n8n-button
|
||||||
|
:loading="loading"
|
||||||
|
:label="i18n.baseText('generic.confirm')"
|
||||||
|
float="right"
|
||||||
|
data-test-id="confirm-password-button"
|
||||||
|
@click="onSubmitClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" module>
|
||||||
|
.description {
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ConfirmPasswordModal > should render correctly 1`] = `
|
||||||
|
"<!--teleport start-->
|
||||||
|
<!--teleport end-->"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
|
|
||||||
|
export interface ConfirmPasswordClosedEventPayload {
|
||||||
|
currentPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmPasswordModalEvents {
|
||||||
|
close: ConfirmPasswordClosedEventPayload | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmPasswordEventBus = createEventBus<ConfirmPasswordModalEvents>();
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
||||||
|
CONFIRM_PASSWORD_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -50,6 +51,7 @@ import ActivationModal from '@/components/ActivationModal.vue';
|
|||||||
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
|
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
|
||||||
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
|
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
|
||||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
|
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
|
||||||
|
import ConfirmPasswordModal from '@/components/ConfirmPasswordModal/ConfirmPasswordModal.vue';
|
||||||
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
|
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
|
||||||
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
|
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
|
||||||
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
|
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
|
||||||
@@ -168,6 +170,10 @@ import NodeRecommendationModal from '@/experiments/templateRecoV2/components/Nod
|
|||||||
<ChangePasswordModal />
|
<ChangePasswordModal />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="CONFIRM_PASSWORD_MODAL_KEY">
|
||||||
|
<ConfirmPasswordModal />
|
||||||
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
||||||
<template #default="{ modalName, data }">
|
<template #default="{ modalName, data }">
|
||||||
<InviteUsersModal :modal-name="modalName" :data="data" />
|
<InviteUsersModal :modal-name="modalName" :data="data" />
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const MAX_TAG_NAME_LENGTH = 24;
|
|||||||
export const ABOUT_MODAL_KEY = 'about';
|
export const ABOUT_MODAL_KEY = 'about';
|
||||||
export const CHAT_EMBED_MODAL_KEY = 'chatEmbed';
|
export const CHAT_EMBED_MODAL_KEY = 'chatEmbed';
|
||||||
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
|
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
|
||||||
|
export const CONFIRM_PASSWORD_MODAL_KEY = 'confirmPassword';
|
||||||
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
|
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
|
||||||
export const API_KEY_CREATE_OR_EDIT_MODAL_KEY = 'createOrEditApiKey';
|
export const API_KEY_CREATE_OR_EDIT_MODAL_KEY = 'createOrEditApiKey';
|
||||||
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
|
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
WORKFLOW_DIFF_MODAL_KEY,
|
WORKFLOW_DIFF_MODAL_KEY,
|
||||||
PRE_BUILT_AGENTS_MODAL_KEY,
|
PRE_BUILT_AGENTS_MODAL_KEY,
|
||||||
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
||||||
|
CONFIRM_PASSWORD_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import type {
|
import type {
|
||||||
@@ -102,6 +103,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
ABOUT_MODAL_KEY,
|
ABOUT_MODAL_KEY,
|
||||||
CHAT_EMBED_MODAL_KEY,
|
CHAT_EMBED_MODAL_KEY,
|
||||||
CHANGE_PASSWORD_MODAL_KEY,
|
CHANGE_PASSWORD_MODAL_KEY,
|
||||||
|
CONFIRM_PASSWORD_MODAL_KEY,
|
||||||
CONTACT_PROMPT_MODAL_KEY,
|
CONTACT_PROMPT_MODAL_KEY,
|
||||||
CREDENTIAL_SELECT_MODAL_KEY,
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
DUPLICATE_MODAL_KEY,
|
DUPLICATE_MODAL_KEY,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { IFormInputs, ThemeOption } from '@/Interface';
|
|||||||
import type { IUser } from '@n8n/rest-api-client/api/users';
|
import type { IUser } from '@n8n/rest-api-client/api/users';
|
||||||
import {
|
import {
|
||||||
CHANGE_PASSWORD_MODAL_KEY,
|
CHANGE_PASSWORD_MODAL_KEY,
|
||||||
|
CONFIRM_PASSWORD_MODAL_KEY,
|
||||||
MFA_DOCS_URL,
|
MFA_DOCS_URL,
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
@@ -21,11 +22,17 @@ import type { MfaModalEvents } from '@/event-bus/mfa';
|
|||||||
import { promptMfaCodeBus } from '@/event-bus/mfa';
|
import { promptMfaCodeBus } from '@/event-bus/mfa';
|
||||||
import type { BaseTextKey } from '@n8n/i18n';
|
import type { BaseTextKey } from '@n8n/i18n';
|
||||||
import { useSSOStore } from '@/stores/sso.store';
|
import { useSSOStore } from '@/stores/sso.store';
|
||||||
|
import type { ConfirmPasswordModalEvents } from '@/components/ConfirmPasswordModal/confirm-password.event-bus';
|
||||||
|
import { confirmPasswordEventBus } from '@/components/ConfirmPasswordModal/confirm-password.event-bus';
|
||||||
|
|
||||||
type UserBasicDetailsForm = {
|
type UserBasicDetailsForm = {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
/**
|
||||||
|
* Required when changing the user email and no MFA enabled
|
||||||
|
*/
|
||||||
|
currentPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
|
type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
|
||||||
@@ -215,6 +222,20 @@ async function onSubmit(data: Record<string, string | number | boolean | null |
|
|||||||
mfaCode: payload.mfaCode,
|
mfaCode: payload.mfaCode,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else if (emailChanged) {
|
||||||
|
uiStore.openModal(CONFIRM_PASSWORD_MODAL_KEY);
|
||||||
|
confirmPasswordEventBus.once('close', async (payload: ConfirmPasswordModalEvents['close']) => {
|
||||||
|
if (!payload) {
|
||||||
|
// User closed the modal without submitting the form
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUserSettings({
|
||||||
|
...form,
|
||||||
|
currentPassword: payload.currentPassword,
|
||||||
|
});
|
||||||
|
uiStore.closeModal(CONFIRM_PASSWORD_MODAL_KEY);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await saveUserSettings(form);
|
await saveUserSettings(form);
|
||||||
}
|
}
|
||||||
@@ -230,6 +251,7 @@ async function updateUserBasicInfo(userBasicInfo: UserBasicDetailsWithMfa) {
|
|||||||
lastName: userBasicInfo.lastName,
|
lastName: userBasicInfo.lastName,
|
||||||
email: userBasicInfo.email,
|
email: userBasicInfo.email,
|
||||||
mfaCode: userBasicInfo.mfaCode,
|
mfaCode: userBasicInfo.mfaCode,
|
||||||
|
currentPassword: userBasicInfo.currentPassword,
|
||||||
});
|
});
|
||||||
hasAnyBasicInfoChanges.value = false;
|
hasAnyBasicInfoChanges.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user