Files
chatwoot/app/javascript/v3/views/login/Index.vue
T

325 lines
10 KiB
Vue

<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email as emailValidator } from '@vuelidate/validators';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { parseBoolean } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { login } from '../../api/auth';
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const props = defineProps({
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
ssoConversationId: { type: String, default: '' },
email: { type: String, default: '' },
authError: { type: String, default: '' },
});
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
};
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const globalConfig = useMapGetter('globalConfig/get');
const credentials = reactive({
email: '',
password: '',
});
const showLoading = ref(false);
const hasErrored = ref(false);
const mfaRequired = ref(false);
const mfaToken = ref(null);
const rules = computed(() => ({
credentials: {
password: {
required,
},
email: {
required,
email: emailValidator,
},
},
}));
const v$ = useVuelidate(rules, { credentials });
const showGoogleOAuth = computed(() => {
return Boolean(window.chatwootConfig.googleOAuthClientId);
});
const showSignupLink = computed(() => {
return parseBoolean(window.chatwootConfig.signupEnabled);
});
const showSamlLogin = computed(() => {
return Boolean(globalConfig.value.isEnterprise);
});
const getTranslatedMessage = key => {
// Avoid dynamic key warning by handling each case explicitly
switch (key) {
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
return t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
return t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
case 'LOGIN.API.UNAUTH':
default:
return t('LOGIN.API.UNAUTH');
}
};
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
const requestIdleCallbackPolyfill = callback => {
if (window.requestIdleCallback) {
window.requestIdleCallback(callback);
} else {
// Fallback for safari
// Using a delay of 0 allows the callback to be executed asynchronously
// in the next available event loop iteration, similar to requestIdleCallback
setTimeout(callback, 0);
}
};
const showAlertMessage = message => {
showLoading.value = false;
useAlert(message);
};
const handleImpersonation = () => {
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
const urlParams = new URLSearchParams(window.location.search);
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
if (impersonation) {
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
}
};
const submitLogin = async () => {
hasErrored.value = false;
showLoading.value = true;
const loginCredentials = {
email: props.email ? decodeURIComponent(props.email) : credentials.email,
password: credentials.password,
sso_auth_token: props.ssoAuthToken,
ssoAccountId: props.ssoAccountId,
ssoConversationId: props.ssoConversationId,
};
try {
const result = await login(loginCredentials);
// Check if MFA is required
if (result?.mfaRequired) {
showLoading.value = false;
mfaRequired.value = true;
mfaToken.value = result.mfaToken;
return;
}
handleImpersonation();
showAlertMessage(t('LOGIN.API.SUCCESS_MESSAGE'));
} catch (response) {
// Reset URL Params if the authentication is invalid
if (props.email) {
window.location = '/app/login';
}
hasErrored.value = true;
showAlertMessage(response?.message || t('LOGIN.API.UNAUTH'));
}
};
const submitFormLogin = () => {
if (v$.value.credentials.email.$invalid && !props.email) {
showAlertMessage(t('LOGIN.EMAIL.ERROR'));
return;
}
submitLogin();
};
const handleMfaVerified = () => {
// MFA verification successful, continue with login
handleImpersonation();
window.location = '/app';
};
const handleMfaCancel = () => {
// User cancelled MFA, reset state
mfaRequired.value = false;
mfaToken.value = null;
credentials.password = '';
};
onMounted(() => {
if (props.ssoAuthToken) {
submitLogin();
}
if (props.authError) {
const messageKey = ERROR_MESSAGES[props.authError] ?? 'LOGIN.API.UNAUTH';
const translatedMessage = getTranslatedMessage(messageKey);
useAlert(translatedMessage);
// wait for idle state
requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
const { query } = route;
router.replace({ query: { ...query, error: undefined } });
});
}
});
</script>
<template>
<main
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
>
<section class="max-w-5xl mx-auto">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="block w-auto h-8 mx-auto dark:hidden"
/>
<img
v-if="globalConfig.logoDark"
:src="globalConfig.logoDark"
:alt="globalConfig.installationName"
class="hidden w-auto h-8 mx-auto dark:block"
/>
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</h2>
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
{{ $t('COMMON.OR') }}
<router-link to="auth/signup" class="lowercase text-link text-n-brand">
{{ $t('LOGIN.CREATE_NEW_ACCOUNT') }}
</router-link>
</p>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@cancel="handleMfaCancel"
/>
</section>
<!-- Regular Login Section -->
<section
v-else
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,
'animate-wiggle': hasErrored,
}"
>
<div v-if="!props.email">
<div class="flex flex-col gap-4">
<GoogleOAuthButton v-if="showGoogleOAuth" />
<div v-if="showSamlLogin" class="text-center">
<router-link
to="/app/login/sso"
class="inline-flex justify-center w-full px-4 py-3 items-center bg-n-background dark:bg-n-solid-3 rounded-lg shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
>
<Icon
icon="i-lucide-lock-keyhole"
class="size-5 text-n-slate-11"
/>
<span class="ml-2 text-base font-medium text-n-slate-12">
{{ $t('LOGIN.SAML.LABEL') }}
</span>
</router-link>
</div>
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div>
<form class="space-y-5" @submit.prevent="submitFormLogin">
<Input
v-model="credentials.email"
name="email_address"
type="text"
data-testid="email_input"
autocomplete="email"
:tabindex="1"
required
:label="$t('LOGIN.EMAIL.LABEL')"
:placeholder="$t('LOGIN.EMAIL.PLACEHOLDER')"
:message-type="v$.credentials.email.$error ? 'error' : ''"
@input="v$.credentials.email.$touch"
/>
<Input
v-model="credentials.password"
type="password"
name="password"
data-testid="password_input"
autocomplete="current-password"
required
:tabindex="2"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('LOGIN.PASSWORD.PLACEHOLDER')"
:message-type="v$.credentials.password.$error ? 'error' : ''"
@input="v$.credentials.password.$touch"
>
<template #prefix>
<p
v-if="!globalConfig.disableUserProfileUpdate"
class="absolute ltr:right-0 rtl:left-0"
>
<router-link
to="auth/reset/password"
class="text-sm text-link"
tabindex="4"
>
{{ $t('LOGIN.FORGOT_PASSWORD') }}
</router-link>
</p>
</template>
</Input>
<NextButton
lg
type="submit"
data-testid="submit_button"
class="w-full"
:tabindex="3"
:label="$t('LOGIN.SUBMIT')"
:disabled="showLoading"
:is-loading="showLoading"
/>
</form>
</div>
<div v-else class="flex items-center justify-center">
<Spinner color-scheme="primary" size="" />
</div>
</section>
</main>
</template>