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

@@ -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>