Merge branch 'develop' into feat/voice-as-twilio-capability

This commit is contained in:
Muhsin Keloth
2026-04-08 12:09:59 +04:00
committed by GitHub
41 changed files with 1201 additions and 394 deletions
@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
defineProps({
featurePrefix: {
type: String,
default: 'CAPTAIN',
},
});
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
@@ -31,7 +38,7 @@ const openBilling = () => {
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:feature-prefix="featurePrefix"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasCustomTools = computed(() => {
return (
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
) ||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
);
});
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_inboxes_index',
}),
},
...(hasCustomTools.value
? [
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
]
: []),
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
@@ -313,7 +313,12 @@ const plugins = computed(() => {
const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) {
if (
props.allowSignature &&
!props.isPrivate &&
props.channelType &&
!props.disabled
) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
@@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
}
function addSignature() {
if (props.disabled) return;
let content = props.modelValue;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
@@ -454,6 +460,7 @@ function addSignature() {
}
function removeSignature() {
if (props.disabled) return;
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
@@ -806,7 +813,7 @@ watch(
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature) {
if (props.allowSignature && !props.disabled) {
toggleSignatureInEditor(newValue);
}
});
@@ -128,7 +128,6 @@ export default {
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'selectWhatsappTemplate',
'selectContentTemplate',
@@ -277,9 +276,6 @@ export default {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
@@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
@@ -636,10 +635,17 @@ export default {
return message;
}
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
// still normalize stale signatures out of drafts when signature is off.
if (this.isEditorDisabled && this.sendWithSignature) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
@@ -911,32 +917,6 @@ export default {
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
@@ -1435,7 +1415,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
+1
View File
@@ -51,6 +51,7 @@ export const FEATURE_FLAGS = {
export const PREMIUM_FEATURES = [
FEATURE_FLAGS.SLA,
FEATURE_FLAGS.CAPTAIN,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
@@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space
}
/**
* Removes inline base64 markdown images from signature content.
*
* @param {string} content
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
*/
export function stripInlineBase64Images(content) {
if (!content || typeof content !== 'string') {
return { sanitizedContent: content || '', hasInlineImages: false };
}
const markdownInlineBase64ImageRegex =
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
const hasInlineImages = sanitizedContent !== content;
return { sanitizedContent, hasInlineImages };
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
@@ -15,6 +15,7 @@ import {
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
});
});
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -838,6 +838,18 @@
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"PAYWALL": {
"TITLE": "Upgrade to use tools with Captain",
"AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.",
"UPGRADE_PROMPT": "",
"UPGRADE_NOW": "Open billing",
"CANCEL_ANYTIME": ""
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain Tools are only available in the paid plans.",
"UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"TEST": {
"BUTTON": "Test connection",
"SUCCESS": "Endpoint returned HTTP {status}",
@@ -68,7 +68,8 @@
"API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
},
"MESSAGE_SIGNATURE": {
"LABEL": "Message Signature",
@@ -45,6 +45,13 @@
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?"
"HAVE_AN_ACCOUNT": "Already have an account?",
"VERIFY_EMAIL": {
"TITLE": "Check your inbox",
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
"RESEND": "Resend verification email",
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
"RESEND_ERROR": "Could not send verification email. Please try again."
}
}
}
@@ -23,6 +23,12 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaCustomTools = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@@ -46,7 +52,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta,
meta: metaCustomTools,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
@@ -5,13 +5,14 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const store = useStore();
const { isFeatureFlagEnabled } = usePolicy();
const { isFeatureFlagEnabled, shouldShowPaywall } = usePolicy();
const SOFT_LIMIT = 10;
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
@@ -80,7 +81,9 @@ const onDeleteSuccess = () => {
};
onMounted(() => {
fetchCustomTools();
if (!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)) {
fetchCustomTools();
}
});
</script>
@@ -89,6 +92,7 @@ onMounted(() => {
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
:button-policy="['administrator']"
:feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS"
:total-count="customToolsMeta.totalCount"
:current-page="customToolsMeta.page"
:show-pagination-footer="!isFetching && !!customTools.length"
@@ -98,6 +102,10 @@ onMounted(() => {
@update:current-page="onPageChange"
@click="openCreateDialog"
>
<template #paywall>
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
</template>
<template #emptyState>
<CustomToolsPageEmptyState @click="openCreateDialog" />
</template>
@@ -1,4 +1,5 @@
<script setup>
import { useAdmin } from 'dashboard/composables/useAdmin';
import Icon from 'next/icon/Icon.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -22,6 +23,11 @@ defineProps({
});
const emit = defineEmits(['upgrade']);
// Cloud agents land on this modal too, but billing is admin-only they need
// the escalation message instead of a button they cannot use. Mirrors the
// pattern in UpgradePage.vue.
const { isAdmin } = useAdmin();
</script>
<template>
@@ -47,11 +53,14 @@ const emit = defineEmits(['upgrade']);
/>
<p class="text-sm font-normal text-n-slate-11">
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
<span v-if="isOnChatwootCloud && !isAdmin">
{{ $t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</span>
<span v-else-if="!isOnChatwootCloud && !isSuperAdmin">
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
</span>
</p>
<template v-if="isOnChatwootCloud">
<template v-if="isOnChatwootCloud && isAdmin">
<ButtonV4 blue solid md @click="emit('upgrade')">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</ButtonV4>
@@ -59,7 +68,7 @@ const emit = defineEmits(['upgrade']);
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
</span>
</template>
<template v-else-if="isSuperAdmin">
<template v-else-if="!isOnChatwootCloud && isSuperAdmin">
<a href="/super_admin" class="block w-full">
<ButtonV4 solid blue md class="w-full">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
@@ -1,5 +1,8 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { stripInlineBase64Images } from 'dashboard/helper/editorHelper';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -11,7 +14,9 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const signature = ref(props.messageSignature);
const { t } = useI18n();
const signature = ref(props.messageSignature ?? '');
watch(
() => props.messageSignature ?? '',
newValue => {
@@ -20,6 +25,15 @@ watch(
);
const updateSignature = () => {
const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(
signature.value || ''
);
signature.value = sanitizedContent.trim();
if (hasInlineImages) {
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INLINE_IMAGE_WARNING')
);
}
emit('updateSignature', signature.value);
};
</script>
+7 -1
View File
@@ -57,7 +57,6 @@ export const register = async creds => {
password: creds.password,
h_captcha_client_response: creds.hCaptchaClientResponse,
});
setAuthCredentials(response);
return response.data;
} catch (error) {
throwErrorMessage(error);
@@ -65,6 +64,13 @@ export const register = async creds => {
return null;
};
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
return wootAPI.post('resend_confirmation', {
email,
h_captcha_client_response: hCaptchaClientResponse,
});
};
export const verifyPasswordToken = async ({ confirmationToken }) => {
try {
const response = await wootAPI.post('auth/confirmation', {
@@ -4,8 +4,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -19,6 +19,7 @@ const MIN_PASSWORD_LENGTH = 6;
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const hCaptcha = ref(null);
const isPasswordFocused = ref(false);
@@ -76,7 +77,10 @@ const performRegistration = async () => {
isSignupInProgress.value = true;
try {
await register(credentials);
window.location = DEFAULT_REDIRECT_URL;
router.push({
name: 'auth_verify_email',
state: { email: credentials.email },
});
} catch (error) {
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
if (globalConfig.value.hCaptchaSiteKey) {
@@ -0,0 +1,110 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { resendConfirmation } from '../../../api/auth';
const props = defineProps({
email: {
type: String,
default: '',
},
});
const { t } = useI18n();
const router = useRouter();
const store = useStore();
if (!props.email) {
router.push({ name: 'login' });
}
const globalConfig = computed(() => store.getters['globalConfig/get']);
const isResendingEmail = ref(false);
const hCaptcha = ref(null);
let captchaToken = '';
const performResend = async () => {
isResendingEmail.value = true;
try {
await resendConfirmation({
email: props.email,
hCaptchaClientResponse: captchaToken,
});
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_SUCCESS'));
} catch {
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_ERROR'));
} finally {
isResendingEmail.value = false;
captchaToken = '';
if (globalConfig.value.hCaptchaSiteKey) {
hCaptcha.value.reset();
}
}
};
const handleResendEmail = () => {
if (isResendingEmail.value) return;
if (globalConfig.value.hCaptchaSiteKey) {
hCaptcha.value.execute();
} else {
performResend();
}
};
const onCaptchaVerified = token => {
captchaToken = token;
performResend();
};
const onCaptchaError = () => {
isResendingEmail.value = false;
captchaToken = '';
hCaptcha.value.reset();
};
</script>
<template>
<main
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
>
<section
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<div class="mb-6">
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('REGISTER.VERIFY_EMAIL.TITLE') }}
</h2>
<p class="mt-2 text-sm text-n-slate-11">
{{ $t('REGISTER.VERIFY_EMAIL.DESCRIPTION', { email }) }}
</p>
</div>
<div class="space-y-4">
<VueHcaptcha
v-if="globalConfig.hCaptchaSiteKey"
ref="hCaptcha"
size="invisible"
:sitekey="globalConfig.hCaptchaSiteKey"
@verify="onCaptchaVerified"
@error="onCaptchaError"
@expired="onCaptchaError"
@challenge-expired="onCaptchaError"
@closed="onCaptchaError"
/>
<NextButton
lg
type="button"
data-testid="resend_email_button"
class="w-full"
:label="$t('REGISTER.VERIFY_EMAIL.RESEND')"
:is-loading="isResendingEmail"
@click="handleResendEmail"
/>
</div>
</section>
</main>
</template>
+10
View File
@@ -5,6 +5,7 @@ import SamlLogin from './login/Saml.vue';
import Signup from './auth/signup/Index.vue';
import ResetPassword from './auth/reset/password/Index.vue';
import Confirmation from './auth/confirmation/Index.vue';
import VerifyEmail from './auth/verify-email/Index.vue';
import PasswordEdit from './auth/password/Edit.vue';
export default [
@@ -48,6 +49,15 @@ export default [
redirectUrl: route.query.route_url,
}),
},
{
path: frontendURL('auth/verify-email'),
name: 'auth_verify_email',
component: VerifyEmail,
meta: { ignoreSession: true },
props: () => ({
email: window.history.state?.email || '',
}),
},
{
path: frontendURL('auth/password/edit'),
name: 'auth_password_edit',