Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma bc8ad35f22 fix(security): keep fallback templates running when captain feature is revoked 2026-07-23 13:29:21 +05:30
Tanmay Deep Sharma c86f7539a2 fix(security): enable sla feature in enterprise conversations spec 2026-07-23 13:24:20 +05:30
Tanmay Deep Sharma 0d4fd30eaa Merge remote-tracking branch 'origin/develop' into fix/premium-feature-and-agent-guards
# Conflicts:
#	app/controllers/api/v1/accounts/agents_controller.rb
2026-07-23 13:09:39 +05:30
Tanmay Deep SharmaandGitHub 6dc66cac0a Merge branch 'develop' into fix/premium-feature-and-agent-guards 2026-07-22 18:00:41 +05:30
Tanmay Deep Sharma 5bf6c0ddc3 fix(security): enforce premium flags on SLA automation, Captain runtime, and v2-only scenarios 2026-07-22 17:59:05 +05:30
Tanmay Deep Sharma 3b6e07c737 fix(security): gate custom_role and SLA assignment on their premium feature flags 2026-07-22 17:45:59 +05:30
Tanmay Deep Sharma 33913d9585 fix(security): gate copilot on Captain feature and guard enterprise agent seat race 2026-07-22 17:36:08 +05:30
Tanmay Deep Sharma 746abd8b28 Merge remote-tracking branch 'origin/develop' into fix/premium-feature-and-agent-guards
# Conflicts:
#	enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb
#	enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
2026-07-22 17:25:29 +05:30
Tanmay Deep Sharma 3b7a2c2b0e fix(security): restrict participating scope, agent seat race, and premium feature access 2026-07-22 17:21:33 +05:30
80 changed files with 227 additions and 697 deletions
+1 -1
View File
@@ -1 +1 @@
4.16.1
4.16.0
@@ -79,21 +79,13 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: config_param_keys }
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
def config_param_keys
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
# Enterprise lets knowledge_base_manage roles edit other portal settings.
keys << { analytics: %i[ga4 gtm clarity hotjar meta_pixel] } if Current.account_user&.administrator?
keys
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
+2 -1
View File
@@ -142,7 +142,8 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
participating_ids = current_user.participating_conversations.where(account_id: current_account.id).select(:id)
@conversations = @conversations.where(id: participating_ids)
when 'unattended'
@conversations = @conversations.unattended
end
@@ -28,12 +28,7 @@ const inboxes = computed(() => {
return {
name: inbox.name,
id: inbox.id,
icon: getInboxIconByType(
inbox.channelType,
inbox.medium,
'line',
inbox.voiceEnabled
),
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
};
});
});
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
@@ -61,7 +60,7 @@ const resultLabel = computed(() => {
});
const providerIcon = computed(() =>
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
);
const createdAtLabel = computed(() =>
@@ -151,7 +150,7 @@ const conversationRoute = computed(() => ({
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
@@ -170,12 +169,9 @@ const conversationRoute = computed(() => ({
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<div
v-if="agentActionLabel"
class="gap-x-1.5 min-w-0 flex items-center"
>
<template v-if="agentActionLabel">
<span
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
>
{{ agentActionLabel }}
</span>
@@ -196,7 +192,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }}
</span>
</span>
</div>
</template>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
@@ -216,10 +212,10 @@ const conversationRoute = computed(() => ({
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0">
<span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }}
</span>
</div>
@@ -236,7 +232,7 @@ const conversationRoute = computed(() => ({
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
>
{{ createdAtLabel }}
</span>
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
@@ -118,16 +117,10 @@ const moreFiltersSections = computed(() => [
},
]);
const selectedAssignee = computed(
() => props.agents.find(agent => agent.id === assigneeId.value) || null
);
const selectedAssigneeLabel = computed(
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const isOtherActivitySelected = computed(() =>
OTHER_ACTIVITIES.includes(activity.value)
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
@@ -150,7 +143,7 @@ const applyMoreFilter = ({ action, value }) => {
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-3">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
@@ -164,13 +157,13 @@ const applyMoreFilter = ({ action, value }) => {
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0 !h-7 !px-2"
class="shrink-0"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
<div class="w-px h-4 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
@@ -179,7 +172,7 @@ const applyMoreFilter = ({ action, value }) => {
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-11 !h-7 !px-2"
class="shrink-0 text-n-slate-12"
@click="setActivity(chip)"
/>
<OnClickOutside
@@ -191,10 +184,7 @@ const applyMoreFilter = ({ action, value }) => {
color="slate"
size="sm"
icon="i-lucide-phone"
class="!h-7 !px-2"
:class="
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
"
class="text-n-slate-12"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
@@ -218,19 +208,10 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
color="slate"
size="sm"
icon="i-woot-empty-assignee"
class="max-w-52 !h-7 !px-2"
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
@click="toggleMenu('assignee')"
>
<template v-if="selectedAssignee" #icon>
<Avatar
:src="selectedAssignee.thumbnail"
:name="selectedAssignee.name"
:size="16"
rounded-full
/>
</template>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
@@ -247,9 +228,8 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
size="sm"
icon="i-lucide-list-filter"
class="!h-7 !px-2"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
@@ -83,12 +83,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
});
</script>
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const lastActivityAt = computed(() => {
@@ -1,148 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Select from 'dashboard/components-next/select/Select.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
// `pattern` mirrors Portal::ANALYTICS_PROVIDERS to flag bad ids before saving.
const PROVIDERS = computed(() => [
{
key: 'ga4',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GA4.LABEL'),
placeholder: 'G-XXXXXXXXXX',
pattern: /^G-[A-Z0-9]+$/,
},
{
key: 'gtm',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GTM.LABEL'),
placeholder: 'GTM-XXXXXXX',
pattern: /^GTM-[A-Z0-9]+$/,
},
{
key: 'clarity',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.CLARITY.LABEL'),
placeholder: 'abcd1234ef',
pattern: /^[a-z0-9]+$/,
},
{
key: 'hotjar',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HOTJAR.LABEL'),
placeholder: '1234567',
pattern: /^\d+$/,
},
{
key: 'meta_pixel',
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.META_PIXEL.LABEL'),
placeholder: '1234567890',
pattern: /^\d+$/,
},
]);
const portalConfig = computed(() => props.activePortal?.config || {});
const providerFor = key =>
PROVIDERS.value.find(provider => provider.key === key);
const state = reactive({ provider: PROVIDERS.value[0].key, id: '' });
let saved = { provider: '', id: '' };
const resetFromPortal = () => {
const analytics = portalConfig.value.analytics || {};
const [key, value] = Object.entries(analytics)[0] || [];
saved = { provider: key || '', id: value || '' };
state.provider = saved.provider || PROVIDERS.value[0].key;
state.id = saved.id;
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
// Clear the id when switching to a provider other than the saved one.
watch(
() => state.provider,
provider => {
state.id = provider === saved.provider ? saved.id : '';
}
);
const providerOptions = computed(() =>
PROVIDERS.value.map(provider => ({
value: provider.key,
label: provider.name,
}))
);
const currentProvider = computed(() => providerFor(state.provider));
const error = computed(() => {
const value = state.id.trim();
if (!value || currentProvider.value.pattern.test(value)) return '';
return t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.INVALID_ID');
});
const hasChanges = computed(() => {
const id = state.id.trim();
if (!id) return saved.id !== '';
return id !== saved.id || state.provider !== saved.provider;
});
const handleSave = () => {
const id = state.id.trim();
const analytics = id ? { [state.provider]: id } : {};
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: { analytics },
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.PROVIDER') }}
</label>
<Select v-model="state.provider" :options="providerOptions" />
</div>
<Input
v-model="state.id"
:label="currentProvider.name"
:placeholder="currentProvider.placeholder"
:message="error"
:message-type="error ? 'error' : 'info'"
/>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.SAVE')"
:disabled="!hasChanges || Boolean(error) || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -3,13 +3,11 @@ import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useAdmin } from 'dashboard/composables/useAdmin';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import PortalAnalyticsSettings from './PortalAnalyticsSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -35,7 +33,6 @@ const emit = defineEmits([
const { t } = useI18n();
const route = useRoute();
const { isAdmin } = useAdmin();
const confirmDeletePortalDialogRef = ref(null);
@@ -111,14 +108,6 @@ const handleDeletePortal = () => {
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<template v-if="isAdmin">
<div class="w-full h-px bg-n-weak" />
<PortalAnalyticsSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
</template>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const hasSlaThreshold = computed(() => {
@@ -37,11 +37,10 @@ const transformInbox = ({
channelType,
phoneNumber,
medium,
voiceEnabled,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -55,7 +54,6 @@ const transformInbox = ({
phoneNumber,
channelType,
medium,
voiceEnabled,
...rest,
});
@@ -79,20 +79,6 @@ describe('composeConversationHelper', () => {
channelType: INBOX_TYPES.EMAIL,
});
});
it('uses the voice glyph for a voice-enabled inbox', () => {
const inboxes = [
{
id: 2,
name: 'WhatsApp Cloud',
channelType: INBOX_TYPES.WHATSAPP,
voiceEnabled: true,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
});
});
describe('getCapitalizedNameFromEmail', () => {
@@ -117,8 +117,8 @@ const downloadRecording = () => {
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
class="size-4 flex-shrink-0 text-n-slate-11"
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
@@ -127,10 +127,10 @@ const downloadRecording = () => {
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
@@ -58,12 +58,8 @@ const menuItems = computed(() => [
]);
const icon = computed(() => {
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
});
const handleAction = ({ action, value }) => {
@@ -1,5 +1,6 @@
<script setup>
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
@@ -20,6 +21,7 @@ defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
@@ -29,7 +31,13 @@ const icon = computed(() =>
</script>
<template>
<span class="inline-flex" v-bind="$attrs">
<span class="relative inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span>
</template>
@@ -1,9 +1,4 @@
import {
INBOX_TYPES,
TWILIO_CHANNEL_MEDIUM,
isVoiceCallEnabled,
getInboxVoiceIcon,
} from 'dashboard/helper/inbox';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue';
const channelTypeIconMap = {
@@ -66,9 +61,16 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
if (isVoiceCallEnabled(inboxDetails)) {
icon = getInboxVoiceIcon(type, inboxDetails.medium);
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
// is presented as a Voice channel, so show the phone icon.
const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
@@ -19,32 +19,13 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice-call');
});
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::Whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
expect(icon).toBe('i-woot-voice');
});
it('returns correct icon for Line channel', () => {
@@ -457,11 +457,10 @@ function handleReplyTo() {
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium, voice_enabled } =
inbox.value;
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
@@ -77,15 +77,6 @@ const handleClose = () => {
emit('close');
};
const handleFocusOut = event => {
// Keep the menu open while focus stays inside it (e.g. the label search
// input); close it once focus leaves the menu entirely.
if (menuRef.value?.contains(event.relatedTarget)) {
return;
}
handleClose();
};
onUnmounted(() => {
isLocked.value = false;
});
@@ -98,7 +89,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@focusout="handleFocusOut"
@blur="handleClose"
>
<slot />
</div>
@@ -7,13 +7,10 @@ import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import { picoSearch } from '@scmmishra/pico-search';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
@@ -34,8 +31,6 @@ export default {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
NextInput,
Icon,
},
props: {
chatId: {
@@ -92,7 +87,6 @@ export default {
data() {
return {
MENU,
labelSearchQuery: '',
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -222,14 +216,6 @@ export default {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
filteredLabels() {
const labels = this.labelSearchQuery
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
: this.labels;
// Assigned labels first, keeping each group's existing order.
const isAssigned = label => this.conversationLabels.includes(label.title);
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -349,49 +335,21 @@ export default {
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<div class="pb-1 w-[12.5rem]">
<NextInput
v-model="labelSearchQuery"
type="search"
size="sm"
class="w-full"
custom-input-class="!ps-8 !text-xs"
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
@click.stop
@keydown.stop
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
/>
</template>
</NextInput>
</div>
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
<MenuItem
v-for="label in filteredLabels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@mousedown.prevent
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<p
v-if="!filteredLabels.length"
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
>
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
</p>
</div>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
@@ -15,7 +15,7 @@ defineProps({
</script>
<template>
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
@@ -52,7 +52,7 @@ defineProps({
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
+1 -27
View File
@@ -55,30 +55,11 @@ export const getVoiceCallProvider = inbox => {
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
// Combined channel + voice-wave badge glyph per voice-call provider.
export const VOICE_CALL_ICONS = {
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
};
export const getVoiceCallIcon = provider =>
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
export const getInboxVoiceIcon = (channelType, medium) => {
const isWhatsapp =
channelType === INBOX_TYPES.WHATSAPP ||
(channelType === INBOX_TYPES.TWILIO &&
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
return getVoiceCallIcon(
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
);
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -201,14 +182,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (
type,
medium,
variant = 'fill',
voiceEnabled = false
) => {
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
export const getInboxIconByType = (type, medium, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
@@ -1,11 +1,8 @@
import {
INBOX_TYPES,
VOICE_CALL_PROVIDERS,
getInboxClassByType,
getInboxIconByType,
getInboxVoiceIcon,
getInboxWarningIconClass,
getVoiceCallIcon,
} from '../inbox';
describe('#Inbox Helpers', () => {
@@ -169,63 +166,4 @@ describe('#Inbox Helpers', () => {
);
});
});
describe('getVoiceCallIcon', () => {
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for the twilio provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
'i-woot-voice-call'
);
});
it('falls back to the generic voice-call glyph for an unknown provider', () => {
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
});
});
describe('getInboxVoiceIcon', () => {
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-woot-voice-call'
);
});
});
describe('getInboxIconByType with voice enabled', () => {
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
).toBe('i-woot-whatsapp-voice');
});
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
'i-woot-voice-call'
);
});
it('returns the normal channel icon when voice is not enabled', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
).toBe('i-woot-whatsapp');
});
});
});
@@ -193,8 +193,6 @@
},
"ASSIGN_AGENT": "Assign agent",
"ASSIGN_LABEL": "Assign label",
"SEARCH_LABELS": "Search labels",
"NO_LABELS_FOUND": "No labels found",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
@@ -972,28 +972,6 @@
},
"SAVE": "Save changes"
},
"ANALYTICS": {
"HEADER": "Analytics",
"DESCRIPTION": "Choose an analytics provider and enter its ID. We add the tracking code to every public help center page automatically. Switching providers replaces the previous one.",
"PROVIDER": "Provider",
"INVALID_ID": "This doesn't look like a valid ID for this provider.",
"GA4": {
"LABEL": "Google Analytics (GA4)"
},
"GTM": {
"LABEL": "Google Tag Manager"
},
"CLARITY": {
"LABEL": "Microsoft Clarity"
},
"HOTJAR": {
"LABEL": "Hotjar"
},
"META_PIXEL": {
"LABEL": "Meta Pixel"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
</script>
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const fileAttachments = computed(() => {
@@ -129,22 +129,22 @@ onMounted(async () => {
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="shrink-0">
<div class="w-full px-6 pt-6">
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5 pb-4 border-b border-n-weak mx-6"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
@@ -79,15 +79,13 @@ const breadcrumbItems = computed(() => {
});
const buildInboxList = allInboxes =>
allInboxes?.map(
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || [];
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
@@ -64,23 +64,13 @@ const allInboxes = computed(
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(
({
name,
id,
email,
phoneNumber,
channelType,
medium,
voiceEnabled,
}) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || []
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
);
const formData = computed(() => ({
@@ -29,8 +29,7 @@ const inboxIcon = computed(() => {
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line',
props.inbox.voiceEnabled
'line'
);
});
@@ -281,12 +281,8 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxIcon() {
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = this.inbox;
return getInboxIconByType(type, medium, 'line', voiceEnabled);
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium, 'line');
},
bannerMaxWidth() {
const narrowTabs = ['collaborators', 'bot-configuration'];
@@ -33,7 +33,6 @@ const state = {
allFetched: false,
isFetching: false,
isSwitching: false,
isFetchingSSLStatus: false,
},
};
@@ -43,11 +43,6 @@ module PortalConfigSchema
'popular_content' => {
'type' => %w[object null],
'additionalProperties' => POPULAR_CONTENT_SCHEMA
},
# Analytics ids keyed by provider, e.g. { "ga4" => "G-XXXX" }.
'analytics' => {
'type' => %w[object null],
'additionalProperties' => { 'type' => %w[string null] }
}
},
'required' => [],
+1 -30
View File
@@ -46,7 +46,6 @@ class Portal < ApplicationRecord
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
before_validation :normalize_config
validate :validate_config
validate :validate_analytics
validates_with JsonSchemaValidator,
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
@@ -55,17 +54,7 @@ class Portal < ApplicationRecord
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
popular_content analytics].freeze
# Analytics providers and their accepted id formats. Add a provider here and its
# snippet in layouts/_portal_analytics.html.erb.
ANALYTICS_PROVIDERS = {
'ga4' => /\AG-[A-Z0-9]+\z/,
'gtm' => /\AGTM-[A-Z0-9]+\z/,
'clarity' => /\A[a-z0-9]+\z/,
'hotjar' => /\A\d+\z/,
'meta_pixel' => /\A\d+\z/
}.freeze
popular_content].freeze
# Max number of recommended categories/articles shown per locale.
POPULAR_CATEGORY_LIMIT = 3
@@ -143,11 +132,6 @@ class Portal < ApplicationRecord
config_value('social_profiles') || {}
end
# Valid analytics ids keyed by provider; drops unknown providers and blank ids.
def analytics
(config_value('analytics') || {}).select { |provider, id| ANALYTICS_PROVIDERS.key?(provider) && id.present? }
end
private
def normalize_config
@@ -163,19 +147,6 @@ class Portal < ApplicationRecord
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
def validate_analytics
(config_value('analytics') || {}).each do |provider, id|
next if id.blank?
format = ANALYTICS_PROVIDERS[provider]
if format.nil?
errors.add(:config, "analytics provider #{provider} is not supported")
elsif !id.match?(format)
errors.add(:config, "analytics id for #{provider} is invalid")
end
end
end
def normalize_locale_codes(locale_codes)
Array(locale_codes).filter_map(&:presence).uniq
end
@@ -20,7 +20,6 @@ json.config do
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
json.popular_content portal.config['popular_content'] || {}
json.analytics portal.config['analytics'] || {}
end
if portal.channel_web_widget
@@ -1,65 +0,0 @@
<%# Analytics snippets for the portal's configured ids (validated on save). %>
<%# The raw output tag emits to_json so JS strings are not HTML-escaped. %>
<% analytics = @portal.analytics %>
<% nonce = request.content_security_policy_nonce %>
<% if analytics['ga4'].present? %>
<!-- Google Analytics (GA4) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= analytics['ga4'] %>" nonce="<%= nonce %>"></script>
<script nonce="<%= nonce %>">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', <%== analytics['ga4'].to_json %>);
</script>
<% end %>
<% if analytics['gtm'].present? %>
<!-- Google Tag Manager -->
<script nonce="<%= nonce %>">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer',<%== analytics['gtm'].to_json %>);</script>
<% end %>
<% if analytics['clarity'].present? %>
<!-- Microsoft Clarity -->
<script nonce="<%= nonce %>">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", <%== analytics['clarity'].to_json %>);
</script>
<% end %>
<% if analytics['hotjar'].present? %>
<!-- Hotjar -->
<script nonce="<%= nonce %>">
(function(h,o,t,j,a,r){
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
h._hjSettings={hjid:<%== analytics['hotjar'].to_json %>,hjsv:6};
a=o.getElementsByTagName('head')[0];
r=o.createElement('script');r.async=1;
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
</script>
<% end %>
<% if analytics['meta_pixel'].present? %>
<!-- Meta Pixel -->
<script nonce="<%= nonce %>">
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', <%== analytics['meta_pixel'].to_json %>);
fbq('track', 'PageView');
</script>
<% end %>
@@ -1,7 +0,0 @@
<%# GTM noscript fallback; must sit right after <body> (invalid in <head>). %>
<% if @portal.analytics['gtm'].present? %>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=<%= @portal.analytics['gtm'] %>"
title="Google Tag Manager" height="0" width="0"
style="display:none;visibility:hidden"></iframe></noscript>
<% end %>
-2
View File
@@ -23,8 +23,6 @@
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<%= render 'layouts/portal_analytics' %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',
-1
View File
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= yield %>
-1
View File
@@ -4,7 +4,6 @@
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<%= render 'layouts/portal_analytics_body' %>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/header', portal: @portal %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.16.1'
version: '4.16.0'
development:
<<: *shared
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
@@ -0,0 +1,13 @@
class Api::V1::Accounts::Captain::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_captain_enabled
private
# Captain is a premium feature; block API access when neither the v1 nor v2 flag is enabled for the account.
# `current_account` resolves and memoizes the account, so this does not depend on before_action ordering.
def ensure_captain_enabled
return if current_account.feature_enabled?('captain_integration') || current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::Captain::BaseController
before_action :set_copilot_thread
def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::Captain::BaseController
before_action :ensure_message, only: :create
def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::Captain::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Captain::BaseController
before_action :ensure_captain_v2_enabled
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
@@ -24,6 +25,13 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
private
# Scenarios are a Captain v2-only feature; the shared base guard allows either flag, so require v2 here.
def ensure_captain_v2_enabled
return if current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
def set_assistant
@assistant = account_assistants.find(params[:assistant_id])
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_custom_roles_feature_enabled
before_action :fetch_custom_role, only: [:show, :update, :destroy]
before_action :check_authorization
@@ -28,4 +29,8 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
end
def ensure_custom_roles_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('custom_roles')
end
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_sla_feature_enabled
before_action :fetch_sla, only: [:show, :update, :destroy]
before_action :check_authorization
@@ -29,4 +30,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end
def ensure_sla_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('sla')
end
end
@@ -14,6 +14,9 @@ module Enterprise::Api::V1::Accounts::AgentsController
private
def associate_agent_with_custom_role
# Custom roles are a premium feature; ignore custom_role_id assignment when the feature is disabled.
return unless Current.account.feature_enabled?('custom_roles')
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end
@@ -16,6 +16,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
def permitted_update_params
# SLA is a premium feature; only accept sla_policy_id assignment when it is enabled for the account.
return super unless Current.account.feature_enabled?('sla')
super.merge(params.permit(:sla_policy_id))
end
+1 -1
View File
@@ -62,7 +62,7 @@ class CallFinder
end
def paginated_calls
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
.order(created_at: :desc)
.page(@params[:page] || 1)
.per(RESULTS_PER_PAGE)
@@ -1,6 +1,8 @@
module Enterprise::ActionService
def add_sla(sla_policy_id)
return if sla_policy_id.blank?
# SLA is a premium feature; automation rules must not keep applying SLAs once it is disabled.
return unless @account.feature_enabled?('sla')
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil?
@@ -50,7 +50,14 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
conversation.pending? && message.incoming? && inbox.captain_assistant.present? && captain_feature_enabled?
end
# Captain is a premium feature; stop the runtime responder when it has been revoked, even if an
# assistant is still linked to the inbox.
def captain_feature_enabled?
account = conversation.account
account.feature_enabled?('captain_integration') || account.feature_enabled?('captain_integration_v2')
end
def perform_handoff
@@ -75,7 +82,9 @@ module Enterprise::MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# Only let Captain suppress the fallback templates when it can actually reply; otherwise a revoked
# feature would leave the contact with neither a Captain response nor a greeting/OOO/email-collect.
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? && captain_feature_enabled?
end
end
@@ -19,8 +19,6 @@ end
json.inbox do
json.id call.inbox_id
json.name call.inbox.name
json.channel_type call.inbox.channel_type
json.medium call.inbox.channel.try(:medium)
end
if call.accepted_by_agent
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.16.1",
"version": "4.16.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -175,21 +175,11 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {},
'popular_content' => {},
'analytics' => {}
'popular_content' => {}
}
)
end
it 'allows administrators to set analytics config' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { config: { analytics: { ga4: 'G-ADMIN12345' } } } },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(portal.reload.config['analytics']).to eq('ga4' => 'G-ADMIN12345')
end
it 'preserves drafted locales when draft_locales is omitted' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
@@ -32,7 +32,6 @@ RSpec.describe 'Calls API', type: :request do
item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number)
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
end
it 'scopes the list to calls the agent accepted' do
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -24,6 +24,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
)
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
before { account.enable_features!('captain_integration') }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -13,6 +13,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
}.with_indifferent_access
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -9,6 +9,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,6 +6,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
before { account.enable_features!('captain_integration_v2') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,6 +6,8 @@ RSpec.describe 'Custom Roles API', type: :request do
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
before { account.enable_features!('custom_roles') }
describe 'GET #index' do
context 'when it is an authenticated administrator' do
it 'returns all custom roles in the account' do
@@ -6,6 +6,7 @@ RSpec.describe 'Enterprise SLA API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!('sla')
create(:sla_policy, account: account, name: 'SLA 1')
end
@@ -5,6 +5,8 @@ RSpec.describe 'Enterprise Agents API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:custom_role) { create(:custom_role, account: account) }
before { account.enable_features!('custom_roles') }
describe 'POST /api/v1/accounts/{account.id}/agents' do
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
@@ -13,6 +13,7 @@ RSpec.describe 'Enterprise Conversations API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!('sla')
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
@@ -85,17 +85,6 @@ RSpec.describe 'Enterprise Portal API', type: :request do
json_response = response.parsed_body
expect(json_response['name']).to eq('updated_portal')
end
it 'ignores analytics config for knowledge_base_manage users' do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { name: 'updated_portal', config: { analytics: { ga4: 'G-KBMANAGER1' } } } },
headers: agent_with_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(portal.reload.name).to eq('updated_portal')
expect(portal.config['analytics']).to be_blank
end
end
end
@@ -8,6 +8,8 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
before { account.enable_features!('sla') }
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
@@ -8,6 +8,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
let(:assistant) { create(:captain_assistant, account: account) }
before do
account.enable_features!('captain_integration')
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
@@ -202,6 +203,29 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
end
context 'when the Captain feature is disabled but an assistant is still linked' do
before do
account.disable_features!('captain_integration', 'captain_integration_v2')
conversation.update!(status: :pending)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'falls back to the greeting message instead of leaving the contact without a reply' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
expect(conversation.reload.messages.template.last.content).to eq('Hello! How can we help you?')
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
-20
View File
@@ -269,16 +269,6 @@ export const icons = {
width: 24,
height: 24,
},
'voice-call': {
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
'whatsapp-voice': {
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
instagram: {
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24,
@@ -480,15 +470,5 @@ export const icons = {
width: 24,
height: 24,
},
'audio-play': {
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
'audio-pause': {
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
/** Ends */
};