mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
Merge branch 'develop' into dependabot/npm_and_yarn/axios-1.12.0
This commit is contained in:
7
Rakefile
7
Rakefile
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
26
app/javascript/dashboard/api/samlSettings.js
Normal file
26
app/javascript/dashboard/api/samlSettings.js
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 you’re 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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import samlSettingsAPI from 'dashboard/api/samlSettings';
|
||||
|
||||
import SectionLayout from '../../account/components/SectionLayout.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import TextInput from 'next/input/Input.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import SamlInfoSection from './SamlInfoSection.vue';
|
||||
import SamlAttributeMap from './SamlAttributeMap.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const id = ref(null);
|
||||
const fingerprint = ref('');
|
||||
const spEntityId = ref('');
|
||||
const isEnabled = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const formState = reactive({
|
||||
ssoUrl: '',
|
||||
certificate: '',
|
||||
idpEntityId: '',
|
||||
});
|
||||
|
||||
const validations = {
|
||||
ssoUrl: { required },
|
||||
certificate: { required },
|
||||
idpEntityId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, formState);
|
||||
|
||||
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
|
||||
|
||||
const ssoUrlError = computed(() =>
|
||||
v$.value.ssoUrl.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const certificateError = computed(() =>
|
||||
v$.value.certificate.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const idpEntityIdError = computed(() =>
|
||||
v$.value.idpEntityId.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const loadSamlSettings = async () => {
|
||||
if (!hasFeature.value) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await samlSettingsAPI.get();
|
||||
const settings = response.data;
|
||||
|
||||
if (settings.sso_url) {
|
||||
id.value = settings.id;
|
||||
formState.ssoUrl = settings.sso_url;
|
||||
formState.certificate = settings.certificate || '';
|
||||
spEntityId.value = settings.sp_entity_id || '';
|
||||
formState.idpEntityId = settings.idp_entity_id || '';
|
||||
fingerprint.value = settings.fingerprint || '';
|
||||
isEnabled.value = formState.ssoUrl !== '';
|
||||
}
|
||||
} catch (error) {
|
||||
// If no settings exist (404), that's expected - just keep defaults
|
||||
if (error.response?.status !== 404) {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSamlSettings = async settings => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
|
||||
if (isEnabled.value && formState.ssoUrl) {
|
||||
// Create or update settings based on existing id
|
||||
let response;
|
||||
if (id.value) {
|
||||
response = await samlSettingsAPI.update(settings);
|
||||
} else {
|
||||
response = await samlSettingsAPI.create(settings);
|
||||
}
|
||||
|
||||
// Update local state with response data including fingerprint and id
|
||||
if (response?.data) {
|
||||
id.value = response.data.id;
|
||||
fingerprint.value = response.data.fingerprint || '';
|
||||
spEntityId.value = response.data.sp_entity_id || '';
|
||||
}
|
||||
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
|
||||
} else {
|
||||
// Disable/delete settings
|
||||
await samlSettingsAPI.delete();
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle backend validation errors
|
||||
if (error.response?.data?.errors) {
|
||||
const errorMessages = error.response.data.errors;
|
||||
const firstError = Array.isArray(errorMessages)
|
||||
? errorMessages[0]
|
||||
: errorMessages;
|
||||
useAlert(firstError);
|
||||
} else {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const settings = {
|
||||
sso_url: formState.ssoUrl,
|
||||
certificate: formState.certificate,
|
||||
idp_entity_id: formState.idpEntityId,
|
||||
role_mappings: {},
|
||||
};
|
||||
|
||||
await saveSamlSettings(settings);
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
id.value = null;
|
||||
formState.ssoUrl = '';
|
||||
formState.certificate = '';
|
||||
spEntityId.value = '';
|
||||
formState.idpEntityId = '';
|
||||
fingerprint.value = '';
|
||||
|
||||
// the empty save will delete the SAML settings item
|
||||
await saveSamlSettings({});
|
||||
};
|
||||
|
||||
const toggleSaml = async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleDisable();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSamlSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
|
||||
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
|
||||
:hide-content="!hasFeature || !isEnabled || isLoading"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch
|
||||
v-model="isEnabled"
|
||||
:disabled="isLoading"
|
||||
@change="toggleSaml"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SamlInfoSection
|
||||
class="mb-5"
|
||||
:fingerprint="fingerprint"
|
||||
:sp-entity-id="spEntityId"
|
||||
/>
|
||||
<SamlAttributeMap class="mb-5" />
|
||||
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
name="ssoUrl"
|
||||
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
|
||||
:has-error="v$.ssoUrl.$error"
|
||||
:error-message="ssoUrlError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.ssoUrl"
|
||||
class="w-full"
|
||||
type="url"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="idpEntityId"
|
||||
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
|
||||
:has-error="v$.idpEntityId.$error"
|
||||
:error-message="idpEntityIdError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.idpEntityId"
|
||||
class="w-full"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="certificate"
|
||||
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
|
||||
:has-error="v$.certificate.$error"
|
||||
:error-message="certificateError"
|
||||
required
|
||||
>
|
||||
<TextArea
|
||||
v-model="formState.certificate"
|
||||
class="w-full"
|
||||
rows="8"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/security'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'SECURITY_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-shield',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'security_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.SAML,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ------ ##
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
8
enterprise/tasks_railtie.rb
Normal file
8
enterprise/tasks_railtie.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
42
spec/services/widget/token_service_expiry_spec.rb
Normal file
42
spec/services/widget/token_service_expiry_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user