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

@@ -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 {
key,
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
count,
};
});
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: 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);
this.focusEditorInputField();
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,29 +223,39 @@ export default {
</header>
<div class="search-results">
<div v-if="showResultsSection">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
<Policy :permissions="[...rolePermissions, contactPermissions]">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
: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,156 +1,164 @@
<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: () => {},
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 },
};
const v$ = useVuelidate(rules, {
agentName,
agentEmail,
selectedRoleId,
});
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'),
},
},
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,
{
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,
};
},
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,
});
useAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
const {
response: {
data: {
error: errorResponse = '',
attributes: attributes = [],
message: attrError = '',
} = {},
} = {},
} = error;
if (selectedRole.value.name.startsWith('custom_')) {
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
}
let errorMessage = '';
if (error?.response?.status === 422 && !attributes.includes('base')) {
errorMessage = this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
} else {
errorMessage = this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE');
}
useAlert(errorResponse || attrError || errorMessage);
}
},
},
await store.dispatch('agents/create', payload);
useAlert(t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
const {
response: {
data: {
error: errorResponse = '',
attributes: attributes = [],
message: attrError = '',
} = {},
} = {},
} = error;
let errorMessage = '';
if (error?.response?.status === 422 && !attributes.includes('base')) {
errorMessage = t('AGENT_MGMT.ADD.API.EXIST_MESSAGE');
} else {
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')"
/>
<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">
<div class="w-full">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.NAME.LABEL') }}
<input
v-model.trim="agentName"
type="text"
:placeholder="$t('AGENT_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@input="v$.agentName.$touch"
/>
</label>
</div>
<form
class="flex flex-col items-start w-full"
@submit.prevent="addAgent()"
>
<div class="w-full">
<label :class="{ error: v$.selectedRoleId.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.AGENT_TYPE.LABEL') }}
<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$.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="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">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.ADD.FORM.NAME.LABEL') }}
<input
v-model.trim="agentName"
type="text"
:placeholder="$t('AGENT_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@input="v$.agentName.$touch"
/>
</label>
<WootSubmitButton
:disabled="v$.$invalid || uiFlags.isCreating"
:button-text="$t('AGENT_MGMT.ADD.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="emit('close')">
{{ $t('AGENT_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
<div class="w-full">
<label :class="{ error: v$.agentType.$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">
{{ role.label }}
</option>
</select>
<span v-if="v$.agentType.$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"
: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
"
:button-text="$t('AGENT_MGMT.ADD.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('AGENT_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</woot-modal>
</div>
</form>
</div>
</template>

View File

@@ -1,203 +1,220 @@
<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 props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
email: {
type: String,
default: '',
},
type: {
type: String,
default: '',
},
availability: {
type: String,
default: '',
},
customRoleId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['close']);
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
export default {
components: {
WootSubmitButton,
Modal,
},
props: {
id: {
type: Number,
required: true,
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'),
},
name: {
type: String,
required: true,
{
id: 'agent',
name: 'agent',
label: t('AGENT_MGMT.AGENT_TYPES.AGENT'),
},
email: {
type: String,
default: '',
},
type: {
type: String,
default: '',
},
availability: {
type: String,
default: '',
},
onClose: {
type: Function,
required: true,
},
},
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 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: props.availability === AVAILABILITY_STATUS_KEYS[index],
})
)
);
const editAgent = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
const payload = {
id: props.id,
name: agentName.value,
availability: agentAvailability.value,
};
},
validations: {
agentName: {
required,
minLength: minLength(1),
},
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(
(statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
})
);
},
},
methods: {
async editAgent() {
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'));
}
},
async resetPassword() {
try {
await Auth.resetPassword(this.agentCredentials);
useAlert(
this.$t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.PASSWORD_RESET.ERROR_MESSAGE'));
}
},
},
if (selectedRole.value.name.startsWith('custom_')) {
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
payload.custom_role_id = null;
}
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(agentCredentials.value);
useAlert(t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_SUCCESS_MESSAGE'));
} catch (error) {
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()">
<div class="w-full">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.EDIT.FORM.NAME.LABEL') }}
<input
v-model.trim="agentName"
type="text"
:placeholder="$t('AGENT_MGMT.EDIT.FORM.NAME.PLACEHOLDER')"
@input="v$.agentName.$touch"
/>
</label>
</div>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="pageTitle" />
<form class="w-full" @submit.prevent="editAgent">
<div class="w-full">
<label :class="{ error: v$.agentName.$error }">
{{ $t('AGENT_MGMT.EDIT.FORM.NAME.LABEL') }}
<input
v-model.trim="agentName"
type="text"
:placeholder="$t('AGENT_MGMT.EDIT.FORM.NAME.PLACEHOLDER')"
@input="v$.agentName.$touch"
/>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.agentType.$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">
{{ role.label }}
</option>
</select>
<span v-if="v$.agentType.$error" class="message">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_TYPE.ERROR') }}
</span>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.selectedRoleId.$error }">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_TYPE.LABEL') }}
<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$.selectedRoleId.$error" class="message">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_TYPE.ERROR') }}
</span>
</label>
</div>
<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"
>
{{ role.label }}
</option>
</select>
<span v-if="v$.agentAvailability.$error" class="message">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_AVAILABILITY.ERROR') }}
</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
"
:button-text="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
:loading="uiFlags.isUpdating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('AGENT_MGMT.EDIT.CANCEL_BUTTON_TEXT') }}
</button>
</div>
<div class="w-[50%] text-right">
<woot-button
icon="lock-closed"
variant="clear"
@click.prevent="resetPassword"
<div class="w-full">
<label :class="{ error: v$.agentAvailability.$error }">
{{ $t('PROFILE_SETTINGS.FORM.AVAILABILITY.LABEL') }}
<select
v-model="agentAvailability"
@change="v$.agentAvailability.$touch"
>
<option
v-for="status in availabilityStatuses"
:key="status.value"
:value="status.value"
>
{{ $t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_RESET_BUTTON') }}
</woot-button>
</div>
{{ status.label }}
</option>
</select>
<span v-if="v$.agentAvailability.$error" class="message">
{{ $t('AGENT_MGMT.EDIT.FORM.AGENT_AVAILABILITY.ERROR') }}
</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$.$invalid || uiFlags.isUpdating"
:button-text="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
:loading="uiFlags.isUpdating"
/>
<button class="button clear" @click.prevent="emit('close')">
{{ $t('AGENT_MGMT.EDIT.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</Modal>
<div class="w-[50%] text-right">
<woot-button
icon="lock-closed"
variant="clear"
@click.prevent="resetPassword"
>
{{ $t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_RESET_BUTTON') }}
</woot-button>
</div>
</div>
</form>
</div>
</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,17 +243,21 @@ export default {
>
<ChangePassword />
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<AudioNotifications />
</FormSection>
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<NotificationPreferences />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<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
@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>
<BasePaywallModal
feature-prefix="SLA"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@click="emit('click')"
/>
</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',