diff --git a/cmd/handlers.go b/cmd/handlers.go index 56d5cf17..f21d998c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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)) diff --git a/cmd/messages.go b/cmd/messages.go index 147cb081..e28b65ec 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -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) +} diff --git a/cmd/messages_test.go b/cmd/messages_test.go index 48674d09..764446c3 100644 --- a/cmd/messages_test.go +++ b/cmd/messages_test.go @@ -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 diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 30d6229b..f33f2f37 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -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 diff --git a/frontend/apps/main/src/constants/permissions.js b/frontend/apps/main/src/constants/permissions.js index 823cbad1..ccdd1ee7 100644 --- a/frontend/apps/main/src/constants/permissions.js +++ b/frontend/apps/main/src/constants/permissions.js @@ -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', diff --git a/frontend/apps/main/src/features/admin/roles/RoleForm.vue b/frontend/apps/main/src/features/admin/roles/RoleForm.vue index 9fd991dc..fc09c2bd 100644 --- a/frontend/apps/main/src/features/admin/roles/RoleForm.vue +++ b/frontend/apps/main/src/features/admin/roles/RoleForm.vue @@ -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') } ] diff --git a/frontend/apps/main/src/features/conversation/Conversation.vue b/frontend/apps/main/src/features/conversation/Conversation.vue index 79f4ad77..4e9d404b 100644 --- a/frontend/apps/main/src/features/conversation/Conversation.vue +++ b/frontend/apps/main/src/features/conversation/Conversation.vue @@ -67,7 +67,7 @@ {{ t('conversation.downloadTranscript') }} @@ -81,7 +81,7 @@
- +
@@ -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 `-conversation`. const goBackToList = () => { diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index f7a4dd9c..5089ab81 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -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" /> @@ -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 ) diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index b68e8da4..e3d19836 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -9,6 +9,7 @@ {{ $t('globals.terms.copy') }} - +