iachat/app/javascript/v3/views/login/Index.vue
Pranav b9864fe1f6
fix: Use gap-4 instead of margins to define space between elements (#12728)
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.
2025-10-24 17:22:44 -07:00

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>