We should avoid using margins to define space between elements, instead use the gap utility. The problem with this particular instance was that if Google auth was turned off and SSO is available, there is a weird spacing at the top caused by the margin from the SSO element. This PR will fix that. It also introduces a gap between the divider and the button, but that should be okay.
337 lines
11 KiB
Vue
337 lines
11 KiB
Vue
<script>
|
|
// utils and composables
|
|
import { login } from '../../api/auth';
|
|
import { mapGetters } from 'vuex';
|
|
import { parseBoolean } from '@chatwoot/utils';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { required, email } from '@vuelidate/validators';
|
|
import { useVuelidate } from '@vuelidate/core';
|
|
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
|
import SessionStorage from 'shared/helpers/sessionStorage';
|
|
import { useBranding } from 'shared/composables/useBranding';
|
|
|
|
// components
|
|
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
|
import FormInput from '../../components/Form/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 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';
|
|
|
|
export default {
|
|
components: {
|
|
FormInput,
|
|
GoogleOAuthButton,
|
|
Spinner,
|
|
NextButton,
|
|
SimpleDivider,
|
|
MfaVerification,
|
|
Icon,
|
|
},
|
|
props: {
|
|
ssoAuthToken: { type: String, default: '' },
|
|
ssoAccountId: { type: String, default: '' },
|
|
ssoConversationId: { type: String, default: '' },
|
|
email: { type: String, default: '' },
|
|
authError: { type: String, default: '' },
|
|
},
|
|
setup() {
|
|
const { replaceInstallationName } = useBranding();
|
|
return {
|
|
replaceInstallationName,
|
|
v$: useVuelidate(),
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
// We need to initialize the component with any
|
|
// properties that will be used in it
|
|
credentials: {
|
|
email: '',
|
|
password: '',
|
|
},
|
|
loginApi: {
|
|
message: '',
|
|
showLoading: false,
|
|
hasErrored: false,
|
|
},
|
|
error: '',
|
|
mfaRequired: false,
|
|
mfaToken: null,
|
|
};
|
|
},
|
|
validations() {
|
|
return {
|
|
credentials: {
|
|
password: {
|
|
required,
|
|
},
|
|
email: {
|
|
required,
|
|
email,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
computed: {
|
|
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
|
showGoogleOAuth() {
|
|
return Boolean(window.chatwootConfig.googleOAuthClientId);
|
|
},
|
|
showSignupLink() {
|
|
return parseBoolean(window.chatwootConfig.signupEnabled);
|
|
},
|
|
showSamlLogin() {
|
|
return this.globalConfig.isEnterprise;
|
|
},
|
|
},
|
|
created() {
|
|
if (this.ssoAuthToken) {
|
|
this.submitLogin();
|
|
}
|
|
if (this.authError) {
|
|
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
|
|
// Use a method to get the translated text to avoid dynamic key warning
|
|
const translatedMessage = this.getTranslatedMessage(messageKey);
|
|
useAlert(translatedMessage);
|
|
// wait for idle state
|
|
this.requestIdleCallbackPolyfill(() => {
|
|
// Remove the error query param from the url
|
|
const { query } = this.$route;
|
|
this.$router.replace({ query: { ...query, error: undefined } });
|
|
});
|
|
}
|
|
},
|
|
methods: {
|
|
getTranslatedMessage(key) {
|
|
// Avoid dynamic key warning by handling each case explicitly
|
|
switch (key) {
|
|
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
|
|
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
|
|
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
|
|
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
|
|
case 'LOGIN.API.UNAUTH':
|
|
default:
|
|
return this.$t('LOGIN.API.UNAUTH');
|
|
}
|
|
},
|
|
// TODO: Remove this when Safari gets wider support
|
|
// Ref: https://caniuse.com/requestidlecallback
|
|
//
|
|
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);
|
|
}
|
|
},
|
|
showAlertMessage(message) {
|
|
// Reset loading, current selected agent
|
|
this.loginApi.showLoading = false;
|
|
this.loginApi.message = message;
|
|
useAlert(this.loginApi.message);
|
|
},
|
|
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);
|
|
}
|
|
},
|
|
submitLogin() {
|
|
this.loginApi.hasErrored = false;
|
|
this.loginApi.showLoading = true;
|
|
|
|
const credentials = {
|
|
email: this.email
|
|
? decodeURIComponent(this.email)
|
|
: this.credentials.email,
|
|
password: this.credentials.password,
|
|
sso_auth_token: this.ssoAuthToken,
|
|
ssoAccountId: this.ssoAccountId,
|
|
ssoConversationId: this.ssoConversationId,
|
|
};
|
|
|
|
login(credentials)
|
|
.then(result => {
|
|
// Check if MFA is required
|
|
if (result?.mfaRequired) {
|
|
this.loginApi.showLoading = false;
|
|
this.mfaRequired = true;
|
|
this.mfaToken = result.mfaToken;
|
|
return;
|
|
}
|
|
|
|
this.handleImpersonation();
|
|
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
|
})
|
|
.catch(response => {
|
|
// Reset URL Params if the authentication is invalid
|
|
if (this.email) {
|
|
window.location = '/app/login';
|
|
}
|
|
this.loginApi.hasErrored = true;
|
|
this.showAlertMessage(
|
|
response?.message || this.$t('LOGIN.API.UNAUTH')
|
|
);
|
|
});
|
|
},
|
|
submitFormLogin() {
|
|
if (this.v$.credentials.email.$invalid && !this.email) {
|
|
this.showAlertMessage(this.$t('LOGIN.EMAIL.ERROR'));
|
|
return;
|
|
}
|
|
|
|
this.submitLogin();
|
|
},
|
|
handleMfaVerified() {
|
|
// MFA verification successful, continue with login
|
|
this.handleImpersonation();
|
|
window.location = '/app';
|
|
},
|
|
handleMfaCancel() {
|
|
// User cancelled MFA, reset state
|
|
this.mfaRequired = false;
|
|
this.mfaToken = null;
|
|
this.credentials.password = '';
|
|
},
|
|
},
|
|
};
|
|
</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': loginApi.hasErrored,
|
|
}"
|
|
>
|
|
<div v-if="!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-md 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">
|
|
<FormInput
|
|
v-model="credentials.email"
|
|
name="email_address"
|
|
type="text"
|
|
data-testid="email_input"
|
|
:tabindex="1"
|
|
required
|
|
:label="$t('LOGIN.EMAIL.LABEL')"
|
|
:placeholder="$t('LOGIN.EMAIL.PLACEHOLDER')"
|
|
:has-error="v$.credentials.email.$error"
|
|
@input="v$.credentials.email.$touch"
|
|
/>
|
|
<FormInput
|
|
v-model="credentials.password"
|
|
type="password"
|
|
name="password"
|
|
data-testid="password_input"
|
|
required
|
|
:tabindex="2"
|
|
:label="$t('LOGIN.PASSWORD.LABEL')"
|
|
:placeholder="$t('LOGIN.PASSWORD.PLACEHOLDER')"
|
|
:has-error="v$.credentials.password.$error"
|
|
@input="v$.credentials.password.$touch"
|
|
>
|
|
<p v-if="!globalConfig.disableUserProfileUpdate">
|
|
<router-link
|
|
to="auth/reset/password"
|
|
class="text-sm text-link"
|
|
tabindex="4"
|
|
>
|
|
{{ $t('LOGIN.FORGOT_PASSWORD') }}
|
|
</router-link>
|
|
</p>
|
|
</FormInput>
|
|
<NextButton
|
|
lg
|
|
type="submit"
|
|
data-testid="submit_button"
|
|
class="w-full"
|
|
:tabindex="3"
|
|
:label="$t('LOGIN.SUBMIT')"
|
|
:disabled="loginApi.showLoading"
|
|
:is-loading="loginApi.showLoading"
|
|
/>
|
|
</form>
|
|
</div>
|
|
<div v-else class="flex items-center justify-center">
|
|
<Spinner color-scheme="primary" size="" />
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|