Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Muhsin Keloth
2025-09-22 16:38:21 +05:30
committed by GitHub
1074 changed files with 57428 additions and 5840 deletions
@@ -6,6 +6,7 @@ import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -20,6 +21,7 @@ const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
@@ -42,35 +44,25 @@ const breadcrumbItems = computed(() => {
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
title: 'Prospective Buyer',
description:
'Handle customers who are showing interest in purchasing a license',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if its a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
},
];
@@ -248,7 +240,9 @@ onMounted(() => {
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<component
:is="renderInstruction(formatMessage(item.instruction, false))"
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
@@ -6,10 +6,12 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
@@ -36,6 +38,7 @@ const bulkDeleteDialog = ref(null);
const selectedStatus = ref('all');
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
@@ -138,6 +141,9 @@ const fetchResponses = (page = 1) => {
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
store.dispatch('captainResponses/get', filterParams);
};
@@ -250,6 +256,10 @@ const handleAssistantFilterChange = assistant => {
fetchResponses();
};
const debouncedSearch = debounce(async () => {
fetchResponses();
}, 500);
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
@@ -292,34 +302,47 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
class="mb-4 -mt-3 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
autofocus
@input="debouncedSearch"
/>
</div>
@@ -1,5 +1,6 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
@@ -84,6 +85,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
@@ -127,6 +136,10 @@ export const filterAttributeGroups = [
key: 'blocked',
i18nKey: 'BLOCKED',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
],
},
];
@@ -11,6 +11,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
@@ -28,6 +29,7 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
},
props: {
contact: {
@@ -278,6 +280,14 @@ export default {
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -94,12 +94,7 @@ const loadMoreNotifications = () => {
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
const { id, primaryActorId, primaryActorType } = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
@@ -23,7 +23,7 @@ defineProps({
<div class="flex flex-col w-full h-full gap-8 font-inter">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<div>
<main>
<slot name="preBody" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
@@ -37,6 +37,6 @@ defineProps({
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
</div>
</main>
</div>
</template>
@@ -7,7 +7,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -33,12 +32,12 @@ export default {
NextInput,
},
setup() {
const { updateUISettings } = useUISettings();
const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { accountId } = useAccount();
const v$ = useVuelidate();
return { updateUISettings, v$, enabledLanguages, accountId };
return { updateUISettings, uiSettings, v$, enabledLanguages, accountId };
},
data() {
return {
@@ -112,7 +111,7 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = locale;
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
this.name = name;
this.locale = locale;
this.id = id;
@@ -137,21 +136,19 @@ export default {
domain: this.domain,
support_email: this.supportEmail,
});
this.$root.$i18n.locale = this.locale;
// If user locale is set, update the locale with user locale
if (this.uiSettings?.locale) {
this.$root.$i18n.locale = this.uiSettings?.locale;
} else {
// If user locale is not set, update the locale with account locale
this.$root.$i18n.locale = this.locale;
}
this.getAccount(this.id).locale = this.locale;
this.updateDirectionView(this.locale);
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
updateDirectionView(locale) {
const isRTLSupported = getLanguageDirection(locale);
this.updateUISettings({
rtl_view: isRTLSupported,
});
},
},
};
</script>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter();
const { t } = useI18n();
const agentAssignments = computed(() => [
{
key: 'agent_assignment_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-scale',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-inbox',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
},
],
},
{
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
},
]);
const handleClick = key => {
router.push({ name: key });
};
</script>
<template>
<SettingsLayout :no-records-found="false" class="gap-10">
<template #header>
<BaseSettingsHeader
:title="$t('ASSIGNMENT_POLICY.INDEX.HEADER.TITLE')"
:description="$t('ASSIGNMENT_POLICY.INDEX.HEADER.DESCRIPTION')"
feature-name="assignment-policy"
/>
</template>
<template #body>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6">
<AssignmentCard
v-for="item in agentAssignments"
:key="item.key"
:title="item.title"
:description="item.description"
:features="item.features"
@click="handleClick(item.key)"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,90 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import AssignmentPolicyIndex from './Index.vue';
import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue';
import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue';
import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue';
import AgentCapacityIndex from './pages/AgentCapacityIndexPage.vue';
import AgentCapacityCreate from './pages/AgentCapacityCreatePage.vue';
import AgentCapacityEdit from './pages/AgentCapacityEditPage.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/assignment-policy'),
component: SettingsWrapper,
children: [
{
path: '',
redirect: to => {
return { name: 'assignment_policy_index', params: to.params };
},
},
{
path: 'index',
name: 'assignment_policy_index',
component: AssignmentPolicyIndex,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'assignment',
name: 'agent_assignment_policy_index',
component: AgentAssignmentIndex,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'assignment/create',
name: 'agent_assignment_policy_create',
component: AgentAssignmentCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'assignment/edit/:id',
name: 'agent_assignment_policy_edit',
component: AgentAssignmentEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'capacity',
name: 'agent_capacity_policy_index',
component: AgentCapacityIndex,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'capacity/create',
name: 'agent_capacity_policy_create',
component: AgentCapacityCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'capacity/edit/:id',
name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
],
},
],
};
@@ -0,0 +1,17 @@
// Assignment order types
export const ROUND_ROBIN = 'round_robin';
export const BALANCED = 'balanced';
// Assignment priority types
export const EARLIEST_CREATED = 'earliest_created';
export const LONGEST_WAITING = 'longest_waiting';
// Default values for fair distribution
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
PRIORITY: [EARLIEST_CREATED, LONGEST_WAITING],
};
@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
]);
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch('assignmentPolicies/create', formState);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_assignment_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,197 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import {
ROUND_ROBIN,
EARLIEST_CREATED,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const selectedPolicyById = useMapGetter(
'assignmentPolicies/getAssignmentPolicyById'
);
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
);
const inboxList = computed(() =>
buildInboxList(
inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name))
)
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
}));
const handleDeleteInbox = inboxId =>
store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const setInboxPolicy = async (inboxId, policyId) => {
try {
await store.dispatch('assignmentPolicies/setInboxPolicy', {
inboxId,
policyId,
});
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.SUCCESS_MESSAGE`));
await store.dispatch(
'assignmentPolicies/getInboxes',
Number(routeId.value)
);
return true;
} catch (error) {
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.ERROR_MESSAGE`));
return false;
}
};
const handleAddInbox = async inbox => {
try {
const policy = await store.dispatch('assignmentPolicies/getInboxPolicy', {
inboxId: inbox?.id,
});
if (policy?.id !== selectedPolicy.value?.id) {
inboxLinkedPolicy.value = {
...policy,
assignedInboxCount: policy.assignedInboxCount - 1,
};
confirmInboxDialogRef.value.openDialog(inbox);
return;
}
} catch (error) {
// If getInboxPolicy fails, continue to setInboxPolicy
}
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
if (success) {
// Update the policy to reflect the assigned inbox count change
await store.dispatch('assignmentPolicies/updateInboxPolicy', {
policy: inboxLinkedPolicy.value,
});
// Fetch the updated inboxes for the policy after update, to reflect real-time changes
store.dispatch(
'assignmentPolicies/getInboxes',
inboxLinkedPolicy.value?.id
);
inboxLinkedPolicy.value = null;
confirmInboxDialogRef.value.closeDialog();
}
};
const handleSubmit = async formState => {
try {
await store.dispatch('assignmentPolicies/update', {
id: selectedPolicy.value?.id,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch policy if not available
if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
await store.dispatch('assignmentPolicies/getInboxes', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-inboxes="policyInboxes"
:inbox-list="inboxList"
show-inbox-section
:is-loading="uiFlags.isUpdating"
:is-inbox-loading="inboxUiFlags.isFetching"
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
/>
</template>
<ConfirmInboxDialog
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
</SettingsLayout>
</template>
@@ -0,0 +1,128 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyCard from 'dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue';
import ConfirmDeletePolicyDialog from './components/ConfirmDeletePolicyDialog.vue';
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const agentAssignmentsPolicies = useMapGetter(
'assignmentPolicies/getAssignmentPolicies'
);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const confirmDeletePolicyDialogRef = ref(null);
const breadcrumbItems = computed(() => {
const items = [
{
label: t('ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
},
];
return items;
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const onClickCreatePolicy = () => {
router.push({
name: 'agent_assignment_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_assignment_policy_edit',
params: {
id,
},
});
};
const handleFetchInboxes = id => {
if (inboxUiFlags.value.isFetching) return;
store.dispatch('assignmentPolicies/getInboxes', id);
};
const handleDelete = id => {
confirmDeletePolicyDialogRef.value.openDialog(id);
};
const handleDeletePolicy = async policyId => {
try {
await store.dispatch('assignmentPolicies/delete', policyId);
useAlert(
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.DELETE_POLICY.SUCCESS_MESSAGE'
)
);
confirmDeletePolicyDialogRef.value.closeDialog();
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.DELETE_POLICY.ERROR_MESSAGE')
);
}
};
onMounted(() => {
store.dispatch('assignmentPolicies/get');
});
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:no-records-found="agentAssignmentsPolicies.length === 0"
:no-records-message="
$t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.NO_RECORDS_FOUND')
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
$t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.CREATE_POLICY'
)
}}
</Button>
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<AssignmentPolicyCard
v-for="policy in agentAssignmentsPolicies"
:key="policy.id"
v-bind="policy"
:is-fetching-inboxes="inboxUiFlags.isFetching"
@fetch-inboxes="handleFetchInboxes"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>
</template>
<ConfirmDeletePolicyDialog
ref="confirmDeletePolicyDialogRef"
@delete="handleDeletePolicy"
/>
</SettingsLayout>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const labelsList = useMapGetter('labels/getLabels');
const allLabels = computed(() =>
labelsList.value?.map(({ title, color, id }) => ({
id,
name: title,
color,
}))
);
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_capacity_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.HEADER.TITLE'),
},
]);
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch(
'agentCapacityPolicies/create',
formState
);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_capacity_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
:label-list="allLabels"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,192 @@
<script setup>
import { computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
const selectedPolicyById = useMapGetter(
'agentCapacityPolicies/getAgentCapacityPolicyById'
);
const agentsList = useMapGetter('agents/getAgents');
const labelsList = useMapGetter('labels/getLabels');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const selectedPolicyId = computed(() => selectedPolicy.value?.id);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_capacity_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
const buildList = items =>
items?.map(({ name, title, id, email, avatarUrl, thumbnail, color }) => ({
name: name || title,
id,
email,
avatarUrl: avatarUrl || thumbnail,
color,
})) || [];
const policyUsers = computed(() => buildList(selectedPolicy.value?.users));
const allAgents = computed(() =>
buildList(camelcaseKeys(agentsList.value)).filter(
agent => !policyUsers.value?.some(user => user.id === agent.id)
)
);
const allLabels = computed(() => buildList(labelsList.value));
const allInboxes = computed(
() =>
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
exclusionRules: {
excludedLabels: [
...(selectedPolicy.value?.exclusionRules?.excludedLabels || []),
],
excludeOlderThanHours:
selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10,
},
inboxCapacityLimits:
selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({
...limit,
})) || [],
}));
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleDeleteUser = agentId => {
store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
};
const handleAddUser = agent => {
store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
};
const handleDeleteInboxLimit = limitId => {
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
};
const handleAddInboxLimit = limit => {
store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
};
const handleLimitChange = limit => {
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
};
const handleSubmit = async formState => {
try {
await store.dispatch('agentCapacityPolicies/update', {
id: selectedPolicyId.value,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch policy if not available
if (!selectedPolicyId.value)
await store.dispatch('agentCapacityPolicies/show', routeId.value);
await store.dispatch('agentCapacityPolicies/getUsers', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-users="policyUsers"
:agent-list="allAgents"
:label-list="allLabels"
:inbox-list="allInboxes"
show-user-section
show-inbox-limit-section
:is-loading="uiFlags.isUpdating"
:is-users-loading="usersUiFlags.isFetching"
:is-inboxes-loading="inboxesUiFlags.isFetching"
@submit="handleSubmit"
@add-user="handleAddUser"
@delete-user="handleDeleteUser"
@add-inbox-limit="handleAddInboxLimit"
@update-inbox-limit="handleLimitChange"
@delete-inbox-limit="handleDeleteInboxLimit"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,126 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyCard from 'dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue';
import ConfirmDeletePolicyDialog from './components/ConfirmDeletePolicyDialog.vue';
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const agentCapacityPolicies = useMapGetter(
'agentCapacityPolicies/getAgentCapacityPolicies'
);
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
const confirmDeletePolicyDialogRef = ref(null);
const breadcrumbItems = computed(() => {
const items = [
{
label: t('ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
},
];
return items;
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const onClickCreatePolicy = () => {
router.push({
name: 'agent_capacity_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_capacity_policy_edit',
params: {
id,
},
});
};
const handleFetchUsers = id => {
if (usersUiFlags.value.isFetching) return;
store.dispatch('agentCapacityPolicies/getUsers', id);
};
const handleDelete = id => {
confirmDeletePolicyDialogRef.value.openDialog(id);
};
const handleDeletePolicy = async policyId => {
try {
await store.dispatch('agentCapacityPolicies/delete', policyId);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.DELETE_POLICY.SUCCESS_MESSAGE')
);
confirmDeletePolicyDialogRef.value.closeDialog();
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.DELETE_POLICY.ERROR_MESSAGE')
);
}
};
onMounted(() => {
store.dispatch('agentCapacityPolicies/get');
});
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:no-records-found="agentCapacityPolicies.length === 0"
:no-records-message="
$t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.NO_RECORDS_FOUND')
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
$t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.CREATE_POLICY'
)
}}
</Button>
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<AgentCapacityPolicyCard
v-for="policy in agentCapacityPolicies"
:key="policy.id"
v-bind="policy"
:is-fetching-users="usersUiFlags.isFetching"
@fetch-users="handleFetchUsers"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>
</template>
<ConfirmDeletePolicyDialog
ref="confirmDeletePolicyDialogRef"
@delete="handleDeletePolicy"
/>
</SettingsLayout>
</template>
@@ -0,0 +1,257 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfig } from 'dashboard/composables/useConfig';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
OPTIONS,
ROUND_ROBIN,
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyInboxes: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showInboxSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isInboxLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'validationChange',
]);
const { t } = useI18n();
const { isEnterprise } = useConfig();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
const validationState = ref({
isValid: false,
});
const createOption = (type, key, stateKey) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
});
const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter(
key => isEnterprise || key !== 'balanced'
);
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
);
});
const assignmentPriorityOptions = computed(() =>
OPTIONS.PRIORITY.map(key =>
createOption('ASSIGNMENT_PRIORITY', key, 'conversationPriority')
)
);
const radioSections = computed(() => [
{
key: 'assignmentOrder',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.LABEL`),
options: assignmentOrderOptions.value,
},
{
key: 'conversationPriority',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_PRIORITY.LABEL`),
options: assignmentPriorityOptions.value,
},
]);
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak mb-4">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
:status-placeholder="
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
"
@validation-change="handleValidationChange"
/>
<div class="flex flex-col items-center">
<div
v-for="section in radioSections"
:key="section.key"
class="py-4 flex flex-col items-start gap-3 w-full"
>
<WithLabel
:label="section.label"
name="assignmentPolicy"
class="w-full flex items-start flex-col gap-3"
>
<div class="grid grid-cols-1 xs:grid-cols-2 gap-4 w-full">
<RadioCard
v-for="option in section.options"
:id="option.key"
:key="option.key"
:label="option.label"
:description="option.description"
:is-active="option.isActive"
@select="state[section.key] = $event"
/>
</div>
</WithLabel>
</div>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.DESCRIPTION`) }}
</p>
</div>
<FairDistribution
v-model:fair-distribution-limit="state.fairDistributionLimit"
v-model:fair-distribution-window="state.fairDistributionWindow"
v-model:window-unit="state.windowUnit"
/>
</div>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxSection"
class="py-4 flex-col flex gap-4 border-t border-n-weak mt-6"
>
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
/>
</div>
</form>
</template>
@@ -0,0 +1,214 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ExclusionRules from 'dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue';
import InboxCapacityLimits from 'dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyUsers: {
type: Array,
default: () => [],
},
agentList: {
type: Array,
default: () => [],
},
labelList: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showUserSection: {
type: Boolean,
default: false,
},
showInboxLimitSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isUsersLoading: {
type: Boolean,
default: false,
},
isInboxesLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addUser',
'deleteUser',
'validationChange',
'deleteInboxLimit',
'addInboxLimit',
'updateInboxLimit',
]);
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const state = reactive({
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
const validationState = ref({
isValid: false,
});
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const handleDeleteInboxLimit = id => {
emit('deleteInboxLimit', id);
};
const handleAddInboxLimit = limit => {
emit('addInboxLimit', limit);
};
const handleLimitChange = limit => {
emit('updateInboxLimit', limit);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 mb-2 divide-y divide-n-weak">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
@validation-change="handleValidationChange"
/>
<ExclusionRules
v-model:excluded-labels="state.exclusionRules.excludedLabels"
v-model:exclude-older-than-minutes="
state.exclusionRules.excludeOlderThanHours
"
:tags-list="labelList"
/>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxLimitSection || showUserSection"
class="flex flex-col gap-4 divide-y divide-n-weak border-t border-n-weak mt-6"
>
<InboxCapacityLimits
v-if="showInboxLimitSection"
v-model:inbox-capacity-limits="state.inboxCapacityLimits"
:inbox-list="inboxList"
:is-fetching="isInboxesLoading"
@delete="handleDeleteInboxLimit"
@add="handleAddInboxLimit"
@update="handleLimitChange"
/>
<div v-if="showUserSection" class="py-4 flex-col flex gap-4">
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.USERS.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.USERS.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.USERS.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.USERS.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="agentList"
@add="$emit('addUser', $event)"
/>
</div>
<DataTable
:items="policyUsers"
:is-fetching="isUsersLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.USERS.EMPTY_STATE`)"
@delete="$emit('deleteUser', $event)"
/>
</div>
</div>
</form>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentPolicyId = ref(null);
const openDialog = policyId => {
currentPolicyId.value = policyId;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('delete', currentPolicyId.value);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('ASSIGNMENT_POLICY.DELETE_POLICY.TITLE')"
:description="t('ASSIGNMENT_POLICY.DELETE_POLICY.DESCRIPTION')"
:confirm-button-label="
t('ASSIGNMENT_POLICY.DELETE_POLICY.CONFIRM_BUTTON_LABEL')
"
:cancel-button-label="
t('ASSIGNMENT_POLICY.DELETE_POLICY.CANCEL_BUTTON_LABEL')
"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentInbox = ref(null);
const openDialog = inbox => {
currentInbox.value = inbox;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('add', currentInbox.value.id);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.TITLE'
)
"
:description="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.DESCRIPTION',
{
inboxName: currentInbox?.name,
}
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CANCEL_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>
@@ -68,6 +68,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -186,6 +192,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -308,6 +320,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -424,6 +442,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -63,9 +63,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="addAgents()">
<div class="w-full">
<PageHeader
@@ -1,83 +1,108 @@
<script>
import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import { useBranding } from 'shared/composables/useBranding';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
export default {
components: {
ChannelItem,
PageHeader,
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
enabledFeatures: {},
};
},
computed: {
account() {
return this.$store.getters['accounts/getAccount'](this.accountId);
import { useAccount } from 'dashboard/composables/useAccount';
import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
const { t } = useI18n();
const router = useRouter();
const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const channelList = computed(() => {
const { apiChannelName } = globalConfig.value;
return [
{
key: 'website',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.DESCRIPTION'),
icon: 'i-woot-website',
},
channelList() {
const { apiChannelName, apiChannelThumbnail } = this.globalConfig;
return [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Messenger' },
{ key: 'whatsapp', name: 'WhatsApp' },
{ key: 'sms', name: 'SMS' },
{ key: 'email', name: 'Email' },
{
key: 'api',
name: apiChannelName || 'API',
thumbnail: apiChannelThumbnail,
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
{
key: 'facebook',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.DESCRIPTION'),
icon: 'i-woot-messenger',
},
...mapGetters({
accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}),
},
mounted() {
this.initializeEnabledFeatures();
},
methods: {
async initializeEnabledFeatures() {
this.enabledFeatures = this.account.features;
{
key: 'whatsapp',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.DESCRIPTION'),
icon: 'i-woot-whatsapp',
},
initChannelAuth(channel) {
const params = {
sub_page: channel,
accountId: this.accountId,
};
router.push({ name: 'settings_inboxes_page_channel', params });
{
key: 'sms',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.DESCRIPTION'),
icon: 'i-woot-sms',
},
},
{
key: 'email',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.EMAIL.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.EMAIL.DESCRIPTION'),
icon: 'i-woot-mail',
},
{
key: 'api',
title: apiChannelName || t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.DESCRIPTION'),
icon: 'i-woot-api',
},
{
key: 'telegram',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.DESCRIPTION'),
icon: 'i-woot-telegram',
},
{
key: 'line',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.DESCRIPTION'),
icon: 'i-woot-line',
},
{
key: 'instagram',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-ri-phone-fill',
},
];
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
accountId: accountId.value,
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
<div
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
/>
<div class="w-full p-8 overflow-auto">
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
>
<ChannelItem
v-for="channel in channelList"
@@ -1,102 +1,175 @@
<script>
<script setup>
import { computed, onMounted, reactive, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
EmptyState,
NextButton,
DuplicateInboxBanner,
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const qrCodes = reactive({
whatsapp: '',
messenger: '',
telegram: '',
});
const currentInbox = computed(() =>
store.getters['inboxes/getInbox'](route.params.inbox_id)
);
// Use useInbox composable with the inbox ID
const {
isAWhatsAppCloudChannel,
isATwilioChannel,
isASmsInbox,
isALineChannel,
isAnEmailChannel,
isAWhatsAppChannel,
isAFacebookInbox,
isATelegramChannel,
isATwilioWhatsAppChannel,
} = useInbox(route.params.inbox_id);
const hasDuplicateInstagramInbox = computed(() => {
const instagramId = currentInbox.value.instagram_id;
const facebookInbox =
store.getters['inboxes/getFacebookInboxByInstagramId'](instagramId);
return (
currentInbox.value.channel_type === INBOX_TYPES.INSTAGRAM && facebookInbox
);
});
const shouldShowWhatsAppWebhookDetails = computed(() => {
return (
isAWhatsAppCloudChannel.value &&
currentInbox.value.provider_config?.source !== 'embedded_signup'
);
});
const isWhatsAppEmbeddedSignup = computed(() => {
return (
isAWhatsAppCloudChannel.value &&
currentInbox.value.provider_config?.source === 'embedded_signup'
);
});
const message = computed(() => {
if (isATwilioChannel.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE'
)}`;
}
if (isASmsInbox.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE'
)}`;
}
if (isALineChannel.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE'
)}`;
}
if (isAWhatsAppCloudChannel.value && shouldShowWhatsAppWebhookDetails.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
}
if (isAnEmailChannel.value && !currentInbox.value.provider) {
return t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (currentInbox.value.web_widget_script) {
return t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
}
if (isWhatsAppEmbeddedSignup.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION'
)}`;
}
return t('INBOX_MGMT.FINISH.MESSAGE');
});
async function generateQRCode(platform, identifier) {
if (!identifier || !identifier.trim()) {
// eslint-disable-next-line no-console
console.warn(`Invalid identifier for ${platform} QR code`);
return;
}
try {
const platformUrls = {
whatsapp: id => `https://wa.me/${id}`,
messenger: id => `https://m.me/${id}`,
telegram: id => `https://t.me/${id}`,
};
const url = platformUrls[platform](identifier);
const qrDataUrl = await QRCode.toDataURL(url);
qrCodes[platform] = qrDataUrl;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error generating ${platform} QR code:`, error);
qrCodes[platform] = '';
}
}
async function generateQRCodes() {
if (!currentInbox.value) return;
// WhatsApp (both Cloud and Twilio)
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
// For Twilio WhatsApp, phone_number format is "whatsapp:+1234567890"
// Extract just the phone number part for QR code generation
const phoneNumber = currentInbox.value.phone_number.replace(
'whatsapp:',
''
);
await generateQRCode('whatsapp', phoneNumber);
}
// Facebook Messenger
if (currentInbox.value.page_id && isAFacebookInbox.value) {
await generateQRCode('messenger', currentInbox.value.page_id);
}
// Telegram
if (isATelegramChannel.value && currentInbox.value.bot_name) {
await generateQRCode('telegram', currentInbox.value.bot_name);
}
}
// Watch for currentInbox changes and regenerate QR codes when available
watch(
currentInbox,
newInbox => {
if (newInbox) {
generateQRCodes();
}
},
computed: {
currentInbox() {
return this.$store.getters['inboxes/getInbox'](
this.$route.params.inbox_id
);
},
isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms';
},
// Check if a facebook inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.currentInbox.instagram_id;
const facebookInbox =
this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
instagramId
);
{ immediate: true }
);
return (
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
facebookInbox
);
},
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
isALineInbox() {
return this.currentInbox.channel_type === 'Channel::Line';
},
isASmsInbox() {
return this.currentInbox.channel_type === 'Channel::Sms';
},
isWhatsAppCloudInbox() {
return (
this.currentInbox.channel_type === 'Channel::Whatsapp' &&
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isASmsInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isALineInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isAEmailInbox && !this.currentInbox.provider) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (this.currentInbox.web_widget_script) {
return this.$t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
}
return this.$t('INBOX_MGMT.FINISH.MESSAGE');
},
},
};
onMounted(() => {
generateQRCodes();
});
</script>
<template>
<div
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
@@ -115,7 +188,7 @@ export default {
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code
v-if="isATwilioInbox"
v-if="isATwilioWhatsAppChannel"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
@@ -142,7 +215,7 @@ export default {
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code
v-if="isALineInbox"
v-if="isALineChannel"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
@@ -155,12 +228,58 @@ export default {
/>
</div>
<div
v-if="isAEmailInbox && !currentInbox.provider"
v-if="isAnEmailChannel && !currentInbox.provider"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<woot-code lang="html" :script="currentInbox.forward_to_email" />
</div>
<div class="flex justify-center gap-2 mt-4">
<div
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
class="flex flex-col gap-3 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.whatsapp"
alt="WhatsApp QR Code"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
<div
v-if="isAFacebookInbox && qrCodes.messenger"
class="flex flex-col gap-3 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.MESSENGER_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.messenger"
alt="Messenger QR Code"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
<div
v-if="isATelegramChannel && qrCodes.telegram"
class="flex flex-col gap-4 items-center mt-8"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.TELEGRAM_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<img
:src="qrCodes.telegram"
alt="Telegram QR Code"
class="rounded-lg size-48 dark:invert"
/>
</div>
</div>
<div class="flex gap-2 justify-center mt-4">
<router-link
:to="{
name: 'settings_inbox_show',
@@ -1,55 +1,106 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
export default {
setup() {
const { replaceInstallationName } = useBranding();
import PageHeader from '../SettingsSubPageHeader.vue';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const route = useRoute();
const { replaceInstallationName } = useBranding();
const globalConfig = useMapGetter('globalConfig/get');
const ALL_CHANNEL_ICONS = [
'i-woot-line',
'i-woot-facebook',
'i-woot-whatsapp',
'i-woot-instagram',
'i-woot-messenger',
'i-woot-website',
'i-woot-mail',
'i-woot-sms',
'i-woot-telegram',
'i-woot-api',
'i-woot-twilio',
'i-woot-gmail',
'i-woot-outlook',
];
const createFlowSteps = computed(() => {
const steps = ['CHANNEL', 'INBOX', 'AGENT', 'FINISH'];
const routes = {
CHANNEL: 'settings_inbox_new',
INBOX: 'settings_inboxes_page_channel',
AGENT: 'settings_inboxes_add_agents',
FINISH: 'settings_inbox_finish',
};
return steps.map(step => {
return {
replaceInstallationName,
title: t(`INBOX_MGMT.CREATE_FLOW.${step}.TITLE`),
body: t(`INBOX_MGMT.CREATE_FLOW.${step}.BODY`),
route: routes[step],
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
createFlowSteps() {
const steps = ['CHANNEL', 'INBOX', 'AGENT', 'FINISH'];
});
});
const routes = {
CHANNEL: 'settings_inbox_new',
INBOX: 'settings_inboxes_page_channel',
AGENT: 'settings_inboxes_add_agents',
FINISH: 'settings_inbox_finish',
};
const isFirstStep = computed(() => {
return route.name === 'settings_inbox_new';
});
return steps.map(step => {
return {
title: this.$t(`INBOX_MGMT.CREATE_FLOW.${step}.TITLE`),
body: this.$t(`INBOX_MGMT.CREATE_FLOW.${step}.BODY`),
route: routes[step],
};
});
},
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.replaceInstallationName(item.body),
}));
},
},
};
const isFinishStep = computed(() => {
return route.name === 'settings_inbox_finish';
});
const pageTitle = computed(() => {
if (isFirstStep.value) {
return t('INBOX_MGMT.ADD.AUTH.TITLE');
}
if (isFinishStep.value) {
return t('INBOX_MGMT.ADD.AUTH.TITLE_FINISH');
}
return t('INBOX_MGMT.ADD.AUTH.TITLE_NEXT');
});
const items = computed(() => {
return createFlowSteps.value.map(item => ({
...item,
body: replaceInstallationName(item.body),
}));
});
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full">
<woot-wizard
class="hidden md:block col-span-2"
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6">
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
<div class="hidden lg:grid grid-cols-1 lg:grid-cols-8 items-center gap-2">
<div class="col-span-2 w-full" />
<div class="flex items-center gap-2 col-span-6 ltr:ml-8 rtl:mr-8">
<div
v-for="icon in ALL_CHANNEL_ICONS"
:key="icon"
class="size-8 bg-n-alpha-2 flex items-center flex-shrink-0 justify-center rounded-full"
>
<Icon :icon="icon" class="size-4 text-n-slate-10" />
</div>
</div>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6 overflow-hidden">
<router-view />
</div>
</div>
</div>
</template>
@@ -116,7 +116,7 @@ const openDelete = inbox => {
v-else
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<ChannelIcon class="size-5" :inbox="inbox" />
<ChannelIcon class="size-5 text-n-slate-10" :inbox="inbox" />
</div>
<div>
<span class="block font-medium capitalize">
@@ -116,16 +116,22 @@ export default {
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@@ -144,6 +150,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -321,7 +328,7 @@ export default {
this.continuityViaEmail = this.inbox.continuity_via_email;
this.channelWebsiteUrl = this.inbox.website_url;
this.channelWelcomeTitle = this.inbox.welcome_title;
this.channelWelcomeTagline = this.inbox.welcome_tagline;
this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
this.replyTime = this.inbox.reply_time;
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
@@ -676,7 +683,7 @@ export default {
}}
</p>
</label>
<div class="pb-4">
<div v-if="!isAVoiceChannel" class="pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
@@ -65,9 +65,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.API_CHANNEL.DESC')"
@@ -20,22 +20,25 @@ const isAChatwootInstance = getters['globalConfig/isAChatwootInstance'];
const emailProviderList = computed(() => {
return [
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT.DESCRIPTION'),
isEnabled: !!globalConfig.value.azureAppId,
key: 'microsoft',
src: '/assets/images/dashboard/channels/microsoft.png',
icon: 'i-woot-outlook',
},
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.DESCRIPTION'),
isEnabled: !!window.chatwootConfig.googleOAuthClientId,
key: 'google',
src: '/assets/images/dashboard/channels/google.png',
icon: 'i-woot-gmail',
},
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS.DESCRIPTION'),
isEnabled: true,
key: 'other_provider',
src: '/assets/images/dashboard/channels/email.png',
icon: 'i-woot-mail',
},
].filter(providerConfig => {
if (isAChatwootInstance.value) {
@@ -53,22 +56,20 @@ function onClick(emailProvider) {
</script>
<template>
<div
v-if="!provider"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div v-if="!provider" class="h-full w-full p-6 col-span-6">
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.DESCRIPTION')"
/>
<div class="grid max-w-3xl grid-cols-4 mx-0 mt-6">
<div class="grid max-w-3xl grid-cols-4 gap-6 mx-0 mt-6">
<ChannelSelector
v-for="emailProvider in emailProviderList"
:key="emailProvider.key"
:class="{ inactive: !emailProvider.isEnabled }"
:title="emailProvider.title"
:src="emailProvider.src"
:description="emailProvider.description"
:icon="emailProvider.icon"
:disabled="!emailProvider.isEnabled"
@click="() => onClick(emailProvider)"
/>
</div>
@@ -206,16 +206,14 @@ export default {
</script>
<template>
<div
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<div class="w-full h-full col-span-6 p-6 overflow-auto">
<div
v-if="!hasLoginStarted"
class="flex flex-col items-center justify-center h-full text-center"
>
<a href="#" @click="startLogin()">
<img
class="w-auto h-10"
class="w-auto h-10 rounded-md"
src="~dashboard/assets/images/channels/facebook_login.png"
alt="Facebook-logo"
/>
@@ -62,9 +62,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-background h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="h-full p-6 w-full max-w-full flex-shrink-0 flex-grow-0">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
@@ -75,7 +73,7 @@ export default {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -71,9 +71,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.LINE_CHANNEL.DESC')"
@@ -18,9 +18,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.SMS.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.SMS.DESC')"
@@ -65,9 +65,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.DESC')"
@@ -30,9 +30,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<div class="login-init h-full text-center">
<form @submit.prevent="requestAuthorization">
<NextButton
@@ -22,7 +22,6 @@ const state = reactive({
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
@@ -33,7 +32,6 @@ const validationRules = {
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
@@ -67,7 +62,6 @@ function getProviderConfig() {
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
@@ -99,9 +93,7 @@ async function createChannel() {
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<PageHeader
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
@@ -160,17 +152,6 @@ async function createChannel() {
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
@@ -78,9 +78,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')"
@@ -1,24 +1,23 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useI18n, I18nT } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const store = useStore();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog',
};
@@ -29,14 +28,6 @@ const hasWhatsappAppId = computed(() => {
);
});
const isWhatsappEmbeddedSignupEnabled = computed(() => {
const accountId = route.params.accountId;
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
@@ -45,16 +36,16 @@ const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
key: PROVIDER_TYPES.WHATSAPP,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: '/assets/images/dashboard/channels/whatsapp.png',
icon: 'i-woot-whatsapp',
},
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
key: PROVIDER_TYPES.TWILIO,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: '/assets/images/dashboard/channels/twilio.png',
icon: 'i-woot-twilio',
},
]);
@@ -66,35 +57,20 @@ const selectProvider = providerValue => {
});
};
const shouldShowEmbeddedSignup = provider => {
// Check if the feature is enabled for the account
if (!isWhatsappEmbeddedSignupEnabled.value) {
return false;
}
const shouldShowCloudWhatsapp = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
);
};
const shouldShowCloudWhatsapp = provider => {
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
return false;
}
// Show cloud whatsapp when:
// 1. Provider is whatsapp AND
// 2. Either no app ID is configured OR embedded signup feature is disabled
return provider === PROVIDER_TYPES.WHATSAPP;
const handleManualLinkClick = () => {
selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL);
};
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
@@ -106,42 +82,55 @@ const shouldShowCloudWhatsapp = provider => {
</div>
<div class="flex gap-6 justify-start">
<div
<ChannelSelector
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<div class="flex justify-start mb-5">
<div
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
:key="provider.key"
:title="provider.title"
:description="provider.description"
:icon="provider.icon"
@click="selectProvider(provider.key)"
/>
</div>
</div>
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<!-- Show embedded signup if app ID is configured -->
<div
v-if="
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
<I18nT
keypath="INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK"
tag="p"
class="text-sm text-n-slate-11"
>
<template #link>
<a
href="#"
class="underline text-n-brand"
@click.prevent="handleManualLinkClick"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_LINK_TEXT'
)
}}
</a>
</template>
</I18nT>
</div>
</div>
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<!-- Other providers -->
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
@@ -2,12 +2,13 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useI18n, I18nT } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
@@ -28,9 +29,6 @@ const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
const benefits = computed(() => [
{
key: 'EASY_SETUP',
@@ -235,14 +233,9 @@ onBeforeUnmount(() => {
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
class="flex size-11 items-center justify-center rounded-full bg-n-alpha-2"
>
<img
:src="whatsappIconPath"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
<Icon icon="i-woot-whatsapp" class="text-n-slate-10 size-6" />
</div>
</div>
@@ -266,22 +259,26 @@ onBeforeUnmount(() => {
</div>
<div class="flex flex-col gap-2 mb-6">
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }}
{{ ' ' }}
<a
:href="
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_URL')
"
target="_blank"
rel="noopener noreferrer"
class="underline text-primary"
>
{{
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT')
}}
</a>
</span>
<I18nT
keypath="INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT"
tag="span"
class="text-sm text-n-slate-11"
>
<template #link>
<a
:href="globalConstants.WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL"
target="_blank"
rel="noopener noreferrer"
class="underline text-n-brand"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT'
)
}}
</a>
</template>
</I18nT>
</div>
<div class="flex mt-4">
@@ -70,9 +70,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.DESC')"
@@ -60,9 +60,7 @@ async function requestAuthorization() {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<SettingsSubPageHeader
:header-title="title"
:header-content="description"
@@ -86,7 +86,7 @@
"Riyadh (GMT+03:00)": "Asia/Riyadh",
"Nairobi (GMT+03:00)": "Africa/Nairobi",
"Baghdad (GMT+03:00)": "Asia/Baghdad",
"Tehran (GMT+04:30)": "Asia/Tehran",
"Tehran (GMT+03:30)": "Asia/Tehran",
"Abu Dhabi (GMT+04:00)": "Asia/Muscat",
"Muscat (GMT+04:00)": "Asia/Muscat",
"Baku (GMT+04:00)": "Asia/Baku",
@@ -125,7 +125,39 @@ export default {
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
v-if="isATwilioWhatsAppChannel"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
@@ -7,16 +7,19 @@ import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { parseBoolean } from '@chatwoot/utils';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import {
ROLES,
@@ -28,6 +31,7 @@ export default {
MessageSignature,
FormSection,
FontSize,
UserLanguageSelect,
UserProfilePicture,
Policy,
UserBasicDetails,
@@ -36,6 +40,7 @@ export default {
NotificationPreferences,
AudioNotifications,
AccessToken,
MfaSettingsCard,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -93,6 +98,9 @@ export default {
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
},
mounted() {
if (this.currentUserId) {
@@ -230,6 +238,12 @@ export default {
"
@change="updateFontSize"
/>
<UserLanguageSelect
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
@@ -275,6 +289,13 @@ export default {
>
<ChangePassword />
</FormSection>
<FormSection
v-if="isMfaEnabled"
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
>
<MfaSettingsCard />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
@@ -0,0 +1,248 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
backupCodes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']);
const { t } = useI18n();
// Dialog refs
const disableDialogRef = ref(null);
const regenerateDialogRef = ref(null);
const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const regenerateOtpCode = ref('');
// Utility functions
const copyBackupCodes = async () => {
const codesText = props.backupCodes.join('\n');
await copyTextToClipboard(codesText);
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
};
const downloadBackupCodes = () => {
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
const blob = new Blob([codesText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chatwoot-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
});
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
});
};
// Methods exposed for parent component
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableDialogRef.value?.close();
};
const resetRegenerateForm = () => {
regenerateOtpCode.value = '';
regenerateDialogRef.value?.close();
};
const showBackupCodesDialog = () => {
backupCodesDialogRef.value?.open();
};
defineExpose({
resetDisableForm,
resetRegenerateForm,
showBackupCodesDialog,
});
</script>
<template>
<div v-if="mfaEnabled">
<!-- Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Regenerate Backup Codes -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-key"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
</p>
<Button
faded
slate
:label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
@click="regenerateDialogRef?.open()"
/>
</div>
</div>
<!-- Disable MFA -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole-open"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
</p>
<Button
faded
ruby
:label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
@click="disableDialogRef?.open()"
/>
</div>
</div>
</div>
<!-- Disable MFA Dialog -->
<Dialog
ref="disableDialogRef"
type="alert"
:title="$t('MFA_SETTINGS.DISABLE.TITLE')"
:description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleDisableMfa"
>
<div class="space-y-4">
<Input
v-model="disablePassword"
type="password"
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
</div>
</Dialog>
<!-- Regenerate Backup Codes Dialog -->
<Dialog
ref="regenerateDialogRef"
type="edit"
:title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleRegenerateBackupCodes"
>
<Input
v-model="regenerateOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
/>
</Dialog>
<!-- Backup Codes Display Dialog -->
<Dialog
ref="backupCodesDialogRef"
type="edit"
width="2xl"
:title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
:show-cancel-button="false"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
@confirm="backupCodesDialogRef?.close()"
>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
</Dialog>
</div>
<template v-else />
</template>
@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
// State
const mfaEnabled = ref(false);
const backupCodesGenerated = ref(false);
const showSetup = ref(false);
const provisioningUri = ref('');
const qrCodeUrl = ref('');
const secretKey = ref('');
const backupCodes = ref([]);
// Component refs
const setupWizardRef = ref(null);
const managementActionsRef = ref(null);
// Load MFA status on mount
onMounted(async () => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
router.push({
name: 'profile_settings_index',
params: {
accountId: route.params.accountId,
},
});
return;
}
try {
const response = await mfaAPI.get();
mfaEnabled.value = response.data.enabled;
backupCodesGenerated.value = response.data.backup_codes_generated;
} catch (error) {
// Handle error silently
}
});
// Start MFA setup
const startMfaSetup = async () => {
try {
const response = await mfaAPI.enable();
// Store the provisioning URI
provisioningUri.value =
response.data.provisioning_uri || response.data.provisioning_url;
// Store QR code URL if provided by backend
if (response.data.qr_code_url) {
qrCodeUrl.value = response.data.qr_code_url;
}
secretKey.value = response.data.secret;
// Backup codes are now generated after verification, not during enable
backupCodes.value = [];
showSetup.value = true;
} catch (error) {
useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
}
};
// Verify OTP code
const verifyCode = async verificationCode => {
try {
const response = await mfaAPI.verify(verificationCode);
// Store backup codes returned from verification
if (response.data.backup_codes) {
backupCodes.value = response.data.backup_codes;
}
return true;
} catch (error) {
setupWizardRef.value?.handleVerificationError(
error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
);
throw error;
}
};
// Complete MFA setup
const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
// Cancel setup
const cancelSetup = () => {
showSetup.value = false;
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
}
};
// Regenerate backup codes
const regenerateBackupCodes = async ({ otpCode }) => {
try {
const response = await mfaAPI.regenerateBackupCodes(otpCode);
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
}
};
</script>
<template>
<div
class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
>
<!-- Page Header -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SUBTITLE') }}
</p>
</div>
<div class="grid gap-4 w-full">
<!-- MFA Status Card -->
<MfaStatusCard
:mfa-enabled="mfaEnabled"
:show-setup="showSetup"
@enable-mfa="startMfaSetup"
/>
<!-- MFA Setup Wizard -->
<MfaSetupWizard
ref="setupWizardRef"
:show-setup="showSetup"
:mfa-enabled="mfaEnabled"
:provisioning-uri="provisioningUri"
:secret-key="secretKey"
:backup-codes="backupCodes"
:qr-code-url-prop="qrCodeUrl"
@cancel="cancelSetup"
@verify="verifyCode"
@complete="completeMfaSetup"
/>
<!-- MFA Management Actions -->
<MfaManagementActions
ref="managementActionsRef"
:mfa-enabled="mfaEnabled"
:backup-codes="backupCodes"
@disable-mfa="disableMfa"
@regenerate-backup-codes="regenerateBackupCodes"
/>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const router = useRouter();
const route = useRoute();
const navigateToMfa = () => {
router.push({
name: 'profile_settings_mfa',
params: {
accountId: route.params.accountId,
},
});
};
</script>
<template>
<div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
<div class="flex flex-col xs:flex-row items-center justify-between gap-4">
<div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<h5 class="text-sm font-semibold text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h5>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
</p>
</div>
<Button
type="button"
faded
:label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
icon="i-lucide-settings"
class="flex-shrink-0"
@click="navigateToMfa"
/>
</div>
</div>
</template>
@@ -0,0 +1,323 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
showSetup: {
type: Boolean,
required: true,
},
mfaEnabled: {
type: Boolean,
required: true,
},
provisioningUri: {
type: String,
default: '',
},
secretKey: {
type: String,
default: '',
},
backupCodes: {
type: Array,
default: () => [],
},
qrCodeUrlProp: {
type: String,
default: '',
},
});
const emit = defineEmits(['cancel', 'verify', 'complete']);
const { t } = useI18n();
// Local state
const setupStep = ref('qr');
const qrCodeUrl = ref('');
const verificationCode = ref('');
const verificationError = ref('');
const backupCodesConfirmed = ref(false);
// Generate QR code from provisioning URI
const generateQRCode = async provisioningUrl => {
try {
const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return qrCodeDataUrl;
} catch (error) {
return null;
}
};
// Watch for provisioning URI changes
watch(
() => props.provisioningUri,
async newUri => {
if (newUri) {
qrCodeUrl.value = await generateQRCode(newUri);
} else if (props.qrCodeUrlProp) {
qrCodeUrl.value = props.qrCodeUrlProp;
}
},
{ immediate: true }
);
const verifyCode = async () => {
verificationError.value = '';
try {
emit('verify', verificationCode.value);
setupStep.value = 'backup';
verificationCode.value = '';
} catch (error) {
verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
}
};
const copySecret = async () => {
await copyTextToClipboard(props.secretKey);
useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
};
const copyBackupCodes = async () => {
const codesText = props.backupCodes.join('\n');
await copyTextToClipboard(codesText);
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
};
const downloadBackupCodes = () => {
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
const blob = new Blob([codesText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chatwoot-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const cancelSetup = () => {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
emit('cancel');
};
const completeMfaSetup = () => {
setupStep.value = 'qr';
backupCodesConfirmed.value = false;
emit('complete');
};
// Reset when showSetup changes
watch(
() => props.showSetup,
newVal => {
if (newVal) {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
}
}
);
// Handle verification error
const handleVerificationError = error => {
verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
};
defineExpose({
handleVerificationError,
setupStep,
});
</script>
<template>
<div v-if="showSetup && !mfaEnabled">
<!-- Step 1: QR Code -->
<div v-if="setupStep === 'qr'" class="space-y-6">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
>
<div class="text-center">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
</p>
</div>
<div class="flex justify-center">
<div
class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
>
<img
v-if="qrCodeUrl"
:src="qrCodeUrl"
alt="MFA QR Code"
class="w-48 h-48 dark:invert-0"
/>
<div
v-else
class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
>
<span class="text-n-slate-10">
{{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
</span>
</div>
</div>
</div>
<details class="border border-n-slate-4 rounded-lg">
<summary
class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
>
{{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
</summary>
<div class="px-4 pb-4">
<label class="block text-xs text-n-slate-10 mb-2">
{{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
</label>
<div class="flex items-center gap-2">
<Input :model-value="secretKey" readonly class="flex-1" />
<Button
variant="outline"
color="slate"
size="sm"
:label="$t('MFA_SETTINGS.SETUP.COPY')"
@click="copySecret"
/>
</div>
</div>
</details>
<div class="flex flex-col items-start gap-3 w-full">
<Input
v-model="verificationCode"
type="text"
maxlength="6"
pattern="[0-9]{6}"
:label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
:placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
:message="verificationError"
:message-type="verificationError ? 'error' : 'info'"
class="w-full"
@keyup.enter="verifyCode"
/>
<div class="flex gap-3 mt-1 w-full justify-between">
<Button
faded
color="slate"
class="flex-1"
:label="$t('MFA_SETTINGS.SETUP.CANCEL')"
@click="cancelSetup"
/>
<Button
class="flex-1"
:disabled="verificationCode.length !== 6"
:label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
@click="verifyCode"
/>
</div>
</div>
</div>
</div>
<!-- Step 2: Backup Codes -->
<div v-if="setupStep === 'backup'" class="space-y-6">
<div class="text-start">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
</p>
</div>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<!-- Backup Codes Grid -->
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
<!-- Confirmation -->
<div class="space-y-4">
<label class="flex items-start gap-3">
<input
v-model="backupCodesConfirmed"
type="checkbox"
class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
/>
<span class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
</span>
</label>
<Button
:disabled="!backupCodesConfirmed"
:label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
@click="completeMfaSetup"
/>
</div>
</div>
</div>
<template v-else />
</template>
@@ -0,0 +1,63 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
showSetup: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['enableMfa']);
const startSetup = () => {
emit('enableMfa');
};
</script>
<template>
<div v-if="!mfaEnabled && !showSetup" class="space-y-6">
<div
class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
>
<Icon
icon="i-lucide-lock-keyhole"
class="size-8 text-n-slate-10 mx-auto mb-4 block"
/>
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
</p>
<Button
icon="i-lucide-settings"
:label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
@click="startSetup"
/>
</div>
</div>
<div v-else-if="mfaEnabled && !showSetup">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,103 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import FormSelect from 'v3/components/Form/Select.vue';
defineProps({
label: { type: String, default: '' },
description: { type: String, default: '' },
});
const { t, locale } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { currentAccount } = useAccount();
const currentLanguage = computed(() => uiSettings.value?.locale ?? '');
const languageOptions = computed(() => [
{
name: t(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.USE_ACCOUNT_DEFAULT'
),
iso_639_1_code: '',
},
...(enabledLanguages ?? []),
]);
const updateLanguage = async languageCode => {
try {
if (!languageCode) {
// Clear preference to use account default
await updateUISettings({ locale: null });
locale.value = currentAccount.value.locale;
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
);
return;
}
const valid = (enabledLanguages || []).some(
l => l.iso_639_1_code === languageCode
);
if (!valid) {
throw new Error(`Invalid language code: ${languageCode}`);
}
await updateUISettings({ locale: languageCode });
// Apply immediately if the user explicitly chose a preference
locale.value = languageCode;
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
);
} catch (error) {
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_ERROR')
);
throw error;
}
};
const selectedValue = computed({
get: () => currentLanguage.value,
set: value => {
updateLanguage(value);
},
});
</script>
<template>
<div class="flex gap-2 justify-between w-full items-start">
<div>
<label class="text-n-gray-12 font-medium leading-6 text-sm">
{{ label }}
</label>
<p class="text-n-gray-11">
{{ description }}
</p>
</div>
<FormSelect
v-model="selectedValue"
name="language"
spacing="compact"
class="min-w-28 mt-px"
:options="languageOptions"
label=""
>
<option
v-for="option in languageOptions"
:key="option.iso_639_1_code || 'default'"
:value="option.iso_639_1_code"
:selected="option.iso_639_1_code === selectedValue"
>
{{ option.name }}
</option>
</FormSelect>
</div>
</template>
@@ -1,7 +1,9 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { parseBoolean } from '@chatwoot/utils';
import SettingsContent from './Wrapper.vue';
import Index from './Index.vue';
import MfaSettings from './MfaSettings.vue';
export default {
routes: [
@@ -21,6 +23,23 @@ export default {
permissions: ['administrator', 'agent', 'custom_role'],
},
},
{
path: 'mfa',
name: 'profile_settings_mfa',
component: MfaSettings,
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
beforeEnter: (to, from, next) => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
next({ name: 'profile_settings_index' });
} else {
next();
}
},
},
],
},
],
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import SamlSettings from './components/SamlSettings.vue';
import SamlPaywall from './components/SamlPaywall.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { shouldShow, shouldShowPaywall } = usePolicy();
const shouldShowSaml = computed(() =>
shouldShow(
FEATURE_FLAGS.SAML,
['administrator'],
[INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE]
)
);
const showPaywall = computed(() => shouldShowPaywall('saml'));
</script>
<template>
<SettingsLayout
class="max-w-2xl mx-auto"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('SECURITY_SETTINGS.TITLE')"
:description="$t('SECURITY_SETTINGS.DESCRIPTION')"
:link-text="$t('SECURITY_SETTINGS.LINK_TEXT')"
feature-name="saml"
/>
</template>
<template #body>
<SamlPaywall v-if="showPaywall" />
<SamlSettings v-else-if="shouldShowSaml" />
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const isExpanded = ref(false);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden"
>
<button
type="button"
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors"
@click="toggleExpanded"
>
<h4 class="font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }}
</h4>
<Icon
icon="i-lucide-chevron-down"
class="transition-transform duration-200"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div
class="transition-[height] duration-200 ease-in-out overflow-hidden"
:class="isExpanded ? 'h-auto' : 'h-0'"
>
<div class="px-4 pb-3">
<p class="text-n-slate-11 mb-2">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }}
</p>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<ul class="list-none text-n-slate-12 space-y-1">
<li><code class="px-1 rounded bg-n-slate-3">email</code></li>
<li><code class="px-1 rounded bg-n-slate-3">first_name</code></li>
<li><code class="px-1 rounded bg-n-slate-3">last_name</code></li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import NextButton from 'next/button/Button.vue';
const props = defineProps({
fingerprint: {
type: String,
default: '',
},
spEntityId: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { accountId } = useAccount();
const acsUrl = computed(() => {
const currentHost = window.location.origin;
return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`;
});
const allInfoItems = computed(() => [
{
key: 'ACS_URL',
label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'),
value: acsUrl.value,
tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'),
show: true,
},
{
key: 'SP_ENTITY_ID',
label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'),
value: props.spEntityId,
tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'),
show: !!props.spEntityId,
},
{
key: 'FINGERPRINT',
label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'),
value: props.fingerprint,
tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'),
show: !!props.fingerprint,
},
]);
const visibleInfoItems = computed(() =>
allInfoItems.value.filter(item => item.show)
);
const handleCopy = async text => {
await copyTextToClipboard(text);
useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS'));
};
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }}
</h3>
<i
v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')"
class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help"
/>
</div>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div
v-for="item in visibleInfoItems"
:key="item.key"
class="ps-4 pe-1 py-1 flex justify-between items-center"
>
<div class="flex items-center gap-2">
<span class="text-n-slate-11 w-32 flex items-center gap-1">
{{ item.label }}
<i
v-tooltip.top="item.tooltip"
class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help"
/>
</span>
<span class="flex-1">{{ item.value }}</span>
</div>
<NextButton
type="button"
ghost
sm
slate
icon="i-lucide-copy"
@click="handleCopy(item.value)"
/>
</div>
</section>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[28rem] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="SECURITY_SETTINGS.SAML"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>
@@ -0,0 +1,251 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import samlSettingsAPI from 'dashboard/api/samlSettings';
import SectionLayout from '../../account/components/SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextInput from 'next/input/Input.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'next/button/Button.vue';
import SamlInfoSection from './SamlInfoSection.vue';
import SamlAttributeMap from './SamlAttributeMap.vue';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const id = ref(null);
const fingerprint = ref('');
const spEntityId = ref('');
const isEnabled = ref(false);
const isSubmitting = ref(false);
const isLoading = ref(true);
const formState = reactive({
ssoUrl: '',
certificate: '',
idpEntityId: '',
});
const validations = {
ssoUrl: { required },
certificate: { required },
idpEntityId: { required },
};
const v$ = useVuelidate(validations, formState);
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
const ssoUrlError = computed(() =>
v$.value.ssoUrl.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
: ''
);
const certificateError = computed(() =>
v$.value.certificate.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
: ''
);
const idpEntityIdError = computed(() =>
v$.value.idpEntityId.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
: ''
);
const loadSamlSettings = async () => {
if (!hasFeature.value) return;
try {
isLoading.value = true;
const response = await samlSettingsAPI.get();
const settings = response.data;
if (settings.sso_url) {
id.value = settings.id;
formState.ssoUrl = settings.sso_url;
formState.certificate = settings.certificate || '';
spEntityId.value = settings.sp_entity_id || '';
formState.idpEntityId = settings.idp_entity_id || '';
fingerprint.value = settings.fingerprint || '';
isEnabled.value = formState.ssoUrl !== '';
}
} catch (error) {
// If no settings exist (404), that's expected - just keep defaults
if (error.response?.status !== 404) {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
}
} finally {
isLoading.value = false;
}
};
const saveSamlSettings = async settings => {
try {
isSubmitting.value = true;
if (isEnabled.value && formState.ssoUrl) {
// Create or update settings based on existing id
let response;
if (id.value) {
response = await samlSettingsAPI.update(settings);
} else {
response = await samlSettingsAPI.create(settings);
}
// Update local state with response data including fingerprint and id
if (response?.data) {
id.value = response.data.id;
fingerprint.value = response.data.fingerprint || '';
spEntityId.value = response.data.sp_entity_id || '';
}
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
} else {
// Disable/delete settings
await samlSettingsAPI.delete();
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
}
} catch (error) {
// Handle backend validation errors
if (error.response?.data?.errors) {
const errorMessages = error.response.data.errors;
const firstError = Array.isArray(errorMessages)
? errorMessages[0]
: errorMessages;
useAlert(firstError);
} else {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
}
throw error;
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const settings = {
sso_url: formState.ssoUrl,
certificate: formState.certificate,
idp_entity_id: formState.idpEntityId,
role_mappings: {},
};
await saveSamlSettings(settings);
};
const handleDisable = async () => {
id.value = null;
formState.ssoUrl = '';
formState.certificate = '';
spEntityId.value = '';
formState.idpEntityId = '';
fingerprint.value = '';
// the empty save will delete the SAML settings item
await saveSamlSettings({});
};
const toggleSaml = async () => {
if (!isEnabled.value) {
await handleDisable();
}
};
onMounted(() => {
loadSamlSettings();
});
</script>
<template>
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>
<div class="flex justify-end">
<Switch
v-model="isEnabled"
:disabled="isLoading"
@change="toggleSaml"
/>
</div>
</template>
<SamlInfoSection
class="mb-5"
:fingerprint="fingerprint"
:sp-entity-id="spEntityId"
/>
<SamlAttributeMap class="mb-5" />
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
name="ssoUrl"
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
:has-error="v$.ssoUrl.$error"
:error-message="ssoUrlError"
required
>
<TextInput
v-model="formState.ssoUrl"
class="w-full"
type="url"
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="idpEntityId"
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
:has-error="v$.idpEntityId.$error"
:error-message="idpEntityIdError"
required
>
<TextInput
v-model="formState.idpEntityId"
class="w-full"
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="certificate"
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
:has-error="v$.certificate.$error"
:error-message="certificateError"
required
>
<TextArea
v-model="formState.certificate"
class="w-full"
rows="8"
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
/>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
/>
</div>
</form>
</SectionLayout>
</template>
@@ -0,0 +1,41 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/security'),
meta: {
permissions: ['administrator'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
component: SettingsWrapper,
props: {
headerTitle: 'SECURITY_SETTINGS.TITLE',
icon: 'i-lucide-shield',
showNewButton: false,
},
children: [
{
path: '',
name: 'security_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.SAML,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
],
},
],
};
@@ -6,6 +6,7 @@ import {
import account from './account/account.routes';
import agent from './agents/agent.routes';
import assignmentPolicy from './assignmentPolicy/assignmentPolicy.routes';
import agentBot from './agentBots/agentBot.routes';
import attributes from './attributes/attributes.routes';
import automation from './automation/automation.routes';
@@ -22,6 +23,7 @@ import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
export default {
routes: [
@@ -44,6 +46,7 @@ export default {
},
...account.routes,
...agent.routes,
...assignmentPolicy.routes,
...agentBot.routes,
...attributes.routes,
...automation.routes,
@@ -59,5 +62,6 @@ export default {
...teams.routes,
...customRoles.routes,
...profile.routes,
...security.routes,
],
};
@@ -88,9 +88,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
@@ -37,9 +37,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.DESC')"
@@ -23,8 +23,15 @@ export default {
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full px-5 flex-1">
<woot-wizard class="hidden md:block col-span-2" :items="items" />
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:items="items"
/>
<router-view />
</div>
</div>
</template>
@@ -103,9 +103,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-8 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
@@ -57,9 +57,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-8 col-span-6">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.DESC')"
@@ -27,8 +27,15 @@ export default {
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full px-5 flex-1">
<woot-wizard class="hidden md:block col-span-2" :items="items" />
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:items="items"
/>
<router-view />
</div>
</div>
</template>
@@ -11,9 +11,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<EmptyState
:title="$t('TEAMS_SETTINGS.FINISH.TITLE')"
:message="$t('TEAMS_SETTINGS.FINISH.MESSAGE')"