Compare commits

...

1 Commits

Author SHA1 Message Date
Rodribm10
fc0105785b
feat: allow FAQ management via knowledge-base role
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
Allow Captain FAQ management through the existing knowledge_base_manage custom role while keeping plain agents read-only for FAQ actions.
2026-06-10 13:45:20 -03:00
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 Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
const props = defineProps({
id: {
@ -71,6 +72,7 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const modelValue = computed({
get: () => props.isSelected,
@ -142,7 +144,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="responseManagePermissions"
class="relative flex items-center group"
>
<Button
@ -168,7 +170,7 @@ const handleDocumentableClick = () => {
v-if="!compact"
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">
<Button
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 FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { computed } from 'vue';
@ -28,6 +29,7 @@ const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const onClick = () => {
emit('click');
@ -56,7 +58,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:action-perms="responseManagePermissions"
:show-backdrop="isApproved"
>
<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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Policy from 'dashboard/components/policy.vue';
const emit = defineEmits(['close', 'createAssistant']);
@ -105,14 +106,16 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
<Policy :permissions="['administrator']">
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</Policy>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button

View File

@ -1,5 +1,6 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@ -31,6 +32,11 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const knowledgeBaseMeta = {
...meta,
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@ -42,13 +48,13 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -78,7 +84,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -119,7 +125,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
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 { debounce } from '@chatwoot/utils';
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 Input from 'dashboard/components-next/input/Input.vue';
@ -24,6 +26,7 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
@ -38,6 +41,10 @@ const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
@ -206,7 +213,7 @@ onMounted(() => {
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:button-policy="responseManagePermissions"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
@ -247,6 +254,7 @@ onMounted(() => {
<template #subHeader>
<BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds"
:all-items="responses"
:select-all-label="buildSelectedCountLabel"
@ -293,8 +301,11 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"

View File

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

View File

@ -24,10 +24,6 @@ class Captain::AssistantResponsePolicy < ApplicationPolicy
def manage?
return true if @account_user.administrator?
if @account_user.custom_role.present?
return @account_user.custom_role.permissions.include?('knowledge_base_manage')
end
@account_user.agent?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || false
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')
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
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: agent.create_new_auth_token,
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(json_response[:question]).to eq('Test question?')
expect(response).to have_http_status(:forbidden)
end
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)
end
end
end
@ -281,12 +279,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Updated answer')
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}",
params: update_params,
headers: agent.create_new_auth_token,
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(json_response[:question]).to eq('Updated question?')
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
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
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent.create_new_auth_token,
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)
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
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",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(2)
expect(response).to have_http_status(:forbidden)
# Verify responses were approved
pending_responses.each do |response|
expect(response.reload.status).to eq('approved')
expect(response.reload.status).to eq('pending')
end
end
end
@ -56,20 +53,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
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
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: delete_params,
headers: agent.create_new_auth_token,
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(json_response).to eq([])
expect(response).to have_http_status(:forbidden)
# Verify responses were deleted
pending_responses.each do |response|
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(response.reload.status).to eq('pending')
end
end
end