n8n/packages/editor-ui/src/views/MfaView.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

279 lines
6.8 KiB
Vue
Raw Normal View History

<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
? i18.baseText('mfa.recovery.modal.title')
: i18.baseText('mfa.code.modal.title')
}}</n8n-heading>
</div>
<div :class="[$style.formContainer, reportError ? $style.formError : '']">
<n8n-form-inputs
v-if="formInputs"
data-test-id="mfa-login-form"
:inputs="formInputs"
:event-bus="formBus"
@input="onInput"
@submit="onSubmit"
/>
<div :class="$style.infoBox">
<n8n-text
v-if="!showRecoveryCodeForm && !reportError"
size="small"
color="text-base"
:bold="false"
>{{ i18.baseText('mfa.code.input.info') }}
<a data-test-id="mfa-enter-recovery-code-button" @click="onRecoveryCodeClick">{{
i18.baseText('mfa.code.input.info.action')
}}</a></n8n-text
>
<n8n-text v-if="reportError" color="danger" size="small"
>{{ formError }}
<a
v-if="!showRecoveryCodeForm"
:class="$style.recoveryCodeLink"
@click="onRecoveryCodeClick"
>
{{ i18.baseText('mfa.recovery.input.info.action') }}</a
>
</n8n-text>
</div>
</div>
<div>
<n8n-button
float="right"
:loading="verifyingMfaToken"
:label="
showRecoveryCodeForm
? i18.baseText('mfa.recovery.button.verify')
: i18.baseText('mfa.code.button.continue')
"
size="large"
:disabled="!hasAnyChanges"
@click="onSaveClick"
/>
<n8n-button
float="left"
:label="i18.baseText('mfa.button.back')"
size="large"
type="tertiary"
@click="onBackClick"
/>
</div>
</n8n-card>
</div>
</template>
<script setup lang="ts">
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,
MFA_FORM,
} from '@/constants';
import { mfaEventBus } from '@/event-bus';
import { onMounted, ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { toRefs } from '@vueuse/core';
// ---------------------------------------------------------------------------
// #region Props
// ---------------------------------------------------------------------------
const props = defineProps<{
reportError: boolean;
}>();
// #endregion
// ---------------------------------------------------------------------------
// #region Reactive properties
// ---------------------------------------------------------------------------
const hasAnyChanges = ref(false);
const formBus = ref(mfaEventBus);
const formInputs = ref<null | IFormInputs>(null);
const showRecoveryCodeForm = ref(false);
const verifyingMfaToken = ref(false);
const formError = ref('');
const { reportError } = toRefs(props);
// ---------------------------------------------------------------------------
// #region Composable
// ---------------------------------------------------------------------------
const i18 = useI18n();
// #endregion
// ---------------------------------------------------------------------------
// #region Emit
// ---------------------------------------------------------------------------
const emit = defineEmits<{
(event: 'onFormChanged', formField: string): void;
(event: 'onBackClick', formField: string): void;
(event: 'submit', form: { token: string; recoveryCode: string }): void;
}>();
// #endregion
// ---------------------------------------------------------------------------
// #region Methods
// ---------------------------------------------------------------------------
const formField = (
name: string,
label: string,
placeholder: string,
maxlength: number,
focus = true,
) => {
return {
name,
initialValue: '',
properties: {
label,
placeholder,
maxlength,
capitalize: true,
validateOnBlur: false,
focusInitially: focus,
},
};
};
const onRecoveryCodeClick = () => {
formError.value = '';
showRecoveryCodeForm.value = true;
hasAnyChanges.value = false;
formInputs.value = [mfaRecoveryCodeFieldWithDefaults()];
emit('onFormChanged', MFA_FORM.MFA_RECOVERY_CODE);
};
const onBackClick = () => {
if (!showRecoveryCodeForm.value) {
emit('onBackClick', MFA_FORM.MFA_TOKEN);
return;
}
showRecoveryCodeForm.value = false;
hasAnyChanges.value = true;
formInputs.value = [mfaTokenFieldWithDefaults()];
emit('onBackClick', MFA_FORM.MFA_RECOVERY_CODE);
};
const onSubmit = async (form: { token: string; recoveryCode: string }) => {
formError.value = !showRecoveryCodeForm.value
? i18.baseText('mfa.code.invalid')
: i18.baseText('mfa.recovery.invalid');
emit('submit', form);
};
const 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) {
hasAnyChanges.value = false;
return;
}
verifyingMfaToken.value = true;
hasAnyChanges.value = true;
const dataToSubmit = isSubmittingMfaToken
? { token: value, recoveryCode: '' }
: { token: '', recoveryCode: value };
onSubmit(dataToSubmit)
.catch(() => {})
.finally(() => (verifyingMfaToken.value = false));
};
const mfaRecoveryCodeFieldWithDefaults = () => {
return formField(
'recoveryCode',
i18.baseText('mfa.recovery.input.label'),
i18.baseText('mfa.recovery.input.placeholder'),
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
);
};
const mfaTokenFieldWithDefaults = () => {
return formField(
'token',
i18.baseText('mfa.code.input.label'),
i18.baseText('mfa.code.input.placeholder'),
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
);
};
const onSaveClick = () => {
formBus.value.emit('submit');
};
// #endregion
// ---------------------------------------------------------------------------
// #region Lifecycle hooks
// ---------------------------------------------------------------------------
onMounted(() => {
formInputs.value = [mfaTokenFieldWithDefaults()];
});
// #endregion
</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;
}
.formContainer {
padding-bottom: var(--spacing-xl);
}
.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>