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:
Konstantin Tieber
2025-09-16 14:00:14 +02:00
committed by GitHub
parent ccee1acf05
commit f0388aae7e
16 changed files with 501 additions and 37 deletions

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -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(
'Request to update user failed because SAML user may not change their email',
{
userId,
payload,
},
);
throw new BadRequestError('SAML user may not change their email');
}
if (mfaEnabled && isEmailBeingChanged) { await this.externalHooks.run('user.profile.beforeUpdate', [
if (!payload.mfaCode) { userId,
throw new BadRequestError('Two-factor code is required to change email'); currentEmail,
} payloadWithoutPassword,
]);
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.
*/ */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ConfirmPasswordModal > should render correctly 1`] = `
"<!--teleport start-->
<!--teleport end-->"
`;

View File

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

View File

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

View File

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

View File

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

View File

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