Merge branch 'develop' into fix/hc-editor
This commit is contained in:
@@ -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 it’s 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 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 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>
|
||||
+90
@@ -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],
|
||||
};
|
||||
+74
@@ -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>
|
||||
+197
@@ -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>
|
||||
+128
@@ -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>
|
||||
+87
@@ -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>
|
||||
+192
@@ -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>
|
||||
+126
@@ -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>
|
||||
+257
@@ -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>
|
||||
+214
@@ -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>
|
||||
+44
@@ -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>
|
||||
+59
@@ -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"
|
||||
|
||||
+24
-27
@@ -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">
|
||||
|
||||
+1
-3
@@ -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')"
|
||||
|
||||
+1
-3
@@ -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",
|
||||
|
||||
+32
@@ -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>
|
||||
+50
@@ -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>
|
||||
+102
@@ -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>
|
||||
+41
@@ -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>
|
||||
+251
@@ -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')"
|
||||
|
||||
Reference in New Issue
Block a user