Merge pull request #549 from LaQuay/feat/role-write-private-notes

feat: add separate permission for private conversation notes
This commit is contained in:
Abhinav Raut
2026-08-30 23:29:57 +05:30
committed by GitHub
20 changed files with 499 additions and 19 deletions
+3 -3
View File
@@ -70,9 +70,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/transcript", perm(handleDownloadConversationTranscript, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.POST("/api/v1/conversations/{cuuid}/messages", auth(handleSendMessage))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write"))
g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write_private"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))
g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes))
@@ -267,7 +267,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI assistant: reply drafting + copilot chat.
g.POST("/api/v1/ai/generate-reply", auth(handleAIGenerateReply))
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write"))
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private"))
g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags))
g.POST("/api/v1/ai/copilot", auth(handleAICopilot))
g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages))
+14
View File
@@ -1,6 +1,7 @@
package main
import (
"slices"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -198,6 +199,10 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if !canCreateConversationMessage(user, req) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
@@ -303,3 +308,12 @@ func resolveQuotedCIDs(app *App, msg *cmodels.Message) {
msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url)
}
}
// canCreateConversationMessage returns whether the user may create a message of the requested visibility.
func canCreateConversationMessage(user umodels.User, req messageReq) bool {
requiredPermission := authzModels.PermMessagesWrite
if req.Private {
requiredPermission = authzModels.PermMessagesWritePrivate
}
return slices.Contains(user.Permissions, requiredPermission)
}
+41
View File
@@ -4,9 +4,50 @@ import (
"testing"
"github.com/abhinavxd/libredesk/internal/attachment"
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/lib/pq"
)
func TestCanCreateConversationMessagePermissionCombinations(t *testing.T) {
tests := []struct {
name string
permissions pq.StringArray
wantPublic bool
wantPrivateNote bool
}{
{"both permissions", pq.StringArray{authzmodels.PermMessagesWrite, authzmodels.PermMessagesWritePrivate}, true, true},
{"public message only", pq.StringArray{authzmodels.PermMessagesWrite}, true, false},
{"private note only", pq.StringArray{authzmodels.PermMessagesWritePrivate}, false, true},
{"neither permission", nil, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := umodels.User{Permissions: tt.permissions}
public := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent})
privateNote := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent, Private: true})
if public != tt.wantPublic {
t.Errorf("public reply allowed = %v, want %v", public, tt.wantPublic)
}
if privateNote != tt.wantPrivateNote {
t.Errorf("private note allowed = %v, want %v", privateNote, tt.wantPrivateNote)
}
})
}
}
func TestContactMessageStillRequiresPublicMessagePermission(t *testing.T) {
req := messageReq{SenderType: umodels.UserTypeContact}
if canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWriteAsContact}}, req) {
t.Fatal("write_as_contact alone unexpectedly bypassed messages:write")
}
if !canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWrite}}, req) {
t.Fatal("messages:write should pass the base contact-message permission check")
}
}
func TestResolveAttachmentCIDs(t *testing.T) {
tests := []struct {
name string
+1
View File
@@ -47,6 +47,7 @@ var migList = []migFunc{
{"v2.5.0", migrations.V2_5_0},
{"v2.6.0", migrations.V2_6_0},
{"v2.8.0", migrations.V2_8_0},
{"v2.9.0", migrations.V2_9_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
@@ -13,6 +13,7 @@ export const permissions = {
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_PRIVATE: 'messages:write_private',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage',
SHARED_VIEWS_MANAGE: 'shared_views:manage',
@@ -149,6 +149,7 @@ const permissions = ref([
{ name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
{ name: perms.MESSAGES_WRITE_PRIVATE, label: t('admin.role.messages.writePrivate') },
{ name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
{ name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
]
@@ -67,7 +67,7 @@
{{ t('conversation.downloadTranscript') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="userStore.can('messages:write')"
v-if="userStore.can(perms.MESSAGES_WRITE_PRIVATE)"
:disabled="isSummarizing"
@click="summarize"
>
@@ -81,7 +81,7 @@
<!-- Messages & reply box -->
<div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" />
<ReplyBox />
<ReplyBox v-if="canCompose" />
</div>
</div>
</template>
@@ -111,6 +111,7 @@ import { useI18n } from 'vue-i18n'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { downloadBlobResponse, parseBlobError } from '@shared-ui/utils/file'
import api from '@main/api'
import { permissions as perms } from '@main/constants/permissions.js'
const conversationStore = useConversationStore()
const userStore = useUserStore()
const emitter = useEmitter()
@@ -118,6 +119,9 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const canCompose = computed(
() => userStore.can(perms.MESSAGES_WRITE) || userStore.can(perms.MESSAGES_WRITE_PRIVATE)
)
// Each detail route is `<list route name>-conversation`.
const goBackToList = () => {
@@ -76,6 +76,8 @@
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@generateReply="handleGenerateReply"
class="h-full flex-grow"
/>
@@ -136,6 +138,8 @@
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@generateReply="handleGenerateReply"
/>
</div>
@@ -175,6 +179,7 @@ import { useFileUpload } from '@main/composables/useFileUpload'
import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import { UserTypeAgent } from '@/constants/user'
import { permissions as perms } from '@main/constants/permissions.js'
const { t } = useI18n()
const conversationStore = useConversationStore()
@@ -185,6 +190,16 @@ const userStore = useUserStore()
const isCramped = useIsComposerCramped()
useVisualViewportHeight()
const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE))
const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE))
const defaultMessageType = computed(() => (canSendReply.value ? 'reply' : 'private_note'))
const isAllowedMessageType = (type) =>
(type === 'reply' && canSendReply.value) || (type === 'private_note' && canSendPrivateNote.value)
const resolveAllowedDraftType = (uuid) => {
const type = conversationStore.resolveDraftType(uuid)
return isAllowedMessageType(type) ? type : defaultMessageType.value
}
// Setup file upload composable
const {
uploadingFiles,
@@ -205,14 +220,14 @@ watch(
async (uuid, prevUuid) => {
if (prevUuid) conversationStore.setSelectedDraftType(prevUuid, messageType.value)
if (!uuid) {
messageType.value = 'reply'
messageType.value = defaultMessageType.value
return
}
messageType.value = conversationStore.resolveDraftType(uuid)
messageType.value = resolveAllowedDraftType(uuid)
// Prefetch may still be in flight on first load; re-resolve once drafts land.
await conversationStore.draftsReady
if (uuid !== currentConversationUUID.value) return
messageType.value = conversationStore.resolveDraftType(uuid)
messageType.value = resolveAllowedDraftType(uuid)
},
{ immediate: true }
)
@@ -277,7 +292,7 @@ const handleGenerateReply = () =>
// Copilot's "Insert into reply" replaces the draft with its answer (already HTML from the panel),
// forcing reply mode so a private note in progress does not silently receive customer-facing text.
const handleCopilotInsertReply = (html) => {
if (!html) return
if (!html || !canSendReply.value) return
if (messageType.value === 'private_note') messageType.value = 'reply'
htmlContent.value = html
}
@@ -311,6 +326,8 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
const convUUID = conversationStore.current.uuid
const isPrivate = messageType.value === 'private_note'
if ((isPrivate && !canSendPrivateNote.value) || (!isPrivate && !canSendReply.value)) return
const currentInbox = inboxStore.inboxes.find(
(i) => i.id === conversationStore.current.inbox_id
)
@@ -9,6 +9,7 @@
<Tabs v-model="messageType" class="rounded-md border">
<TabsList class="bg-muted p-1 rounded-md">
<TabsTrigger
v-if="canSendReply"
value="reply"
class="px-3 py-1 max-md:py-2.5 rounded-md transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
@@ -16,6 +17,7 @@
{{ $t('globals.terms.reply') }}
</TabsTrigger>
<TabsTrigger
v-if="canSendPrivateNote"
value="private_note"
class="px-3 py-1 max-md:py-2.5 rounded-md transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
@@ -242,6 +244,14 @@ const props = defineProps({
isGenerating: {
type: Boolean,
default: false
},
canSendReply: {
type: Boolean,
required: true
},
canSendPrivateNote: {
type: Boolean,
required: true
}
})
@@ -100,7 +100,7 @@
</TooltipTrigger>
<TooltipContent>{{ $t('globals.terms.copy') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canSendReply">
<TooltipTrigger as-child>
<Button
type="button"
@@ -114,7 +114,7 @@
</TooltipTrigger>
<TooltipContent>{{ $t('copilot.insertIntoReply') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canSendPrivateNote">
<TooltipTrigger as-child>
<Button
type="button"
@@ -178,20 +178,25 @@ import { Eraser, Bot, Copy, Reply, StickyNote } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { useCopilotStore } from '@/stores/copilot'
import { useAIAssistantStore } from '@/stores/aiAssistant'
import { useUserStore } from '@/stores/user'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { getTextFromHTML } from '@shared-ui/utils/string.js'
import { UserTypeAgent } from '@/constants/user'
import { COPILOT_NAME } from '@/constants/copilot'
import { permissions as perms } from '@/constants/permissions.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const conversationStore = useConversationStore()
const copilotStore = useCopilotStore()
const aiAssistantStore = useAIAssistantStore()
const userStore = useUserStore()
const emitter = useEmitter()
const { t } = useI18n()
const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE))
const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE))
const presets = computed(() => [
t('copilot.preset.summarize'),
@@ -338,10 +343,12 @@ const copyAnswer = async (content) => {
}
const insertIntoReply = (content) => {
if (!canSendReply.value) return
emitter.emit(EMITTER_EVENTS.COPILOT_INSERT_REPLY, content)
}
const addAsPrivateNote = async (content) => {
if (!canSendPrivateNote.value) return
const uuid = conversationStore.current?.uuid || ''
if (!uuid) return
const rev = revision(uuid)
+6 -5
View File
@@ -19,7 +19,7 @@ export const useMacroStore = defineStore('macroStore', () => {
assign_user: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
set_status: perms.CONVERSATIONS_UPDATE_STATUS,
set_priority: perms.CONVERSATIONS_UPDATE_PRIORITY,
send_private_note: perms.MESSAGES_WRITE,
send_private_note: perms.MESSAGES_WRITE_PRIVATE,
send_reply: perms.MESSAGES_WRITE,
add_tags: perms.CONVERSATIONS_UPDATE_TAGS,
set_tags: perms.CONVERSATIONS_UPDATE_TAGS,
@@ -43,13 +43,14 @@ export const useMacroStore = defineStore('macroStore', () => {
}
// Filter macros based on permissions.
filtered.forEach(macro => {
macro.actions = macro.actions.filter(action => {
filtered = filtered.map(macro => ({
...macro,
actions: macro.actions.filter(action => {
const permission = actionPermissions[action.type]
if (!permission) return true
return userStore.can(permission)
})
})
}))
// Skip macros that do not have any actions left AND the macro field `message_content` is empty.
filtered = filtered.filter(macro => !(macro.actions.length === 0 && macro.message_content === ""))
@@ -85,4 +86,4 @@ export const useMacroStore = defineStore('macroStore', () => {
loadMacros,
setCurrentView
}
})
})
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useMacroStore } from './macro'
import { useUserStore } from './user'
vi.mock('../composables/useEmitter', () => ({
useEmitter: () => ({ emit: vi.fn() })
}))
describe('macro store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('restores filtered actions when permissions change', () => {
const macroStore = useMacroStore()
const userStore = useUserStore()
const setPermissions = (permissions) => {
userStore.setCurrentUser({ id: 1, teams: [], permissions })
}
setPermissions(['messages:write'])
macroStore.macroList = [
{
id: 1,
name: 'Reply and note',
visibility: 'all',
message_content: '',
actions: [{ type: 'send_reply' }, { type: 'send_private_note' }]
}
]
expect(macroStore.macroOptions[0].actions).toEqual([{ type: 'send_reply' }])
expect(macroStore.macroList[0].actions).toHaveLength(2)
setPermissions(['messages:write', 'messages:write_private'])
expect(macroStore.macroOptions[0].actions).toEqual([
{ type: 'send_reply' },
{ type: 'send_private_note' }
])
})
})
@@ -0,0 +1,263 @@
describe('Private note permissions', () => {
const stamp = Date.now()
const password = 'StrongPass!123'
const contactEmail = `private-note.customer.${stamp}@example.com`
const smtpHost = Cypress.env('SMTP_HOST') || '127.0.0.1'
const smtpPort = Number(Cypress.env('SMTP_PORT') || 1025)
const commonPermissions = [
'conversations:read_all',
'conversations:read',
'messages:read',
'view:manage'
]
const combinations = [
{
key: 'both',
label: 'both permissions',
permissions: ['messages:write', 'messages:write_private'],
canReply: true,
canPrivateNote: true
},
{
key: 'public',
label: 'public-message permission only',
permissions: ['messages:write'],
canReply: true,
canPrivateNote: false
},
{
key: 'private',
label: 'private-note permission only',
permissions: ['messages:write_private'],
canReply: false,
canPrivateNote: true
},
{
key: 'neither',
label: 'neither permission',
permissions: [],
canReply: false,
canPrivateNote: false
}
].map((combination) => ({
...combination,
roleName: `Private Note ${combination.key} ${stamp}`,
email: `private-note.${combination.key}.${stamp}@example.com`
}))
let conversationUUID
let adminNoteUUID
let inboxID
const roleIDs = []
const agentIDs = []
const loginAsAgent = (combination) => {
cy.session(
['private-note-permissions', combination.email],
() => {
cy.visit('/')
cy.get('#email').clear()
cy.get('#email').type(combination.email)
cy.get('#password').clear()
cy.get('#password').type(password, { log: false })
cy.contains('button', 'Sign in').click()
cy.url().should('include', '/inboxes')
},
{
validate() {
cy.request('/api/v1/agents/me').its('status').should('eq', 200)
}
}
)
}
const sendMessage = (combination, privateNote) =>
cy.api(
'POST',
`/api/v1/conversations/${conversationUUID}/messages`,
{
sender_type: 'agent',
private: privateNote,
message: `<p>${combination.key}-${privateNote ? 'note' : 'reply'}-${stamp}</p>`,
attachments: [],
mentions: [],
to: privateNote ? [] : [contactEmail],
cc: [],
bcc: []
},
{ failOnStatusCode: false }
)
const deleteMessage = (messageUUID) =>
cy.api('DELETE', `/api/v1/conversations/${conversationUUID}/messages/${messageUUID}`, null, {
failOnStatusCode: false
})
const summarize = () =>
cy.api(
'POST',
'/api/v1/ai/summarize',
{ conversation_uuid: conversationUUID },
{ failOnStatusCode: false }
)
const expectPermissionResult = (response, allowed) => {
expect(response.status).to.eq(allowed ? 200 : 403)
if (!allowed) expect(response.body.error_type).to.eq('PermissionException')
}
const openConversation = () => {
cy.intercept('GET', '**/messages?page=*').as('loadMessages')
// Stubbed so the copilot answer renders without a configured AI provider.
cy.intercept('GET', '**/ai/copilot/messages*', {
body: { data: [{ role: 'assistant', content: '<p>copilot answer</p>' }] }
}).as('loadCopilot')
cy.visit(`/inboxes/all/conversation/${conversationUUID}`)
cy.wait('@loadMessages')
}
before(() => {
cy.login()
cy.api('POST', '/api/v1/inboxes', {
name: `Private Note Permissions ${stamp}`,
channel: 'email',
enabled: true,
from: `Private Note Permissions <private-note+${stamp}@cypress.test>`,
config: {
auth_type: 'password',
imap: [],
smtp: [
{
host: smtpHost,
port: smtpPort,
auth_protocol: 'none',
max_conns: 2,
idle_timeout: '5s',
pool_wait_timeout: '5s',
max_msg_retries: 1,
tls_type: 'none'
}
]
}
}).then(({ body }) => {
inboxID = body.data.id
cy.api('POST', '/api/v1/conversations', {
inbox_id: inboxID,
contact_email: contactEmail,
first_name: 'Private',
last_name: `Note${stamp}`,
subject: `Private note permissions ${stamp}`,
content: '<p>Permission test conversation.</p>',
initiator: 'contact'
}).then((response) => {
conversationUUID = response.body.data.uuid
cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
sender_type: 'agent',
private: true,
message: `<p>admin-note-${stamp}</p>`,
attachments: [],
mentions: [],
to: [],
cc: [],
bcc: []
}).then(({ body }) => {
adminNoteUUID = body.data.uuid
})
})
})
combinations.forEach((combination) => {
cy.api('POST', '/api/v1/roles', {
name: combination.roleName,
description: `Tests ${combination.label}`,
permissions: [...commonPermissions, ...combination.permissions]
}).then(({ body }) => {
roleIDs.push(body.data.id)
})
cy.api('POST', '/api/v1/agents', {
first_name: 'Permission',
last_name: combination.key,
email: combination.email,
roles: [combination.roleName],
enabled: true,
send_welcome_email: false
}).then(({ body }) => {
agentIDs.push(body.data.id)
cy.api('PUT', `/api/v1/agents/${body.data.id}`, {
first_name: 'Permission',
last_name: combination.key,
email: combination.email,
roles: [combination.roleName],
enabled: true,
new_password: password
})
})
})
})
after(() => {
cy.login()
const drop = (path) => cy.api('DELETE', path, null, { failOnStatusCode: false })
agentIDs.forEach((id) => drop(`/api/v1/agents/${id}`))
roleIDs.forEach((id) => drop(`/api/v1/roles/${id}`))
if (inboxID) drop(`/api/v1/inboxes/${inboxID}`)
})
combinations.forEach((combination) => {
it(`enforces and renders ${combination.label}`, () => {
cy.viewport(1440, 900)
loginAsAgent(combination)
sendMessage(combination, false).then((response) => {
expectPermissionResult(response, combination.canReply)
})
sendMessage(combination, true).then((response) => {
expectPermissionResult(response, combination.canPrivateNote)
if (combination.canPrivateNote) {
deleteMessage(response.body.data.uuid).its('status').should('eq', 200)
} else {
deleteMessage(adminNoteUUID).then((deleteResponse) => {
expectPermissionResult(deleteResponse, false)
})
}
})
// AI is not configured in CI, so an allowed agent fails later than the permission gate.
summarize().then((response) => {
if (combination.canPrivateNote) expect(response.status).to.not.eq(403)
else expectPermissionResult(response, false)
})
openConversation()
cy.contains('button', /^Reply$/).should(combination.canReply ? 'exist' : 'not.exist')
cy.contains('button', /^Private note$/).should(
combination.canPrivateNote ? 'exist' : 'not.exist'
)
cy.get('.tiptap.ProseMirror').should(
combination.canReply || combination.canPrivateNote ? 'exist' : 'not.exist'
)
if (!combination.canReply && combination.canPrivateNote) {
cy.contains('button', /^Private note$/).should('have.attr', 'data-state', 'active')
}
cy.get('button.w-11.h-11.p-0').click()
cy.contains('[role="menuitem"]', 'Download transcript').should('exist')
cy.contains('[role="menuitem"]', 'Summarize with AI').should(
combination.canPrivateNote ? 'exist' : 'not.exist'
)
cy.get('body').type('{esc}')
cy.contains('button', 'Juno').click()
cy.wait('@loadCopilot')
cy.contains('copilot answer')
.closest('.flex.flex-col.gap-1')
.find('button')
.should(
'have.length',
1 + Number(combination.canReply) + Number(combination.canPrivateNote)
)
})
})
})
+3
View File
@@ -29,6 +29,7 @@ describe('Role form', () => {
cy.get('input[name="name"]').type(roleName)
cy.get('input[name="description"]').type(roleDescription)
togglePermission('View all conversations')
togglePermission('Send private notes in conversations')
togglePermission('Manage tags')
togglePermission('Manage conversation statuses')
@@ -37,6 +38,7 @@ describe('Role form', () => {
expect(response.statusCode).to.eq(200)
expect(response.body.data.permissions).to.include.members([
'conversations:read_all',
'messages:write_private',
'tags:manage',
'status:manage'
])
@@ -56,6 +58,7 @@ describe('Role form', () => {
cy.get('input[name="name"]').should('have.value', roleName)
cy.get('input[name="description"]').should('have.value', roleDescription)
permission('View all conversations').should('have.attr', 'data-state', 'checked')
permission('Send private notes in conversations').should('have.attr', 'data-state', 'checked')
permission('Manage tags').should('have.attr', 'data-state', 'checked')
permission('Manage conversation statuses').should('have.attr', 'data-state', 'checked')
permission('Manage webhooks').should('have.attr', 'data-state', 'unchecked')
+1
View File
@@ -488,6 +488,7 @@
"admin.role.messages.read": "View conversation messages",
"admin.role.messages.write": "Send messages in conversations",
"admin.role.messages.writeAsContact": "Send messages as contact",
"admin.role.messages.writePrivate": "Send private notes in conversations",
"admin.role.notificationSettings.manage": "Manage notification settings",
"admin.role.oidc.manage": "Manage SSO configuration",
"admin.role.reports.manage": "Manage reports",
+2
View File
@@ -16,6 +16,7 @@ const (
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
PermMessagesWritePrivate = "messages:write_private"
PermMessagesWriteAsContact = "messages:write_as_contact"
// View
@@ -114,6 +115,7 @@ var validPermissions = map[string]struct{}{
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermMessagesWritePrivate: {},
PermMessagesWriteAsContact: {},
PermViewManage: {},
PermSharedViewsManage: {},
+1 -1
View File
@@ -88,7 +88,7 @@ var ActionPermissions = map[string]string{
ActionAssignUser: authzModels.PermConversationsUpdateUserAssignee,
ActionSetStatus: authzModels.PermConversationsUpdateStatus,
ActionSetPriority: authzModels.PermConversationsUpdatePriority,
ActionSendPrivateNote: authzModels.PermMessagesWrite,
ActionSendPrivateNote: authzModels.PermMessagesWritePrivate,
ActionReply: authzModels.PermMessagesWrite,
ActionAddTags: authzModels.PermConversationsUpdateTags,
ActionSetTags: authzModels.PermConversationsUpdateTags,
+17
View File
@@ -0,0 +1,17 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'messages:write_private')
WHERE 'messages:write' = ANY(permissions)
AND NOT ('messages:write_private' = ANY(permissions));
`)
return err
}
+54
View File
@@ -0,0 +1,54 @@
package migrations
import (
"slices"
"testing"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/lib/pq"
)
func TestV2_9_0PrivateNotePermissionMigration(t *testing.T) {
db := testutil.NewDB(t, "migration_v2_9_0")
roles := []struct {
name string
permissions pq.StringArray
wantPrivate bool
}{
{"With old permission", pq.StringArray{"conversations:read", "messages:write"}, true},
{"Without old permission", pq.StringArray{"conversations:read", "messages:read"}, false},
{"Already migrated", pq.StringArray{"messages:write", "messages:write_private"}, true},
}
for _, role := range roles {
if _, err := db.Exec(`INSERT INTO roles (name, description, permissions) VALUES ($1, '', $2)`, role.name, role.permissions); err != nil {
t.Fatalf("inserting role %q: %v", role.name, err)
}
}
// Running the migration twice verifies that it does not append duplicates.
for range 2 {
if err := V2_9_0(db, nil, nil); err != nil {
t.Fatalf("running migration: %v", err)
}
}
for _, role := range roles {
var got pq.StringArray
if err := db.Get(&got, `SELECT permissions FROM roles WHERE name = $1`, role.name); err != nil {
t.Fatalf("reading role %q: %v", role.name, err)
}
count := 0
for _, permission := range got {
if permission == "messages:write_private" {
count++
}
}
if slices.Contains(got, "messages:write_private") != role.wantPrivate {
t.Errorf("role %q permissions = %v, want private permission = %v", role.name, got, role.wantPrivate)
}
if count > 1 {
t.Errorf("role %q has duplicate private permissions: %v", role.name, got)
}
}
}
+2 -2
View File
@@ -1018,7 +1018,7 @@ VALUES
(
'Agent',
'Role for all agents with limited access to conversations.',
'{conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage}'
'{conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,messages:write_private,view:manage}'
);
INSERT INTO
@@ -1027,7 +1027,7 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contacts:delete,contacts:export,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,help_center:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contacts:delete,contacts:export,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,help_center:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,messages:write_private,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);