diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index bdaf82f6c..92d9de55f 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController before_action :fetch_portal, except: [:index, :create] before_action :check_authorization + before_action :validate_analytics_params, only: [:create, :update] before_action :set_current_page, only: [:index] def index @@ -23,6 +24,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def update ActiveRecord::Base.transaction do + # Lock the row so concurrent saves merge onto the latest committed config + # instead of a stale snapshot, which would drop the other save's keys. + @portal.lock! @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? @@ -75,17 +79,37 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController params.permit(:id, :email) end + def validate_analytics_params + analytics = params.dig(:portal, :config, :analytics) + return if analytics.blank? + + valid = analytics.respond_to?(:each_pair) && + analytics.keys.all? { |key| Portal::ANALYTICS_CONFIG_FORMATS.key?(key.to_s) } && + analytics.values.all?(String) + return if valid + + render json: { error: I18n.t('portals.analytics.invalid_configuration') }, status: :unprocessable_entity + end + def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, - { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, - { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, - { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }, - { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] } + { config: config_param_keys } ) end + def config_param_keys + keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }, + { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] + # Analytics injects tracking scripts into every public page, so keep it admin-only even though + # Enterprise lets knowledge_base_manage roles edit other portal settings. + keys << { analytics: Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym) } if Current.account_user&.administrator? + keys + end + def locale_translation_keys params.dig(:portal, :config, :locale_translations)&.keys || [] end diff --git a/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue index fb564622a..d5b2bf56a 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue @@ -30,6 +30,10 @@ defineProps({ type: Boolean, default: true, }, + breadcrumbLabel: { + type: String, + default: '', + }, }); const emit = defineEmits(['update:currentPage']); @@ -44,10 +48,11 @@ const portals = useMapGetter('portals/allPortals'); const currentPortalSlug = computed(() => route.params.portalSlug); -const activePortalName = computed(() => { - return portals.value?.find(portal => portal.slug === currentPortalSlug.value) - ?.name; -}); +const activePortal = computed(() => + portals.value?.find(portal => portal.slug === currentPortalSlug.value) +); + +const activePortalName = computed(() => activePortal.value?.name); const updateCurrentPage = page => { emit('update:currentPage', page); @@ -65,32 +70,40 @@ const togglePortalSwitcher = () => { v-if="showHeaderTitle" class="flex items-center justify-start h-20 gap-2" > - - {{ activePortalName }} - -
- -
+ + + + + + + +
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue index c3ea1a6e9..a8d2a7aec 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue @@ -344,6 +344,7 @@ watch( :total-items="articlesCount" :items-per-page="25" :header="portalName" + :breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')" :show-pagination-footer="shouldShowPaginationFooter" @update:current-page="handlePageChange" > diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue index 95a21a023..d6ed4f428 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue @@ -123,7 +123,10 @@ const reorderCategories = async reorderedGroup => {