mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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
@@ -156,6 +156,9 @@
|
||||
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
|
||||
"auth.changePassword.reenterNewPassword": "Re-enter new password",
|
||||
"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.validation.missingParameters": "Missing token or user id",
|
||||
"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_SHARE_MODAL_KEY,
|
||||
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
||||
CONFIRM_PASSWORD_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
@@ -50,6 +51,7 @@ import ActivationModal from '@/components/ActivationModal.vue';
|
||||
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
|
||||
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
|
||||
import ConfirmPasswordModal from '@/components/ConfirmPasswordModal/ConfirmPasswordModal.vue';
|
||||
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
|
||||
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
|
||||
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
|
||||
@@ -168,6 +170,10 @@ import NodeRecommendationModal from '@/experiments/templateRecoV2/components/Nod
|
||||
<ChangePasswordModal />
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="CONFIRM_PASSWORD_MODAL_KEY">
|
||||
<ConfirmPasswordModal />
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
||||
<template #default="{ modalName, 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 CHAT_EMBED_MODAL_KEY = 'chatEmbed';
|
||||
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
|
||||
export const CONFIRM_PASSWORD_MODAL_KEY = 'confirmPassword';
|
||||
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
|
||||
export const API_KEY_CREATE_OR_EDIT_MODAL_KEY = 'createOrEditApiKey';
|
||||
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
WORKFLOW_DIFF_MODAL_KEY,
|
||||
PRE_BUILT_AGENTS_MODAL_KEY,
|
||||
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
|
||||
CONFIRM_PASSWORD_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import type {
|
||||
@@ -102,6 +103,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
ABOUT_MODAL_KEY,
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
CHANGE_PASSWORD_MODAL_KEY,
|
||||
CONFIRM_PASSWORD_MODAL_KEY,
|
||||
CONTACT_PROMPT_MODAL_KEY,
|
||||
CREDENTIAL_SELECT_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 {
|
||||
CHANGE_PASSWORD_MODAL_KEY,
|
||||
CONFIRM_PASSWORD_MODAL_KEY,
|
||||
MFA_DOCS_URL,
|
||||
MFA_SETUP_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 type { BaseTextKey } from '@n8n/i18n';
|
||||
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 = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
/**
|
||||
* Required when changing the user email and no MFA enabled
|
||||
*/
|
||||
currentPassword?: string;
|
||||
};
|
||||
|
||||
type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
|
||||
@@ -215,6 +222,20 @@ async function onSubmit(data: Record<string, string | number | boolean | null |
|
||||
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 {
|
||||
await saveUserSettings(form);
|
||||
}
|
||||
@@ -230,6 +251,7 @@ async function updateUserBasicInfo(userBasicInfo: UserBasicDetailsWithMfa) {
|
||||
lastName: userBasicInfo.lastName,
|
||||
email: userBasicInfo.email,
|
||||
mfaCode: userBasicInfo.mfaCode,
|
||||
currentPassword: userBasicInfo.currentPassword,
|
||||
});
|
||||
hasAnyBasicInfoChanges.value = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user