Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602c56d1c5 | ||
|
|
647a65d481 | ||
|
|
99ec18c95b | ||
|
|
64ba23688b | ||
|
|
2fec2e5993 |
@@ -30,7 +30,14 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def facebook_pages
|
||||
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
|
||||
pages = []
|
||||
fb_pages = fb_object.get_connections('me', 'accounts')
|
||||
pages.concat(fb_pages)
|
||||
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
|
||||
fb_pages = next_page
|
||||
pages.concat(fb_pages)
|
||||
end
|
||||
@page_details = mark_already_existing_facebook_pages(pages)
|
||||
end
|
||||
|
||||
def set_instagram_id(page_access_token, facebook_channel)
|
||||
|
||||
@@ -47,6 +47,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.sso_config.merge!(sso_config_params) if sso_config_params.present? && sso_feature_enabled?
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -95,6 +96,14 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
||||
end
|
||||
|
||||
def sso_config_params
|
||||
params.permit(sso_config: [:enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry])[:sso_config]
|
||||
end
|
||||
|
||||
def sso_feature_enabled?
|
||||
@account.feature_enabled?('sso')
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
end
|
||||
|
||||
@@ -52,9 +52,9 @@ const handleBreadcrumbClick = item => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="my-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
>
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full">
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</header>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import AddNewRulesDialog from './AddNewRulesDialog.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/AddNewRulesDialog"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="px-4 py-4 bg-n-background h-[200px]">
|
||||
<AddNewRulesDialog
|
||||
button-label="Add a guardrail"
|
||||
placeholder="Type in another guardrail..."
|
||||
confirm-label="Create"
|
||||
cancel-label="Cancel"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
const onClickAdd = () => {
|
||||
if (!modelValue.value?.trim()) return;
|
||||
emit('add', modelValue.value.trim());
|
||||
modelValue.value = '';
|
||||
togglePopover(false);
|
||||
};
|
||||
|
||||
const onClickCancel = () => {
|
||||
togglePopover(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex relative">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
sm
|
||||
slate
|
||||
class="flex-shrink-0"
|
||||
@click="togglePopover(!showPopover)"
|
||||
/>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
|
||||
>
|
||||
<InlineInput
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="onClickAdd"
|
||||
/>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button
|
||||
:label="cancelLabel"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="h-10 hover:!no-underline"
|
||||
@click="onClickCancel"
|
||||
/>
|
||||
<Button :label="confirmLabel" sm @click="onClickAdd" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import AddNewRulesInput from './AddNewRulesInput.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/AddNewRulesInput"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="px-6 py-4 bg-n-background">
|
||||
<AddNewRulesInput
|
||||
placeholder="Type in another response guideline..."
|
||||
label="Add and save (↵)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const onClickAdd = () => {
|
||||
if (!modelValue.value?.trim()) return;
|
||||
emit('add', modelValue.value.trim());
|
||||
modelValue.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
|
||||
>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
|
||||
|
||||
<InlineInput
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="onClickAdd"
|
||||
/>
|
||||
<Button
|
||||
:label="label"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onClickAdd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
allItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectAllLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedCountLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
deleteLabel: {
|
||||
type: String,
|
||||
default: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['bulkDelete']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => modelValue.value.size);
|
||||
const totalCount = computed(() => props.allItems.length);
|
||||
|
||||
const hasSelected = computed(() => selectedCount.value > 0);
|
||||
const isIndeterminate = computed(
|
||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||
);
|
||||
const allSelected = computed(
|
||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
);
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
const newSelectedIds = shouldSelectAll
|
||||
? new Set(props.allItems.map(item => item.id))
|
||||
: new Set();
|
||||
modelValue.value = newSelectedIds;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||
{{ selectAllLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<slot name="default-actions" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import RuleCard from './RuleCard.vue';
|
||||
|
||||
const sampleRules = [
|
||||
{ id: 1, content: 'Block sensitive personal information', selectable: true },
|
||||
{ id: 2, content: 'Reject offensive language', selectable: true },
|
||||
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/RuleCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Selectable List">
|
||||
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||
<RuleCard
|
||||
v-for="rule in sampleRules"
|
||||
:id="rule.id"
|
||||
:key="rule.id"
|
||||
:content="rule.content"
|
||||
:selectable="rule.selectable"
|
||||
@select="id => console.log('Selected rule', id)"
|
||||
@edit="id => console.log('Edit', id)"
|
||||
@delete="id => console.log('Delete', id)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Non-Selectable">
|
||||
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||
<RuleCard id="4" content="Replies should be friendly and clear." />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const isEditing = ref(false);
|
||||
const editedContent = ref(props.content);
|
||||
|
||||
// Local content to display to avoid flicker until parent prop updates on inline edit
|
||||
const localContent = ref(props.content);
|
||||
|
||||
// Keeps localContent in sync when parent updates content prop
|
||||
watch(
|
||||
() => props.content,
|
||||
newVal => {
|
||||
localContent.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const startEdit = () => {
|
||||
isEditing.value = true;
|
||||
editedContent.value = props.content;
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
isEditing.value = false;
|
||||
// Update local content
|
||||
localContent.value = editedContent.value;
|
||||
emit('edit', { id: props.id, content: editedContent.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout
|
||||
selectable
|
||||
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
|
||||
layout="row"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<InlineInput
|
||||
v-if="isEditing"
|
||||
v-model="editedContent"
|
||||
focus-on-mount
|
||||
custom-input-class="flex items-center gap-2 text-sm text-n-slate-12"
|
||||
@keyup.enter="saveEdit"
|
||||
/>
|
||||
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
|
||||
{{ localContent }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
|
||||
<span class="w-px h-4 bg-n-weak" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="emit('delete', id)"
|
||||
/>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import SuggestedRules from './SuggestedRules.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const guidelinesExample = [
|
||||
{
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/SuggestedRules"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Suggested Rules List">
|
||||
<div class="px-20 py-4 bg-n-background">
|
||||
<SuggestedRules
|
||||
title="Example response guidelines"
|
||||
:items="guidelinesExample"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
|
||||
<Button
|
||||
label="Add this"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const onAddClick = () => {
|
||||
emit('add');
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
|
||||
<span class="h-3 w-px bg-n-weak" />
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onAddClick"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onClickClose"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
|
||||
>
|
||||
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
|
||||
<slot :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SSO: 'sso',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
@@ -48,4 +49,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.CAPTAIN_V2,
|
||||
FEATURE_FLAGS.SSO,
|
||||
];
|
||||
|
||||
@@ -123,6 +123,50 @@
|
||||
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
||||
}
|
||||
},
|
||||
"SSO": {
|
||||
"TITLE": "Single Sign-On",
|
||||
"DESCRIPTION": "Configure SSO authentication for your organization",
|
||||
"FORM": {
|
||||
"ENABLE_SSO": {
|
||||
"LABEL": "Enable SSO",
|
||||
"HELP": "Allow users to log in using Single Sign-On"
|
||||
},
|
||||
"PROVIDER_NAME": {
|
||||
"LABEL": "Provider Name",
|
||||
"PLACEHOLDER": "e.g., 'Okta', 'Azure AD', 'Google Workspace'",
|
||||
"ERROR": "Please enter a provider name"
|
||||
},
|
||||
"LOGIN_URL": {
|
||||
"LABEL": "SSO Login URL",
|
||||
"PLACEHOLDER": "https://your-sso-provider.com/login",
|
||||
"ERROR": "Please enter a valid SSO login URL",
|
||||
"HELP": "Users will be redirected to this URL for authentication"
|
||||
},
|
||||
"LOGOUT_URL": {
|
||||
"LABEL": "SSO Logout URL (Optional)",
|
||||
"PLACEHOLDER": "https://your-sso-provider.com/logout",
|
||||
"HELP": "Users will be redirected to this URL after logout"
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"LABEL": "Secret Key",
|
||||
"PLACEHOLDER": "Enter your SSO secret key",
|
||||
"ERROR": "Please enter a secret key",
|
||||
"HELP": "Secret key used to verify SSO tokens"
|
||||
},
|
||||
"TOKEN_EXPIRY": {
|
||||
"LABEL": "Token Expiry (minutes)",
|
||||
"PLACEHOLDER": "5",
|
||||
"ERROR": "Please enter a valid expiry time",
|
||||
"HELP": "How long SSO tokens remain valid"
|
||||
},
|
||||
"ERROR": "Please fix the form errors"
|
||||
},
|
||||
"SUBMIT": "Update SSO Settings",
|
||||
"UPDATE": {
|
||||
"SUCCESS": "SSO settings updated successfully",
|
||||
"ERROR": "Failed to update SSO settings"
|
||||
}
|
||||
},
|
||||
"UPDATE_CHATWOOT": "An update {latestChatwootVersion} for Chatwoot is available. Please update your instance.",
|
||||
"LEARN_MORE": "Learn more",
|
||||
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
|
||||
|
||||
@@ -521,6 +521,100 @@
|
||||
"TITLE": "Captain Assistant",
|
||||
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
}
|
||||
},
|
||||
"GUARDRAILS": {
|
||||
"TITLE": "Guardrails",
|
||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Guardrails"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example guardrails",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another guardrail..."
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a guardrail",
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel",
|
||||
"PLACEHOLDER": "Type in another guardrail...",
|
||||
"TEST_ALL": "Test all"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Guardrails added successfully",
|
||||
"ERROR": "There was an error adding guardrails, please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Guardrails updated successfully",
|
||||
"ERROR": "There was an error updating guardrails, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Guardrails deleted successfully",
|
||||
"ERROR": "There was an error deleting guardrails, please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSE_GUIDELINES": {
|
||||
"TITLE": "Response Guidelines",
|
||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Response Guidelines"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example response guidelines",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another response guideline..."
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a response guideline",
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel",
|
||||
"PLACEHOLDER": "Type in another response guideline...",
|
||||
"TEST_ALL": "Test all"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Response Guidelines added successfully",
|
||||
"ERROR": "There was an error adding response guidelines, please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Response Guidelines updated successfully",
|
||||
"ERROR": "There was an error updating response guidelines, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Response Guidelines deleted successfully",
|
||||
"ERROR": "There was an error deleting response guidelines, please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
|
||||
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
|
||||
},
|
||||
"SSO": {
|
||||
"BUTTON_TEXT": "Login with {provider}"
|
||||
},
|
||||
"FORGOT_PASSWORD": "Forgot your password?",
|
||||
"CREATE_NEW_ACCOUNT": "Create a new account",
|
||||
"SUBMIT": "Login"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
|
||||
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
|
||||
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
|
||||
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BREADCRUMB.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
|
||||
|
||||
const displayGuardrails = computed(() =>
|
||||
guardrailsContent.value.map((c, idx) => ({ id: idx, content: c }))
|
||||
);
|
||||
|
||||
const guardrailsExample = [
|
||||
{
|
||||
id: 1,
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredGuardrails = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return displayGuardrails.value;
|
||||
return picoSearch(displayGuardrails.value, query, ['content']);
|
||||
});
|
||||
|
||||
const shouldShowSuggestedRules = computed(() => {
|
||||
return uiSettings.value?.show_guardrails_suggestions !== false;
|
||||
});
|
||||
|
||||
const closeSuggestedRules = () => {
|
||||
updateUISettings({ show_guardrails_suggestions: false });
|
||||
};
|
||||
|
||||
// Bulk selection & hover state
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleRuleSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const handleRuleHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = displayGuardrails.value.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const saveGuardrails = async list => {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
assistant: { guardrails: list },
|
||||
});
|
||||
};
|
||||
|
||||
const addGuardrail = async content => {
|
||||
try {
|
||||
const newGuardrails = [...guardrailsContent.value, content];
|
||||
await saveGuardrails(newGuardrails);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const editGuardrail = async ({ id, content }) => {
|
||||
try {
|
||||
const updated = [...guardrailsContent.value];
|
||||
updated[id] = content;
|
||||
await saveGuardrails(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGuardrail = async id => {
|
||||
try {
|
||||
const updated = guardrailsContent.value.filter((_, idx) => idx !== id);
|
||||
await saveGuardrails(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteGuardrails = async () => {
|
||||
try {
|
||||
if (bulkSelectedIds.value.size === 0) return;
|
||||
const updated = guardrailsContent.value.filter(
|
||||
(_, idx) => !bulkSelectedIds.value.has(idx)
|
||||
);
|
||||
await saveGuardrails(updated);
|
||||
bulkSelectedIds.value.clear();
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const addAllExample = () => {
|
||||
updateUISettings({ show_guardrails_suggestions: false });
|
||||
try {
|
||||
const exampleContents = guardrailsExample.map(example => example.content);
|
||||
const newGuardrails = [...guardrailsContent.value, ...exampleContents];
|
||||
saveGuardrails(newGuardrails);
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<template #body>
|
||||
<SettingsHeader
|
||||
:heading="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
|
||||
:description="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.DESCRIPTION')"
|
||||
/>
|
||||
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
|
||||
<SuggestedRules
|
||||
:title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.TITLE')"
|
||||
:items="guardrailsExample"
|
||||
@add="addAllExample"
|
||||
@close="closeSuggestedRules"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ item.content }}
|
||||
</span>
|
||||
<Button
|
||||
:label="
|
||||
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD_SINGLE')
|
||||
"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="addGuardrail(item.content)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
<div class="flex mt-7 flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="displayGuardrails"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="
|
||||
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.BULK_DELETE_BUTTON')
|
||||
"
|
||||
@bulk-delete="bulkDeleteGuardrails"
|
||||
>
|
||||
<template #default-actions>
|
||||
<AddNewRulesDialog
|
||||
v-model="newDialogRule"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.PLACEHOLDER')
|
||||
"
|
||||
:button-label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TITLE')"
|
||||
:confirm-label="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CREATE')
|
||||
"
|
||||
:cancel-label="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CANCEL')
|
||||
"
|
||||
@add="addGuardrail"
|
||||
/>
|
||||
<!-- Will enable this feature in future -->
|
||||
<!-- <div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TEST_ALL')"
|
||||
xs
|
||||
ghost
|
||||
slate
|
||||
class="!text-sm"
|
||||
/> -->
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<div
|
||||
v-if="displayGuardrails.length && bulkSelectedIds.size === 0"
|
||||
class="max-w-[22.5rem] w-full min-w-0"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.LIST.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayGuardrails.length === 0" class="mt-1 mb-2">
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<RuleCard
|
||||
v-for="guardrail in filteredGuardrails"
|
||||
:id="guardrail.id"
|
||||
:key="guardrail.id"
|
||||
:content="guardrail.content"
|
||||
:is-selected="bulkSelectedIds.has(guardrail.id)"
|
||||
:selectable="
|
||||
hoveredCard === guardrail.id || bulkSelectedIds.size > 0
|
||||
"
|
||||
@select="handleRuleSelect"
|
||||
@edit="editGuardrail"
|
||||
@delete="deleteGuardrail"
|
||||
@hover="isHovered => handleRuleHover(isHovered, guardrail.id)"
|
||||
/>
|
||||
</div>
|
||||
<AddNewRulesInput
|
||||
v-model="newInlineRule"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.PLACEHOLDER')
|
||||
"
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.SAVE')"
|
||||
@add="addGuardrail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
|
||||
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
|
||||
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
|
||||
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guidelinesContent = computed(
|
||||
() => assistant.value?.response_guidelines || []
|
||||
);
|
||||
|
||||
const displayGuidelines = computed(() =>
|
||||
guidelinesContent.value.map((c, idx) => ({ id: idx, content: c }))
|
||||
);
|
||||
|
||||
const guidelinesExample = [
|
||||
{
|
||||
id: 1,
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredGuidelines = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return displayGuidelines.value;
|
||||
return picoSearch(displayGuidelines.value, query, ['content']);
|
||||
});
|
||||
|
||||
const shouldShowSuggestedRules = computed(() => {
|
||||
return uiSettings.value?.show_response_guidelines_suggestions !== false;
|
||||
});
|
||||
|
||||
const closeSuggestedRules = () => {
|
||||
updateUISettings({ show_response_guidelines_suggestions: false });
|
||||
};
|
||||
|
||||
// Bulk selection & hover state
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleRuleSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const handleRuleHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = displayGuidelines.value.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.UNSELECT_ALL', {
|
||||
count,
|
||||
})
|
||||
: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECT_ALL', {
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const saveGuidelines = async list => {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
assistant: { response_guidelines: list },
|
||||
});
|
||||
};
|
||||
|
||||
const addGuideline = async content => {
|
||||
try {
|
||||
const updated = [...guidelinesContent.value, content];
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const editGuideline = async ({ id, content }) => {
|
||||
try {
|
||||
const updated = [...guidelinesContent.value];
|
||||
updated[id] = content;
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGuideline = async id => {
|
||||
try {
|
||||
const updated = guidelinesContent.value.filter((_, idx) => idx !== id);
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteGuidelines = async () => {
|
||||
try {
|
||||
if (bulkSelectedIds.value.size === 0) return;
|
||||
const updated = guidelinesContent.value.filter(
|
||||
(_, idx) => !bulkSelectedIds.value.has(idx)
|
||||
);
|
||||
await saveGuidelines(updated);
|
||||
bulkSelectedIds.value.clear();
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const addAllExample = async () => {
|
||||
updateUISettings({ show_response_guidelines_suggestions: false });
|
||||
try {
|
||||
const exampleContents = guidelinesExample.map(example => example.content);
|
||||
const newGuidelines = [...guidelinesContent.value, ...exampleContents];
|
||||
await saveGuidelines(newGuidelines);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<template #body>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
||||
:description="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.DESCRIPTION')"
|
||||
/>
|
||||
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
|
||||
<SuggestedRules
|
||||
:title="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
||||
:items="guidelinesExample"
|
||||
@add="addAllExample"
|
||||
@close="closeSuggestedRules"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ item.content }}
|
||||
</span>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.ADD_SINGLE'
|
||||
)
|
||||
"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="addGuideline(item.content)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
<div class="flex mt-7 flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="displayGuidelines"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="
|
||||
$t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.BULK_DELETE_BUTTON'
|
||||
)
|
||||
"
|
||||
@bulk-delete="bulkDeleteGuidelines"
|
||||
>
|
||||
<template #default-actions>
|
||||
<AddNewRulesDialog
|
||||
v-model="newDialogRule"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:button-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TITLE')
|
||||
"
|
||||
:confirm-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CREATE')
|
||||
"
|
||||
:cancel-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CANCEL')
|
||||
"
|
||||
@add="addGuideline"
|
||||
/>
|
||||
<!-- Will enable this feature in future -->
|
||||
<!-- <div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TEST_ALL')
|
||||
"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
/> -->
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<div
|
||||
v-if="displayGuidelines.length && bulkSelectedIds.size === 0"
|
||||
class="max-w-[22.5rem] w-full min-w-0"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.LIST.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayGuidelines.length === 0" class="mt-1 mb-2">
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<RuleCard
|
||||
v-for="guideline in filteredGuidelines"
|
||||
:id="guideline.id"
|
||||
:key="guideline.id"
|
||||
:content="guideline.content"
|
||||
:is-selected="bulkSelectedIds.has(guideline.id)"
|
||||
:selectable="
|
||||
hoveredCard === guideline.id || bulkSelectedIds.size > 0
|
||||
"
|
||||
@select="handleRuleSelect"
|
||||
@hover="isHovered => handleRuleHover(isHovered, guideline.id)"
|
||||
@edit="editGuideline"
|
||||
@delete="deleteGuideline"
|
||||
/>
|
||||
</div>
|
||||
<AddNewRulesInput
|
||||
v-model="newInlineRule"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.SAVE')
|
||||
"
|
||||
@add="addGuideline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -32,7 +32,7 @@ const controlItems = computed(() => {
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
|
||||
),
|
||||
// routeName: 'captain_assistants_guardrails_index',
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
@@ -50,7 +50,7 @@ const controlItems = computed(() => {
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
|
||||
),
|
||||
// routeName: 'captain_assistants_guidelines_index',
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantEdit from './assistants/Edit.vue';
|
||||
// import AssistantSettings from './assistants/settings/Settings.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
|
||||
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
|
||||
@@ -50,6 +52,36 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guardrails'
|
||||
),
|
||||
component: AssistantGuardrailsIndex,
|
||||
name: 'captain_assistants_guardrails_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
|
||||
),
|
||||
component: AssistantGuidelinesIndex,
|
||||
name: 'captain_assistants_guidelines_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/documents'),
|
||||
component: DocumentsIndex,
|
||||
|
||||
@@ -17,6 +17,7 @@ import BuildInfo from './components/BuildInfo.vue';
|
||||
import AccountDelete from './components/AccountDelete.vue';
|
||||
import AutoResolve from './components/AutoResolve.vue';
|
||||
import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import SSOConfiguration from './components/SSOConfiguration.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
@@ -28,6 +29,7 @@ export default {
|
||||
AccountDelete,
|
||||
AutoResolve,
|
||||
AudioTranscription,
|
||||
SSOConfiguration,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
@@ -77,6 +79,9 @@ export default {
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
},
|
||||
showSSOConfig() {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SSO);
|
||||
},
|
||||
languagesSortedByCode() {
|
||||
const enabledLanguages = [...this.enabledLanguages];
|
||||
return enabledLanguages.sort((l1, l2) =>
|
||||
@@ -244,6 +249,7 @@ export default {
|
||||
</div>
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<SSOConfiguration v-if="showSSOConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url } from '@vuelidate/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import NextInput from 'next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SectionLayout from './SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
NextInput,
|
||||
NextButton,
|
||||
SectionLayout,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const v$ = useVuelidate();
|
||||
|
||||
return { accountId, v$ };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ssoConfig: {
|
||||
enabled: false,
|
||||
provider_name: 'SSO',
|
||||
login_url: '',
|
||||
logout_url: '',
|
||||
secret_key: '',
|
||||
token_expiry: 5,
|
||||
},
|
||||
showSecretKey: false,
|
||||
isUpdating: false,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
ssoConfig: {
|
||||
provider_name: {
|
||||
required,
|
||||
},
|
||||
login_url: {
|
||||
required: this.ssoConfig.enabled ? required : true,
|
||||
url: this.ssoConfig.enabled ? url : true,
|
||||
},
|
||||
secret_key: {
|
||||
required: this.ssoConfig.enabled ? required : true,
|
||||
},
|
||||
token_expiry: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
}),
|
||||
currentAccount() {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSSO();
|
||||
},
|
||||
methods: {
|
||||
initializeSSO() {
|
||||
try {
|
||||
const { sso_config } = this.currentAccount;
|
||||
if (sso_config) {
|
||||
this.ssoConfig = {
|
||||
enabled: sso_config.enabled || false,
|
||||
provider_name: sso_config.provider_name || 'SSO',
|
||||
login_url: sso_config.login_url || '',
|
||||
logout_url: sso_config.logout_url || '',
|
||||
secret_key: sso_config.secret_key || '',
|
||||
token_expiry: sso_config.token_expiry || 5,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
async updateSSO() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.SSO.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUpdating = true;
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('accounts/update', {
|
||||
sso_config: this.ssoConfig,
|
||||
});
|
||||
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.ERROR'));
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleSecretKey() {
|
||||
this.showSecretKey = !this.showSecretKey;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="$t('GENERAL_SETTINGS.SSO.TITLE')"
|
||||
:description="$t('GENERAL_SETTINGS.SSO.DESCRIPTION')"
|
||||
>
|
||||
<form class="grid gap-4" @submit.prevent="updateSSO">
|
||||
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.LABEL')">
|
||||
<div class="flex items-center">
|
||||
<input v-model="ssoConfig.enabled" type="checkbox" class="mr-2" />
|
||||
<span>{{ $t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.HELP') }}</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
|
||||
<template v-if="ssoConfig.enabled">
|
||||
<WithLabel
|
||||
:has-error="v$.ssoConfig.provider_name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.ERROR')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="ssoConfig.provider_name"
|
||||
type="text"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.ssoConfig.provider_name.$touch"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
:has-error="v$.ssoConfig.login_url.$error"
|
||||
:label="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.ERROR')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="ssoConfig.login_url"
|
||||
type="url"
|
||||
class="w-full"
|
||||
:placeholder="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.PLACEHOLDER')"
|
||||
@blur="v$.ssoConfig.login_url.$touch"
|
||||
/>
|
||||
<template #help>
|
||||
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.HELP') }}
|
||||
</template>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.LABEL')">
|
||||
<NextInput
|
||||
v-model="ssoConfig.logout_url"
|
||||
type="url"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<template #help>
|
||||
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.HELP') }}
|
||||
</template>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
:has-error="v$.ssoConfig.secret_key.$error"
|
||||
:label="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.ERROR')"
|
||||
>
|
||||
<div class="relative">
|
||||
<NextInput
|
||||
v-model="ssoConfig.secret_key"
|
||||
:type="showSecretKey ? 'text' : 'password'"
|
||||
class="w-full pr-10"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.ssoConfig.secret_key.$touch"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
@click="toggleSecretKey"
|
||||
>
|
||||
<svg
|
||||
v-if="showSecretKey"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<template #help>
|
||||
{{ $t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.HELP') }}
|
||||
</template>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
:has-error="v$.ssoConfig.token_expiry.$error"
|
||||
:label="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.ERROR')"
|
||||
>
|
||||
<NextInput
|
||||
v-model="ssoConfig.token_expiry"
|
||||
type="number"
|
||||
class="w-full"
|
||||
min="1"
|
||||
max="60"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.ssoConfig.token_expiry.$touch"
|
||||
/>
|
||||
<template #help>
|
||||
{{ $t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.HELP') }}
|
||||
</template>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<NextButton blue :is-loading="isUpdating" type="submit">
|
||||
{{ $t('GENERAL_SETTINGS.SSO.SUBMIT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
+41
-1
@@ -12,6 +12,7 @@
|
||||
# locale :integer default("en")
|
||||
# name :string not null
|
||||
# settings :jsonb
|
||||
# sso_config :jsonb not null
|
||||
# status :integer default("active")
|
||||
# support_email :string(100)
|
||||
# created_at :datetime not null
|
||||
@@ -19,7 +20,8 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_accounts_on_status (status)
|
||||
# index_accounts_on_sso_config (sso_config) USING gin
|
||||
# index_accounts_on_status (status)
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
@@ -55,6 +57,7 @@ class Account < ApplicationRecord
|
||||
|
||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :sso_config, :enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
@@ -158,6 +161,43 @@ class Account < ApplicationRecord
|
||||
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
# SSO Configuration Methods
|
||||
def sso_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(sso_config['enabled'])
|
||||
end
|
||||
|
||||
def sso_provider_name
|
||||
sso_config['provider_name'].presence || 'SSO'
|
||||
end
|
||||
|
||||
def sso_login_url
|
||||
sso_config['login_url']
|
||||
end
|
||||
|
||||
def sso_logout_url
|
||||
sso_config['logout_url']
|
||||
end
|
||||
|
||||
def sso_secret_key
|
||||
sso_config['secret_key']
|
||||
end
|
||||
|
||||
def sso_token_expiry
|
||||
(sso_config['token_expiry'].presence || 5).to_i
|
||||
end
|
||||
|
||||
def update_sso_config(config)
|
||||
# Validate required fields if SSO is enabled
|
||||
return false if ActiveModel::Type::Boolean.new.cast(config['enabled']) && (config['login_url'].blank? || config['secret_key'].blank?)
|
||||
|
||||
# Set default values
|
||||
config['token_expiry'] = 5 if config['token_expiry'].blank?
|
||||
config['provider_name'] = 'SSO' if config['provider_name'].blank?
|
||||
|
||||
self.sso_config = config
|
||||
save
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# enabled :boolean default(TRUE)
|
||||
# message :text not null
|
||||
# scheduled_at :datetime
|
||||
# template_params :jsonb
|
||||
# template_params :jsonb not null
|
||||
# title :string not null
|
||||
# trigger_only_during_business_hours :boolean default(FALSE)
|
||||
# trigger_rules :jsonb
|
||||
|
||||
@@ -2,8 +2,11 @@ module SsoAuthenticatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def generate_sso_auth_token
|
||||
return nil unless account&.sso_enabled?
|
||||
|
||||
token = SecureRandom.hex(32)
|
||||
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
|
||||
expiry_minutes = account.sso_token_expiry.minutes
|
||||
::Redis::Alfred.setex(sso_token_key(token), true, expiry_minutes)
|
||||
token
|
||||
end
|
||||
|
||||
@@ -16,14 +19,30 @@ module SsoAuthenticatable
|
||||
end
|
||||
|
||||
def generate_sso_link
|
||||
return nil unless account&.sso_enabled?
|
||||
|
||||
encoded_email = ERB::Util.url_encode(email)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
||||
end
|
||||
|
||||
def generate_sso_link_with_impersonation
|
||||
return nil unless account&.sso_enabled?
|
||||
|
||||
"#{generate_sso_link}&impersonation=true"
|
||||
end
|
||||
|
||||
def sso_external_login_url
|
||||
return nil unless account&.sso_enabled?
|
||||
|
||||
account.sso_login_url
|
||||
end
|
||||
|
||||
def sso_external_logout_url
|
||||
return nil unless account&.sso_enabled?
|
||||
|
||||
account.sso_logout_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_token_key(token)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
json.settings resource.settings
|
||||
json.sso_config resource.sso_config
|
||||
json.created_at resource.created_at
|
||||
if resource.custom_attributes.present?
|
||||
json.custom_attributes do
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddSsoConfigToAccounts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :accounts, :sso_config, :jsonb, default: {}, null: false
|
||||
add_index :accounts, :sso_config, using: :gin
|
||||
end
|
||||
end
|
||||
+4
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -59,6 +59,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
t.integer "status", default: 0
|
||||
t.jsonb "internal_attributes", default: {}, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
t.jsonb "sso_config", default: {}, null: false
|
||||
t.index ["sso_config"], name: "index_accounts_on_sso_config", using: :gin
|
||||
t.index ["status"], name: "index_accounts_on_status"
|
||||
end
|
||||
|
||||
@@ -237,7 +239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
t.jsonb "audience", default: []
|
||||
t.datetime "scheduled_at", precision: nil
|
||||
t.boolean "trigger_only_during_business_hours", default: false
|
||||
t.jsonb "template_params"
|
||||
t.jsonb "template_params", default: {}, null: false
|
||||
t.index ["account_id"], name: "index_campaigns_on_account_id"
|
||||
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
|
||||
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
|
||||
|
||||
@@ -51,8 +51,9 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
])
|
||||
|
||||
# Handle array parameters separately to allow partial updates
|
||||
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant][:response_guidelines].present?
|
||||
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant][:guardrails].present?
|
||||
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant].key?(:response_guidelines)
|
||||
|
||||
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
@@ -121,16 +121,22 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def upload_file
|
||||
return unless message.attachments.first.with_attached_file?
|
||||
message.attachments.each do |attachment|
|
||||
next unless attachment.with_attached_file?
|
||||
|
||||
result = slack_client.files_upload_v2(
|
||||
filename: message.attachments.first.file.filename,
|
||||
content: message.attachments.first.file.download,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier,
|
||||
channel_id: hook.reference_id
|
||||
)
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
begin
|
||||
result = slack_client.files_upload_v2(
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: attachment.file.download,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier,
|
||||
channel_id: hook.reference_id
|
||||
)
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
rescue Slack::Web::Api::Errors::SlackError => e
|
||||
Rails.logger.error "Failed to upload file #{attachment.file.filename}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def file_type
|
||||
|
||||
@@ -163,7 +163,7 @@ describe Integrations::Slack::SendOnSlackService do
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
filename: attachment.file.filename,
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
@@ -178,6 +178,61 @@ describe Integrations::Slack::SendOnSlackService do
|
||||
expect(message.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'sent multiple attachments on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment1 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
attachment2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'logo.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).twice.and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
expect(message.attachments.count).to eq 2
|
||||
end
|
||||
|
||||
it 'handles file upload errors gracefully' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_raise(Slack::Web::Api::Errors::SlackError.new('File upload failed'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('Failed to upload file avatar.png: File upload failed')
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'will not call file_upload if attachment does not have a file (e.g facebook - fallback type)' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
|
||||
Reference in New Issue
Block a user