mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 02:02:27 +00:00
# Pull Request Template ## Type of change ### Screenshots **Before** <img width="694" height="767" alt="image" src="https://github.com/user-attachments/assets/4a92816a-c13e-4750-88fc-b05fd6d05db6" /> <img width="395" height="690" alt="image" src="https://github.com/user-attachments/assets/eac0f15c-7c0f-4c20-942d-fbebdaec903e" /> <img width="506" height="753" alt="image" src="https://github.com/user-attachments/assets/b14bbf5a-5e0a-4ca5-91e2-dcc93b22ac26" /> **After** <img width="694" height="767" alt="image" src="https://github.com/user-attachments/assets/6984c8af-0e98-4688-bda4-fc5ceb3227ca" /> <img width="411" height="682" alt="image" src="https://github.com/user-attachments/assets/3b0f2c13-e4ea-4edc-9146-3c017d301a13" /> <img width="509" height="682" alt="image" src="https://github.com/user-attachments/assets/2090d3ed-36ef-4684-8185-3a0e6b1b0c15" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
335 lines
11 KiB
Vue
335 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',
|
|
};
|
|
|
|
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">
|
|
<GoogleOAuthButton v-if="showGoogleOAuth" />
|
|
<div v-if="showSamlLogin" class="mt-4 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>
|