mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 06:05:41 +00:00
Merge pull request #549 from LaQuay/feat/role-write-private-notes
feat: add separate permission for private conversation notes
This commit is contained in:
+3
-3
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}'
|
||||
);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user