Compare commits

..

No commits in common. "main" and "codex/hermes-reply-context" have entirely different histories.

9 changed files with 44 additions and 96 deletions

View File

@ -10,7 +10,6 @@ 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: {
@ -72,7 +71,6 @@ 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,
@ -144,7 +142,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="responseManagePermissions" :permissions="['administrator']"
class="relative flex items-center group" class="relative flex items-center group"
> >
<Button <Button
@ -170,7 +168,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="responseManagePermissions"> <Policy v-if="showActions" :permissions="['administrator']">
<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,7 +6,6 @@ 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';
@ -29,7 +28,6 @@ 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');
@ -58,7 +56,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="responseManagePermissions" :action-perms="['administrator']"
:show-backdrop="isApproved" :show-backdrop="isApproved"
> >
<template v-if="isApproved" #empty-state-item> <template v-if="isApproved" #empty-state-item>

View File

@ -6,7 +6,6 @@ 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']);
@ -106,16 +105,14 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }} {{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p> </p>
</div> </div>
<Policy :permissions="['administrator']"> <Button
<Button :label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')" color="slate"
color="slate" icon="i-lucide-plus"
icon="i-lucide-plus" size="sm"
size="sm" class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3" @click="openCreateAssistantDialog"
@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,6 +1,5 @@
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';
@ -32,11 +31,6 @@ 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,
@ -48,13 +42,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: knowledgeBaseMeta, meta,
}, },
{ {
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: knowledgeBaseMeta, meta,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'), path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -84,7 +78,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: knowledgeBaseMeta, meta,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'), path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -125,7 +119,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: knowledgeBaseMeta, meta,
}, },
]; ];

View File

@ -6,8 +6,6 @@ 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';
@ -26,7 +24,6 @@ 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');
@ -41,10 +38,6 @@ 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));
@ -213,7 +206,7 @@ onMounted(() => {
<PageLayout <PageLayout
:total-count="responseMeta.totalCount" :total-count="responseMeta.totalCount"
:current-page="responseMeta.page" :current-page="responseMeta.page"
:button-policy="responseManagePermissions" :button-policy="['administrator']"
: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"
@ -254,7 +247,6 @@ 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"
@ -301,11 +293,8 @@ 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=" :selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
canManageResponses && :show-menu="!bulkSelectedIds.has(response.id)"
(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,8 +7,6 @@ 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';
@ -27,7 +25,6 @@ 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');
@ -43,10 +40,6 @@ 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',
@ -293,7 +286,6 @@ 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"
@ -346,14 +338,9 @@ 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=" :selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="false" :show-menu="false"
:show-actions=" :show-actions="!bulkSelectedIds.has(response.id)"
canManageResponses && !bulkSelectedIds.has(response.id)
"
@action="handleAction" @action="handleAction"
@navigate="handleNavigationAction" @navigate="handleNavigationAction"
@select="handleCardSelect" @select="handleCardSelect"

View File

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

View File

@ -180,15 +180,16 @@ 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 'does not create a new response if the user is an agent without knowledge base permission' do it 'creates a new response if the user is an agent' 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.not_to change(Captain::AssistantResponse, :count) end.to change(Captain::AssistantResponse, :count).by(1)
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:success)
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
@ -253,6 +254,7 @@ 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
@ -279,25 +281,12 @@ 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 'does not update the response if the user is an agent without knowledge base permission' do it 'updates the response if the user is an agent' 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
@ -326,24 +315,11 @@ 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 'does not delete the response if the user is an agent without knowledge base permission' do it 'deletes the response' 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,16 +30,19 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
} }
end end
it 'does not approve the responses if the user is an agent without knowledge base permission' do it 'approves the responses and returns the updated records' 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(:forbidden) expect(response).to have_http_status(:ok)
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('pending') expect(response.reload.status).to eq('approved')
end end
end end
end end
@ -53,18 +56,20 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
} }
end end
it 'does not delete the responses if the user is an agent without knowledge base permission' do it 'deletes the responses and returns an empty array' 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.not_to change(Captain::AssistantResponse, :count) end.to change(Captain::AssistantResponse, :count).by(-2)
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
# Verify responses were deleted
pending_responses.each do |response| pending_responses.each do |response|
expect(response.reload.status).to eq('pending') expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
end end