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

This commit is contained in:
Muhsin Keloth
2026-04-28 09:43:04 +04:00
committed by GitHub
138 changed files with 3961 additions and 729 deletions
+1
View File
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
+4
View File
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
+5 -1
View File
@@ -44,7 +44,11 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: account_name, locale: I18n.locale)
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account
end
+2 -1
View File
@@ -29,8 +29,9 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
@@ -0,0 +1,43 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
end
private
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
email = account_params[:email].presence || @user&.email
return if email.blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Account::BrandingEnrichmentJob.perform_later(@account.id, 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
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :password)
params.permit(:otp_code, :backup_code, :password)
end
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
end
def show; end
+3 -3
View File
@@ -59,7 +59,6 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -107,8 +106,9 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -131,7 +131,7 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
v-if="!authUIFlags.isFetching"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
COMPANY_NAME: 'company_name',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
@@ -30,6 +30,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
@@ -82,6 +86,7 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -106,6 +111,7 @@ defineExpose({
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
});
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
};
}
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
@@ -182,6 +182,7 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Company",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -387,6 +387,7 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Country",
"CITY": "City",
"COMPANY": "Company",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Referer link",
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -39,6 +39,7 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import yearInReview from './yearInReview.json';
export default {
@@ -83,5 +84,6 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...onboarding,
...yearInReview,
};
@@ -51,10 +51,14 @@
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "Select timezone",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "Saving...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -53,6 +53,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'company_name',
attributeI18nKey: 'COMPANY',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
@@ -124,6 +132,10 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
{
key: 'company_name',
i18nKey: 'COMPANY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
export default {
routes: [
@@ -31,6 +32,14 @@ export default {
...campaignsRoutes.routes,
],
},
{
path: frontendURL('accounts/:accountId/onboarding'),
name: 'onboarding_account_details',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
</div>
</template>
@@ -0,0 +1,417 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
COMPANY_SIZE_OPTIONS,
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
const userRole = ref('');
const website = ref('');
const locale = ref('');
const timezone = ref('');
const companySize = ref('');
const industry = ref('');
const referralSource = ref('');
const isSubmitting = ref(false);
const isEditingWebsite = ref(false);
const websiteInput = ref(null);
const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
locale: {},
timezone: {},
companySize: {},
industry: {},
referralSource: {},
};
const v$ = useVuelidate(validationRules, {
userRole,
website,
locale,
timezone,
companySize,
industry,
referralSource,
});
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
return (square || logos[0])?.url || '';
});
const languageOptions = computed(() => {
const langs = [...(enabledLanguages || [])];
return langs
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
});
const timezoneOptions = computed(() => {
try {
return Intl.supportedValuesOf('timeZone').map(tz => ({
value: tz,
label: tz.replace(/_/g, ' '),
}));
} catch {
return [];
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = account?.domain || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
};
const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values the controller would otherwise clear
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
showErrorOnFields.value = true;
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
return;
}
isSubmitting.value = true;
try {
await updateAccount({
name: accountName.value,
locale: locale.value,
domain: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
referral_source: referralSource.value,
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<OnboardingLayout
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
:is-loading="isSubmitting"
:disabled="isEnriching"
>
<OnboardingSection
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
icon="i-lucide-user"
>
<div class="flex items-center gap-2 px-3 py-3">
<Avatar :name="userName" :size="16" rounded-full />
<span class="text-sm font-medium text-n-slate-12">
{{ userName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
icon="i-lucide-mail"
>
<div class="flex items-center justify-end gap-1.5">
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
<Icon
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
icon="i-lucide-circle-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
icon="i-lucide-user"
>
<OnboardingFormSelect
v-model="userRole"
:has-error="showErrorOnFields && v$.userRole.$error"
:options="USER_ROLE_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
/>
</OnboardingFormRow>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
icon="i-lucide-briefcase-business"
>
<div
v-if="isEnriching"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
</span>
</div>
<template v-else>
<div class="flex items-center gap-2 px-3 py-3">
<img
v-if="companyLogo"
:src="companyLogo"
:alt="accountName"
class="size-4 object-contain"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ accountName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
icon="i-lucide-globe"
>
<div class="flex items-center justify-end gap-2">
<InlineInput
ref="websiteInput"
v-model="website"
:readonly="!isEditingWebsite"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
:custom-input-class="[
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
{ 'animate-shake': showErrorOnFields && v$.website.$error },
]"
@enter-press="handleWebsiteEnter"
@blur="isEditingWebsite = false"
/>
<NextButton
type="button"
icon="i-lucide-pencil"
slate
xs
ghost
@click="enableWebsiteEditing"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
icon="i-lucide-languages"
>
<OnboardingFormSelect
v-model="locale"
:has-error="showErrorOnFields && v$.locale.$error"
:options="languageOptions"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
icon="i-lucide-clock"
>
<OnboardingFormSelect
v-model="timezone"
:has-error="showErrorOnFields && v$.timezone.$error"
:options="timezoneOptions"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
icon="i-lucide-factory"
>
<OnboardingFormSelect
v-model="industry"
:has-error="showErrorOnFields && v$.industry.$error"
:options="INDUSTRY_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
icon="i-lucide-users"
>
<OnboardingFormSelect
v-model="companySize"
:has-error="showErrorOnFields && v$.companySize.$error"
:options="COMPANY_SIZE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
icon="i-lucide-megaphone"
>
<OnboardingFormSelect
v-model="referralSource"
:has-error="showErrorOnFields && v$.referralSource.$error"
:options="REFERRAL_SOURCE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
"
/>
</OnboardingFormRow>
</template>
</OnboardingSection>
</OnboardingLayout>
</form>
</template>
@@ -0,0 +1,18 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
<div class="flex items-center gap-2">
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
<span class="text-n-slate-11">{{ title }}</span>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
hasError: { type: Boolean, default: false },
});
defineEmits(['update:modelValue']);
</script>
<template>
<div class="relative flex items-center justify-end">
<select
:value="modelValue"
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
:class="[
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
{ 'animate-shake': hasError },
]"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<Icon
icon="i-lucide-chevron-down"
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
/>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
</script>
<template>
<div
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
>
<!-- Grid background with corner fade -->
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
width="1"
height="100%"
preserveAspectRatio="none"
>
<line
x1="0"
y1="0"
x2="0"
y2="97%"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="3 3"
/>
</svg>
<!-- Greeting -->
<div class="mb-6 -ms-12 flex items-start gap-4">
<div
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
>
<slot name="greeting-icon">
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
</slot>
</div>
<div>
<h1 class="text-heading-1 text-n-slate-12">
{{ greeting }}
</h1>
<p v-if="subtitle" class="text-sm text-n-slate-11">
{{ subtitle }}
</p>
</div>
</div>
<!-- Sections -->
<slot />
</div>
<!-- Continue button with curved connector -->
<div class="relative ps-12 overflow-visible">
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
<svg
width="48"
height="40"
viewBox="0 0 47 40"
fill="none"
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
>
<defs>
<linearGradient
id="line-gradient"
x1="15"
y1="0"
x2="48"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
</linearGradient>
</defs>
<path
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
stroke="url(#line-gradient)"
stroke-width="1"
stroke-dasharray="3 3"
fill="none"
/>
</svg>
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="mb-5">
<!-- Section header with icon + triangles -->
<div class="flex items-center gap-4 mb-3 -ms-12">
<div class="flex flex-col items-center z-10 flex-shrink-0">
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
</svg>
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
>
<Icon :icon="icon" class="size-4 text-n-slate-11" />
</div>
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
</svg>
</div>
<span class="text-heading-3 text-n-slate-12">
{{ title }}
</span>
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,68 @@
export const COMPANY_SIZE_OPTIONS = [
{ value: '1-10', label: '1 - 10' },
{ value: '11-50', label: '11 - 50' },
{ value: '51-200', label: '51 - 200' },
{ value: '201-500', label: '201 - 500' },
{ value: '500+', label: '500+' },
];
export const INDUSTRY_OPTIONS = [
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
{
value: 'Automotive & Transportation',
label: 'Automotive & Transportation',
},
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
{
value: 'Construction & Built Environment',
label: 'Construction & Built Environment',
},
{
value: 'Consumer Packaged Goods (CPG)',
label: 'Consumer Packaged Goods (CPG)',
},
{ value: 'Education', label: 'Education' },
{ value: 'Entertainment', label: 'Entertainment' },
{ value: 'Finance', label: 'Finance' },
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
{ value: 'Healthcare', label: 'Healthcare' },
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
{ value: 'News & Media', label: 'News & Media' },
{
value: 'Professional Services & Agencies',
label: 'Professional Services & Agencies',
},
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
{ value: 'Sports', label: 'Sports' },
{ value: 'Technology', label: 'Technology' },
{ value: 'Telecommunications', label: 'Telecommunications' },
{ value: 'Other', label: 'Other' },
];
export const REFERRAL_SOURCE_OPTIONS = [
{ value: 'google', label: 'Google' },
{ value: 'reddit', label: 'Reddit' },
{ value: 'twitter', label: 'Twitter/X' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'friend', label: 'Friend/Colleague' },
{ value: 'blog', label: 'Blog/Article' },
{ value: 'github', label: 'GitHub' },
{ value: 'other', label: 'Other' },
];
export const USER_ROLE_OPTIONS = [
{ value: 'founder', label: 'Founder/CEO' },
{ value: 'product_manager', label: 'Product Manager' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'support_lead', label: 'Support Lead' },
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'other', label: 'Other' },
];
@@ -74,6 +74,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'labels',
name: 'LABELS',
@@ -180,6 +186,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'referer',
name: 'REFERER_LINK',
@@ -314,6 +326,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
@@ -460,6 +478,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -590,6 +614,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -157,12 +168,32 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -104,9 +104,9 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
const disableMfa = async ({ password, otpCode, backupCode }) => {
try {
await mfaAPI.disable(password, otpCode);
await mfaAPI.disable(password, { otpCode, backupCode });
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
+24 -6
View File
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
import dashboard from './dashboard/dashboard.routes';
import store from 'dashboard/store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const routes = [...dashboard.routes];
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = (to, next) => {
export const validateAuthenticateRoutePermission = async (to, next) => {
const { isLoggedIn, getCurrentUser: user } = store.getters;
if (!isLoggedIn) {
@@ -27,8 +29,25 @@ export const validateAuthenticateRoutePermission = (to, next) => {
return next(frontendURL('no-accounts'));
}
const routeAccountId = Number(to.params?.accountId || accountId);
const userAccount = accounts.find(a => a.id === routeAccountId);
const isAdmin = userAccount?.role === 'administrator';
const isActive = userAccount?.status === 'active';
const needsOnboarding =
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
isAdmin &&
isActive;
if (to.name === 'no_accounts' || !to.name) {
return next(frontendURL(`accounts/${accountId}/dashboard`));
const target = needsOnboarding ? 'onboarding' : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
@@ -38,15 +57,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
userAuthentication.then(() => {
return validateAuthenticateRoutePermission(to, next, store);
});
await userAuthentication;
await validateAuthenticateRoutePermission(to, next, store);
});
};
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
id: null,
accounts: [],
},
'accounts/getAccount': () => ({}),
},
dispatch: vi.fn(() => Promise.resolve()),
},
}));
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
});
describe('when route is not accessible to current user', () => {
it('should redirect to dashboard', () => {
it('should redirect to dashboard', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
});
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
};
});
it('should go to the intended route', () => {
it('should go to the intended route', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith();
});
@@ -54,18 +54,19 @@ export const getters = {
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
get: async ({ commit }, { silent } = {}) => {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
}
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch {
// silent failure
} finally {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
}
}
},
update: async ({ commit }, { options, ...updateObj }) => {
@@ -269,6 +269,20 @@ export const mutations = {
accounts,
};
},
[types.RESET_ONBOARDING](_state, accountId) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === accountId) {
const { onboarding_step, ...rest } = account;
return rest;
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
bulkTranslate: async (
_,
{ portalSlug, articleIds, locale, categoryId, force = false }
) => {
const { data } = await articlesAPI.bulkTranslate({
portalSlug,
articleIds,
locale,
categoryId,
force,
});
return data;
},
};
@@ -57,4 +57,46 @@ describe('#mutations', () => {
);
});
});
describe('#RESET_ONBOARDING', () => {
it('removes onboarding_step from the targeted account', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{
id: 1,
onboarding_step: 'account_details',
role: 'administrator',
},
],
},
};
mutations[types.RESET_ONBOARDING](state, 1);
expect(state.currentUser.accounts[0]).not.toHaveProperty(
'onboarding_step'
);
expect(state.currentUser.accounts[0].role).toEqual('administrator');
});
it('targets the route account, not currentUser.account_id', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{ id: 1, onboarding_step: 'account_details' },
{ id: 2, onboarding_step: 'account_details' },
],
},
};
mutations[types.RESET_ONBOARDING](state, 2);
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
'account_details'
);
expect(state.currentUser.accounts[1]).not.toHaveProperty(
'onboarding_step'
);
});
});
});
@@ -4,6 +4,7 @@ export default {
SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
RESET_ONBOARDING: 'RESET_ONBOARDING',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
+84 -84
View File
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
"DOWNLOAD": "Laadi alla",
"UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
"SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
"RETRY": "Send message again",
"ERROR_MESSAGE": "Couldn't send, try again"
"RETRY": "Saada sõnum uuesti",
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"ONLINE": "Oleme võrgus",
"OFFLINE": "Oleme hetkel eemal",
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
"BACK_AT_TIME": "Oleme tagasi kell {time}",
"BACK_ON_DAY": "Oleme tagasi {day}",
"BACK_TOMORROW": "Oleme tagasi homme",
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
"MONDAY": "Monday",
"TUESDAY": "Tuesday",
"WEDNESDAY": "Wednesday",
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
"SUNDAY": "Pühapäev",
"MONDAY": "Esmaspäev",
"TUESDAY": "Teisipäev",
"WEDNESDAY": "Kolmapäev",
"THURSDAY": "Neljapäev",
"FRIDAY": "Reede",
"SATURDAY": "Laupäev"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"START_NEW_CONVERSATION": "Start a new conversation",
"VIEW_UNREAD_MESSAGES": "You have unread messages",
"START_CONVERSATION": "Alusta vestlust",
"END_CONVERSATION": "Lõpeta vestlus",
"CONTINUE_CONVERSATION": "Jätka vestlust",
"YOU": "Sina",
"START_NEW_CONVERSATION": "Alusta uut vestlust",
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close",
"COMPANY_FROM": "from",
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
"CLOSE_MESSAGES_BUTTON": "Sulge",
"COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
"LABEL": "Chat with us"
"LABEL": "Vestle meiega"
},
"POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"POWERED_BY": "Toetab Chatwoot",
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
"TODAY": "Täna",
"YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"REQUIRED_ERROR": "Full Name is required"
"LABEL": "Täisnimi",
"PLACEHOLDER": "Palun sisesta oma täisnimi",
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"REQUIRED_ERROR": "Email Address is required",
"VALID_ERROR": "Please enter a valid email address"
"LABEL": "E-posti aadress",
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter your phone number",
"REQUIRED_ERROR": "Phone Number is required",
"DIAL_CODE_VALID_ERROR": "Please select a country code",
"VALID_ERROR": "Please enter a valid phone number",
"DROPDOWN_EMPTY": "No results found",
"DROPDOWN_SEARCH": "Search country"
"LABEL": "Telefoninumber",
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
"DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
"LABEL": "Sõnum",
"PLACEHOLDER": "Palun sisesta oma sõnum",
"ERROR": "Sõnum on liiga lühike"
}
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
"IS_REQUIRED": "on kohustuslik",
"REQUIRED": "Kohustuslik",
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
"FIELD": "Invalid field"
"FIELD": "Vigane väli"
}
},
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
"PLACEHOLDER": "Otsi emotikone",
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
"PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
"BUTTON_TEXT": "Request a conversation transcript",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again"
"BUTTON_TEXT": "Taotle vestluse koopiat",
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
"CLICK_HERE_TO_JOIN": "Click here to join",
"LEAVE_THE_ROOM": "Leave the call"
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
"LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"POPULAR_ARTICLES": "Populaarsed artiklid",
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
"CONTENT": "Picture message"
"CONTENT": "Pildisõnum"
},
"audio": {
"CONTENT": "Audio message"
"CONTENT": "Helisõnum"
},
"video": {
"CONTENT": "Video message"
"CONTENT": "Videosõnum"
},
"file": {
"CONTENT": "File Attachment"
"CONTENT": "Faili manus"
},
"location": {
"CONTENT": "Location"
"CONTENT": "Asukoht"
},
"fallback": {
"CONTENT": "has shared a url"
"CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
"REPLY_TO": "Replying to:"
"REPLY_TO": "Vastus sõnumile:"
}
}
+6 -1
View File
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
def attach_export_file(csv_data)
return if csv_data.blank?
# Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
# correctly recognise the file encoding for non-ASCII characters
# such as Arabic, Japanese, and Chinese.
bom = "\xEF\xBB\xBF"
@account.contacts_export.attach(
io: StringIO.new(csv_data),
io: StringIO.new("#{bom}#{csv_data}"),
filename: "#{@account.name}_#{@account.id}_contacts.csv",
content_type: 'text/csv'
)
+2 -2
View File
@@ -1,6 +1,6 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
rescue Webhooks::Trigger::RetryableError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end
+42 -17
View File
@@ -9,27 +9,17 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
include UrlHelper
queue_as :purgable
MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Avatarable::ALLOWED_AVATAR_CONTENT_TYPES
MAX_DOWNLOAD_SIZE = 15.megabytes
RATE_LIMIT_WINDOW = 1.minute
def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar)
return unless url_valid?(avatar_url)
return unless syncable_avatar?(avatarable, avatar_url)
return unless should_sync_avatar?(avatarable, avatar_url)
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
fetch_and_attach_avatar(avatarable, avatar_url)
rescue SafeFetch::HttpError => e
log_http_error(avatar_url, e)
rescue SafeFetch::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure
update_avatar_sync_attributes(avatarable, avatar_url)
@@ -37,6 +27,41 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
private
def syncable_avatar?(avatarable, avatar_url)
avatarable.respond_to?(:avatar) &&
url_valid?(avatar_url) &&
should_sync_avatar?(avatarable, avatar_url)
end
def fetch_and_attach_avatar(avatarable, avatar_url)
SafeFetch.fetch(
avatar_url,
max_bytes: MAX_DOWNLOAD_SIZE,
allowed_content_type_prefixes: [],
allowed_content_types: ALLOWED_CONTENT_TYPES
) do |avatar_file|
attach_avatar(avatarable, avatar_file)
end
end
def attach_avatar(avatarable, avatar_file)
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file.tempfile,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
end
def log_http_error(avatar_url, error)
if error.message.start_with?('404')
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
else
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
end
end
def should_sync_avatar?(avatarable, avatar_url)
# Only Contacts are rate-limited and hash-gated.
return true unless avatarable.is_a?(Contact)
+1
View File
@@ -106,6 +106,7 @@ class DataImportJob < ApplicationJob
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
clean_data = clean_data.delete_prefix("\xEF\xBB\xBF")
CSV.new(StringIO.new(clean_data), headers: true)
end
+8
View File
@@ -157,6 +157,14 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
def onboarding_step
step = custom_attributes['onboarding_step']
return nil if step.blank?
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
private
def notify_creation
+1
View File
@@ -32,6 +32,7 @@ class AgentBot < ApplicationRecord
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
+1 -1
View File
@@ -35,7 +35,7 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) }
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
mail_subject phone_number priority conversation_language labels private_note]
end
+3 -2
View File
@@ -4,6 +4,8 @@ module Avatarable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
ALLOWED_AVATAR_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
included do
has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? }
@@ -30,7 +32,6 @@ module Avatarable
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
errors.add(:avatar, 'filetype not supported') unless ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
end
end
+1 -1
View File
@@ -25,7 +25,7 @@ class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
+1
View File
@@ -75,6 +75,7 @@ class User < ApplicationRecord
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :name, presence: true
validates :email, presence: true
serialize :otp_backup_codes, type: Array
+1 -1
View File
@@ -61,7 +61,7 @@ class DataImport::ContactManager
def update_contact_attributes(params, contact)
contact.name = params[:name] if params[:name].present?
contact.additional_attributes ||= {}
contact.additional_attributes[:company] = params[:company] if params[:company].present?
contact.additional_attributes[:company_name] = params[:company_name] if params[:company_name].present?
contact.additional_attributes[:city] = params[:city] if params[:city].present?
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
end
@@ -10,7 +10,9 @@ if resource.custom_attributes.present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
@@ -22,6 +22,7 @@ json.accounts do
json.id account_user.account_id
json.name account_user.account.name
json.status account_user.account.status
json.onboarding_step account_user.account.onboarding_step
json.active_at account_user.active_at
json.role account_user.role
json.permissions account_user.permissions
@@ -0,0 +1,89 @@
<tr>
<td style="padding: 0 0 16px;">
<span style="display: inline-block; padding: 6px 10px; border-radius: 999px; background-color: #EFF6FF; color: #1D4ED8; font-size: 12px; line-height: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;">
<%= eyebrow %>
</span>
</td>
</tr>
<tr>
<td style="padding: 0 0 12px;">
<h1 style="margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; color: #0F172A;"><%= heading %></h1>
</td>
</tr>
<tr>
<td style="padding: 0 0 8px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #334155;">Hi <%= recipient_name %>,</p>
</td>
</tr>
<tr>
<td style="padding: 0 0 10px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= intro_text %></p>
</td>
</tr>
<tr>
<td style="padding: 0 0 <%= detail_rows.any? || info_title.present? ? '16px' : '24px' %>;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= supporting_text %></p>
</td>
</tr>
<% if detail_rows.any? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 16px; background-color: #F8FBFF;">
<% detail_rows.each_with_index do |(label, value), index| %>
<tr>
<td style="padding: 16px 18px;<%= ' border-top: 1px solid #E2E8F0;' unless index.zero? %>">
<p style="margin: 0 0 4px; font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748B;">
<%= label %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; font-weight: 600; color: #0F172A;"><%= value %></p>
</td>
</tr>
<% end %>
</table>
</td>
</tr>
<% end %>
<% if action_url.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
<tr>
<td bgcolor="#2781F6" style="border-radius: 14px; background-color: #2781F6;">
<%= link_to(
action_text,
action_url,
style: 'display:block; width:100%; box-sizing:border-box; padding:12px 24px; font-size:14px; line-height:20px; font-weight:700; color:#FFFFFF; text-align:center; text-decoration:none;'
) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 0 20px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #64748B;">
If the button does not work, open
<%= link_to(
'this secure link',
action_url,
style: 'color:#2781F6; text-decoration:none; font-weight:600;'
) %>.
</p>
</td>
</tr>
<% elsif info_title.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DBEAFE; border-radius: 16px; background-color: #EFF6FF;">
<tr>
<td style="padding: 18px;">
<p style="margin: 0 0 6px; font-size: 14px; line-height: 21px; font-weight: 700; color: #0F172A;">
<%= info_title %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= info_text %></p>
</td>
</tr>
</table>
</td>
</tr>
<% end %>
@@ -1,29 +1,65 @@
<p>Hi <%= @resource.name %>,</p>
<%
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
recipient_name = @resource.name.presence || @resource.email
account_user = @resource&.account_users&.first
inviter = account_user&.inviter
account_name = account_user&.account&.name
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
<% account_user = @resource&.account_users&.first %>
eyebrow = 'Welcome'
heading = 'Confirm your email to get started'
intro_text =
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
supporting_text = 'This only takes a moment.'
action_text = 'Confirm my account'
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
info_title = nil
info_text = nil
detail_rows = []
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
if @resource.unconfirmed_email.present?
eyebrow = 'Email update'
heading = 'Confirm your new email address'
intro_text = "We received a request to update the email address on your #{brand_name} account."
supporting_text = 'Confirm the new address below to finish the change.'
action_text = 'Confirm email address'
elsif @resource.confirmed?
eyebrow = 'Account ready'
heading = 'Your account is ready'
intro_text = "Your #{brand_name} account is already active."
supporting_text = 'Use the button below to sign in and continue where you left off.'
action_text = 'Open my account'
action_url = frontend_url('auth/sign_in')
detail_rows = []
elsif invited_user
eyebrow = 'Workspace invitation'
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
intro_text = if account_name.present?
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to try #{brand_name}."
end
supporting_text = 'Create your account to start collaborating with your team.'
action_text = 'Accept invitation'
action_url = frontend_url(
'auth/password/edit',
reset_password_token: @resource.send(:set_reset_password_token)
)
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
end
%>
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% elsif account_user&.inviter.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<%= render partial: 'devise/mailer/confirmation_body', locals: {
action_text: action_text,
action_url: action_url,
detail_rows: detail_rows,
eyebrow: eyebrow,
heading: heading,
info_text: info_text,
info_title: info_title,
intro_text: intro_text,
recipient_name: recipient_name,
supporting_text: supporting_text
} %>
+98 -55
View File
@@ -7,86 +7,129 @@
<style type="text/css">
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
height: 100%;
line-height: 1.6em;
line-height: 21px;
width: 100% !important;
margin: 0;
padding: 0;
background-color: #F4F7FB;
}
body {
background-color: #F8FAFC;
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
a {
color: #2781F6;
}
.email-container {
width: 100%;
max-width: 640px;
}
.main-card {
width: 100%;
border: 1px solid #DCE7F5;
border-radius: 24px;
background-color: #FFFFFF;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.accent-bar {
height: 6px;
background-color: #2781F6;
font-size: 0;
line-height: 0;
}
.content-wrap {
padding: 40px 36px 20px;
}
.footer {
padding: 20px 8px 0;
text-align: center;
color: #64748B;
font-size: 13px;
line-height: 21px;
}
.footer a {
color: #2781F6;
font-weight: 600;
text-decoration: none;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-size: 22px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
font-size: 28px !important;
line-height: 34px !important;
}
h2 {
font-size: 18px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
.page-wrap {
padding: 24px 12px 32px !important;
}
h3 {
font-size: 16px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
.main-card {
border-radius: 20px !important;
}
.content-wrap {
padding: 10px !important;
padding: 32px 24px 18px !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
<table class="body-wrap" style="width: 100%; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
{% assign brand_name = global_config['BRAND_NAME'] %}
{% if brand_name == nil %}
{% assign brand_name = 'Chatwoot' %}
{% endif %}
{% assign brand_url = global_config['BRAND_URL'] %}
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; line-height: 21px; background-color: #F4F7FB; margin: 0; padding: 0;" bgcolor="#F4F7FB">
<table class="body-wrap" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #F4F7FB; margin: 0;" bgcolor="#F4F7FB">
<tr style="margin: 0;">
<td class="container" width="600" style="display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
<div class="content" style="display: block; margin: 0 auto; padding: 20px; text-align:center;">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="border-radius: 6px; background-color: #fff; text-align:left; margin: 0; border: 1px solid #e9e9e9; border-top:3px solid #0080f8;" bgcolor="#fff">
<td align="center" class="page-wrap" style="padding: 32px 16px 40px;" valign="top">
<table class="email-container" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 640px; margin: 0 auto;">
<tr style="margin: 0;">
<td style="margin: 0;">
<table class="main-card" role="presentation" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 24px; background-color: #FFFFFF; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); overflow: hidden;" bgcolor="#FFFFFF">
<tr style="margin: 0;">
<td class="accent-bar" style="height: 6px; background-color: #2781F6; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 40px 36px 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
{{ content_for_layout }}
</table>
</td>
</tr>
</table>
</td>
</tr>
{% if brand_name != '' %}
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0;">
{{ content_for_layout }}
</table>
<td class="footer" style="padding: 20px 8px 0; text-align: center; color: #64748B; font-size: 13px; line-height: 21px;">
This email was sent by
{% if brand_url != nil and brand_url != '' %}
<a href="{{ brand_url }}" style="color: #2781F6; font-weight: 600; text-decoration: none;">{{ brand_name }}</a>.
{% else %}
<span style="color: #0F172A; font-weight: 600;">{{ brand_name }}</span>.
{% endif %}
</td>
</tr>
</table>
</div>
<div class="footer" style="color: #93AFC8; margin: 0; padding: 0 20px 40px; text-align: center">
<table width="100%" style="margin: 0;">
<tr style="margin: 0;">
{% if global_config['BRAND_NAME'] != '' %}
<td class="content-block" style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Powered by
<a href="{{ global_config['BRAND_URL'] }}" style="vertical-align: top; color: #93AFC8; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
{{ global_config['BRAND_NAME'] }}
</a>
</td>
{% endif %}
</tr>
</table>
</div>
{% endif %}
</table>
</td>
</tr>
</table>
@@ -1,3 +1,6 @@
json.array! @resources do |resource|
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: resource.permissible
bot = resource.permissible
next if bot.nil?
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: bot
end
+1
View File
@@ -37,6 +37,7 @@ module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
config.rails_i18n.enabled_modules = [:pluralization]
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
@@ -0,0 +1,8 @@
# frozen_string_literal: true
other_plural_rule = ->(_count) { :other }
Rails.application.config.after_initialize do
I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } })
I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } })
end
-1
View File
@@ -57,5 +57,4 @@ id:
not_found: "tidak ditemukan"
not_locked: "tidak terkunci"
not_saved:
one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
-1
View File
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
-1
View File
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
-1
View File
@@ -57,5 +57,4 @@ ms:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ th:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ vi:
not_found: "không tìm thấy"
not_locked: "không được khoá"
not_saved:
one: "Có %{count} lỗi được tìm thấy từ %{resource}:"
other: "Có %{count} lỗi được tìm thấy từ %{resource}:"
-1
View File
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
one: "%{count} 个错误禁止保存 %{resource}"
other: "%{count} 个错误禁止保存 %{resource}"
-1
View File
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
+6
View File
@@ -488,6 +488,12 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
articles:
captain_not_available: 'Translation requires Captain to be enabled for this account'
locale_not_available: 'Locale not available in this portal'
category_not_found: 'Category not found in this portal'
no_articles_found: 'No articles found to process'
invalid_status: 'Invalid status value'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
-4
View File
@@ -435,16 +435,12 @@ id:
button: Buka percakapan
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ja:
button: 会話を開く
time_units:
days:
one: '%{count} 日'
other: '%{count} 日'
hours:
one: '%{count} 時間'
other: '%{count} 時間'
minutes:
one: '%{count} 分'
other: '%{count} 分'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ko:
button: 대화 열기
time_units:
days:
one: '%{count}일'
other: '%{count}일'
hours:
one: '%{count}시간'
other: '%{count}시간'
minutes:
one: '%{count}분'
other: '%{count}분'
seconds:
one: '%{count}초'
other: '%{count}초'
auto_assignment:
default_policy_name: '기본 정책'
-4
View File
@@ -435,16 +435,12 @@ ms:
button: Open conversation
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ th:
button: เปิดดูการสนทนา
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ vi:
button: Mở cuộc trò chuyện
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_CN:
button: 重新打开会话
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小时'
other: '%{count} 小时'
minutes:
one: '%{count} 分钟'
other: '%{count} 分钟'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_TW:
button: '開啟對話'
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小時'
other: '%{count} 小時'
minutes:
one: '%{count} 分鐘'
other: '%{count} 分鐘'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: '預設策略'
+7
View File
@@ -358,6 +358,13 @@ Rails.application.routes.draw do
resources :categories do
post :reorder, on: :collection
end
namespace :articles do
resource :bulk_actions, only: [] do
post :translate
patch :update_status
delete :delete_articles
end
end
resources :articles do
post :reorder, on: :collection
end
@@ -0,0 +1,47 @@
class RenameCompanyConditionKeyInAutomationRules < ActiveRecord::Migration[7.1]
def up
migrate_automation_rule_conditions
migrate_contact_custom_filter_queries
end
def down; end
private
def migrate_automation_rule_conditions
AutomationRule.find_each do |rule|
conditions = rename_company_attribute_key(rule.conditions)
next if conditions == rule.conditions
rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations
end
end
def migrate_contact_custom_filter_queries
CustomFilter.contact.find_each do |filter|
query = filter.query.deep_dup
payload = rename_company_attribute_key(query['payload'])
next if payload == query['payload']
query['payload'] = payload
filter.update_column(:query, query) # rubocop:disable Rails/SkipsModelValidations
end
end
def rename_company_attribute_key(conditions)
return conditions unless conditions.is_a?(Array)
conditions.map do |condition|
next condition unless standard_company_condition?(condition)
condition.merge('attribute_key' => 'company_name')
end
end
def standard_company_condition?(condition)
condition['attribute_key'] == 'company' &&
condition['custom_attribute_type'].blank? &&
condition['attribute_model'].in?([nil, '', 'standard'])
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -0,0 +1,68 @@
module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
def translate
return unless validate_translate_params?
duplicates = find_existing_translations
if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
return render json: {
duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
}, status: :conflict
end
@articles.find_each do |article|
Captain::Articles::TranslateJob.perform_later(
Current.account, article.id, @locale, @category&.id, Current.user
)
end
head :ok
end
private
def permitted_params
params.permit(:locale, :category_id, :force, ids: [])
end
def validate_translate_params?
@locale = permitted_params[:locale]
@category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
@articles = @portal.articles.where(id: permitted_params[:ids])
captain_available? && valid_locale? && valid_category? && valid_articles?
end
def find_existing_translations
root_ids = @articles.map { |a| Article.find_root_article_id(a) }
@portal.articles.where(associated_article_id: root_ids, locale: @locale)
end
def captain_available?
return true if Current.account.feature_enabled?('captain_tasks')
render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
false
end
def valid_locale?
return true if @portal.config['allowed_locales']&.include?(@locale)
render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
false
end
def valid_category?
return true if permitted_params[:category_id].blank?
return true if @category.present?
render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
false
end
def valid_articles?
return true if @articles.any?
render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
false
end
end
@@ -0,0 +1,59 @@
class Captain::Articles::TranslateJob < ApplicationJob
queue_as :low
def perform(account, article_id, target_locale, target_category_id, user)
@account = account
@source_article = account.articles.find(article_id)
target_language = language_name_for(target_locale)
translated_title = translate(@source_article.title, target_language: target_language, type: :title)
translated_content = if @source_article.content.present?
translate(@source_article.content, target_language: target_language, type: :content)
else
@source_article.content
end
existing = find_existing_translation(target_locale)
if existing
existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
else
create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
end
end
private
def translate(text, target_language:, type:)
response = Captain::Llm::ArticleTranslationService.new(
account: @account, text: text, target_language: target_language, type: type
).perform
raise "Translation failed: #{response[:error]}" if response[:error]
response[:message]
end
def find_existing_translation(target_locale)
root_id = Article.find_root_article_id(@source_article)
@source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
end
def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
@source_article.portal.articles.create!(
title: translated_title,
content: translated_content,
description: @source_article.description,
category_id: target_category_id,
locale: target_locale,
author_id: user.id,
status: :draft,
associated_article_id: Article.find_root_article_id(@source_article)
)
end
def language_name_for(locale_code)
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
language_map[locale_code] || locale_code
end
end
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
Captain::Llm::FaqGeneratorService.new(document: document).generate
end
def build_paginated_service(document, options)
@@ -117,6 +117,10 @@ class Captain::Document < ApplicationRecord
end
end
def to_llm_metadata
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
private
def enqueue_crawl_job
@@ -0,0 +1,62 @@
class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
TYPES = %i[title content].freeze
pattr_initialize [:account!, :text!, :target_language!, :type!]
def perform
raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
response = make_api_call(model: translation_model, messages: messages)
return response if response[:error]
response.merge(message: response[:message].strip)
end
private
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: text }
]
end
def system_prompt
type == :title ? title_system_prompt : content_system_prompt
end
def event_name
'article_translation'
end
def llm_credential
@llm_credential ||= system_llm_credential
end
def translation_model
@translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
end
def title_system_prompt
<<~SYSTEM_PROMPT_MESSAGE
You are a professional translator.
Translate the following text to #{target_language}.
Return only the translated text, no explanations or extra formatting.
SYSTEM_PROMPT_MESSAGE
end
def content_system_prompt
<<~SYSTEM_PROMPT_MESSAGE
You are a professional translator. Translate the following content to #{target_language}.
The content is markdown that may contain embedded HTML blocks.
Rules:
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
- Keep all image tags (both markdown ![](url) and HTML <img>), iframes, and embedded media completely unchanged.
- Preserve all line breaks, blank lines, and whitespace patterns.
- Return ONLY the translated content, no wrapping or explanations.
SYSTEM_PROMPT_MESSAGE
end
end
@@ -42,7 +42,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
{
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
@assistant.name, @assistant.config['product_name'], @assistant.config,
@assistant.name, @assistant.config['product_name'], @assistant.config.merge('timezone' => inbox_timezone),
contact: contact_attributes,
custom_tools: custom_tools_metadata
)
@@ -70,6 +70,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
)
end
def inbox_timezone
@conversation&.inbox&.timezone.presence || 'UTC'
end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
@@ -1,11 +1,12 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(content, language = 'english', account_id: nil)
def initialize(document:)
super()
@language = language
@content = content
@account_id = account_id
@document = document
@content = document.content
@language = document.account.locale_english_name
@account_id = document.account_id
end
def generate
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
]
],
metadata: document_metadata
}
end
def document_metadata
@document&.to_llm_metadata || {}
end
def parse_response(content)
return [] if content.nil?
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
messages: params[:messages]
messages: params[:messages],
metadata: document_metadata
}
response = instrument_llm_call(instrumentation_params) do
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
metadata: {
document_id: @document&.id,
start_page: start_page,
end_page: end_page,
iteration: @iterations_completed + 1
}
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
}
end
def document_metadata
@document&.to_llm_metadata || {}
end
end
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
<<~PROMPT
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
## Core Requirements
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
**Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
**Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found without containing that information itself omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
## Guidelines
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
- **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
- **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
- **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
- **JSON Validity**: Always return properly formatted, valid JSON
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
## Process
1. Read the entire provided content carefully
2. Identify all key information points, procedures, and examples
3. Create questions that cover each information point
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
5. Verify that combined FAQs represent the complete original content.
6. Format as valid JSON
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
5. Create questions that cover each remaining substantive information point
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
8. Format as valid JSON
PROMPT
end
@@ -168,6 +175,13 @@ class Captain::Llm::SystemPromptsService
[Identity]
Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
[Current Time]
Current time: #{format_current_time(config['timezone'])}.
Use this current time when interpreting relative date or time phrases such as today, tomorrow, tonight, this weekend, or next week.
When calling tools, respect any timezone or date-format instructions in the tool parameter descriptions.
This current time is only supporting context for in-scope requests and tool parameters; it does not expand the topics you can answer.
[Response Guideline]
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
@@ -293,6 +307,12 @@ class Captain::Llm::SystemPromptsService
private
def format_current_time(timezone)
tz = ActiveSupport::TimeZone[timezone] if timezone.present?
time = tz ? Time.current.in_time_zone(tz) : Time.current
time.strftime('%A, %B %d, %Y %I:%M %p %Z')
end
def build_tools_section(custom_tools)
tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
<<~TOOLS.strip
@@ -1,5 +1,6 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
@@ -33,16 +34,20 @@ class Captain::Tools::FirecrawlService
ignoreSitemap: false,
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
onlyMainContent: false,
formats: ['markdown'],
excludeTags: ['iframe']
}
scrapeOptions: scrape_options
}.to_json
end
def scrape_payload(url)
{ url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json
{ url: url }.merge(scrape_options).to_json
end
def scrape_options
{
onlyMainContent: true,
formats: ['markdown'],
excludeTags: FIRECRAWL_EXCLUDE_TAGS
}
end
def headers
@@ -1,45 +1,99 @@
<p>Hi <%= @resource.name %>,</p>
<%
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
recipient_name = @resource.name.presence || @resource.email
account_user = @resource&.account_users&.first
inviter = account_user&.inviter
account_name = account_user&.account&.name
is_saml_account = account_user&.account&.saml_enabled?
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
<% account_user = @resource&.account_users&.first %>
<% is_saml_account = account_user&.account&.saml_enabled? %>
eyebrow = 'Welcome'
heading = 'Confirm your email to get started'
intro_text =
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
supporting_text = 'This only takes a moment.'
action_text = 'Confirm my account'
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
info_title = nil
info_text = nil
detail_rows = []
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<% if is_saml_account %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).</p>
<p>Your organization uses SSO for secure authentication. You will not need a password to access your account.</p>
<% else %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
<% end %>
if @resource.unconfirmed_email.present?
eyebrow = 'Email update'
heading = 'Confirm your new email address'
intro_text = "We received a request to update the email address on your #{brand_name} account."
supporting_text = 'Confirm the new address below to finish the change.'
action_text = 'Confirm email address'
elsif @resource.confirmed?
eyebrow = 'Account ready'
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<% unless is_saml_account %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% end %>
if is_saml_account
heading = 'Your access is ready'
intro_text = "Your #{brand_name} access is already set up."
supporting_text = "Use your organization's Single Sign-On (SSO) portal to access #{brand_name}."
action_text = nil
action_url = nil
info_title = "Sign in with your organization's SSO"
info_text =
"You won't need a separate password for #{brand_name}. Start from your company identity provider portal."
detail_rows = []
detail_rows << ['Workspace', account_name] if account_name.present?
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
else
heading = 'Your account is ready'
intro_text = "Your #{brand_name} account is already active."
supporting_text = 'Use the button below to sign in and continue where you left off.'
action_text = 'Open my account'
action_url = frontend_url('auth/sign_in')
detail_rows = []
end
elsif invited_user
eyebrow = 'Workspace invitation'
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
if is_saml_account
intro_text = if account_name.present?
"#{inviter.name} invited you to access the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to access #{brand_name}."
end
supporting_text =
"Your organization uses Single Sign-On (SSO), so you won't need to create a separate password."
action_text = nil
action_url = nil
info_title = "Use your organization's SSO portal"
info_text = "Continue from your company identity provider portal to access #{brand_name}."
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
else
intro_text = if account_name.present?
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to try #{brand_name}."
end
supporting_text = 'Create your account to start collaborating with your team.'
action_text = 'Accept invitation'
action_url = frontend_url(
'auth/password/edit',
reset_password_token: @resource.send(:set_reset_password_token)
)
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
end
end
%>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<% if is_saml_account %>
<p>You can now access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% end %>
<% elsif account_user&.inviter.present? %>
<% if is_saml_account %>
<p>You can access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% end %>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<%= render partial: 'devise/mailer/confirmation_body', locals: {
action_text: action_text,
action_url: action_url,
detail_rows: detail_rows,
eyebrow: eyebrow,
heading: heading,
info_text: info_text,
info_title: info_title,
intro_text: intro_text,
recipient_name: recipient_name,
supporting_text: supporting_text
} %>

Some files were not shown because too many files have changed in this diff Show More