feat(core): Add MFA (#4767)
https://linear.app/n8n/issue/ADO-947/sync-branch-with-master-and-fix-fe-e2e-tets --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -14,7 +14,7 @@ import { useToast } from '@/composables';
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
@@ -39,7 +39,7 @@ export default defineComponent({
|
||||
...mapStores(useUsersStore),
|
||||
},
|
||||
async mounted() {
|
||||
this.config = {
|
||||
const form: IFormBoxConfig = {
|
||||
title: this.$locale.baseText('auth.changePassword'),
|
||||
buttonText: this.$locale.baseText('auth.changePassword'),
|
||||
redirectText: this.$locale.baseText('auth.signin'),
|
||||
@@ -77,6 +77,24 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const token = this.getResetToken();
|
||||
const mfaEnabled = this.getMfaEnabled();
|
||||
|
||||
if (mfaEnabled) {
|
||||
form.inputs.push({
|
||||
name: 'mfaToken',
|
||||
initialValue: '',
|
||||
properties: {
|
||||
required: true,
|
||||
label: this.$locale.baseText('mfa.code.input.label'),
|
||||
placeholder: this.$locale.baseText('mfa.code.input.placeholder'),
|
||||
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
capitalize: true,
|
||||
validateOnBlur: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.config = form;
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
@@ -110,18 +128,28 @@ export default defineComponent({
|
||||
this.password = e.value;
|
||||
}
|
||||
},
|
||||
getResetToken(): string | null {
|
||||
getResetToken() {
|
||||
return !this.$route.query.token || typeof this.$route.query.token !== 'string'
|
||||
? null
|
||||
: this.$route.query.token;
|
||||
},
|
||||
async onSubmit() {
|
||||
getMfaEnabled() {
|
||||
if (!this.$route.query.mfaEnabled) return null;
|
||||
return this.$route.query.mfaEnabled === 'true' ? true : false;
|
||||
},
|
||||
async onSubmit(values: { mfaToken: string }) {
|
||||
try {
|
||||
this.loading = true;
|
||||
const token = this.getResetToken();
|
||||
|
||||
if (token) {
|
||||
await this.usersStore.changePassword({ token, password: this.password });
|
||||
const changePasswordParameters = {
|
||||
token,
|
||||
password: this.password,
|
||||
...(values.mfaToken && { mfaToken: values.mfaToken }),
|
||||
};
|
||||
|
||||
await this.usersStore.changePassword(changePasswordParameters);
|
||||
|
||||
this.showMessage({
|
||||
type: 'success',
|
||||
|
||||
249
packages/editor-ui/src/views/MfaView.vue
Normal file
249
packages/editor-ui/src/views/MfaView.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.logoContainer">
|
||||
<Logo />
|
||||
</div>
|
||||
<n8n-card>
|
||||
<div :class="$style.headerContainer">
|
||||
<n8n-heading size="xlarge" color="text-dark">{{
|
||||
showRecoveryCodeForm
|
||||
? $locale.baseText('mfa.recovery.modal.title')
|
||||
: $locale.baseText('mfa.code.modal.title')
|
||||
}}</n8n-heading>
|
||||
</div>
|
||||
<div :class="[$style.formContainer, reportError ? $style.formError : '']">
|
||||
<n8n-form-inputs
|
||||
data-test-id="mfa-login-form"
|
||||
v-if="formInputs"
|
||||
:inputs="formInputs"
|
||||
:eventBus="formBus"
|
||||
@input="onInput"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
<div :class="$style.infoBox">
|
||||
<n8n-text
|
||||
size="small"
|
||||
color="text-base"
|
||||
:bold="false"
|
||||
v-if="!showRecoveryCodeForm && !reportError"
|
||||
>{{ $locale.baseText('mfa.code.input.info') }}
|
||||
<a data-test-id="mfa-enter-recovery-code-button" @click="onRecoveryCodeClick">{{
|
||||
$locale.baseText('mfa.code.input.info.action')
|
||||
}}</a></n8n-text
|
||||
>
|
||||
<n8n-text color="danger" v-if="reportError" size="small"
|
||||
>{{ formError }}
|
||||
<a
|
||||
v-if="!showRecoveryCodeForm"
|
||||
@click="onRecoveryCodeClick"
|
||||
:class="$style.recoveryCodeLink"
|
||||
>
|
||||
{{ $locale.baseText('mfa.recovery.input.info.action') }}</a
|
||||
>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-button
|
||||
float="right"
|
||||
:loading="verifyingMfaToken"
|
||||
:label="
|
||||
showRecoveryCodeForm
|
||||
? $locale.baseText('mfa.recovery.button.verify')
|
||||
: $locale.baseText('mfa.code.button.continue')
|
||||
"
|
||||
size="large"
|
||||
:disabled="!hasAnyChanges"
|
||||
@click="onSaveClick"
|
||||
/>
|
||||
<n8n-button
|
||||
float="left"
|
||||
:label="$locale.baseText('mfa.button.back')"
|
||||
size="large"
|
||||
type="tertiary"
|
||||
@click="onBackClick"
|
||||
/>
|
||||
</div>
|
||||
</n8n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
import type { IFormInputs } from '@/Interface';
|
||||
import Logo from '../components/Logo.vue';
|
||||
import {
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
} from '@/constants';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { mapStores } from 'pinia';
|
||||
import { mfaEventBus } from '@/event-bus';
|
||||
import { defineComponent } from 'vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
export const FORM = {
|
||||
MFA_TOKEN: 'MFA_TOKEN',
|
||||
MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE',
|
||||
} as const;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MfaView',
|
||||
mixins: [genericHelpers],
|
||||
components: {
|
||||
Logo,
|
||||
},
|
||||
props: {
|
||||
reportError: Boolean,
|
||||
},
|
||||
async mounted() {
|
||||
this.formInputs = [this.mfaTokenFieldWithDefaults()];
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
...useToast(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasAnyChanges: false,
|
||||
formBus: mfaEventBus,
|
||||
formInputs: null as null | IFormInputs,
|
||||
showRecoveryCodeForm: false,
|
||||
verifyingMfaToken: false,
|
||||
formError: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore),
|
||||
},
|
||||
methods: {
|
||||
onRecoveryCodeClick() {
|
||||
this.formError = '';
|
||||
this.showRecoveryCodeForm = true;
|
||||
this.hasAnyChanges = false;
|
||||
this.formInputs = [this.mfaRecoveryCodeFieldWithDefaults()];
|
||||
this.$emit('onFormChanged', FORM.MFA_RECOVERY_CODE);
|
||||
},
|
||||
onBackClick() {
|
||||
if (!this.showRecoveryCodeForm) {
|
||||
this.$emit('onBackClick', FORM.MFA_TOKEN);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showRecoveryCodeForm = false;
|
||||
this.hasAnyChanges = true;
|
||||
this.formInputs = [this.mfaTokenFieldWithDefaults()];
|
||||
this.$emit('onBackClick', FORM.MFA_RECOVERY_CODE);
|
||||
},
|
||||
onInput({ target: { value, name } }: { target: { value: string; name: string } }) {
|
||||
const isSubmittingMfaToken = name === 'token';
|
||||
const inputValidLength = isSubmittingMfaToken
|
||||
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
||||
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
||||
|
||||
if (value.length !== inputValidLength) {
|
||||
this.hasAnyChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifyingMfaToken = true;
|
||||
this.hasAnyChanges = true;
|
||||
|
||||
this.onSubmit({ token: value, recoveryCode: value })
|
||||
.catch(() => {})
|
||||
.finally(() => (this.verifyingMfaToken = false));
|
||||
},
|
||||
async onSubmit(form: { token: string; recoveryCode: string }) {
|
||||
this.formError = !this.showRecoveryCodeForm
|
||||
? this.$locale.baseText('mfa.code.invalid')
|
||||
: this.$locale.baseText('mfa.recovery.invalid');
|
||||
this.$emit('submit', form);
|
||||
},
|
||||
onSaveClick() {
|
||||
this.formBus.emit('submit');
|
||||
},
|
||||
mfaTokenFieldWithDefaults() {
|
||||
return this.formField(
|
||||
'token',
|
||||
this.$locale.baseText('mfa.code.input.label'),
|
||||
this.$locale.baseText('mfa.code.input.placeholder'),
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
);
|
||||
},
|
||||
mfaRecoveryCodeFieldWithDefaults() {
|
||||
return this.formField(
|
||||
'recoveryCode',
|
||||
this.$locale.baseText('mfa.recovery.input.label'),
|
||||
this.$locale.baseText('mfa.recovery.input.placeholder'),
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
);
|
||||
},
|
||||
formField(name: string, label: string, placeholder: string, maxlength: number) {
|
||||
return {
|
||||
name,
|
||||
initialValue: '',
|
||||
properties: {
|
||||
label,
|
||||
placeholder,
|
||||
maxlength,
|
||||
capitalize: true,
|
||||
validateOnBlur: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
body {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding-top: var(--spacing-2xl);
|
||||
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
width: 352px;
|
||||
}
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formContainer {
|
||||
padding-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.qrContainer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.formError input {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.recoveryCodeLink {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
padding-top: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,42 @@
|
||||
}}</n8n-link>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
<div v-if="isMfaFeatureEnabled">
|
||||
<div :class="$style.mfaSection">
|
||||
<n8n-input-label :label="$locale.baseText('settings.personal.mfa.section.title')">
|
||||
</n8n-input-label>
|
||||
<n8n-text :bold="false" :class="$style.infoText">
|
||||
{{
|
||||
mfaDisabled
|
||||
? $locale.baseText('settings.personal.mfa.button.disabled.infobox')
|
||||
: $locale.baseText('settings.personal.mfa.button.enabled.infobox')
|
||||
}}
|
||||
<n8n-link :to="mfaDocsUrl" size="small" :bold="true">
|
||||
{{ $locale.baseText('generic.learnMore') }}
|
||||
</n8n-link>
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.mfaButtonContainer" v-if="mfaDisabled">
|
||||
<n8n-button
|
||||
:class="$style.button"
|
||||
float="left"
|
||||
type="tertiary"
|
||||
:label="$locale.baseText('settings.personal.mfa.button.enabled')"
|
||||
data-test-id="enable-mfa-button"
|
||||
@click="onMfaEnableClick"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n8n-button
|
||||
:class="$style.disableMfaButton"
|
||||
float="left"
|
||||
type="tertiary"
|
||||
:label="$locale.baseText('settings.personal.mfa.button.disabled')"
|
||||
data-test-id="disable-mfa-button"
|
||||
@click="onMfaDisableClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-button
|
||||
@@ -59,8 +95,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n, useToast } from '@/composables';
|
||||
import { CHANGE_PASSWORD_MODAL_KEY } from '@/constants';
|
||||
import type { IFormInputs, IUser } from '@/Interface';
|
||||
import { CHANGE_PASSWORD_MODAL_KEY, MFA_DOCS_URL, MFA_SETUP_MODAL_KEY } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -84,6 +120,7 @@ export default defineComponent({
|
||||
formInputs: null as null | IFormInputs,
|
||||
formBus: createEventBus(),
|
||||
readyToSubmit: false,
|
||||
mfaDocsUrl: MFA_DOCS_URL,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -143,6 +180,12 @@ export default defineComponent({
|
||||
this.settingsStore.isSamlLoginEnabled && this.settingsStore.isDefaultAuthenticationSaml
|
||||
);
|
||||
},
|
||||
mfaDisabled(): boolean {
|
||||
return !this.usersStore.mfaEnabled;
|
||||
},
|
||||
isMfaFeatureEnabled(): boolean {
|
||||
return this.settingsStore.isMfaFeatureEnabled;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput() {
|
||||
@@ -178,6 +221,25 @@ export default defineComponent({
|
||||
openPasswordModal() {
|
||||
this.uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
|
||||
},
|
||||
onMfaEnableClick() {
|
||||
this.uiStore.openModal(MFA_SETUP_MODAL_KEY);
|
||||
},
|
||||
async onMfaDisableClick() {
|
||||
try {
|
||||
await this.usersStore.disabledMfa();
|
||||
this.showToast({
|
||||
title: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
||||
message: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.message'),
|
||||
type: 'success',
|
||||
duration: 0,
|
||||
});
|
||||
} catch (e) {
|
||||
this.showError(
|
||||
e,
|
||||
this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.error.message'),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -194,7 +256,6 @@ export default defineComponent({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
*:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -220,7 +281,36 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.disableMfaButton {
|
||||
--button-color: var(--color-danger);
|
||||
margin-top: var(--spacing-2xs);
|
||||
> span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: var(--spacing-xs);
|
||||
> span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.mfaSection {
|
||||
margin-top: var(--spacing-l);
|
||||
}
|
||||
|
||||
.infoText {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
margin-top: var(--spacing-2xl);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.mfaButtonContainer {
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
<template>
|
||||
<AuthView
|
||||
:form="FORM_CONFIG"
|
||||
:formLoading="loading"
|
||||
:with-sso="true"
|
||||
data-test-id="signin-form"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
<div>
|
||||
<AuthView
|
||||
v-if="!showMfaView"
|
||||
:form="FORM_CONFIG"
|
||||
:formLoading="loading"
|
||||
:with-sso="true"
|
||||
data-test-id="signin-form"
|
||||
@submit="onEmailPasswordSubmitted"
|
||||
/>
|
||||
<MfaView
|
||||
v-if="showMfaView"
|
||||
@submit="onMFASubmitted"
|
||||
@onBackClick="onBackClick"
|
||||
@onFormChanged="onFormChanged"
|
||||
:reportError="reportError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import AuthView from './AuthView.vue';
|
||||
import MfaView from './MfaView.vue';
|
||||
import { useToast } from '@/composables';
|
||||
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS } from '@/constants';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useCloudPlanStore, useUIStore } from '@/stores';
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
import { FORM } from './MfaView.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SigninView',
|
||||
mixins: [genericHelpers],
|
||||
components: {
|
||||
AuthView,
|
||||
MfaView,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@@ -34,10 +48,17 @@ export default defineComponent({
|
||||
return {
|
||||
FORM_CONFIG: {} as IFormBoxConfig,
|
||||
loading: false,
|
||||
showMfaView: false,
|
||||
email: '',
|
||||
password: '',
|
||||
reportError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore, useSettingsStore, useUIStore, useCloudPlanStore),
|
||||
userHasMfaEnabled() {
|
||||
return !!this.usersStore.currentUser?.mfaEnabled;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
let emailLabel = this.$locale.baseText('auth.email');
|
||||
@@ -84,31 +105,94 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(values: { [key: string]: string }) {
|
||||
async onMFASubmitted(form: { token?: string; recoveryCode?: string }) {
|
||||
await this.login({
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
token: form.token,
|
||||
recoveryCode: form.recoveryCode,
|
||||
});
|
||||
},
|
||||
async onEmailPasswordSubmitted(form: { email: string; password: string }) {
|
||||
await this.login(form);
|
||||
},
|
||||
async login(form: { email: string; password: string; token?: string; recoveryCode?: string }) {
|
||||
try {
|
||||
this.loading = true;
|
||||
await this.usersStore.loginWithCreds(values as { email: string; password: string });
|
||||
await this.usersStore.loginWithCreds({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
mfaToken: form.token,
|
||||
mfaRecoveryCode: form.recoveryCode,
|
||||
});
|
||||
this.loading = false;
|
||||
await this.cloudPlanStore.checkForCloudPlanData();
|
||||
await this.uiStore.initBanners();
|
||||
this.clearAllStickyNotifications();
|
||||
this.loading = false;
|
||||
this.checkRecoveryCodesLeft();
|
||||
|
||||
if (typeof this.$route.query.redirect === 'string') {
|
||||
const redirect = decodeURIComponent(this.$route.query.redirect);
|
||||
if (redirect.startsWith('/')) {
|
||||
// protect against phishing
|
||||
void this.$router.push(redirect);
|
||||
this.$telemetry.track('User attempted to login', {
|
||||
result: this.showMfaView ? 'mfa_success' : 'success',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
if (this.isRedirectSafe()) {
|
||||
const redirect = this.getRedirectQueryParameter();
|
||||
void this.$router.push(redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$router.push({ name: VIEWS.HOMEPAGE });
|
||||
} catch (error) {
|
||||
this.showError(error, this.$locale.baseText('auth.signin.error'));
|
||||
if (error.errorCode === MFA_AUTHENTICATION_REQUIRED_ERROR_CODE) {
|
||||
this.showMfaView = true;
|
||||
this.cacheCredentials(form);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$telemetry.track('User attempted to login', {
|
||||
result: this.showMfaView ? 'mfa_token_rejected' : 'credentials_error',
|
||||
});
|
||||
|
||||
if (!this.showMfaView) {
|
||||
this.showError(error, this.$locale.baseText('auth.signin.error'));
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reportError = true;
|
||||
}
|
||||
},
|
||||
onBackClick(fromForm: string) {
|
||||
this.reportError = false;
|
||||
if (fromForm === FORM.MFA_TOKEN) {
|
||||
this.showMfaView = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
onFormChanged(toForm: string) {
|
||||
if (toForm === FORM.MFA_RECOVERY_CODE) {
|
||||
this.reportError = false;
|
||||
}
|
||||
},
|
||||
cacheCredentials(form: { email: string; password: string }) {
|
||||
this.email = form.email;
|
||||
this.password = form.password;
|
||||
},
|
||||
checkRecoveryCodesLeft() {
|
||||
if (this.usersStore.currentUser) {
|
||||
const { hasRecoveryCodesLeft, mfaEnabled } = this.usersStore.currentUser;
|
||||
|
||||
if (mfaEnabled && !hasRecoveryCodesLeft) {
|
||||
this.showToast({
|
||||
title: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.title'),
|
||||
message: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.message'),
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user