Compare commits

...
Author SHA1 Message Date
Sojan JoseandClaude 602c56d1c5 feat: Add account-level SSO configuration
This implements Single Sign-On (SSO) functionality at the account level, allowing individual accounts to configure their own SSO settings.

Key changes:
- Added SSO configuration to account model with JSONB storage
- Implemented SSO settings UI in account settings panel
- Added feature flag to control SSO access (premium feature)
- Updated authentication flow to support account-level SSO
- Added proper validation and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 18:41:41 -08:00
647a65d481 fix: Fetch all facebook pages during inbox creation (#11956)
## Summary
- fetch all Facebook pages during setup instead of only the first 25

fixes:  #3082

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-17 18:32:12 +05:30
Muhsin KelothandGitHub 99ec18c95b feat: Add support for multiple attachments in Slack (#11958) 2025-07-17 09:38:46 +05:30
Sivin VargheseandGitHub 64ba23688b feat: New GuardRails and Response Guidelines edit page (#11932) 2025-07-16 21:52:25 +05:30
Sojan Jose 2fec2e5993 Merge branch 'release/4.4.0' into develop 2025-07-16 00:10:53 -07:00
31 changed files with 1737 additions and 22 deletions
@@ -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>
@@ -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>
+2
View File
@@ -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 assistants 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 />
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+20 -1
View File
@@ -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
View File
@@ -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,