Merge branch 'develop' into dependabot/npm_and_yarn/axios-1.12.0

This commit is contained in:
Pranav
2025-09-16 13:28:37 -07:00
committed by GitHub
52 changed files with 1384 additions and 297 deletions

View File

@@ -2,9 +2,8 @@
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application'
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
Rails.application.load_tasks
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)

View File

@@ -1,5 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?

View File

@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from './ApiClient';
class SamlSettingsAPI extends ApiClient {
constructor() {
super('saml_settings', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
create(data) {
return axios.post(this.url, { saml_settings: data });
}
update(data) {
return axios.put(this.url, { saml_settings: data });
}
delete() {
return axios.delete(this.url);
}
}
export default new SamlSettingsAPI();

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
@@ -26,10 +26,24 @@ const props = defineProps({
const emit = defineEmits(['add']);
const BUFFER_SPACE = 20;
const [showPopover, togglePopover] = useToggle();
const buttonRef = ref();
const dropdownRef = ref();
const searchValue = ref('');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const {
top: buttonTop,
left: buttonLeft,
width: buttonWidth,
height: buttonHeight,
} = useElementBounding(buttonRef);
const { width: dropdownWidth, height: dropdownHeight } =
useElementBounding(dropdownRef);
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
@@ -42,6 +56,26 @@ const handleAdd = item => {
togglePopover(false);
};
const shouldShowAbove = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceBelow =
windowHeight.value - (buttonTop.value + buttonHeight.value);
const spaceAbove = buttonTop.value;
return (
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
);
});
const shouldAlignRight = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceRight = windowWidth.value - buttonLeft.value;
const spaceLeft = buttonLeft.value + buttonWidth.value;
return (
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
);
});
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
@@ -55,6 +89,7 @@ const handleClickOutside = () => {
class="relative flex items-center group"
>
<Button
ref="buttonRef"
slate
type="button"
icon="i-lucide-plus"
@@ -64,7 +99,12 @@ const handleClickOutside = () => {
/>
<div
v-if="showPopover"
class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
ref="dropdownRef"
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
:class="[
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
shouldAlignRight ? 'right-0' : 'left-0',
]"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
@@ -90,7 +130,7 @@ const handleClickOutside = () => {
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<span
v-else-if="item.color"
@@ -105,24 +145,19 @@ const handleClickOutside = () => {
:size="20"
rounded-full
/>
<div class="flex flex-col items-start gap-2 min-w-0">
<div class="flex items-center gap-1 min-w-0">
<div class="flex flex-col items-start gap-2 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0 w-full">
<span
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0"
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
>
{{ item.name || item.title }}
</span>
<span
v-if="item.id"
class="text-xs text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0"
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
>
{{ item.email || item.phoneNumber }}
</span>

View File

@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>

View File

@@ -110,53 +110,67 @@ const getInboxName = inboxId => {
<div
v-for="(limit, index) in inboxCapacityLimits"
:key="limit.id || `temp-${index}`"
class="flex items-center gap-3"
class="flex flex-col xs:flex-row items-stretch gap-3"
>
<div
class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
:title="getInboxName(limit.inboxId)"
>
{{ getInboxName(limit.inboxId) }}
<span class="truncate min-w-0">
{{ getInboxName(limit.inboxId) }}
</span>
</div>
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
{{
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
}}
</label>
<div class="h-5 w-px bg-n-weak" />
<input
v-model.number="limit.conversationLimit"
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
<div class="flex items-center gap-3 w-full xs:w-auto">
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
: 'placeholder:text-n-slate-10 text-n-slate-12',
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
:placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
"
@blur="handleLimitChange(limit)"
>
<label
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
:title="
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
"
>
{{
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
}}
</label>
<div class="h-5 w-px bg-n-weak" />
<input
v-model.number="limit.conversationLimit"
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
: 'placeholder:text-n-slate-10 text-n-slate-12',
]"
:placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
"
@blur="handleLimitChange(limit)"
/>
</div>
<Button
type="button"
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="handleRemoveLimit(limit.id)"
/>
</div>
<Button
type="button"
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="handleRemoveLimit(limit.id)"
/>
</div>
</div>
</div>

View File

@@ -131,6 +131,8 @@ const props = defineProps({
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
});
const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
@@ -524,6 +526,7 @@ provideMessageContext({
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
@retry="emit('retry')"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">

View File

@@ -1,16 +1,22 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from './provider.js';
import { ORIENTATION } from './constants';
import { hasOneDayPassed } from 'shared/helpers/timeHelper';
import { ORIENTATION, MESSAGE_STATUS } from './constants';
defineProps({
error: { type: String, required: true },
});
const { orientation } = useMessageContext();
const emit = defineEmits(['retry']);
const { orientation, status, createdAt } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>
@@ -35,5 +41,14 @@ const { t } = useI18n();
{{ error }}
</div>
</div>
<button
v-if="canRetry"
type="button"
:disabled="status !== MESSAGE_STATUS.FAILED"
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
@click="emit('retry')"
>
<Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" />
</button>
</div>
</template>

View File

@@ -37,6 +37,8 @@ const props = defineProps({
},
});
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
@@ -113,6 +115,7 @@ const getInReplyToMessage = parentMessage => {
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"
/>
</template>
<slot name="after" />

View File

@@ -494,6 +494,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
icon: 'i-lucide-shield',
to: accountScopedRoute('security_settings_index'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),

View File

@@ -4,6 +4,7 @@ import { ref, provide } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
@@ -437,6 +438,11 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
},
};
</script>
@@ -465,6 +471,7 @@ export default {
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">

View File

@@ -35,6 +35,8 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {

View File

@@ -38,8 +38,8 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
};
export const PREMIUM_FEATURES = [

View File

@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
};
export function getHelpUrlForFeature(featureName) {

View File

@@ -272,8 +272,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +281,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +295,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"

View File

@@ -358,7 +358,8 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs"
"DOCS": "Read docs",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -390,6 +391,77 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View File

@@ -5,6 +5,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
@@ -58,7 +59,19 @@ const allAgents = computed(() =>
const allLabels = computed(() => buildList(labelsList.value));
const allInboxes = computed(() => buildList(inboxes.value));
const allInboxes = computed(
() =>
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',

View File

@@ -215,33 +215,6 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div v-if="showInboxSection" class="py-4 flex-col flex gap-4">
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
/>
</div>
</div>
<Button
@@ -250,5 +223,35 @@ defineExpose({
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxSection"
class="py-4 flex-col flex gap-4 border-t border-n-weak mt-6"
>
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
/>
</div>
</form>
</template>

View File

@@ -145,7 +145,7 @@ defineExpose({
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak">
<div class="flex flex-col gap-4 mb-2 divide-y divide-n-weak">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"

View File

@@ -328,7 +328,7 @@ export default {
this.continuityViaEmail = this.inbox.continuity_via_email;
this.channelWebsiteUrl = this.inbox.website_url;
this.channelWelcomeTitle = this.inbox.welcome_title;
this.channelWelcomeTagline = this.inbox.welcome_tagline;
this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
this.replyTime = this.inbox.reply_time;
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;

View File

@@ -1,25 +1,23 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useI18n, I18nT } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const store = useStore();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog',
};
@@ -30,14 +28,6 @@ const hasWhatsappAppId = computed(() => {
);
});
const isWhatsappEmbeddedSignupEnabled = computed(() => {
const accountId = route.params.accountId;
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
@@ -67,28 +57,15 @@ const selectProvider = providerValue => {
});
};
const shouldShowEmbeddedSignup = provider => {
// Check if the feature is enabled for the account
if (!isWhatsappEmbeddedSignupEnabled.value) {
return false;
}
const shouldShowCloudWhatsapp = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
);
};
const shouldShowCloudWhatsapp = provider => {
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
return false;
}
// Show cloud whatsapp when:
// 1. Provider is whatsapp AND
// 2. Either no app ID is configured OR embedded signup feature is disabled
return provider === PROVIDER_TYPES.WHATSAPP;
const handleManualLinkClick = () => {
selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL);
};
</script>
@@ -117,18 +94,52 @@ const shouldShowCloudWhatsapp = provider => {
</div>
<div v-else-if="showConfiguration">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<!-- Show embedded signup if app ID is configured -->
<div
v-if="
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
<I18nT
keypath="INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK"
tag="p"
class="text-sm text-n-slate-11"
>
<template #link>
<a
href="#"
class="underline text-n-brand"
@click.prevent="handleManualLinkClick"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_LINK_TEXT'
)
}}
</a>
</template>
</I18nT>
</div>
</div>
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<!-- Other providers -->
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>

View File

@@ -2,12 +2,13 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useI18n, I18nT } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
@@ -28,9 +29,6 @@ const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
const benefits = computed(() => [
{
key: 'EASY_SETUP',
@@ -235,14 +233,9 @@ onBeforeUnmount(() => {
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
class="flex size-11 items-center justify-center rounded-full bg-n-alpha-2"
>
<img
:src="whatsappIconPath"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
<Icon icon="i-woot-whatsapp" class="text-n-slate-10 size-6" />
</div>
</div>
@@ -266,22 +259,26 @@ onBeforeUnmount(() => {
</div>
<div class="flex flex-col gap-2 mb-6">
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }}
{{ ' ' }}
<a
:href="
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_URL')
"
target="_blank"
rel="noopener noreferrer"
class="underline text-primary"
>
{{
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT')
}}
</a>
</span>
<I18nT
keypath="INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT"
tag="span"
class="text-sm text-n-slate-11"
>
<template #link>
<a
:href="globalConstants.WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL"
target="_blank"
rel="noopener noreferrer"
class="underline text-n-brand"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT'
)
}}
</a>
</template>
</I18nT>
</div>
<div class="flex mt-4">

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import SamlSettings from './components/SamlSettings.vue';
import SamlPaywall from './components/SamlPaywall.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { shouldShow, shouldShowPaywall } = usePolicy();
const shouldShowSaml = computed(() =>
shouldShow(
FEATURE_FLAGS.SAML,
['administrator'],
[INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE]
)
);
const showPaywall = computed(() => shouldShowPaywall('saml'));
</script>
<template>
<SettingsLayout
class="max-w-2xl mx-auto"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('SECURITY_SETTINGS.TITLE')"
:description="$t('SECURITY_SETTINGS.DESCRIPTION')"
:link-text="$t('SECURITY_SETTINGS.LINK_TEXT')"
feature-name="saml"
/>
</template>
<template #body>
<SamlPaywall v-if="showPaywall" />
<SamlSettings v-else-if="shouldShowSaml" />
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const isExpanded = ref(false);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden"
>
<button
type="button"
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors"
@click="toggleExpanded"
>
<h4 class="font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }}
</h4>
<Icon
icon="i-lucide-chevron-down"
class="transition-transform duration-200"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div
class="transition-[height] duration-200 ease-in-out overflow-hidden"
:class="isExpanded ? 'h-auto' : 'h-0'"
>
<div class="px-4 pb-3">
<p class="text-n-slate-11 mb-2">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }}
</p>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<ul class="list-none text-n-slate-12 space-y-1">
<li><code class="px-1 rounded bg-n-slate-3">email</code></li>
<li><code class="px-1 rounded bg-n-slate-3">first_name</code></li>
<li><code class="px-1 rounded bg-n-slate-3">last_name</code></li>
</ul>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import NextButton from 'next/button/Button.vue';
const props = defineProps({
fingerprint: {
type: String,
default: '',
},
spEntityId: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { accountId } = useAccount();
const acsUrl = computed(() => {
const currentHost = window.location.origin;
return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`;
});
const allInfoItems = computed(() => [
{
key: 'ACS_URL',
label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'),
value: acsUrl.value,
tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'),
show: true,
},
{
key: 'SP_ENTITY_ID',
label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'),
value: props.spEntityId,
tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'),
show: !!props.spEntityId,
},
{
key: 'FINGERPRINT',
label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'),
value: props.fingerprint,
tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'),
show: !!props.fingerprint,
},
]);
const visibleInfoItems = computed(() =>
allInfoItems.value.filter(item => item.show)
);
const handleCopy = async text => {
await copyTextToClipboard(text);
useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS'));
};
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }}
</h3>
<i
v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')"
class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help"
/>
</div>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div
v-for="item in visibleInfoItems"
:key="item.key"
class="ps-4 pe-1 py-1 flex justify-between items-center"
>
<div class="flex items-center gap-2">
<span class="text-n-slate-11 w-32 flex items-center gap-1">
{{ item.label }}
<i
v-tooltip.top="item.tooltip"
class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help"
/>
</span>
<span class="flex-1">{{ item.value }}</span>
</div>
<NextButton
type="button"
ghost
sm
slate
icon="i-lucide-copy"
@click="handleCopy(item.value)"
/>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[28rem] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="SECURITY_SETTINGS.SAML"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>

View File

@@ -0,0 +1,251 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import samlSettingsAPI from 'dashboard/api/samlSettings';
import SectionLayout from '../../account/components/SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextInput from 'next/input/Input.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'next/button/Button.vue';
import SamlInfoSection from './SamlInfoSection.vue';
import SamlAttributeMap from './SamlAttributeMap.vue';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const id = ref(null);
const fingerprint = ref('');
const spEntityId = ref('');
const isEnabled = ref(false);
const isSubmitting = ref(false);
const isLoading = ref(true);
const formState = reactive({
ssoUrl: '',
certificate: '',
idpEntityId: '',
});
const validations = {
ssoUrl: { required },
certificate: { required },
idpEntityId: { required },
};
const v$ = useVuelidate(validations, formState);
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
const ssoUrlError = computed(() =>
v$.value.ssoUrl.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
: ''
);
const certificateError = computed(() =>
v$.value.certificate.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
: ''
);
const idpEntityIdError = computed(() =>
v$.value.idpEntityId.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
: ''
);
const loadSamlSettings = async () => {
if (!hasFeature.value) return;
try {
isLoading.value = true;
const response = await samlSettingsAPI.get();
const settings = response.data;
if (settings.sso_url) {
id.value = settings.id;
formState.ssoUrl = settings.sso_url;
formState.certificate = settings.certificate || '';
spEntityId.value = settings.sp_entity_id || '';
formState.idpEntityId = settings.idp_entity_id || '';
fingerprint.value = settings.fingerprint || '';
isEnabled.value = formState.ssoUrl !== '';
}
} catch (error) {
// If no settings exist (404), that's expected - just keep defaults
if (error.response?.status !== 404) {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
}
} finally {
isLoading.value = false;
}
};
const saveSamlSettings = async settings => {
try {
isSubmitting.value = true;
if (isEnabled.value && formState.ssoUrl) {
// Create or update settings based on existing id
let response;
if (id.value) {
response = await samlSettingsAPI.update(settings);
} else {
response = await samlSettingsAPI.create(settings);
}
// Update local state with response data including fingerprint and id
if (response?.data) {
id.value = response.data.id;
fingerprint.value = response.data.fingerprint || '';
spEntityId.value = response.data.sp_entity_id || '';
}
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
} else {
// Disable/delete settings
await samlSettingsAPI.delete();
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
}
} catch (error) {
// Handle backend validation errors
if (error.response?.data?.errors) {
const errorMessages = error.response.data.errors;
const firstError = Array.isArray(errorMessages)
? errorMessages[0]
: errorMessages;
useAlert(firstError);
} else {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
}
throw error;
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const settings = {
sso_url: formState.ssoUrl,
certificate: formState.certificate,
idp_entity_id: formState.idpEntityId,
role_mappings: {},
};
await saveSamlSettings(settings);
};
const handleDisable = async () => {
id.value = null;
formState.ssoUrl = '';
formState.certificate = '';
spEntityId.value = '';
formState.idpEntityId = '';
fingerprint.value = '';
// the empty save will delete the SAML settings item
await saveSamlSettings({});
};
const toggleSaml = async () => {
if (!isEnabled.value) {
await handleDisable();
}
};
onMounted(() => {
loadSamlSettings();
});
</script>
<template>
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>
<div class="flex justify-end">
<Switch
v-model="isEnabled"
:disabled="isLoading"
@change="toggleSaml"
/>
</div>
</template>
<SamlInfoSection
class="mb-5"
:fingerprint="fingerprint"
:sp-entity-id="spEntityId"
/>
<SamlAttributeMap class="mb-5" />
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
name="ssoUrl"
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
:has-error="v$.ssoUrl.$error"
:error-message="ssoUrlError"
required
>
<TextInput
v-model="formState.ssoUrl"
class="w-full"
type="url"
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="idpEntityId"
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
:has-error="v$.idpEntityId.$error"
:error-message="idpEntityIdError"
required
>
<TextInput
v-model="formState.idpEntityId"
class="w-full"
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="certificate"
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
:has-error="v$.certificate.$error"
:error-message="certificateError"
required
>
<TextArea
v-model="formState.certificate"
class="w-full"
rows="8"
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
/>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
/>
</div>
</form>
</SectionLayout>
</template>

View File

@@ -0,0 +1,41 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/security'),
meta: {
permissions: ['administrator'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
component: SettingsWrapper,
props: {
headerTitle: 'SECURITY_SETTINGS.TITLE',
icon: 'i-lucide-shield',
showNewButton: false,
},
children: [
{
path: '',
name: 'security_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.SAML,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
],
},
],
};

View File

@@ -23,6 +23,7 @@ import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
export default {
routes: [
@@ -61,5 +62,6 @@ export default {
...teams.routes,
...customRoles.routes,
...profile.routes,
...security.routes,
],
};

View File

@@ -4,6 +4,8 @@ import {
dynamicTime,
dateFormat,
shortTimestamp,
getDayDifferenceFromNow,
hasOneDayPassed,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -90,3 +92,148 @@ describe('#shortTimestamp', () => {
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
});
});
describe('#getDayDifferenceFromNow', () => {
it('returns 0 for timestamps from today', () => {
// Mock current date: May 5, 2023
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
});
it('returns 2 for timestamps from 2 days ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const twoDaysAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
); // May 3, 2023
expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
});
it('returns 7 for timestamps from a week ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const weekAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
); // April 28, 2023
expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
});
it('returns 30 for timestamps from a month ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const monthAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
); // April 5, 2023
expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
});
it('handles edge case with different times on same day', () => {
const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
const morningTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
); // May 5, 2023 12:00:01 AM
expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
});
it('handles cross-month boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
const lastMonthTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
); // April 30, 2023
expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
});
it('handles cross-year boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
const lastYearTimestamp = Math.floor(
new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
); // December 31, 2022
expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
});
});
describe('#hasOneDayPassed', () => {
beforeEach(() => {
// Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
const mockDate = new Date(1683288000 * 1000);
vi.setSystemTime(mockDate);
});
it('returns false for timestamps from today', () => {
// Same day, different time - May 5, 2023 8:00 AM UTC
const todayTimestamp = 1683273600;
expect(hasOneDayPassed(todayTimestamp)).toBe(false);
});
it('returns false for timestamps from yesterday (less than 24 hours)', () => {
// Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
const yesterdayTimestamp = 1683230400;
expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
});
it('returns true for timestamps from exactly 1 day ago', () => {
// Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
const oneDayAgoTimestamp = 1683201600;
expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from more than 1 day ago', () => {
// 2 days ago - May 3, 2023 10:00 AM UTC
const twoDaysAgoTimestamp = 1683108000;
expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from a week ago', () => {
// 7 days ago - April 28, 2023 8:00 AM UTC
const weekAgoTimestamp = 1682668800;
expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
});
it('returns true for null timestamp (defensive check)', () => {
expect(hasOneDayPassed(null)).toBe(true);
});
it('returns true for undefined timestamp (defensive check)', () => {
expect(hasOneDayPassed(undefined)).toBe(true);
});
it('returns true for zero timestamp (defensive check)', () => {
expect(hasOneDayPassed(0)).toBe(true);
});
it('returns true for empty string timestamp (defensive check)', () => {
expect(hasOneDayPassed('')).toBe(true);
});
it('handles cross-month boundaries correctly', () => {
// Set current time to May 1, 2023 12:00 PM UTC (1682942400)
const mayFirst = new Date(1682942400 * 1000);
vi.setSystemTime(mayFirst);
// April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
const crossMonthTimestamp = 1682769600;
expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
});
it('handles cross-year boundaries correctly', () => {
// Set current time to January 2, 2023 12:00 PM UTC (1672660800)
const newYear = new Date(1672660800 * 1000);
vi.setSystemTime(newYear);
// December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
const crossYearTimestamp = 1672401600;
expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@ import {
isSameYear,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
} from 'date-fns';
/**
@@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => {
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
};
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.
* @param {number} timestampInSeconds - Unix timestamp in seconds.
* @returns {number} Number of days difference.
*/
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000);
return differenceInDays(now, date);
};
/**
* Checks if more than 24 hours have passed since a given timestamp.
* Useful for determining if retry/refresh actions should be disabled.
* @param {number} timestamp - Unix timestamp.
* @returns {boolean} True if more than 24 hours have passed.
*/
export const hasOneDayPassed = timestamp => {
if (!timestamp) return true; // Defensive check
return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
};

View File

@@ -1,8 +1,10 @@
class Widget::TokenService
DEFAULT_EXPIRY_DAYS = 180
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload, secret_key, 'HS256'
JWT.encode payload_with_expiry, secret_key, 'HS256'
end
def decode_token
@@ -15,6 +17,24 @@ class Widget::TokenService
private
def payload_with_expiry
payload.merge(exp: exp, iat: iat)
end
def iat
Time.zone.now.to_i
end
def exp
iat + expire_in.days.to_i
end
def expire_in
# Value is stored in days, defaulting to 6 months (180 days)
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
def secret_key
Rails.application.secret_key_base
end

View File

@@ -184,6 +184,7 @@
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
deprecated: true
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false

View File

@@ -439,3 +439,11 @@
locked: false
description: 'Zone ID for the Cloudflare domain'
## ------ End of Configs added for Cloudflare ------ ##
## ------ Customizations for Customers ------ ##
- name: WIDGET_TOKEN_EXPIRY
display_title: 'Widget Token Expiry'
value: 180
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##

View File

@@ -103,6 +103,8 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
account_saml_settings:
invalid_certificate: must be a valid X.509 certificate in PEM format
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone

View File

@@ -17,6 +17,7 @@ class SamlUserBuilder
user = User.from_email(auth_attribute('email'))
if user
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
return user
end
@@ -24,6 +25,13 @@ class SamlUserBuilder
create_user
end
def confirm_user_if_required(user)
return if user.confirmed?
user.skip_confirmation!
user.save!
end
def convert_existing_user_to_saml(user)
return if user.provider == 'saml'

View File

@@ -7,11 +7,19 @@ class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseControl
def create
@saml_settings = Current.account.build_saml_settings(saml_settings_params)
@saml_settings.save!
if @saml_settings.save
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def update
@saml_settings.update!(saml_settings_params)
if @saml_settings.update(saml_settings_params)
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy

View File

@@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::PasswordsController
if saml_user_attempting_password_auth?(params[:email])
render json: {
success: false,
message: I18n.t('messages.reset_password_saml_user'),
errors: [I18n.t('messages.reset_password_saml_user')]
}, status: :forbidden
return

View File

@@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::SessionsController
if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token])
render json: {
success: false,
message: I18n.t('messages.login_saml_user'),
errors: [I18n.t('messages.login_saml_user')]
}, status: :unauthorized
return

View File

@@ -10,7 +10,7 @@ module Enterprise::SuperAdmin::AppConfigsController
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
@allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_FIRECRAWL_API_KEY]
@allowed_configs = captain_config_options
else
super
end
@@ -36,4 +36,14 @@ module Enterprise::SuperAdmin::AppConfigsController
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
end
def captain_config_options
%w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_MODEL
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY
]
end
end

View File

@@ -0,0 +1,33 @@
class Saml::UpdateAccountUsersProviderJob < ApplicationJob
queue_as :default
# Updates the authentication provider for users in an account
# This job is triggered when SAML settings are created or destroyed
def perform(account_id, provider)
account = Account.find(account_id)
account.users.find_each(batch_size: 1000) do |user|
next unless should_update_user_provider?(user, provider)
# rubocop:disable Rails/SkipsModelValidations
user.update_column(:provider, provider)
# rubocop:enable Rails/SkipsModelValidations
end
end
private
# Determines if a user's provider should be updated based on their multi-account status
# When resetting to 'email', only update users who don't have SAML enabled on other accounts
# This prevents breaking SAML authentication for users who belong to multiple accounts
def should_update_user_provider?(user, provider)
return !user_has_other_saml_accounts?(user) if provider == 'email'
true
end
# Checks if the user belongs to any other accounts that have SAML configured
# Used to preserve SAML authentication when one account disables SAML but others still use it
def user_has_other_saml_accounts?(user)
user.accounts.joins(:saml_settings).exists?
end
end

View File

@@ -23,9 +23,13 @@ class AccountSamlSettings < ApplicationRecord
validates :sso_url, presence: true
validates :certificate, presence: true
validates :idp_entity_id, presence: true
validate :certificate_must_be_valid_x509
before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation?
after_create_commit :update_account_users_provider
after_destroy_commit :reset_account_users_provider
def saml_enabled?
sso_url.present? && certificate.present?
end
@@ -56,4 +60,20 @@ class AccountSamlSettings < ApplicationRecord
def installation_name
GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')
end
def update_account_users_provider
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'saml')
end
def reset_account_users_provider
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'email')
end
def certificate_must_be_valid_x509
return if certificate.blank?
OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
errors.add(:certificate, I18n.t('errors.account_saml_settings.invalid_certificate'))
end
end

View File

@@ -1,4 +0,0 @@
# Load all rake tasks from the enterprise/lib/tasks directory
module Tasks
Dir.glob(File.join(File.dirname(__FILE__), 'tasks', '*.rake')).each { |r| load r }
end

View File

@@ -1,22 +1,4 @@
module Tasks::SearchTaskHelpers
def check_opensearch_config
if ENV['OPENSEARCH_URL'].blank?
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
return false
end
true
end
def reindex_account(account)
Messages::ReindexService.new(account: account).perform
puts "Reindex task queued for account #{account.id}"
end
end
namespace :search do
desc 'Reindex messages using searchkick'
include Tasks::SearchTaskHelpers
desc 'Reindex messages for all accounts'
task all: :environment do
next unless check_opensearch_config
@@ -47,3 +29,16 @@ namespace :search do
reindex_account(account)
end
end
def check_opensearch_config
if ENV['OPENSEARCH_URL'].blank?
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
return false
end
true
end
def reindex_account(account)
Messages::ReindexService.new(account: account).perform
puts "Reindex task queued for account #{account.id}"
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class TasksRailtie < Rails::Railtie
rake_tasks do
# Load all rake tasks from enterprise/lib/tasks
Dir.glob(Rails.root.join('enterprise/lib/tasks/**/*.rake')).each { |f| load f }
end
end

View File

@@ -81,7 +81,7 @@ class Integrations::OpenaiBaseService
end
def api_url
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions"
end

View File

@@ -16,31 +16,7 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
context 'when feature is not enabled' do
before do
account.disable_features!(:whatsapp_embedded_signup)
end
it 'returns forbidden' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id'
},
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
end
end
context 'when feature is enabled' do
before do
account.enable_features!(:whatsapp_embedded_signup)
end
context 'when authenticated user makes request' do
it 'returns unprocessable entity when code is missing' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
@@ -246,10 +222,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
context 'when user is not authorized for the account' do
let(:other_account) { create(:account) }
before do
account.enable_features!(:whatsapp_embedded_signup)
end
it 'returns unauthorized' do
post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization",
params: {
@@ -265,10 +237,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
end
context 'when user is an administrator' do
before do
account.enable_features!(:whatsapp_embedded_signup)
end
it 'allows channel creation' do
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
@@ -321,10 +289,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
context 'when user is an administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
account.enable_features!(:whatsapp_embedded_signup)
end
context 'with valid parameters' do
let(:valid_params) do
{
@@ -489,7 +453,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!(:whatsapp_embedded_signup)
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
end

View File

@@ -109,6 +109,56 @@ RSpec.describe SamlUserBuilder do
expect { builder.perform }.not_to change(AccountUser, :count)
end
context 'when user is not confirmed' do
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
let(:unconfirmed_auth_hash) do
{
'provider' => 'saml',
'uid' => 'saml-uid-123',
'info' => {
'email' => unconfirmed_email,
'name' => 'SAML User',
'first_name' => 'SAML',
'last_name' => 'User'
},
'extra' => {
'raw_info' => {
'groups' => %w[Administrators Users]
}
}
}
end
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
let!(:existing_user) do
user = build(:user, email: unconfirmed_email)
user.confirmed_at = nil
user.save!(validate: false)
user
end
it 'confirms unconfirmed user after SAML authentication' do
expect(existing_user.confirmed?).to be false
unconfirmed_builder.perform
expect(existing_user.reload.confirmed?).to be true
end
end
context 'when user is already confirmed' do
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
it 'keeps already confirmed user confirmed' do
expect(existing_user.confirmed?).to be true
original_confirmed_at = existing_user.confirmed_at
builder.perform
expect(existing_user.reload.confirmed?).to be true
expect(existing_user.reload.confirmed_at).to be_within(2.seconds).of(original_confirmed_at)
end
end
end
context 'with role mappings' do

View File

@@ -0,0 +1,65 @@
require 'rails_helper'
RSpec.describe Saml::UpdateAccountUsersProviderJob, type: :job do
let(:account) { create(:account) }
let!(:user1) { create(:user, accounts: [account], provider: 'email') }
let!(:user2) { create(:user, accounts: [account], provider: 'email') }
let!(:user3) { create(:user, accounts: [account], provider: 'google') }
describe '#perform' do
context 'when setting provider to saml' do
it 'updates all account users to saml provider' do
described_class.new.perform(account.id, 'saml')
expect(user1.reload.provider).to eq('saml')
expect(user2.reload.provider).to eq('saml')
expect(user3.reload.provider).to eq('saml')
end
end
context 'when resetting provider to email' do
before do
# rubocop:disable Rails/SkipsModelValidations
user1.update_column(:provider, 'saml')
user2.update_column(:provider, 'saml')
user3.update_column(:provider, 'saml')
# rubocop:enable Rails/SkipsModelValidations
end
context 'when users have no other SAML accounts' do
it 'updates all account users to email provider' do
described_class.new.perform(account.id, 'email')
expect(user1.reload.provider).to eq('email')
expect(user2.reload.provider).to eq('email')
expect(user3.reload.provider).to eq('email')
end
end
context 'when users belong to other accounts with SAML enabled' do
let(:other_account) { create(:account) }
before do
create(:account_saml_settings, account: other_account)
user1.account_users.create!(account: other_account, role: :agent)
end
it 'preserves SAML provider for users with other SAML accounts' do
described_class.new.perform(account.id, 'email')
expect(user1.reload.provider).to eq('saml')
expect(user2.reload.provider).to eq('email')
expect(user3.reload.provider).to eq('email')
end
end
end
context 'when account does not exist' do
it 'raises ActiveRecord::RecordNotFound' do
expect do
described_class.new.perform(999_999, 'saml')
end.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -114,4 +114,21 @@ RSpec.describe AccountSamlSettings, type: :model do
expect(fingerprint.count(':')).to eq(19) # 20 bytes = 19 colons
end
end
describe 'callbacks' do
describe 'after_create_commit' do
it 'queues job to set account users to saml provider' do
expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'saml')
create(:account_saml_settings, account: account)
end
end
describe 'after_destroy_commit' do
it 'queues job to reset account users provider' do
settings = create(:account_saml_settings, account: account)
expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'email')
settings.destroy
end
end
end
end

View File

@@ -1,57 +0,0 @@
require 'rails_helper'
RSpec.describe Featurable do
let(:account) { create(:account) }
describe 'WhatsApp embedded signup feature' do
it 'is disabled by default' do
expect(account.feature_whatsapp_embedded_signup?).to be false
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
end
describe '#enable_features!' do
it 'enables the whatsapp embedded signup feature' do
account.enable_features!(:whatsapp_embedded_signup)
expect(account.feature_whatsapp_embedded_signup?).to be true
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
end
it 'enables multiple features at once' do
account.enable_features!(:whatsapp_embedded_signup, :help_center)
expect(account.feature_whatsapp_embedded_signup?).to be true
expect(account.feature_help_center?).to be true
end
end
describe '#disable_features!' do
before do
account.enable_features!(:whatsapp_embedded_signup)
end
it 'disables the whatsapp embedded signup feature' do
expect(account.feature_whatsapp_embedded_signup?).to be true
account.disable_features!(:whatsapp_embedded_signup)
expect(account.feature_whatsapp_embedded_signup?).to be false
end
end
describe '#enabled_features' do
it 'includes whatsapp_embedded_signup when enabled' do
account.enable_features!(:whatsapp_embedded_signup)
expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
end
it 'does not include whatsapp_embedded_signup when disabled' do
account.disable_features!(:whatsapp_embedded_signup)
expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
end
end
describe '#all_features' do
it 'includes whatsapp_embedded_signup in all features list' do
expect(account.all_features).to have_key('whatsapp_embedded_signup')
end
end
end
end

View File

@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe Widget::TokenService, type: :service do
describe 'token expiry configuration' do
let(:service) { described_class.new(payload: {}) }
before do
# Clear any existing configs to ensure test isolation
InstallationConfig.where(name: 'WIDGET_TOKEN_EXPIRY').destroy_all
end
context 'with valid configuration' do
before do
create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '30')
end
it 'uses the configured value for token expiry' do
freeze_time do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
expect(decoded['iat']).to eq(Time.now.to_i)
expect(decoded['exp']).to eq(30.days.from_now.to_i)
end
end
end
context 'with empty configuration' do
before do
create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '')
end
it 'uses the default expiry' do
freeze_time do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
expect(decoded['iat']).to eq(Time.now.to_i)
expect(decoded['exp']).to eq(180.days.from_now.to_i)
end
end
end
end
end