325 lines
10 KiB
Vue
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>
|