Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-09-23 20:20:50 +05:30
committed by GitHub
23 changed files with 632 additions and 46 deletions
@@ -555,10 +555,12 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {
@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
"SUBMIT": "Login",
"SAML": {
"LABEL": "Log in via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
}
}
}
}
@@ -1,21 +1,34 @@
<script setup>
import { watch, computed } from 'vue';
import { watch, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'next/spinner/Spinner.vue';
const { contactId } = defineProps({
contactId: { type: String, required: true },
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
const notes = computed(() => notGetterFn.value(contactId));
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const contactId = computed(() => props.contactId);
const noteContent = ref('');
const shouldShowCreateModal = ref(false);
const notes = computed(() => {
if (!contactId.value) {
return [];
}
return notesByContact.value(contactId.value) || [];
});
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
@@ -24,28 +37,130 @@ const getWrittenBy = ({ user } = {}) => {
: user?.name || t('CONVERSATION.BOT');
};
const openCreateModal = () => {
if (!contactId.value) {
return;
}
noteContent.value = '';
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
noteContent.value = '';
};
const onAdd = async () => {
if (!contactId.value || !noteContent.value || isCreatingNote.value) {
return;
}
await store.dispatch('contactNotes/create', {
content: noteContent.value,
contactId: contactId.value,
});
noteContent.value = '';
closeCreateModal();
};
const onDelete = noteId => {
if (!contactId.value || !noteId) {
return;
}
store.dispatch('contactNotes/delete', {
noteId,
contactId: contactId.value,
});
};
const keyboardEvents = {
'$mod+Enter': {
action: onAdd,
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
watch(
() => contactId,
() => store.dispatch('contactNotes/get', { contactId }),
contactId,
newContactId => {
closeCreateModal();
if (newContactId) {
store.dispatch('contactNotes/get', { contactId: newContactId });
}
},
{ immediate: true }
);
</script>
<template>
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
<Spinner />
</div>
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
</div>
<div v-else class="max-h-[300px] overflow-scroll">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="p-4 last-of-type:border-b-0"
:note="note"
collapsible
:written-by="getWrittenBy(note)"
/>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@click="openCreateModal"
/>
</div>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="notes.length"
class="flex flex-col max-h-[300px] overflow-y-auto"
>
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="py-4 last-of-type:border-b-0 px-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
collapsible
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
</p>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<div class="flex w-full flex-col gap-6 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE') }}
</h3>
<Editor
v-model="noteContent"
focus-on-mount
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
/>
<div class="flex items-center justify-end gap-3">
<NextButton
solid
blue
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
:is-loading="isCreatingNote"
:disabled="!noteContent || isCreatingNote"
@click="onAdd"
/>
</div>
</div>
</woot-modal>
</div>
</template>
@@ -29,9 +29,9 @@ const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
2000,
1500,
false,
5000
10000
);
export const actions = {
+6 -2
View File
@@ -1,3 +1,5 @@
import { parseBoolean } from '@chatwoot/utils';
const {
API_CHANNEL_NAME: apiChannelName,
API_CHANNEL_THUMBNAIL: apiChannelThumbnail,
@@ -15,6 +17,7 @@ const {
LOGO: logo,
LOGO_DARK: logoDark,
PRIVACY_URL: privacyURL,
IS_ENTERPRISE: isEnterprise,
TERMS_URL: termsURL,
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
@@ -30,8 +33,8 @@ const state = {
chatwootInboxToken,
deploymentEnv,
createNewAccountFromDashboard,
directUploadsEnabled: directUploadsEnabled === 'true',
disableUserProfileUpdate: disableUserProfileUpdate === 'true',
directUploadsEnabled: parseBoolean(directUploadsEnabled),
disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate),
displayManifest,
gitSha,
hCaptchaSiteKey,
@@ -42,6 +45,7 @@ const state = {
privacyURL,
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
};
export const getters = {
@@ -55,6 +55,7 @@ const model = defineModel({
<input
v-bind="$attrs"
v-model="model"
:name="name"
:type="type"
class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3"
:class="{
+8 -1
View File
@@ -41,7 +41,14 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
to.meta &&
to.meta.requireSignupEnabled;
if (!to.name || isAnInalidSignupNavigation) {
// Disable navigation to SAML login if enterprise is not enabled
// SAML route has an attribute (requireEnterprise) in it's definition
const isEnterpriseOnlyPath =
chatwootConfig.isEnterprise !== 'true' &&
to.meta &&
to.meta.requireEnterprise;
if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) {
next(frontendURL('login'));
return;
}
+11
View File
@@ -85,6 +85,9 @@ export default {
showSignupLink() {
return parseBoolean(window.chatwootConfig.signupEnabled);
},
showSamlLogin() {
return this.globalConfig.isEnterprise;
},
},
created() {
if (this.ssoAuthToken) {
@@ -302,5 +305,13 @@ export default {
<Spinner color-scheme="primary" size="" />
</div>
</section>
<div v-if="showSamlLogin" class="mt-6 text-center">
<router-link
to="/app/login/sso"
class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark"
>
{{ $t('LOGIN.SAML.LABEL') }}
</router-link>
</div>
</main>
</template>
+102
View File
@@ -0,0 +1,102 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
// components
import FormInput from '../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const store = useStore();
const { t } = useI18n();
const credentials = ref({
email: '',
});
const loginApi = ref({
showLoading: false,
hasErrored: false,
});
const validations = {
credentials: {
email: {
required,
email,
},
},
};
const v$ = useVuelidate(validations, { credentials });
const globalConfig = computed(() => store.getters['globalConfig/get']);
const csrfToken = ref('');
onMounted(() => {
csrfToken.value =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
});
</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">
{{ t('LOGIN.SAML.TITLE') }}
</h2>
</section>
<section
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="{
'animate-wiggle': loginApi.hasErrored,
}"
>
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
<FormInput
v-model="credentials.email"
name="email"
type="text"
:tabindex="1"
required
:label="t('LOGIN.SAML.WORK_EMAIL.LABEL')"
:placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')"
:has-error="v$.credentials.email.$error"
@input="v$.credentials.email.$touch"
/>
<NextButton
lg
type="submit"
class="w-full"
:tabindex="2"
:label="t('LOGIN.SAML.SUBMIT')"
:disabled="loginApi.showLoading"
:is-loading="loginApi.showLoading"
/>
</form>
</section>
<p class="mt-6 text-sm text-center text-n-slate-11">
<router-link to="/app/login" class="text-link text-n-brand">
{{ t('LOGIN.SAML.BACK_TO_LOGIN') }}
</router-link>
</p>
</main>
</template>
+7
View File
@@ -1,6 +1,7 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import Login from './login/Index.vue';
import SamlLogin from './login/Saml.vue';
import Signup from './auth/signup/Index.vue';
import ResetPassword from './auth/reset/password/Index.vue';
import Confirmation from './auth/confirmation/Index.vue';
@@ -20,6 +21,12 @@ export default [
authError: route.query.error,
}),
},
{
path: frontendURL('login/sso'),
name: 'sso_login',
component: SamlLogin,
meta: { requireEnterprise: true },
},
{
path: frontendURL('auth/signup'),
name: 'auth_signup',