fix(contacts): align contact export permissions (#14601)

Allows contact managers to export and import contacts from the Contacts
page while keeping plain agents blocked. The contacts action menu now
mirrors backend permissions for both export and import.

## Closes

- https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken

## What changed

- Allows Enterprise custom roles with `contact_manage` to pass
`ContactPolicy#export?` and `ContactPolicy#import?`.
- Shows Export and Import to admins and contact managers only.
- Adds Enterprise policy coverage for contact export and import.

## Screenshots

Admin: Export and Import are available.

<img width="3840" height="2160" alt="Admin contact actions with Export
and Import visible"
src="https://github.com/user-attachments/assets/2b2cdaf2-ca8f-470d-be34-31cba68b9dce"
/>

Contact manager: Export and Import are available.

<img width="3840" height="2160" alt="Contact manager contact actions
with Export and Import visible"
src="https://github.com/user-attachments/assets/48fc038b-2e78-4d0c-ba17-a5965641bd88"
/>

Regular agent: Export and Import are hidden.

<img width="3840" height="2160" alt="Regular agent contact actions with
Export and Import hidden"
src="https://github.com/user-attachments/assets/a63b5731-743a-4223-8dab-ce58383067fe"
/>

## How to test

- Sign in as an administrator and open Contacts; the action menu shows
Export and Import.
- Sign in as a custom-role user with `contact_manage`; the action menu
shows Export and Import.
- Sign in as a plain agent; Export and Import are not available and both
APIs remain unauthorized.
This commit is contained in:
Sojan Jose
2026-06-01 13:58:57 +05:30
committed by GitHub
parent a3ffb48a47
commit 1afcd36dee
4 changed files with 66 additions and 15 deletions
@@ -1,34 +1,48 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = [
const contactMenuItems = computed(() => [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
]
: []),
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
+2
View File
@@ -51,3 +51,5 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
end
ContactPolicy.prepend_mod_with('ContactPolicy')
@@ -0,0 +1,9 @@
module Enterprise::ContactPolicy
def export?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
def import?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
subject(:contact_policy) { ContactPolicy }
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
let(:agent) { create(:user) }
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
permissions :export? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
permissions :import? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
end