Allow knowledge-base managers to manage Captain FAQs
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled

This commit is contained in:
Rodribm10 2026-06-10 13:32:10 -03:00
parent cbbfccaf42
commit efc61936e9
9 changed files with 96 additions and 44 deletions

View File

@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
const props = defineProps({ const props = defineProps({
id: { id: {
@ -71,6 +72,7 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const modelValue = computed({ const modelValue = computed({
get: () => props.isSelected, get: () => props.isSelected,
@ -142,7 +144,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2"> <div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy <Policy
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']" :permissions="responseManagePermissions"
class="relative flex items-center group" class="relative flex items-center group"
> >
<Button <Button
@ -168,7 +170,7 @@ const handleDocumentableClick = () => {
v-if="!compact" v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3" class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
> >
<Policy v-if="showActions" :permissions="['administrator']"> <Policy v-if="showActions" :permissions="responseManagePermissions">
<div class="flex items-center gap-2 sm:gap-5 w-full"> <div class="flex items-center gap-2 sm:gap-5 w-full">
<Button <Button
v-if="status === 'pending'" v-if="status === 'pending'"

View File

@ -6,6 +6,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue'; import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue'; import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { computed } from 'vue'; import { computed } from 'vue';
@ -28,6 +29,7 @@ const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount(); const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding(); const { replaceInstallationName } = useBranding();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@ -56,7 +58,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE') : $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
" "
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''" :subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']" :action-perms="responseManagePermissions"
:show-backdrop="isApproved" :show-backdrop="isApproved"
> >
<template v-if="isApproved" #empty-state-item> <template v-if="isApproved" #empty-state-item>

View File

@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Policy from 'dashboard/components/policy.vue';
const emit = defineEmits(['close', 'createAssistant']); const emit = defineEmits(['close', 'createAssistant']);
@ -105,14 +106,16 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }} {{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p> </p>
</div> </div>
<Button <Policy :permissions="['administrator']">
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')" <Button
color="slate" :label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
icon="i-lucide-plus" color="slate"
size="sm" icon="i-lucide-plus"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3" size="sm"
@click="openCreateAssistantDialog" class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
/> @click="openCreateAssistantDialog"
/>
</Policy>
</div> </div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4"> <div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button <Button

View File

@ -1,5 +1,6 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue'; import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@ -31,6 +32,11 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE], installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
}; };
const knowledgeBaseMeta = {
...meta,
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
};
const metaV2 = { const metaV2 = {
permissions: ['administrator', 'agent'], permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2, featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@ -42,13 +48,13 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'), path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex, component: ResponsesIndex,
name: 'captain_assistants_responses_index', name: 'captain_assistants_responses_index',
meta, meta: knowledgeBaseMeta,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'), path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex, component: DocumentsIndex,
name: 'captain_assistants_documents_index', name: 'captain_assistants_documents_index',
meta, meta: knowledgeBaseMeta,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'), path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -78,7 +84,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'), path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex, component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending', name: 'captain_assistants_responses_pending',
meta, meta: knowledgeBaseMeta,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'), path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -119,7 +125,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:navigationPath'), path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage, component: AssistantsIndexPage,
name: 'captain_assistants_index', name: 'captain_assistants_index',
meta, meta: knowledgeBaseMeta,
}, },
]; ];

View File

@ -6,6 +6,8 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Banner from 'dashboard/components-next/banner/Banner.vue'; import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@ -24,6 +26,7 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const store = useStore(); const store = useStore();
const { isOnChatwootCloud } = useAccount(); const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags'); const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta'); const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords'); const responses = useMapGetter('captainResponses/getRecords');
@ -38,6 +41,10 @@ const searchQuery = ref('');
const { t } = useI18n(); const { t } = useI18n();
const createDialog = ref(null); const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const selectedAssistantId = computed(() => Number(route.params.assistantId)); const selectedAssistantId = computed(() => Number(route.params.assistantId));
@ -206,7 +213,7 @@ onMounted(() => {
<PageLayout <PageLayout
:total-count="responseMeta.totalCount" :total-count="responseMeta.totalCount"
:current-page="responseMeta.page" :current-page="responseMeta.page"
:button-policy="['administrator']" :button-policy="responseManagePermissions"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')" :header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')" :button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching" :is-fetching="isFetching"
@ -247,6 +254,7 @@ onMounted(() => {
<template #subHeader> <template #subHeader>
<BulkSelectBar <BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds" v-model="bulkSelectedIds"
:all-items="responses" :all-items="responses"
:select-all-label="buildSelectedCountLabel" :select-all-label="buildSelectedCountLabel"
@ -293,8 +301,11 @@ onMounted(() => {
:created-at="response.created_at" :created-at="response.created_at"
:updated-at="response.updated_at" :updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)" :is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0" :selectable="
:show-menu="!bulkSelectedIds.has(response.id)" canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
:show-actions="false" :show-actions="false"
@action="handleAction" @action="handleAction"
@navigate="handleNavigationAction" @navigate="handleNavigationAction"

View File

@ -7,6 +7,8 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@ -25,6 +27,7 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const store = useStore(); const store = useStore();
const { isOnChatwootCloud } = useAccount(); const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags'); const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta'); const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords'); const responses = useMapGetter('captainResponses/getRecords');
@ -40,6 +43,10 @@ const searchQuery = ref('');
const { t } = useI18n(); const { t } = useI18n();
const createDialog = ref(null); const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const backUrl = computed(() => ({ const backUrl = computed(() => ({
name: 'captain_assistants_responses_index', name: 'captain_assistants_responses_index',
@ -286,6 +293,7 @@ onMounted(() => {
<template #subHeader> <template #subHeader>
<BulkSelectBar <BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds" v-model="bulkSelectedIds"
:all-items="filteredResponses" :all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel" :select-all-label="buildSelectedCountLabel"
@ -338,9 +346,14 @@ onMounted(() => {
:created-at="response.created_at" :created-at="response.created_at"
:updated-at="response.updated_at" :updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)" :is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0" :selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="false" :show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)" :show-actions="
canManageResponses && !bulkSelectedIds.has(response.id)
"
@action="handleAction" @action="handleAction"
@navigate="handleNavigationAction" @navigate="handleNavigationAction"
@select="handleCardSelect" @select="handleCardSelect"

View File

@ -24,10 +24,6 @@ class Captain::AssistantResponsePolicy < ApplicationPolicy
def manage? def manage?
return true if @account_user.administrator? return true if @account_user.administrator?
if @account_user.custom_role.present? @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || false
return @account_user.custom_role.permissions.include?('knowledge_base_manage')
end
@account_user.agent?
end end
end end

View File

@ -180,16 +180,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Test answer') expect(json_response[:answer]).to eq('Test answer')
end end
it 'creates a new response if the user is an agent' do it 'does not create a new response if the user is an agent without knowledge base permission' do
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses", post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params, params: valid_params,
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
end.to change(Captain::AssistantResponse, :count).by(1) end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:forbidden)
expect(json_response[:question]).to eq('Test question?')
end end
it 'creates a new response if the user has a custom role with knowledge base permission' do it 'creates a new response if the user has a custom role with knowledge base permission' do
@ -254,7 +253,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
end end
end end
@ -281,12 +279,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Updated answer') expect(json_response[:answer]).to eq('Updated answer')
end end
it 'updates the response if the user is an agent' do it 'does not update the response if the user is an agent without knowledge base permission' do
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}", patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params, params: update_params,
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
expect(response).to have_http_status(:forbidden)
expect(response_record.reload.question).not_to eq('Updated question?')
end
it 'updates the response if the user has a custom role with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response[:question]).to eq('Updated question?') expect(json_response[:question]).to eq('Updated question?')
end end
@ -315,11 +326,24 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) } let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
it 'deletes the response' do it 'does not delete the response if the user is an agent without knowledge base permission' do
expect do expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}", delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:forbidden)
end
it 'deletes the response if the user has a custom role with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent_with_custom_role.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-1) end.to change(Captain::AssistantResponse, :count).by(-1)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)

View File

@ -30,19 +30,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
} }
end end
it 'approves the responses and returns the updated records' do it 'does not approve the responses if the user is an agent without knowledge base permission' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions", post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: valid_params, params: valid_params,
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:forbidden)
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(2)
# Verify responses were approved
pending_responses.each do |response| pending_responses.each do |response|
expect(response.reload.status).to eq('approved') expect(response.reload.status).to eq('pending')
end end
end end
end end
@ -56,20 +53,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
} }
end end
it 'deletes the responses and returns an empty array' do it 'does not delete the responses if the user is an agent without knowledge base permission' do
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions", post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: delete_params, params: delete_params,
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
end.to change(Captain::AssistantResponse, :count).by(-2) end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:forbidden)
expect(json_response).to eq([])
# Verify responses were deleted
pending_responses.each do |response| pending_responses.each do |response|
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound) expect(response.reload.status).to eq('pending')
end end
end end
end end