Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
elsif params[:external_url].present?
|
||||
create_from_url
|
||||
else
|
||||
render_error('No file or URL provided', :unprocessable_entity)
|
||||
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
|
||||
end
|
||||
|
||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_from_url
|
||||
uri = parse_uri(params[:external_url])
|
||||
return if performed?
|
||||
|
||||
fetch_and_process_file_from_uri(uri)
|
||||
end
|
||||
|
||||
def parse_uri(url)
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
uri.open do |file|
|
||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||
end
|
||||
rescue OpenURI::HTTPError => e
|
||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||
rescue SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
rescue SafeFetch::HttpError => e
|
||||
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||
rescue SafeFetch::FetchError
|
||||
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
|
||||
rescue SafeFetch::FileTooLargeError
|
||||
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
|
||||
rescue SafeFetch::UnsupportedContentTypeError
|
||||
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
|
||||
rescue SafeFetch::Error
|
||||
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
|
||||
rescue StandardError
|
||||
render_error('An unexpected error occurred', :internal_server_error)
|
||||
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
|
||||
end
|
||||
|
||||
def create_and_save_blob(io, filename, content_type)
|
||||
|
||||
@@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
enqueue_branding_enrichment
|
||||
if @user
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
# Authenticated users (dashboard "add account") and api_only signups
|
||||
# need the full response with account_id. API-only deployments have no
|
||||
# frontend to handle the email confirmation flow, so they need auth
|
||||
# tokens to proceed.
|
||||
# Unauthenticated web signup returns only the email — no session is
|
||||
# created until the user confirms via the email link.
|
||||
if current_user || api_only_signup?
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
else
|
||||
render json: { email: @user.email }
|
||||
end
|
||||
else
|
||||
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
||||
end
|
||||
@@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def ensure_account_name
|
||||
# ensure that account_name and user_full_name is present
|
||||
# this is becuase the account builder and the models validations are not triggered
|
||||
@@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def api_only_signup?
|
||||
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
|
||||
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
|
||||
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
|
||||
# typecasts it to boolean, coercing 'api_only' to true.
|
||||
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
|
||||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Unauthenticated endpoint for resending confirmation emails during signup.
|
||||
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
|
||||
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
|
||||
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
|
||||
# Inherits from ActionController::API to avoid both issues entirely.
|
||||
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
|
||||
class Auth::ResendConfirmationsController < ActionController::API
|
||||
def create
|
||||
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
|
||||
email = params[:email]
|
||||
return head(:ok) unless email.is_a?(String)
|
||||
|
||||
user = User.from_email(email.strip.downcase)
|
||||
user&.send_confirmation_instructions unless user&.confirmed?
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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\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 = '';
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
class Account::BrandingEnrichmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id, email)
|
||||
result = WebsiteBrandingService.new(email).perform
|
||||
return if result.blank?
|
||||
|
||||
account = Account.find(account_id)
|
||||
account.name = result[:title] if result[:title].present?
|
||||
account.custom_attributes['brand_info'] = result if account.custom_attributes['brand_info'].blank?
|
||||
account.save! if account.changed?
|
||||
ensure
|
||||
finish_enrichment(account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finish_enrichment(account_id)
|
||||
Redis::Alfred.delete(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: account_id))
|
||||
|
||||
account = Account.find(account_id)
|
||||
if account.custom_attributes['onboarding_step'] == 'enrichment'
|
||||
account.custom_attributes['onboarding_step'] = 'account_details'
|
||||
account.save!
|
||||
end
|
||||
|
||||
user = account.administrators.first
|
||||
return unless user
|
||||
|
||||
ActionCableBroadcastJob.perform_later([user.pubsub_token], 'account.enrichment_completed', { account_id: account_id })
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,15 @@
|
||||
class WebsiteBrandingService
|
||||
include SocialLinkParser
|
||||
|
||||
def initialize(url)
|
||||
@url = normalize_url(url)
|
||||
attr_reader :http_status
|
||||
|
||||
DATA_DEFAULTS = { description: nil, slogan: nil, phone: nil, address: nil, links: nil, stock: nil, industries: [], is_nsfw: false }.freeze
|
||||
|
||||
def initialize(email)
|
||||
@email = email
|
||||
@domain = email.split('@').last&.downcase&.strip
|
||||
@url = "https://#{@domain}"
|
||||
@http_status = nil
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -11,13 +18,14 @@ class WebsiteBrandingService
|
||||
|
||||
links = extract_links(doc)
|
||||
|
||||
{
|
||||
business_name: extract_business_name(doc),
|
||||
language: extract_language(doc),
|
||||
industry_category: nil,
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_branding(doc)
|
||||
}
|
||||
DATA_DEFAULTS.merge({
|
||||
domain: @domain,
|
||||
title: extract_title(doc),
|
||||
colors: extract_colors(doc),
|
||||
logos: extract_logos(doc),
|
||||
socials: build_socials(links),
|
||||
email: @email
|
||||
})
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
||||
nil
|
||||
@@ -25,12 +33,9 @@ class WebsiteBrandingService
|
||||
|
||||
private
|
||||
|
||||
def normalize_url(url)
|
||||
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
|
||||
end
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
@@ -39,7 +44,7 @@ class WebsiteBrandingService
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_business_name(doc)
|
||||
def extract_title(doc)
|
||||
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
|
||||
return og_site_name.strip if og_site_name.present?
|
||||
|
||||
@@ -47,8 +52,37 @@ class WebsiteBrandingService
|
||||
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
|
||||
end
|
||||
|
||||
def extract_language(doc)
|
||||
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
|
||||
def extract_colors(doc)
|
||||
color = doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
return [] if color.blank?
|
||||
|
||||
[{ hex: color, name: nil }]
|
||||
end
|
||||
|
||||
def extract_logos(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return [] if favicon.blank?
|
||||
|
||||
url = resolve_url(favicon)
|
||||
return [] if url.blank?
|
||||
|
||||
[{ url: url, type: nil, mode: nil, colors: [], resolution: { aspect_ratio: 1 } }]
|
||||
end
|
||||
|
||||
def build_socials(links)
|
||||
handles = extract_social_from_links(links)
|
||||
handles.filter_map do |platform, handle|
|
||||
next if handle.blank?
|
||||
|
||||
url = reconstruct_social_url(platform, handle)
|
||||
{ type: platform.to_s, url: url }
|
||||
end
|
||||
end
|
||||
|
||||
def reconstruct_social_url(platform, handle)
|
||||
base_urls = { whatsapp: 'https://wa.me/', line: 'https://line.me/', facebook: 'https://facebook.com/',
|
||||
instagram: 'https://instagram.com/', telegram: 'https://t.me/', tiktok: 'https://tiktok.com/' }
|
||||
"#{base_urls[platform]}#{handle}"
|
||||
end
|
||||
|
||||
def extract_links(doc)
|
||||
@@ -62,24 +96,6 @@ class WebsiteBrandingService
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def extract_branding(doc)
|
||||
{
|
||||
favicon: extract_favicon(doc),
|
||||
primary_color: extract_theme_color(doc)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_favicon(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return nil if favicon.blank?
|
||||
|
||||
resolve_url(favicon)
|
||||
end
|
||||
|
||||
def extract_theme_color(doc)
|
||||
doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
end
|
||||
|
||||
def resolve_url(url)
|
||||
return nil if url.blank?
|
||||
return url if url.start_with?('http')
|
||||
|
||||
Reference in New Issue
Block a user