chore: Custom Roles to manage permissions [ UI ] (#9865)

In admin settings, this Pr will add the UI for managing custom roles (
ref: https://github.com/chatwoot/chatwoot/pull/9995 ). It also handles
the routing logic changes to accommodate fine-tuned permissions.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sojan Jose
2024-09-17 11:40:11 -07:00
committed by GitHub
parent fba73c7186
commit 58e78621ba
74 changed files with 2423 additions and 558 deletions

View File

@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end
def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')

View File

@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class CustomRole extends ApiClient {
constructor() {
super('custom_roles', { accountScoped: true });
}
}
export default new CustomRole();

View File

@@ -27,6 +27,11 @@ import {
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import IntersectionObserver from './IntersectionObserver.vue';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
export default {
components: {
@@ -204,6 +209,7 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
@@ -243,20 +249,19 @@ export default {
name,
};
},
userPermissions() {
return getUserPermissions(this.currentUser, this.currentAccountId);
},
assigneeTabItems() {
const ASSIGNEE_TYPE_TAB_KEYS = {
me: 'mineCount',
unassigned: 'unAssignedCount',
all: 'allCount',
};
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
return {
return filterItemsByPermission(
ASSIGNEE_TYPE_TAB_PERMISSIONS,
this.userPermissions,
item => item.permissions
).map(({ key, count: countKey }) => ({
key,
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
count,
};
});
count: this.conversationStats[countKey] || 0,
}));
},
showAssigneeInConversationCard() {
return (

View File

@@ -39,6 +39,7 @@ const settings = accountId => ({
'settings_teams_list',
'settings_teams_new',
'sla_list',
'custom_roles_list',
],
menuItems: [
{
@@ -178,6 +179,18 @@ const settings = accountId => ({
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
},
{
icon: 'scan-person',
label: 'CUSTOM_ROLES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/custom-roles/list`),
toStateName: 'custom_roles_list',
isEnterpriseOnly: true,
beta: true,
},
{
icon: 'document-list-clock',
label: 'SLA',

View File

@@ -52,9 +52,13 @@ export default {
{{ account.name }}
</div>
<div
class="text-xs font-medium text-slate-500 dark:text-slate-500 hover:underline-offset-4"
class="text-xs font-medium lowercase text-slate-500 dark:text-slate-500 hover:underline-offset-4"
>
{{ account.role }}
{{
account.custom_role_id
? account.custom_role.name
: account.role
}}
</div>
</label>
</span>

View File

@@ -21,8 +21,11 @@ const activeTabIndex = computed(() => {
});
const onTabChange = selectedTabIndex => {
if (props.items[selectedTabIndex].key !== props.activeTab) {
emit('chatTabChange', props.items[selectedTabIndex].key);
if (selectedTabIndex >= 0 && selectedTabIndex < props.items.length) {
const selectedItem = props.items[selectedTabIndex];
if (selectedItem.key !== props.activeTab) {
emit('chatTabChange', selectedItem.key);
}
}
};
@@ -32,7 +35,8 @@ const keyboardEvents = {
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
onTabChange(0);
} else {
onTabChange(activeTabIndex.value + 1);
const nextIndex = (activeTabIndex.value + 1) % props.items.length;
onTabChange(nextIndex);
}
},
},

View File

@@ -97,6 +97,7 @@ export default {
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
},
setup() {
const {
@@ -346,7 +347,9 @@ export default {
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
if (this.focusOnMount) {
this.focusEditorInputField();
}
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
@@ -383,7 +386,7 @@ export default {
// these drafts can also have a signature, so we need to check if the body is empty
// and handle things accordingly
this.handleEmptyBodyWithSignature();
} else {
} else if (this.focusOnMount) {
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
// the position is set to start, because the signature is added at the end of the body
this.focusEditorInputField('end');

View File

@@ -0,0 +1,53 @@
export const AVAILABLE_CUSTOM_ROLE_PERMISSIONS = [
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
'contact_manage',
'report_manage',
'knowledge_base_manage',
];
export const ROLES = ['agent', 'administrator'];
export const CONVERSATION_PERMISSIONS = [
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
];
export const MANAGE_ALL_CONVERSATION_PERMISSIONS = 'conversation_manage';
export const CONVERSATION_UNASSIGNED_PERMISSIONS =
'conversation_unassigned_manage';
export const CONVERSATION_PARTICIPATING_PERMISSIONS =
'conversation_participating_manage';
export const CONTACT_PERMISSIONS = 'contact_manage';
export const REPORTS_PERMISSIONS = 'report_manage';
export const PORTAL_PERMISSIONS = 'knowledge_base_manage';
export const ASSIGNEE_TYPE_TAB_PERMISSIONS = {
me: {
count: 'mineCount',
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
unassigned: {
count: 'unAssignedCount',
permissions: [
...ROLES,
MANAGE_ALL_CONVERSATION_PERMISSIONS,
CONVERSATION_UNASSIGNED_PERMISSIONS,
],
},
all: {
count: 'allCount',
permissions: [
...ROLES,
MANAGE_ALL_CONVERSATION_PERMISSIONS,
CONVERSATION_PARTICIPATING_PERMISSIONS,
],
},
};

View File

@@ -31,4 +31,5 @@ export const FEATURE_FLAGS = {
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
};

View File

@@ -5,6 +5,11 @@ import {
getAlertAudio,
initOnEvents,
} from 'shared/helpers/AudioNotificationHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js';
const NOTIFICATION_TIME = 30000;
@@ -14,12 +19,13 @@ class DashboardAudioNotificationHelper {
this.audioAlertType = 'none';
this.playAlertOnlyWhenHidden = true;
this.alertIfUnreadConversationExist = false;
this.currentUser = null;
this.currentUserId = null;
this.audioAlertTone = 'ding';
}
setInstanceValues = ({
currentUserId,
currentUser,
alwaysPlayAudioAlert,
alertIfUnreadConversationExist,
audioAlertType,
@@ -28,7 +34,8 @@ class DashboardAudioNotificationHelper {
this.audioAlertType = audioAlertType;
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.currentUserId = currentUserId;
this.currentUser = currentUser;
this.currentUserId = currentUser.id;
this.audioAlertTone = audioAlertTone;
initOnEvents.forEach(e => {
document.addEventListener(e, this.onAudioListenEvent, false);
@@ -112,6 +119,20 @@ class DashboardAudioNotificationHelper {
return message?.sender_id === this.currentUserId;
};
isUserHasConversationPermission = () => {
const currentAccountId = window.WOOT.$store.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(
this.currentUser,
currentAccountId
);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
};
shouldNotifyOnMessage = message => {
if (this.audioAlertType === 'mine') {
return this.isConversationAssignedToCurrentUser(message);
@@ -120,6 +141,11 @@ class DashboardAudioNotificationHelper {
};
onNewMessage = message => {
// If the user does not have the permission to view the conversation, then dismiss the alert
if (!this.isUserHasConversationPermission()) {
return;
}
// If the message is sent by the current user or the
// correct notification is not enabled, then dismiss the alert
if (

View File

@@ -41,3 +41,32 @@ export const buildPermissionsFromRouter = (routes = []) =>
return acc;
}, {});
/**
* Filters and transforms items based on user permissions.
*
* @param {Object} items - An object containing items to be filtered.
* @param {Array} userPermissions - Array of permissions the user has.
* @param {Function} getPermissions - Function to extract required permissions from an item.
* @param {Function} [transformItem] - Optional function to transform each item after filtering.
* @returns {Array} Filtered and transformed items.
*/
export const filterItemsByPermission = (
items,
userPermissions,
getPermissions,
transformItem = (key, item) => ({ key, ...item })
) => {
// Helper function to check if an item has the required permissions
const hasRequiredPermissions = item => {
const requiredPermissions = getPermissions(item);
return (
requiredPermissions.length === 0 ||
hasPermissions(requiredPermissions, userPermissions)
);
};
return Object.entries(items)
.filter(([, item]) => hasRequiredPermissions(item)) // Keep only items with required permissions
.map(([key, item]) => transformItem(key, item)); // Transform each remaining item
};

View File

@@ -4,11 +4,39 @@ import {
getCurrentAccount,
} from './permissionsHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
REPORTS_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
export const routeIsAccessibleFor = (route, userPermissions = []) => {
const { meta: { permissions: routePermissions = [] } = {} } = route;
return hasPermissions(routePermissions, userPermissions);
};
export const defaultRedirectPage = (to, permissions) => {
const { accountId } = to.params;
const permissionRoutes = [
{
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
path: 'dashboard',
},
{ permissions: [CONTACT_PERMISSIONS], path: 'contacts' },
{ permissions: [REPORTS_PERMISSIONS], path: 'reports/overview' },
{ permissions: [PORTAL_PERMISSIONS], path: 'portals' },
];
const route = permissionRoutes.find(({ permissions: routePermissions }) =>
hasPermissions(routePermissions, permissions)
);
return `accounts/${accountId}/${route ? route.path : 'dashboard'}`;
};
const validateActiveAccountRoutes = (to, user) => {
// If the current account is active, then check for the route permissions
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
@@ -22,7 +50,7 @@ const validateActiveAccountRoutes = (to, user) => {
const isAccessible = routeIsAccessibleFor(to, userPermissions);
// If the route is not accessible for the user, return to dashboard screen
return isAccessible ? null : accountDashboardURL;
return isAccessible ? null : defaultRedirectPage(to, userPermissions);
};
export const validateLoggedInRoutes = (to, user) => {

View File

@@ -26,7 +26,7 @@ const initializeAudioAlerts = user => {
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({
currentUserId: user.id,
currentUser: user,
audioAlertType: audioAlertType || 'none',
audioAlertTone: audioAlertTone || 'ding',
alwaysPlayAudioAlert: alwaysPlayAudioAlert || false,

View File

@@ -3,6 +3,7 @@ import {
getCurrentAccount,
getUserPermissions,
hasPermissions,
filterItemsByPermission,
} from '../permissionsHelper';
describe('#getCurrentAccount', () => {
@@ -105,3 +106,113 @@ describe('buildPermissionsFromRouter', () => {
}).toThrow("The route doesn't have the required permissions defined");
});
});
describe('filterItemsByPermission', () => {
const items = {
item1: { name: 'Item 1', permissions: ['agent', 'administrator'] },
item2: {
name: 'Item 2',
permissions: [
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
],
},
item3: { name: 'Item 3', permissions: ['contact_manage'] },
item4: { name: 'Item 4', permissions: ['report_manage'] },
item5: { name: 'Item 5', permissions: ['knowledge_base_manage'] },
item6: {
name: 'Item 6',
permissions: [
'agent',
'administrator',
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
'contact_manage',
'report_manage',
'knowledge_base_manage',
],
},
item7: { name: 'Item 7', permissions: [] },
};
const getPermissions = item => item.permissions;
it('filters items based on user permissions', () => {
const userPermissions = ['agent', 'contact_manage', 'report_manage'];
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions
);
expect(result).toHaveLength(5);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item1', name: 'Item 1' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item3', name: 'Item 3' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item4', name: 'Item 4' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item6', name: 'Item 6' })
);
});
it('includes items with empty permissions', () => {
const userPermissions = [];
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions
);
expect(result).toHaveLength(1);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item7', name: 'Item 7' })
);
});
it('uses custom transform function when provided', () => {
const userPermissions = ['agent', 'contact_manage'];
const customTransform = (key, item) => ({ id: key, title: item.name });
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions,
customTransform
);
expect(result).toHaveLength(4);
expect(result).toContainEqual({ id: 'item1', title: 'Item 1' });
expect(result).toContainEqual({ id: 'item3', title: 'Item 3' });
expect(result).toContainEqual({ id: 'item6', title: 'Item 6' });
});
it('handles empty items object', () => {
const result = filterItemsByPermission({}, ['agent'], getPermissions);
expect(result).toHaveLength(0);
});
it('handles custom getPermissions function', () => {
const customItems = {
item1: { name: 'Item 1', requiredPerms: ['agent', 'administrator'] },
item2: { name: 'Item 2', requiredPerms: ['contact_manage'] },
};
const customGetPermissions = item => item.requiredPerms;
const result = filterItemsByPermission(
customItems,
['agent'],
customGetPermissions
);
expect(result).toHaveLength(1);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item1', name: 'Item 1' })
);
});
});

View File

@@ -1,6 +1,7 @@
import {
getConversationDashboardRoute,
isAConversationRoute,
defaultRedirectPage,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
@@ -14,6 +15,57 @@ describe('#routeIsAccessibleFor', () => {
});
});
describe('#defaultRedirectPage', () => {
const to = {
params: { accountId: '2' },
fullPath: '/app/accounts/2/dashboard',
name: 'home',
};
it('should return dashboard route for users with conversation permissions', () => {
const permissions = ['conversation_manage', 'agent'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return contacts route for users with contact permissions', () => {
const permissions = ['contact_manage'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/contacts');
});
it('should return reports route for users with report permissions', () => {
const permissions = ['report_manage'];
expect(defaultRedirectPage(to, permissions)).toBe(
'accounts/2/reports/overview'
);
});
it('should return portals route for users with portal permissions', () => {
const permissions = ['knowledge_base_manage'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/portals');
});
it('should return dashboard route as default for users with custom roles', () => {
const permissions = ['custom_role'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return dashboard route for users with administrator role', () => {
const permissions = ['administrator'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return dashboard route for users with multiple permissions', () => {
const permissions = [
'contact_manage',
'custom_role',
'conversation_manage',
'agent',
'administrator',
];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
});
describe('#validateLoggedInRoutes', () => {
describe('when account access is missing', () => {
it('should return the login route', () => {

View File

@@ -18,7 +18,8 @@
"STATUS": "Status",
"ACTIONS": "Actions",
"VERIFIED": "Verified",
"VERIFICATION_PENDING": "Verification Pending"
"VERIFICATION_PENDING": "Verification Pending",
"AVAILABLE_CUSTOM_ROLE": "Available custom role permissions"
},
"ADD": {
"TITLE": "Add agent to your team",

View File

@@ -0,0 +1,86 @@
{
"CUSTOM_ROLE": {
"HEADER": "Custom Roles",
"LEARN_MORE": "Learn more about custom roles",
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
"HEADER_BTN_TXT": "Add custom role",
"LOADING": "Fetching custom roles...",
"SEARCH_404": "There are no items matching this query.",
"PAYWALL": {
"TITLE": "Upgrade to create custom roles",
"AVAILABLE_ON": "The custom role feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The custom role feature is only available in the paid plans.",
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"LIST": {
"404": "There are no custom roles available in this account.",
"TITLE": "Manage custom roles",
"DESC": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
"TABLE_HEADER": ["Name", "Description", "Permissions", "Actions"]
},
"PERMISSIONS": {
"CONVERSATION_MANAGE": "Manage all conversations",
"CONVERSATION_UNASSIGNED_MANAGE": "Manage unassigned conversations and those assigned to them",
"CONVERSATION_PARTICIPATING_MANAGE": "Manage participating conversations and those assigned to them",
"CONTACT_MANAGE": "Manage contacts",
"REPORT_MANAGE": "Manage reports",
"KNOWLEDGE_BASE_MANAGE": "Manage knowledge base"
},
"FORM": {
"NAME": {
"LABEL": "Name",
"PLACEHOLDER": "Please enter a name.",
"ERROR": "Name is required."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Please enter a description.",
"ERROR": "Description is required."
},
"PERMISSIONS": {
"LABEL": "Permissions",
"ERROR": "Permissions are required."
},
"CANCEL_BUTTON_TEXT": "Cancel",
"API": {
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
}
},
"ADD": {
"TITLE": "Add custom role",
"DESC": " Custom roles allows you to create roles with specific permissions and access levels to suit the requirements of the organization.",
"SUBMIT": "Submit",
"API": {
"SUCCESS_MESSAGE": "Custom role added successfully."
}
},
"EDIT": {
"BUTTON_TEXT": "Edit",
"TITLE": "Edit custom role",
"DESC": " Custom roles allows you to create roles with specific permissions and access levels to suit the requirements of the organization.",
"SUBMIT": "Update",
"API": {
"SUCCESS_MESSAGE": "Custom role updated successfully."
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"API": {
"SUCCESS_MESSAGE": "Custom role deleted successfully.",
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"CONFIRM": {
"TITLE": "Confirm deletion",
"MESSAGE": "Are you sure to delete ",
"YES": "Yes, delete ",
"NO": "No, keep "
}
}
}
}

View File

@@ -33,6 +33,7 @@ import sla from './sla.json';
import inbox from './inbox.json';
import general from './general.json';
import datePicker from './datePicker.json';
import customRole from './customRole.json';
export default {
...advancedFilters,
@@ -70,4 +71,5 @@ export default {
...inbox,
...general,
...datePicker,
...customRole,
};

View File

@@ -145,11 +145,7 @@
},
"AVAILABILITY": {
"LABEL": "Availability",
"STATUSES_LIST": [
"Online",
"Busy",
"Offline"
],
"STATUSES_LIST": ["Online", "Busy", "Offline"],
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
},
@@ -277,6 +273,7 @@
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SLA": "SLA",
"CUSTOM_ROLES": "Custom Roles",
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",

View File

@@ -4,6 +4,16 @@ import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import Policy from 'dashboard/components/policy.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -14,16 +24,22 @@ export default {
SearchResultContactsList,
SearchResultConversationsList,
SearchResultMessagesList,
Policy,
},
data() {
return {
selectedTab: 'all',
query: '',
contactPermissions: CONTACT_PERMISSIONS,
conversationPermissions: CONVERSATION_PERMISSIONS,
rolePermissions: ROLES,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
contactRecords: 'conversationSearch/getContactRecords',
conversationRecords: 'conversationSearch/getConversationRecords',
messageRecords: 'conversationSearch/getMessageRecords',
@@ -59,34 +75,76 @@ export default {
filterMessages() {
return this.selectedTab === 'messages' || this.isSelectedTabAll;
},
userPermissions() {
return getUserPermissions(this.currentUser, this.currentAccountId);
},
totalSearchResultsCount() {
return (
this.contacts.length + this.conversations.length + this.messages.length
const permissionCounts = {
contacts: {
permissions: [...this.rolePermissions, this.contactPermissions],
count: () => this.contacts.length,
},
conversations: {
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
count: () => this.conversations.length + this.messages.length,
},
};
const filteredCounts = filterItemsByPermission(
permissionCounts,
this.userPermissions,
item => item.permissions,
(_, item) => item.count
);
return filteredCounts.reduce((total, count) => total + count(), 0);
},
tabs() {
return [
{
const allTabsConfig = {
all: {
key: 'all',
name: this.$t('SEARCH.TABS.ALL'),
count: this.totalSearchResultsCount,
permissions: [
this.contactPermissions,
...this.rolePermissions,
...this.conversationPermissions,
],
},
{
contacts: {
key: 'contacts',
name: this.$t('SEARCH.TABS.CONTACTS'),
count: this.contacts.length,
permissions: [...this.rolePermissions, this.contactPermissions],
},
{
conversations: {
key: 'conversations',
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
count: this.conversations.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
{
messages: {
key: 'messages',
name: this.$t('SEARCH.TABS.MESSAGES'),
count: this.messages.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
];
};
return filterItemsByPermission(
allTabsConfig,
this.userPermissions,
item => item.permissions
);
},
activeTabIndex() {
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab);
@@ -165,6 +223,7 @@ export default {
</header>
<div class="search-results">
<div v-if="showResultsSection">
<Policy :permissions="[...rolePermissions, contactPermissions]">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
@@ -172,7 +231,11 @@ export default {
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
@@ -180,7 +243,11 @@ export default {
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
@@ -188,6 +255,7 @@ export default {
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
</div>
<div v-else-if="showEmptySearchResults" class="empty">
<fluent-icon icon="info" size="16px" class="icon" />

View File

@@ -1,5 +1,10 @@
/* eslint-disable storybook/default-exports */
import { frontendURL } from '../../helper/URLHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
const SearchView = () => import('./components/SearchView.vue');
@@ -8,7 +13,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/search'),
name: 'search',
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
},
component: SearchView,
},

View File

@@ -8,7 +8,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/contacts'),
name: 'contacts_dashboard',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactsView,
},
@@ -16,7 +16,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/contacts/custom_view/:id'),
name: 'contacts_segments_dashboard',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactsView,
props: route => {
@@ -27,7 +27,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/labels/:label/contacts'),
name: 'contacts_labels_dashboard',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactsView,
props: route => {
@@ -38,7 +38,7 @@ export const routes = [
path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contact_profile_dashboard',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactManageView,
props: route => {

View File

@@ -2,13 +2,21 @@
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView.vue');
const CONVERSATION_PERMISSIONS = [
'administrator',
'agent',
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
];
export default {
routes: [
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => {
@@ -19,7 +27,7 @@ export default {
path: frontendURL('accounts/:accountId/conversations/:conversation_id'),
name: 'inbox_conversation',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
@@ -30,7 +38,7 @@ export default {
path: frontendURL('accounts/:accountId/inbox/:inbox_id'),
name: 'inbox_dashboard',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
@@ -43,7 +51,7 @@ export default {
),
name: 'conversation_through_inbox',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
@@ -57,7 +65,7 @@ export default {
path: frontendURL('accounts/:accountId/label/:label'),
name: 'label_conversations',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ label: route.params.label }),
@@ -68,7 +76,7 @@ export default {
),
name: 'conversations_through_label',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
@@ -80,7 +88,7 @@ export default {
path: frontendURL('accounts/:accountId/team/:teamId'),
name: 'team_conversations',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ teamId: route.params.teamId }),
@@ -91,7 +99,7 @@ export default {
),
name: 'conversations_through_team',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
@@ -103,7 +111,7 @@ export default {
path: frontendURL('accounts/:accountId/custom_view/:id'),
name: 'folder_conversations',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
@@ -114,7 +122,7 @@ export default {
),
name: 'conversations_through_folders',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
@@ -126,7 +134,7 @@ export default {
path: frontendURL('accounts/:accountId/mentions/conversations'),
name: 'conversation_mentions',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'mention' }),
@@ -137,7 +145,7 @@ export default {
),
name: 'conversation_through_mentions',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
@@ -149,7 +157,7 @@ export default {
path: frontendURL('accounts/:accountId/unattended/conversations'),
name: 'conversation_unattended',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'unattended' }),
@@ -160,7 +168,7 @@ export default {
),
name: 'conversation_through_unattended',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
@@ -172,7 +180,7 @@ export default {
path: frontendURL('accounts/:accountId/participating/conversations'),
name: 'conversation_participating',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'participating' }),
@@ -183,7 +191,7 @@ export default {
),
name: 'conversation_through_participating',
meta: {
permissions: ['administrator', 'agent'],
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({

View File

@@ -38,7 +38,7 @@ export default {
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'custom_role'],
},
component: Suspended,
},

View File

@@ -33,7 +33,7 @@ const portalRoutes = [
path: getPortalRoute(''),
name: 'default_portal_articles',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
component: DefaultPortalArticles,
},
@@ -41,7 +41,7 @@ const portalRoutes = [
path: getPortalRoute('all'),
name: 'list_all_portals',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllPortals,
},
@@ -54,7 +54,7 @@ const portalRoutes = [
name: 'new_portal_information',
component: PortalDetails,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
@@ -62,7 +62,7 @@ const portalRoutes = [
name: 'portal_customization',
component: PortalCustomization,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
@@ -70,7 +70,7 @@ const portalRoutes = [
name: 'portal_finish',
component: PortalSettingsFinish,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
],
@@ -79,14 +79,14 @@ const portalRoutes = [
path: getPortalRoute(':portalSlug'),
name: 'portalSlug',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ShowPortal,
},
{
path: getPortalRoute(':portalSlug/edit'),
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: EditPortal,
children: [
@@ -95,7 +95,7 @@ const portalRoutes = [
name: 'edit_portal_information',
component: EditPortalBasic,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
@@ -103,7 +103,7 @@ const portalRoutes = [
name: 'edit_portal_customization',
component: EditPortalCustomization,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
@@ -111,14 +111,14 @@ const portalRoutes = [
name: 'edit_portal_locales',
component: EditPortalLocales,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'knowledge_base_manage'],
},
},
{
path: 'categories',
name: 'list_all_locale_categories',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllCategories,
},
@@ -131,7 +131,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles'),
name: 'list_all_locale_articles',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
@@ -139,7 +139,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles/new'),
name: 'new_article',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: NewArticle,
},
@@ -147,7 +147,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles/mine'),
name: 'list_mine_articles',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
@@ -155,7 +155,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles/archived'),
name: 'list_archived_articles',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
@@ -164,7 +164,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles/draft'),
name: 'list_draft_articles',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
@@ -173,7 +173,7 @@ const articleRoutes = [
path: getPortalRoute(':portalSlug/:locale/articles/:articleSlug'),
name: 'edit_article',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: EditArticle,
},
@@ -184,7 +184,7 @@ const categoryRoutes = [
path: getPortalRoute(':portalSlug/:locale/categories'),
name: 'all_locale_categories',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllCategories,
},
@@ -192,7 +192,7 @@ const categoryRoutes = [
path: getPortalRoute(':portalSlug/:locale/categories/new'),
name: 'new_category_in_locale',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: NewCategory,
},
@@ -200,7 +200,7 @@ const categoryRoutes = [
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'),
name: 'show_category',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListAllArticles,
},
@@ -210,7 +210,7 @@ const categoryRoutes = [
),
name: 'show_category_articles',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: ListCategoryArticles,
},
@@ -218,7 +218,7 @@ const categoryRoutes = [
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'),
name: 'edit_category',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
},
component: EditCategory,
},

View File

@@ -2,6 +2,10 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
const InboxListView = () => import('./InboxList.vue');
const InboxDetailView = () => import('./InboxView.vue');
const InboxEmptyStateView = () => import('./InboxEmptyState.vue');
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
export const routes = [
{
@@ -13,7 +17,7 @@ export const routes = [
name: 'inbox_view',
component: InboxEmptyStateView,
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
{
@@ -21,7 +25,7 @@ export const routes = [
name: 'inbox_view_conversation',
component: InboxDetailView,
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
],

View File

@@ -19,7 +19,7 @@ export const routes = [
name: 'notifications_index',
component: NotificationsView,
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'custom_role'],
},
},
],

View File

@@ -1,68 +1,85 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import { required, email } from '@vuelidate/validators';
import WootSubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
export default {
props: {
onClose: {
type: Function,
default: () => {},
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
agentName: '',
agentEmail: '',
agentType: 'agent',
vertical: 'bottom',
horizontal: 'center',
roles: [
{
name: 'administrator',
label: this.$t('AGENT_MGMT.AGENT_TYPES.ADMINISTRATOR'),
},
{
name: 'agent',
label: this.$t('AGENT_MGMT.AGENT_TYPES.AGENT'),
},
],
show: true,
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const agentName = ref('');
const agentEmail = ref('');
const selectedRoleId = ref('agent');
const rules = {
agentName: { required },
agentEmail: { required, email },
selectedRoleId: { required },
};
},
computed: {
...mapGetters({
uiFlags: 'agents/getUIFlags',
}),
},
validations: {
agentName: {
required,
minLength: minLength(1),
},
agentEmail: {
required,
email,
},
agentType: {
required,
},
},
methods: {
async addAgent() {
try {
await this.$store.dispatch('agents/create', {
name: this.agentName,
email: this.agentEmail,
role: this.agentType,
const v$ = useVuelidate(rules, {
agentName,
agentEmail,
selectedRoleId,
});
useAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
const uiFlags = useMapGetter('agents/getUIFlags');
const getCustomRoles = useMapGetter('customRole/getCustomRoles');
const roles = computed(() => {
const defaultRoles = [
{
id: 'administrator',
name: 'administrator',
label: t('AGENT_MGMT.AGENT_TYPES.ADMINISTRATOR'),
},
{
id: 'agent',
name: 'agent',
label: t('AGENT_MGMT.AGENT_TYPES.AGENT'),
},
];
const customRoles = getCustomRoles.value.map(role => ({
id: role.id,
name: `custom_${role.id}`,
label: role.name,
}));
return [...defaultRoles, ...customRoles];
});
const selectedRole = computed(() =>
roles.value.find(
role =>
role.id === selectedRoleId.value || role.name === selectedRoleId.value
)
);
const addAgent = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
const payload = {
name: agentName.value,
email: agentEmail.value,
};
if (selectedRole.value.name.startsWith('custom_')) {
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
}
await store.dispatch('agents/create', payload);
useAlert(t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
const {
response: {
@@ -76,29 +93,22 @@ export default {
let errorMessage = '';
if (error?.response?.status === 422 && !attributes.includes('base')) {
errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
errorMessage = t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
} else {
errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
errorMessage = t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
}
useAlert(errorResponse || attrError || errorMessage);
}
},
},
};
</script>
<template>
<woot-modal :show.sync="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('AGENT_MGMT.ADD.TITLE')"
:header-content="$t('AGENT_MGMT.ADD.DESC')"
/>
<form
class="flex flex-col items-start w-full"
@submit.prevent="addAgent()"
>
<form class="flex flex-col items-start w-full" @submit.prevent="addAgent">
<div class="w-full">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.NAME.LABEL') }}
@@ -110,47 +120,45 @@ export default {
/>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.agentType.$error }">
<label :class="{ error: v$.selectedRoleId.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.AGENT_TYPE.LABEL') }}
<select v-model="agentType">
<option v-for="role in roles" :key="role.name" :value="role.name">
<select v-model="selectedRoleId" @change="v$.selectedRoleId.$touch">
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.label }}
</option>
</select>
<span v-if="v$.agentType.$error" class="message">
<span v-if="v$.selectedRoleId.$error" class="message">
{{ $t('AGENT_MGMT.ADD.FORM.AGENT_TYPE.ERROR') }}
</span>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.agentEmail.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.EMAIL.LABEL') }}
<input
v-model.trim="agentEmail"
type="text"
type="email"
:placeholder="$t('AGENT_MGMT.ADD.FORM.EMAIL.PLACEHOLDER')"
@input="v$.agentEmail.$touch"
/>
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<div class="w-full">
<woot-submit-button
:disabled="
v$.agentEmail.$invalid ||
v$.agentName.$invalid ||
uiFlags.isCreating
"
<WootSubmitButton
:disabled="v$.$invalid || uiFlags.isCreating"
:button-text="$t('AGENT_MGMT.ADD.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="onClose">
<button class="button clear" @click.prevent="emit('close')">
{{ $t('AGENT_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</woot-modal>
</template>

View File

@@ -1,21 +1,15 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton.vue';
import Modal from '../../../../components/Modal.vue';
import WootSubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
export default {
components: {
WootSubmitButton,
Modal,
},
props: {
const props = defineProps({
id: {
type: Number,
required: true,
@@ -36,99 +30,123 @@ export default {
type: String,
default: '',
},
onClose: {
type: Function,
required: true,
customRoleId: {
type: Number,
default: null,
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
roles: [
{
name: 'administrator',
label: this.$t('AGENT_MGMT.AGENT_TYPES.ADMINISTRATOR'),
},
{
name: 'agent',
label: this.$t('AGENT_MGMT.AGENT_TYPES.AGENT'),
},
],
agentName: this.name,
agentAvailability: this.availability,
agentType: this.type,
agentCredentials: {
email: this.email,
},
show: true,
});
const emit = defineEmits(['close']);
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const store = useStore();
const { t } = useI18n();
const agentName = ref(props.name);
const agentAvailability = ref(props.availability);
const selectedRoleId = ref(props.customRoleId || props.type);
const agentCredentials = ref({ email: props.email });
const rules = {
agentName: { required, minLength: minLength(1) },
selectedRoleId: { required },
agentAvailability: { required },
};
const v$ = useVuelidate(rules, {
agentName,
selectedRoleId,
agentAvailability,
});
const pageTitle = computed(
() => `${t('AGENT_MGMT.EDIT.TITLE')} - ${props.name}`
);
const uiFlags = useMapGetter('agents/getUIFlags');
const getCustomRoles = useMapGetter('customRole/getCustomRoles');
const roles = computed(() => {
const defaultRoles = [
{
id: 'administrator',
name: 'administrator',
label: t('AGENT_MGMT.AGENT_TYPES.ADMINISTRATOR'),
},
validations: {
agentName: {
required,
minLength: minLength(1),
{
id: 'agent',
name: 'agent',
label: t('AGENT_MGMT.AGENT_TYPES.AGENT'),
},
agentType: {
required,
},
agentAvailability: {
required,
},
},
computed: {
pageTitle() {
return `${this.$t('AGENT_MGMT.EDIT.TITLE')} - ${this.name}`;
},
...mapGetters({
uiFlags: 'agents/getUIFlags',
}),
availabilityStatuses() {
return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map(
];
const customRoles = getCustomRoles.value.map(role => ({
id: role.id,
name: `custom_${role.id}`,
label: role.name,
}));
return [...defaultRoles, ...customRoles];
});
const selectedRole = computed(() =>
roles.value.find(
role =>
role.id === selectedRoleId.value || role.name === selectedRoleId.value
)
);
const availabilityStatuses = computed(() =>
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map(
(statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
disabled: props.availability === AVAILABILITY_STATUS_KEYS[index],
})
)
);
},
},
methods: {
async editAgent() {
const editAgent = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
await this.$store.dispatch('agents/update', {
id: this.id,
name: this.agentName,
role: this.agentType,
availability: this.agentAvailability,
});
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
const payload = {
id: props.id,
name: agentName.value,
availability: agentAvailability.value,
};
if (selectedRole.value.name.startsWith('custom_')) {
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
payload.custom_role_id = null;
}
},
async resetPassword() {
await store.dispatch('agents/update', payload);
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const resetPassword = async () => {
try {
await Auth.resetPassword(this.agentCredentials);
useAlert(
this.$t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_SUCCESS_MESSAGE')
);
await Auth.resetPassword(agentCredentials.value);
useAlert(t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.PASSWORD_RESET.ERROR_MESSAGE'));
useAlert(t('AGENT_MGMT.EDIT.PASSWORD_RESET.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<Modal :show.sync="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="pageTitle" />
<form class="w-full" @submit.prevent="editAgent()">
<form class="w-full" @submit.prevent="editAgent">
<div class="w-full">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.EDIT.FORM.NAME.LABEL') }}
@@ -142,14 +160,14 @@ export default {
</div>
<div class="w-full">
<label :class="{ error: v$.agentType.$error }">
<label :class="{ error: v$.selectedRoleId.$error }">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_TYPE.LABEL') }}
<select v-model="agentType">
<option v-for="role in roles" :key="role.name" :value="role.name">
<select v-model="selectedRoleId" @change="v$.selectedRoleId.$touch">
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.label }}
</option>
</select>
<span v-if="v$.agentType.$error" class="message">
<span v-if="v$.selectedRoleId.$error" class="message">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_TYPE.ERROR') }}
</span>
</label>
@@ -158,13 +176,16 @@ export default {
<div class="w-full">
<label :class="{ error: v$.agentAvailability.$error }">
{{ $t('PROFILE_SETTINGS.FORM.AVAILABILITY.LABEL') }}
<select v-model="agentAvailability">
<option
v-for="role in availabilityStatuses"
:key="role.value"
:value="role.value"
<select
v-model="agentAvailability"
@change="v$.agentAvailability.$touch"
>
{{ role.label }}
<option
v-for="status in availabilityStatuses"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</option>
</select>
<span v-if="v$.agentAvailability.$error" class="message">
@@ -172,18 +193,15 @@ export default {
</span>
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<div class="w-[50%]">
<WootSubmitButton
:disabled="
v$.agentType.$invalid ||
v$.agentName.$invalid ||
uiFlags.isUpdating
"
:disabled="v$.$invalid || uiFlags.isUpdating"
:button-text="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
:loading="uiFlags.isUpdating"
/>
<button class="button clear" @click.prevent="onClose">
<button class="button clear" @click.prevent="emit('close')">
{{ $t('AGENT_MGMT.EDIT.CANCEL_BUTTON_TEXT') }}
</button>
</div>
@@ -199,5 +217,4 @@ export default {
</div>
</form>
</div>
</Modal>
</template>

View File

@@ -3,7 +3,11 @@ import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import {
useStoreGetters,
useStore,
useMapGetter,
} from 'dashboard/composables/store';
import AddAgent from './AddAgent.vue';
import EditAgent from './EditAgent.vue';
@@ -34,11 +38,32 @@ const deleteMessage = computed(() => {
const agentList = computed(() => getters['agents/getAgents'].value);
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
const customRoles = useMapGetter('customRole/getCustomRoles');
onMounted(() => {
store.dispatch('agents/get');
store.dispatch('customRole/getCustomRole');
});
const findCustomRole = agent =>
customRoles.value.find(role => role.id === agent.custom_role_id);
const getAgentRoleName = agent => {
if (!agent.custom_role_id) {
return t(`AGENT_MGMT.AGENT_TYPES.${agent.role.toUpperCase()}`);
}
const customRole = findCustomRole(agent);
return customRole ? customRole.name : '';
};
const getAgentRolePermissions = agent => {
if (!agent.custom_role_id) {
return [];
}
const customRole = findCustomRole(agent);
return customRole?.permissions || [];
};
const verifiedAdministrators = computed(() => {
return agentList.value.filter(
agent => agent.role === 'administrator' && agent.confirmed
@@ -63,6 +88,7 @@ const showDeleteAction = agent => {
}
return true;
};
const showAlertMessage = message => {
loading.value[currentAgent.value.id] = false;
currentAgent.value = {};
@@ -124,7 +150,7 @@ const confirmDeletion = () => {
>
<template #actions>
<woot-button
class="button nice rounded-md"
class="rounded-md button nice"
icon="add-circle"
@click="openAddPopup"
>
@@ -140,7 +166,7 @@ const confirmDeletion = () => {
>
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<div class="flex flex-row items-center gap-4">
<Thumbnail
:src="agent.thumbnail"
:username="agent.name"
@@ -156,9 +182,39 @@ const confirmDeletion = () => {
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span class="block font-medium capitalize">
{{ $t(`AGENT_MGMT.AGENT_TYPES.${agent.role.toUpperCase()}`) }}
<td class="relative py-4 ltr:pr-4 rtl:pl-4">
<span
class="block font-medium w-fit"
:class="{
'hover:text-gray-900 group cursor-pointer':
agent.custom_role_id,
}"
>
{{ getAgentRoleName(agent) }}
<div
class="absolute left-0 z-10 hidden max-w-[300px] w-auto bg-white rounded-xl border border-slate-50 shadow-lg top-14 md:top-12 dark:bg-slate-800 dark:border-slate-700"
:class="{ 'group-hover:block': agent.custom_role_id }"
>
<div class="flex flex-col gap-1 p-4">
<span class="font-semibold">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="pl-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="font-normal"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
</div>
</div>
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
@@ -200,7 +256,7 @@ const confirmDeletion = () => {
</template>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddAgent :on-close="hideAddPopup" />
<AddAgent @close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
@@ -211,7 +267,8 @@ const confirmDeletion = () => {
:type="currentAgent.role"
:email="currentAgent.email"
:availability="currentAgent.availability_status"
:on-close="hideEditPopup"
:custom-role-id="currentAgent.custom_role_id"
@close="hideEditPopup"
/>
</woot-modal>

View File

@@ -1,5 +1,8 @@
import { frontendURL } from '../../../../helper/URLHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const CannedHome = () => import('./Index.vue');
@@ -17,7 +20,7 @@ export default {
path: 'list',
name: 'canned_list',
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
component: CannedHome,
},

View File

@@ -0,0 +1,79 @@
<script setup>
defineProps({
featurePrefix: {
type: String,
required: true,
},
i18nKey: {
type: String,
required: true,
},
isOnChatwootCloud: {
type: Boolean,
default: false,
},
isSuperAdmin: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
</script>
<template>
<div
class="flex flex-col max-w-md px-6 py-6 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-100 dark:border-slate-900"
>
<div class="flex items-center w-full gap-2 mb-4">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-woot-75/70 dark:bg-woot-800/40"
>
<fluent-icon
size="14"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
icon="lock-closed"
/>
</span>
<span class="text-base font-medium text-slate-900 dark:text-white">
{{ $t(`${featurePrefix}.PAYWALL.TITLE`) }}
</span>
</div>
<p
class="text-sm font-normal"
v-html="$t(`${featurePrefix}.${i18nKey}.AVAILABLE_ON`)"
/>
<p class="text-sm font-normal">
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
</span>
</p>
<template v-if="isOnChatwootCloud || true">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
@click="emit('click')"
>
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</woot-button>
<span class="mt-2 text-xs tracking-tight text-center">
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
</span>
</template>
<template v-else-if="isSuperAdmin">
<a href="/super_admin" class="block w-full">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
>
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</woot-button>
</a>
</template>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import CustomRoleModal from './component/CustomRoleModal.vue';
import CustomRoleTableBody from './component/CustomRoleTableBody.vue';
import CustomRolePaywall from './component/CustomRolePaywall.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const store = useStore();
const { t } = useI18n();
const showCustomRoleModal = ref(false);
const customRoleModalMode = ref('add');
const selectedRole = ref(null);
const loading = ref({});
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const records = useMapGetter('customRole/getCustomRoles');
const uiFlags = useMapGetter('customRole/getUIFlags');
const deleteConfirmText = computed(
() => `${t('CUSTOM_ROLE.DELETE.CONFIRM.YES')} ${activeResponse.value.name}`
);
const deleteRejectText = computed(
() => `${t('CUSTOM_ROLE.DELETE.CONFIRM.NO')} ${activeResponse.value.name}`
);
const deleteMessage = computed(() => {
return ` ${activeResponse.value.name} ? `;
});
const isFeatureEnabledOnAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isBehindAPaywall = computed(() => {
return !isFeatureEnabledOnAccount.value(
currentAccountId.value,
'custom_roles'
);
});
const fetchCustomRoles = async () => {
try {
await store.dispatch('customRole/getCustomRole');
} catch (error) {
// Ignore Error
}
};
onMounted(() => {
fetchCustomRoles();
});
const showAlertMessage = message => {
loading.value[activeResponse.value.id] = false;
activeResponse.value = {};
useAlert(message);
};
const openAddModal = () => {
if (isBehindAPaywall.value) return;
customRoleModalMode.value = 'add';
selectedRole.value = null;
showCustomRoleModal.value = true;
};
const openEditModal = role => {
customRoleModalMode.value = 'edit';
selectedRole.value = role;
showCustomRoleModal.value = true;
};
const hideCustomRoleModal = () => {
selectedRole.value = null;
showCustomRoleModal.value = false;
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
activeResponse.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteCustomRole = async id => {
try {
await store.dispatch('customRole/deleteCustomRole', id);
showAlertMessage(t('CUSTOM_ROLE.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CUSTOM_ROLE.DELETE.API.ERROR_MESSAGE');
showAlertMessage(errorMessage);
}
};
const confirmDeletion = () => {
loading[activeResponse.value.id] = true;
closeDeletePopup();
deleteCustomRole(activeResponse.value.id);
};
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('CUSTOM_ROLE.LOADING')"
:no-records-found="!records.length && !isBehindAPaywall"
:no-records-message="$t('CUSTOM_ROLE.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('CUSTOM_ROLE.HEADER')"
:description="$t('CUSTOM_ROLE.DESCRIPTION')"
:link-text="$t('CUSTOM_ROLE.LEARN_MORE')"
feature-name="canned_responses"
>
<template #actions>
<woot-button
class="rounded-md button nice"
icon="add-circle"
:disabled="isBehindAPaywall"
@click="openAddModal"
>
{{ $t('CUSTOM_ROLE.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<CustomRolePaywall v-if="isBehindAPaywall" />
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<thead>
<th
v-for="thHeader in $t('CUSTOM_ROLE.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 pr-4 font-semibold text-left text-slate-700 dark:text-slate-300"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</thead>
<CustomRoleTableBody
:roles="records"
:loading="loading"
@edit="openEditModal"
@delete="openDeletePopup"
/>
</table>
</template>
<woot-modal
:show.sync="showCustomRoleModal"
:on-close="hideCustomRoleModal"
>
<CustomRoleModal
:mode="customRoleModalMode"
:selected-role="selectedRole"
@close="hideCustomRoleModal"
/>
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('CUSTOM_ROLE.DELETE.CONFIRM.TITLE')"
:message="$t('CUSTOM_ROLE.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,248 @@
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import {
AVAILABLE_CUSTOM_ROLE_PERMISSIONS,
MANAGE_ALL_CONVERSATION_PERMISSIONS,
CONVERSATION_UNASSIGNED_PERMISSIONS,
CONVERSATION_PARTICIPATING_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import WootSubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
mode: {
type: String,
default: 'add',
validator: value => ['add', 'edit'].includes(value),
},
selectedRole: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const name = ref('');
const description = ref('');
const selectedPermissions = ref([]);
const nameInput = ref(null);
const addCustomRole = reactive({
showLoading: false,
message: '',
});
const rules = computed(() => ({
name: { required, minLength: minLength(2) },
description: { required },
selectedPermissions: { required, minLength: minLength(1) },
}));
const v$ = useVuelidate(rules, { name, description, selectedPermissions });
const resetForm = () => {
name.value = '';
description.value = '';
selectedPermissions.value = [];
v$.value.$reset();
};
const populateEditForm = () => {
name.value = props.selectedRole.name || '';
description.value = props.selectedRole.description || '';
selectedPermissions.value = props.selectedRole.permissions || [];
};
watch(
selectedPermissions,
(newValue, oldValue) => {
// Check if manage all conversation permission is added or removed
const hasAddedManageAllConversation =
newValue.includes(MANAGE_ALL_CONVERSATION_PERMISSIONS) &&
!oldValue.includes(MANAGE_ALL_CONVERSATION_PERMISSIONS);
const hasRemovedManageAllConversation =
oldValue.includes(MANAGE_ALL_CONVERSATION_PERMISSIONS) &&
!newValue.includes(MANAGE_ALL_CONVERSATION_PERMISSIONS);
if (hasAddedManageAllConversation) {
// If manage all conversation permission is added,
// then add unassigned and participating permissions automatically
selectedPermissions.value = [
...new Set([
...selectedPermissions.value,
CONVERSATION_UNASSIGNED_PERMISSIONS,
CONVERSATION_PARTICIPATING_PERMISSIONS,
]),
];
} else if (hasRemovedManageAllConversation) {
// If manage all conversation permission is removed,
// then only remove manage all conversation permission
selectedPermissions.value = selectedPermissions.value.filter(
p => p !== MANAGE_ALL_CONVERSATION_PERMISSIONS
);
}
},
{ deep: true }
);
onMounted(() => {
if (props.mode === 'edit') {
populateEditForm();
}
// Focus the name input when mounted
nameInput.value?.focus();
});
const getTranslationKey = base => {
return props.mode === 'edit'
? `CUSTOM_ROLE.EDIT.${base}`
: `CUSTOM_ROLE.ADD.${base}`;
};
const modalTitle = computed(() => t(getTranslationKey('TITLE')));
const modalDescription = computed(() => t(getTranslationKey('DESC')));
const submitButtonText = computed(() => t(getTranslationKey('SUBMIT')));
const handleCustomRole = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
addCustomRole.showLoading = true;
try {
const roleData = {
name: name.value,
description: description.value,
permissions: selectedPermissions.value,
};
if (props.mode === 'edit') {
await store.dispatch('customRole/updateCustomRole', {
id: props.selectedRole.id,
...roleData,
});
useAlert(t('CUSTOM_ROLE.EDIT.API.SUCCESS_MESSAGE'));
} else {
await store.dispatch('customRole/createCustomRole', roleData);
useAlert(t('CUSTOM_ROLE.ADD.API.SUCCESS_MESSAGE'));
}
resetForm();
emit('close');
} catch (error) {
const errorMessage =
error?.message || t(`CUSTOM_ROLE.FORM.API.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
addCustomRole.showLoading = false;
}
};
const isSubmitDisabled = computed(
() => v$.value.$invalid || addCustomRole.showLoading
);
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="modalTitle"
:header-content="modalDescription"
/>
<form class="flex flex-col w-full" @submit.prevent="handleCustomRole">
<div class="w-full">
<label :class="{ 'text-red-500': v$.name.$error }">
{{ $t('CUSTOM_ROLE.FORM.NAME.LABEL') }}
<input
ref="nameInput"
v-model.trim="name"
type="text"
:class="{ '!border-red-500': v$.name.$error }"
:placeholder="$t('CUSTOM_ROLE.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
</label>
</div>
<div class="w-full">
<label :class="{ 'text-red-500': v$.description.$error }">
{{ $t('CUSTOM_ROLE.FORM.DESCRIPTION.LABEL') }}
</label>
<div class="editor-wrap">
<WootMessageEditor
v-model="description"
class="message-editor [&>div]:px-1 h-28"
:class="{ editor_warning: v$.description.$error }"
enable-variables
:focus-on-mount="false"
:enable-canned-responses="false"
:placeholder="$t('CUSTOM_ROLE.FORM.DESCRIPTION.PLACEHOLDER')"
@blur="v$.description.$touch"
/>
</div>
</div>
<div class="w-full">
<label :class="{ 'text-red-500': v$.selectedPermissions.$error }">
{{ $t('CUSTOM_ROLE.FORM.PERMISSIONS.LABEL') }}
</label>
<div class="flex flex-col gap-2.5 mb-4">
<div
v-for="permission in AVAILABLE_CUSTOM_ROLE_PERMISSIONS"
:key="permission"
class="flex items-center"
>
<input
:id="permission"
v-model="selectedPermissions"
type="checkbox"
:value="permission"
name="permissions"
class="ltr:mr-2 rtl:ml-2"
/>
<label :for="permission" class="text-sm">
{{ $t(`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`) }}
</label>
</div>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<WootSubmitButton
:disabled="isSubmitDisabled"
:button-text="submitButtonText"
:loading="addCustomRole.showLoading"
/>
<button class="button clear" @click.prevent="emit('close')">
{{ $t('CUSTOM_ROLE.FORM.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</template>
<style scoped lang="scss">
::v-deep {
.ProseMirror-menubar {
@apply hidden;
}
.ProseMirror-woot-style {
@apply max-h-[110px];
p {
@apply text-base;
}
}
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'dashboard/composables/route';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
import CustomRoleListItem from './CustomRoleTableBody.vue';
const dummyCustomRolesData = [
{
name: 'All Permissions',
description: 'All permissions',
permissions: [
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
'contact_manage',
'report_manage',
'knowledge_base_manage',
],
},
{
name: 'Conversation Permissions',
description: 'Conversation permissions',
permissions: [
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
{
name: 'Contact Permissions',
description: 'Contact permissions',
permissions: ['contact_manage'],
},
{
name: 'Report Permissions',
description: 'Report permissions',
permissions: ['report_manage'],
},
];
const router = useRouter();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: currentAccountId.value },
});
};
</script>
<template>
<div class="w-full min-h-[12rem] relative">
<div class="w-full space-y-3 text-sm">
<thead class="opacity-30 dark:opacity-30">
<th
v-for="thHeader in $t('CUSTOM_ROLE.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 pr-4 font-semibold text-left text-slate-700 dark:text-slate-300"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</thead>
<CustomRoleListItem
class="opacity-25 dark:opacity-20"
:roles="dummyCustomRolesData"
:loading="{}"
/>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
>
<BasePaywallModal
feature-prefix="CUSTOM_ROLE"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@click="goToBillingSettings"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
defineProps({
roles: {
type: Array,
required: true,
},
loading: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const getFormattedPermissions = role => {
return role.permissions
.map(event => t(getI18nKey('CUSTOM_ROLE.PERMISSIONS', event)))
.join(', ');
};
</script>
<template>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
<tr v-for="(customRole, index) in roles" :key="index">
<td
class="max-w-xs py-4 pr-4 font-medium truncate align-baseline"
:title="customRole.name"
>
{{ customRole.name }}
</td>
<td class="py-4 pr-4 whitespace-normal align-baseline md:break-words">
{{ customRole.description }}
</td>
<td class="py-4 pr-4 whitespace-normal align-baseline md:break-words">
{{ getFormattedPermissions(customRole) }}
</td>
<td class="flex justify-end gap-1 py-4">
<woot-button
v-tooltip.top="$t('CUSTOM_ROLE.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
@click="emit('edit', customRole)"
/>
<woot-button
v-tooltip.top="$t('CUSTOM_ROLE.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
</td>
</tr>
</tbody>
</template>

View File

@@ -0,0 +1,27 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const CustomRolesHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/custom-roles'),
component: SettingsWrapper,
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'custom_roles_list',
meta: {
permissions: ['administrator'],
},
component: CustomRolesHome,
},
],
},
],
};

View File

@@ -0,0 +1,4 @@
export const getI18nKey = (prefix, event) => {
const eventName = event.toUpperCase();
return `${prefix}.${eventName}`;
};

View File

@@ -0,0 +1,30 @@
import { getI18nKey } from '../settingsHelper';
describe('settingsHelper', () => {
describe('getI18nKey', () => {
it('should return the correct i18n key', () => {
const prefix = 'CUSTOM_ROLE.PERMISSIONS';
const event = 'conversation_manage';
const expectedKey = 'CUSTOM_ROLE.PERMISSIONS.CONVERSATION_MANAGE';
expect(getI18nKey(prefix, event)).toBe(expectedKey);
});
it('should handle different prefixes', () => {
const prefix = 'INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS';
const event = 'message_created';
const expectedKey =
'INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED';
expect(getI18nKey(prefix, event)).toBe(expectedKey);
});
it('should convert event to uppercase', () => {
const prefix = 'TEST_PREFIX';
const event = 'lowercaseEvent';
const expectedKey = 'TEST_PREFIX.LOWERCASEEVENT';
expect(getI18nKey(prefix, event)).toBe(expectedKey);
});
});
});

View File

@@ -2,7 +2,7 @@
import { useVuelidate } from '@vuelidate/core';
import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals';
import { getEventNamei18n } from './webhookHelper';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -69,7 +69,7 @@ export default {
subscriptions: this.subscriptions,
});
},
getEventNamei18n,
getI18nKey,
},
};
</script>
@@ -108,13 +108,20 @@ export default {
class="mr-2"
/>
<label :for="event" class="text-sm">
{{ `${$t(getEventNamei18n(event))} (${event})` }}
{{
`${$t(
getI18nKey(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS',
event
)
)} (${event})`
}}
</label>
</div>
</div>
</div>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<div class="w-full">
<woot-button
:disabled="v$.$invalid || isSubmitting"

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue';
import { getEventNamei18n } from './webhookHelper';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'dashboard/composables/useI18n';
@@ -17,7 +17,16 @@ const props = defineProps({
const { t } = useI18n();
const subscribedEvents = computed(() => {
const { subscriptions } = props.webhook;
return subscriptions.map(event => t(getEventNamei18n(event))).join(', ');
return subscriptions
.map(event =>
t(
getI18nKey(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS',
event
)
)
)
.join(', ');
});
</script>
@@ -27,7 +36,7 @@ const subscribedEvents = computed(() => {
<div class="font-medium break-words text-slate-700 dark:text-slate-100">
{{ webhook.url }}
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 block mt-1">
<div class="block mt-1 text-sm text-slate-500 dark:text-slate-400">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
@@ -35,7 +44,7 @@ const subscribedEvents = computed(() => {
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<div class="flex justify-end gap-1">
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
variant="smooth"

View File

@@ -1,9 +0,0 @@
import { getEventNamei18n } from '../webhookHelper';
describe('#getEventNamei18n', () => {
it('returns correct i18n translation text', () => {
expect(getEventNamei18n('message_created')).toEqual(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
);
});
});

View File

@@ -1,4 +0,0 @@
export const getEventNamei18n = event => {
const eventName = event.toUpperCase();
return `INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`;
};

View File

@@ -1,5 +1,9 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const Macros = () => import('./Index.vue');
@@ -16,7 +20,7 @@ export default {
name: 'macros_wrapper',
component: Macros,
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
],
@@ -37,7 +41,7 @@ export default {
name: 'macros_edit',
component: MacroEditor,
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
{
@@ -45,7 +49,7 @@ export default {
name: 'macros_new',
component: MacroEditor,
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
],

View File

@@ -14,12 +14,18 @@ import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
import Policy from 'dashboard/components/policy.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
export default {
components: {
MessageSignature,
FormSection,
UserProfilePicture,
Policy,
UserBasicDetails,
HotKeyCard,
ChangePassword,
@@ -71,6 +77,8 @@ export default {
'/assets/images/dashboard/profile/hot-key-ctrl-enter-dark.svg',
},
],
notificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
audioNotificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
};
},
computed: {
@@ -235,6 +243,7 @@ export default {
>
<ChangePassword />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
@@ -243,9 +252,12 @@ export default {
>
<AudioNotifications />
</FormSection>
</Policy>
<Policy :permissions="notificationPermissions">
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<NotificationPreferences />
</FormSection>
</Policy>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="

View File

@@ -9,7 +9,7 @@ export default {
path: frontendURL('accounts/:accountId/profile'),
name: 'profile_settings',
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'custom_role'],
},
component: SettingsContent,
children: [
@@ -18,7 +18,7 @@ export default {
name: 'profile_settings_index',
component: Index,
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator', 'agent', 'custom_role'],
},
},
],

View File

@@ -30,7 +30,7 @@ export default {
path: 'overview',
name: 'account_overview_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: LiveReports,
},
@@ -49,7 +49,7 @@ export default {
path: 'conversation',
name: 'conversation_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: Index,
},
@@ -68,7 +68,7 @@ export default {
path: 'csat',
name: 'csat_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: CsatResponses,
},
@@ -87,7 +87,7 @@ export default {
path: 'bot',
name: 'bot_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: BotReports,
},
@@ -106,7 +106,7 @@ export default {
path: 'agent',
name: 'agent_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: AgentReports,
},
@@ -125,7 +125,7 @@ export default {
path: 'label',
name: 'label_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: LabelReports,
},
@@ -144,7 +144,7 @@ export default {
path: 'inboxes',
name: 'inbox_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: InboxReports,
},
@@ -162,7 +162,7 @@ export default {
path: 'teams',
name: 'team_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: TeamReports,
},
@@ -181,7 +181,7 @@ export default {
path: 'sla',
name: 'sla_reports',
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'report_manage'],
},
component: SLAReports,
},

View File

@@ -1,4 +1,9 @@
import { frontendURL } from '../../../helper/URLHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import account from './account/account.routes';
import agent from './agents/agent.routes';
import agentBot from './agentBots/agentBot.routes';
@@ -16,6 +21,7 @@ import reports from './reports/reports.routes';
import store from '../../../store';
import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
export default {
@@ -24,10 +30,13 @@ export default {
path: frontendURL('accounts/:accountId/settings'),
name: 'settings_home',
meta: {
permissions: ['administrator', 'agent'],
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
redirect: () => {
if (store.getters.getCurrentRole === 'administrator') {
if (
store.getters.getCurrentRole === 'administrator' &&
store.getters.getCurrentCustomRoleId === null
) {
return frontendURL('accounts/:accountId/settings/general');
}
return frontendURL('accounts/:accountId/settings/canned-response');
@@ -49,6 +58,7 @@ export default {
...reports.routes,
...sla.routes,
...teams.routes,
...customRoles.routes,
...profile.routes,
],
};

View File

@@ -1,5 +1,6 @@
<script setup>
import BaseEmptyState from './BaseEmptyState.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
isSuperAdmin: {
@@ -18,59 +19,12 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
<template>
<BaseEmptyState>
<div
class="flex flex-col max-w-md px-6 py-6 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-100 dark:border-slate-900"
>
<div class="flex items-center w-full gap-2 mb-4">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-woot-75/70 dark:bg-woot-800/40"
>
<fluent-icon
size="14"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
icon="lock-closed"
/>
</span>
<span class="text-base font-medium text-slate-900 dark:text-white">
{{ $t('SLA.PAYWALL.TITLE') }}
</span>
</div>
<p
class="text-sm font-normal"
v-html="$t(`SLA.${i18nKey}.AVAILABLE_ON`)"
/>
<p class="text-sm font-normal">
{{ $t(`SLA.${i18nKey}.UPGRADE_PROMPT`) }}
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
{{ $t('SLA.ENTERPRISE_PAYWALL.ASK_ADMIN') }}
</span>
</p>
<template v-if="isOnChatwootCloud || true">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
<BasePaywallModal
feature-prefix="SLA"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@click="emit('click')"
>
{{ $t('SLA.PAYWALL.UPGRADE_NOW') }}
</woot-button>
<span class="mt-2 text-xs tracking-tight text-center">
{{ $t('SLA.PAYWALL.CANCEL_ANYTIME') }}
</span>
</template>
<template v-else-if="isSuperAdmin">
<a href="/super_admin" class="block w-full">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
>
{{ $t('SLA.PAYWALL.UPGRADE_NOW') }}
</woot-button>
</a>
</template>
</div>
/>
</BaseEmptyState>
</template>

View File

@@ -27,6 +27,7 @@ import conversationTypingStatus from './modules/conversationTypingStatus';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customViews from './modules/customViews';
import customRole from './modules/customRole';
import dashboardApps from './modules/dashboardApps';
import globalConfig from 'shared/store/globalConfig';
import inboxAssignableAgents from './modules/inboxAssignableAgents';
@@ -77,6 +78,7 @@ export default new Vuex.Store({
conversationWatchers,
csat,
customViews,
customRole,
dashboardApps,
globalConfig,
inboxAssignableAgents,

View File

@@ -66,6 +66,14 @@ export const getters = {
return currentAccount.role;
},
getCurrentCustomRoleId($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount.custom_role_id;
},
getCurrentUser($state) {
return $state.currentUser;
},

View File

@@ -0,0 +1,100 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import CustomRoleAPI from '../../api/customRole';
export const state = {
records: [],
uiFlags: {
fetchingList: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
};
export const getters = {
getCustomRoles($state) {
return $state.records;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
getCustomRole: async function getCustomRole({ commit }) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: true });
try {
const response = await CustomRoleAPI.get();
commit(types.default.SET_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false });
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false });
}
},
createCustomRole: async function createCustomRole({ commit }, customRoleObj) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: true });
try {
const response = await CustomRoleAPI.create(customRoleObj);
commit(types.default.ADD_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false });
return throwErrorMessage(error);
}
},
updateCustomRole: async function updateCustomRole(
{ commit },
{ id, ...updateObj }
) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: true });
try {
const response = await CustomRoleAPI.update(id, updateObj);
commit(types.default.EDIT_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
deleteCustomRole: async function deleteCustomRole({ commit }, id) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
try {
await CustomRoleAPI.delete(id);
commit(types.default.DELETE_CUSTOM_ROLE, id);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
return id;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
return throwErrorMessage(error);
}
},
};
export const mutations = {
[types.default.SET_CUSTOM_ROLE_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.default.SET_CUSTOM_ROLE]: MutationHelpers.set,
[types.default.ADD_CUSTOM_ROLE]: MutationHelpers.create,
[types.default.EDIT_CUSTOM_ROLE]: MutationHelpers.update,
[types.default.DELETE_CUSTOM_ROLE]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -42,6 +42,26 @@ describe('#getters', () => {
});
});
describe('#getCurrentCustomRoleId', () => {
it('returns current custom role id', () => {
expect(
getters.getCurrentCustomRoleId(
{ currentUser: { accounts: [{ id: 1, custom_role_id: 1 }] } },
{ getCurrentAccountId: 1 }
)
).toEqual(1);
});
it('returns undefined if account is not available', () => {
expect(
getters.getCurrentCustomRoleId(
{ currentUser: { accounts: [{ id: 1, custom_role_id: 1 }] } },
{ getCurrentAccountId: 2 }
)
).toEqual(undefined);
});
});
describe('#getCurrentUserAvailability', () => {
it('returns correct availability status', () => {
expect(

View File

@@ -0,0 +1,97 @@
import axios from 'axios';
import { actions } from '../../customRole';
import * as types from '../../../mutation-types';
import { customRoleList } from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#getCustomRole', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: customRoleList });
await actions.getCustomRole({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: true }],
[types.default.SET_CUSTOM_ROLE, customRoleList],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.getCustomRole({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: true }],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false }],
]);
});
});
describe('#createCustomRole', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: customRoleList[0] });
await actions.createCustomRole({ commit }, customRoleList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: true }],
[types.default.ADD_CUSTOM_ROLE, customRoleList[0]],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createCustomRole({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: true }],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false }],
]);
});
});
describe('#updateCustomRole', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: customRoleList[0] });
await actions.updateCustomRole(
{ commit },
{ id: 1, ...customRoleList[0] }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: true }],
[types.default.EDIT_CUSTOM_ROLE, customRoleList[0]],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.updateCustomRole({ commit }, { id: 1 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: true }],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false }],
]);
});
});
describe('#deleteCustomRole', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: customRoleList[0] });
await actions.deleteCustomRole({ commit }, 1);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true }],
[types.default.DELETE_CUSTOM_ROLE, 1],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.deleteCustomRole({ commit }, 1)).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true }],
[types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true }],
]);
});
});
});

View File

@@ -0,0 +1,77 @@
export const customRoleList = [
{
id: 1,
name: 'Super Custom Role',
description: 'Role with all available custom role permissions',
permissions: [
'conversation_participating_manage',
'conversation_unassigned_manage',
'conversation_manage',
'contact_manage',
'report_manage',
'knowledge_base_manage',
],
created_at: '2024-09-04T05:30:22.282Z',
updated_at: '2024-09-05T09:21:02.844Z',
},
{
id: 2,
name: 'Conversation Manager Role',
description: 'Role for managing all aspects of conversations',
permissions: [
'conversation_unassigned_manage',
'conversation_participating_manage',
'conversation_manage',
],
created_at: '2024-09-05T09:21:38.692Z',
updated_at: '2024-09-05T09:21:38.692Z',
},
{
id: 3,
name: 'Participating Agent Role',
description: 'Role for agents participating in conversations',
permissions: ['conversation_participating_manage'],
created_at: '2024-09-06T08:03:14.550Z',
updated_at: '2024-09-06T08:03:14.550Z',
},
{
id: 4,
name: 'Contact Manager Role',
description: 'Role for managing contacts only',
permissions: ['contact_manage'],
created_at: '2024-09-06T08:15:56.877Z',
updated_at: '2024-09-06T09:53:28.103Z',
},
{
id: 5,
name: 'Report Analyst Role',
description: 'Role for accessing and managing reports',
permissions: ['report_manage'],
created_at: '2024-09-06T09:53:58.277Z',
updated_at: '2024-09-06T09:53:58.277Z',
},
{
id: 6,
name: 'Knowledge Base Editor Role',
description: 'Role for managing the knowledge base',
permissions: ['knowledge_base_manage'],
created_at: '2024-09-06T09:54:27.649Z',
updated_at: '2024-09-06T09:54:27.649Z',
},
{
id: 7,
name: 'Unassigned Queue Manager Role',
description: 'Role for managing unassigned conversations',
permissions: ['conversation_unassigned_manage'],
created_at: '2024-09-06T09:55:00.503Z',
updated_at: '2024-09-06T09:55:00.503Z',
},
{
id: 8,
name: 'Basic Conversation Handler Role',
description: 'Role for basic conversation management',
permissions: ['conversation_manage'],
created_at: '2024-09-06T09:55:19.519Z',
updated_at: '2024-09-06T09:55:19.519Z',
},
];

View File

@@ -0,0 +1,26 @@
import { getters } from '../../customRole';
import { customRoleList } from './fixtures';
describe('#getters', () => {
it('getCustomRoles', () => {
const state = { records: customRoleList };
expect(getters.getCustomRoles(state)).toEqual(customRoleList);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
fetchingList: true,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
creatingItem: false,
updatingItem: false,
deletingItem: false,
});
});
});

View File

@@ -0,0 +1,48 @@
import types from '../../../mutation-types';
import { mutations } from '../../customRole';
import { customRoleList } from './fixtures';
describe('#mutations', () => {
describe('#SET_CUSTOM_ROLE', () => {
it('set custom role records', () => {
const state = { records: [] };
mutations[types.SET_CUSTOM_ROLE](state, customRoleList);
expect(state.records).toEqual(customRoleList);
});
});
describe('#ADD_CUSTOM_ROLE', () => {
it('push newly created custom role to the store', () => {
const state = { records: [customRoleList[0]] };
mutations[types.ADD_CUSTOM_ROLE](state, customRoleList[1]);
expect(state.records).toEqual([customRoleList[0], customRoleList[1]]);
});
});
describe('#EDIT_CUSTOM_ROLE', () => {
it('update custom role record', () => {
const state = { records: [customRoleList[0]] };
const updatedRole = { ...customRoleList[0], name: 'Updated Role' };
mutations[types.EDIT_CUSTOM_ROLE](state, updatedRole);
expect(state.records).toEqual([updatedRole]);
});
});
describe('#DELETE_CUSTOM_ROLE', () => {
it('delete custom role record', () => {
const state = { records: [customRoleList[0], customRoleList[1]] };
mutations[types.DELETE_CUSTOM_ROLE](state, customRoleList[0].id);
expect(state.records).toEqual([customRoleList[1]]);
});
});
describe('#SET_CUSTOM_ROLE_UI_FLAG', () => {
it('set custom role UI flags', () => {
const state = { uiFlags: {} };
mutations[types.SET_CUSTOM_ROLE_UI_FLAG](state, {
fetchingList: true,
});
expect(state.uiFlags).toEqual({ fetchingList: true });
});
});
});

View File

@@ -98,6 +98,13 @@ export default {
EDIT_CANNED: 'EDIT_CANNED',
DELETE_CANNED: 'DELETE_CANNED',
// Custom Role
SET_CUSTOM_ROLE_UI_FLAG: 'SET_CUSTOM_ROLE_UI_FLAG',
SET_CUSTOM_ROLE: 'SET_CUSTOM_ROLE',
ADD_CUSTOM_ROLE: 'ADD_CUSTOM_ROLE',
EDIT_CUSTOM_ROLE: 'EDIT_CUSTOM_ROLE',
DELETE_CUSTOM_ROLE: 'DELETE_CUSTOM_ROLE',
// Labels
SET_LABEL_UI_FLAG: 'SET_LABEL_UI_FLAG',
SET_LABELS: 'SET_LABELS',

View File

@@ -271,15 +271,9 @@
"M11.1667 17.8333C14.8486 17.8333 17.8333 14.8486 17.8333 11.1667C17.8333 7.48477 14.8486 4.5 11.1667 4.5C7.48477 4.5 4.5 7.48477 4.5 11.1667C4.5 14.8486 7.48477 17.8333 11.1667 17.8333Z",
"M19.5001 19.5001L15.9167 15.9167"
],
"chevrons-left-outline": [
"m11 17-5-5 5-5",
"m18 17-5-5 5-5"
],
"chevrons-left-outline": ["m11 17-5-5 5-5", "m18 17-5-5 5-5"],
"chevron-left-single-outline": "m15 18-6-6 6-6",
"chevrons-right-outline": [
"m6 17 5-5-5-5",
"m13 17 5-5-5-5"
],
"chevrons-right-outline": ["m6 17 5-5-5-5", "m13 17 5-5-5-5"],
"chevron-right-single-outline": "m9 18 6-6-6-6",
"avatar-upload-outline": "M19.754 11a.75.75 0 0 1 .743.648l.007.102v7a3.25 3.25 0 0 1-3.065 3.246l-.185.005h-11a3.25 3.25 0 0 1-3.244-3.066l-.006-.184V11.75a.75.75 0 0 1 1.494-.102l.006.102v7a1.75 1.75 0 0 0 1.607 1.745l.143.006h11A1.75 1.75 0 0 0 19 18.894l.005-.143V11.75a.75.75 0 0 1 .75-.75ZM6.22 7.216l4.996-4.996a.75.75 0 0 1 .976-.073l.084.072l5.005 4.997a.75.75 0 0 1-.976 1.134l-.084-.073l-3.723-3.716l.001 11.694a.75.75 0 0 1-.648.743l-.102.007a.75.75 0 0 1-.743-.648L11 16.255V4.558L7.28 8.277a.75.75 0 0 1-.976.073l-.084-.073a.75.75 0 0 1-.073-.977l.073-.084l4.996-4.996L6.22 7.216Z",
"text-copy-outline": "M5.503 4.627L5.5 6.75v10.504a3.25 3.25 0 0 0 3.25 3.25h8.616a2.25 2.25 0 0 1-2.122 1.5H8.75A4.75 4.75 0 0 1 4 17.254V6.75c0-.98.627-1.815 1.503-2.123M17.75 2A2.25 2.25 0 0 1 20 4.25v13a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-13A2.25 2.25 0 0 1 8.75 2zm0 1.5h-9a.75.75 0 0 0-.75.75v13c0 .414.336.75.75.75h9a.75.75 0 0 0 .75-.75v-13a.75.75 0 0 0-.75-.75",
@@ -290,5 +284,6 @@
"M6.49573 9.20645C6.49573 8.67008 6.93058 8.23523 7.46695 8.23523C8.00337 8.23523 8.43817 8.67008 8.43817 9.20645V11.4511C8.43817 11.9875 8.00337 12.4223 7.46695 12.4223C6.93058 12.4223 6.49573 11.9875 6.49573 11.4511V9.20645Z",
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
]
],
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0"
}

View File

@@ -33,3 +33,5 @@ class ArticlePolicy < ApplicationPolicy
@record.first.portal.members.include?(@user)
end
end
ArticlePolicy.prepend_mod_with('Enterprise::ArticlePolicy')

View File

@@ -29,3 +29,5 @@ class CategoryPolicy < ApplicationPolicy
@record.first.portal.members.include?(@user)
end
end
CategoryPolicy.prepend_mod_with('Enterprise::CategoryPolicy')

View File

@@ -37,3 +37,5 @@ class PortalPolicy < ApplicationPolicy
@record.first.members.include?(@user)
end
end
PortalPolicy.prepend_mod_with('Enterprise::PortalPolicy')

View File

@@ -3,3 +3,5 @@ class ReportPolicy < ApplicationPolicy
@account_user.administrator?
end
end
ReportPolicy.prepend_mod_with('Enterprise::ReportPolicy')

View File

@@ -30,5 +30,6 @@ json.accounts do
# availability derived from presence
json.availability_status account_user.availability_status
json.auto_offline account_user.auto_offline
json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise?
end
end

View File

@@ -89,3 +89,6 @@
enabled: false
- name: captain_integration
enabled: false
- name: custom_roles
enabled: false
premium: true

View File

@@ -1,9 +1,17 @@
module Enterprise::Api::V1::Accounts::AgentsController
def account_user_attributes
super + [:custom_role_id]
def create
super
associate_agent_with_custom_role
end
def allowed_agent_params
super + [:custom_role_id]
def update
super
associate_agent_with_custom_role
end
private
def associate_agent_with_custom_role
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Api::V2::Accounts::ReportsController
def check_authorization
return if Current.account_user.custom_role&.permissions&.include?('report_manage')
super
end
end

View File

@@ -0,0 +1,29 @@
module Enterprise::ArticlePolicy
def index?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def show?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def create?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def reorder?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
end

View File

@@ -0,0 +1,25 @@
module Enterprise::CategoryPolicy
def index?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def show?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def create?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
end

View File

@@ -0,0 +1,33 @@
module Enterprise::PortalPolicy
def index?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def show?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def create?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def destroy?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def add_members?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
def logo?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
end

View File

@@ -0,0 +1,5 @@
module Enterprise::ReportPolicy
def view?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
end

View File

@@ -0,0 +1,2 @@
json.custom_role_id account_user&.custom_role_id
json.custom_role account_user&.custom_role&.as_json(only: [:id, :name, :description, :permissions])

View File

@@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe 'Profile API', type: :request do
describe 'GET /api/v1/profile' do
let(:account) { create(:account) }
let!(:custom_role_account) { create(:account, name: 'Custom Role Account') }
let!(:custom_role) { create(:custom_role, name: 'Custom Role', account: custom_role_account) }
let!(:agent) { create(:user, account: account, custom_attributes: { test: 'test' }, role: :agent) }
before do
create(:account_user, account: custom_role_account, user: agent, custom_role: custom_role)
end
context 'when it is an authenticated user' do
it 'returns user custom role information' do
get '/api/v1/profile',
headers: agent.create_new_auth_token,
as: :json
parsed_response = response.parsed_body
# map accounts object and make sure custom role id and name are present
role_account = parsed_response['accounts'].find { |account| account['id'] == custom_role_account.id }
expect(role_account['custom_role']['id']).to eq(custom_role.id)
expect(role_account['custom_role']['name']).to eq(custom_role.name)
end
end
end
end

View File

@@ -3,10 +3,25 @@ require 'rails_helper'
RSpec.describe 'Enterprise Agents API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:custom_role) { create(:custom_role, account: account) }
describe 'POST /api/v1/accounts/{account.id}/agents' do
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
context 'when it is an authenticated administrator' do
it 'creates an agent with the specified custom role' do
post "/api/v1/accounts/#{account.id}/agents", headers: admin.create_new_auth_token, params: params, as: :json
expect(response).to have_http_status(:success)
agent = account.agents.last
expect(agent.account_users.first.custom_role_id).to eq(custom_role.id)
expect(JSON.parse(response.body)['custom_role_id']).to eq(custom_role.id)
end
end
end
describe 'PUT /api/v1/accounts/{account.id}/agents/:id' do
let(:other_agent) { create(:user, account: account, role: :agent) }
let!(:custom_role) { create(:custom_role, account: account) }
context 'when it is an authenticated administrator' do
it 'modified the custom role of the agent' do