Merge remote-tracking branch 'origin/main' into feat/bulk-agent-import

This commit is contained in:
Abhinav Raut
2026-01-09 01:32:59 +05:30
205 changed files with 14580 additions and 2202 deletions
+22
View File
@@ -0,0 +1,22 @@
# Contributing to Libredesk
Thanks for your interest in contributing.
## Contribution Guidelines
- **All PRs must align with the projects scope, goals, and technical direction.**
- **Large or non-trivial PRs should be discussed first via an issue.**
- Bug fixes and documentation improvements do **not** require prior discussion.
- Please keep PRs focused on a single concern where possible.
## Process
- **Propose first (for large changes)**
Open an issue describing what you want to build, why it fits Libredesk,
and the high-level implementation approach.
- **Keep PRs small**
Smaller, well-scoped PRs are easier to review and test.
Bundling multiple features into a single PR is discouraged.
Thanks for helping keep Libredesk focused and maintainable.
+1 -2
View File
@@ -1,5 +1,4 @@
# Use the latest version of Alpine Linux as the base image
FROM alpine:latest
FROM alpine:3.18
# Install necessary packages
RUN apk --no-cache add ca-certificates tzdata
+3 -1
View File
@@ -76,5 +76,7 @@ demo-build:
# Run tests.
.PHONY: test
test:
@echo "→ Running tests..."
@echo "→ Running Go tests..."
go test -count=1 ./...
@echo "→ Running frontend tests..."
cd ${FRONTEND_DIR} && npx pnpm install --frozen-lockfile && npx pnpm test:run
+8 -3
View File
@@ -5,7 +5,7 @@
Modern, open source, self-hosted customer support desk. Single binary app.
![image](https://libredesk.io/hero.png)
![image](https://libredesk.io/hero.png?v=1)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
@@ -79,9 +79,14 @@ __________________
See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
- If you are interested in contributing, please read [CONTRIBUTING.md](./CONTRIBUTING.md) first.
- For local development and setup, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup).
- For planned features and project direction, see [ROADMAP.md](./ROADMAP.md).
The backend is written in Go and the frontend is Vue.js 3 with Shadcn UI.
## Translators
+16
View File
@@ -0,0 +1,16 @@
# Libredesk Roadmap
## Vision
A high-performance, omni-channel, self-hosted customer support desk.
## Near Term
- OAuth inbox support for Microsoft accounts - Done
- Draft saving support for conversations - Done
- Support for mentions - WIP
- Ability to import agents in bulk - WIP
## Mid Term
- Full-fledged live chat widget - WIP
## Long Term
- WhatsApp channel - TODO
+92 -17
View File
@@ -1,6 +1,7 @@
package main
import (
"slices"
"strconv"
"time"
@@ -12,6 +13,7 @@ import (
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
vmodels "github.com/abhinavxd/libredesk/internal/view/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
@@ -56,6 +58,7 @@ type createConversationRequest struct {
func handleGetAllConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
@@ -64,7 +67,7 @@ func handleGetAllConversations(r *fastglue.Request) error {
total = 0
)
conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetAllConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -94,7 +97,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -115,6 +118,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
func handleGetUnassignedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
@@ -123,7 +127,37 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
total = 0
)
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetMentionedConversations retrieves conversations where the current user is mentioned.
func handleGetMentionedConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(amodels.User)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
conversations, err := app.conversation.GetMentionedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -161,17 +195,31 @@ func handleGetViewConversations(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.UserID != auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
}
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
hasAccess := false
switch view.Visibility {
case vmodels.VisibilityUser:
hasAccess = view.UserID != nil && *view.UserID == auser.ID
case vmodels.VisibilityAll:
hasAccess = true
case vmodels.VisibilityTeam:
if view.TeamID != nil {
hasAccess = slices.Contains(user.Teams.IDs(), *view.TeamID)
}
}
if !hasAccess {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
}
// Prepare lists user has access to based on user permissions, internally this prepares the SQL query.
lists := []string{}
hasTeamAll := slices.Contains(user.Permissions, authzModels.PermConversationsReadTeamAll)
for _, perm := range user.Permissions {
if perm == authzModels.PermConversationsReadAll {
// No further lists required as user has access to all conversations.
@@ -184,9 +232,13 @@ func handleGetViewConversations(r *fastglue.Request) error {
if perm == authzModels.PermConversationsReadAssigned {
lists = append(lists, cmodels.AssignedConversations)
}
if perm == authzModels.PermConversationsReadTeamInbox {
// Skip TeamUnassignedConversations if user has TeamAllConversations (superset).
if perm == authzModels.PermConversationsReadTeamInbox && !hasTeamAll {
lists = append(lists, cmodels.TeamUnassignedConversations)
}
if perm == authzModels.PermConversationsReadTeamAll {
lists = append(lists, cmodels.TeamAllConversations)
}
}
// No lists found, user doesn't have access to any conversations.
@@ -194,7 +246,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -239,7 +291,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil))
}
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
conversations, err := app.conversation.GetTeamUnassignedConversationsList(auser.ID, teamID, order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -279,7 +331,7 @@ func handleGetConversation(r *fastglue.Request) error {
return r.SendEnvelope(conv)
}
// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation.
// handleUpdateConversationAssigneeLastSeen updates the current user's last seen timestamp for a conversation.
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -294,7 +346,30 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
if err = app.conversation.UpdateUserLastSeen(uuid, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleMarkConversationAsUnread marks a conversation as unread for the current user.
func handleMarkConversationAsUnread(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.MarkAsUnread(uuid, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -594,7 +669,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(0, uuid)
conversation, err := app.conversation.GetConversation(0, uuid, "")
if err != nil {
return nil, err
}
@@ -747,16 +822,16 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
}
// Assign the conversation to the agent or team.
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
// Assign the conversation to team/agent if provided, always assign team first as it clears assigned agent.
if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
}
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
// Trigger webhook event for conversation created.
conversation, err := app.conversation.GetConversation(conversationID, "")
conversation, err := app.conversation.GetConversation(conversationID, "", "")
if err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation)
}
+1 -1
View File
@@ -35,7 +35,7 @@ func handleShowCSAT(r *fastglue.Request) error {
})
}
conversation, err := app.conversation.GetConversation(csat.ConversationID, "")
conversation, err := app.conversation.GetConversation(csat.ConversationID, "", "")
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
const maxMetaSize = 32 * 1024 // 32KB
type draftReq struct {
Content string `json:"content"`
Meta json.RawMessage `json:"meta"`
}
// handleUpsertConversationDraft saves or updates a draft for a conversation.
func handleUpsertConversationDraft(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = draftReq{}
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check access to conversation.
conv, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding draft request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if len(req.Meta) > maxMetaSize {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "meta"), nil, envelope.InputError)
}
// Validate content is not empty
if strings.TrimSpace(req.Content) == "" && (len(req.Meta) == 0 || string(req.Meta) == "{}") {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "content"), nil, envelope.InputError)
}
draft, err := app.conversation.UpsertConversationDraft(conv.ID, user.ID, req.Content, req.Meta)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(draft)
}
// handleGetAllDrafts retrieves all drafts for the current user.
func handleGetAllDrafts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
drafts, err := app.conversation.GetAllUserDrafts(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(drafts)
}
// handleDeleteConversationDraft deletes a draft for a conversation.
func handleDeleteConversationDraft(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.DeleteConversationDraft(0, uuid, user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
+32 -3
View File
@@ -26,8 +26,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Public config for app initialization.
g.GET("/api/v1/config", handleGetConfig)
// Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
// Media - supports both authenticated access and signed URLs.
g.GET("/uploads/{uuid}", authOrSignedURL(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload))
// Settings.
@@ -47,6 +47,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations:read_all"))
g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations:read_unassigned"))
g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations:read_assigned"))
g.GET("/api/v1/conversations/mentioned", perm(handleGetMentionedConversations, "conversations:read"))
g.GET("/api/v1/teams/{id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations:read_team_inbox"))
g.GET("/api/v1/views/{id}/conversations", perm(handleGetViewConversations, "conversations:read"))
g.GET("/api/v1/conversations/{uuid}", perm(handleGetConversation, "conversations:read"))
@@ -58,6 +59,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority"))
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status"))
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read"))
g.PUT("/api/v1/conversations/{uuid}/mark-unread", perm(handleMarkConversationAsUnread, "conversations:read"))
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
@@ -66,6 +68,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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))
// Draft endpoints
g.GET("/api/v1/drafts", auth(handleGetAllDrafts))
g.POST("/api/v1/conversations/{uuid}/draft", auth(handleUpsertConversationDraft))
g.DELETE("/api/v1/conversations/{uuid}/draft", auth(handleDeleteConversationDraft))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
@@ -78,6 +84,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/views/me/{id}", perm(handleUpdateUserView, "view:manage"))
g.DELETE("/api/v1/views/me/{id}", perm(handleDeleteUserView, "view:manage"))
g.GET("/api/v1/views/shared", auth(handleGetSharedViews))
g.GET("/api/v1/shared-views", perm(handleGetAllSharedViews, "shared_views:manage"))
g.GET("/api/v1/shared-views/{id}", perm(handleGetSharedView, "shared_views:manage"))
g.POST("/api/v1/shared-views", perm(handleCreateSharedView, "shared_views:manage"))
g.PUT("/api/v1/shared-views/{id}", perm(handleUpdateSharedView, "shared_views:manage"))
g.DELETE("/api/v1/shared-views/{id}", perm(handleDeleteSharedView, "shared_views:manage"))
// Status and priority.
g.GET("/api/v1/statuses", auth(handleGetStatuses))
g.POST("/api/v1/statuses", perm(handleCreateStatus, "status:manage"))
@@ -156,6 +170,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// OAuth endpoints for email inboxes.
g.POST("/api/v1/inboxes/oauth/{provider}/authorize", perm(handleOAuthAuthorize, "inboxes:manage"))
g.GET("/api/v1/inboxes/oauth/{provider}/callback", perm(handleOAuthCallback, "inboxes:manage"))
// Roles.
g.GET("/api/v1/roles", auth(handleGetRoles))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
@@ -176,6 +194,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
g.GET("/api/v1/reports/overview/csat", perm(handleOverviewCSAT, "reports:manage"))
g.GET("/api/v1/reports/overview/messages", perm(handleOverviewMessageVolume, "reports:manage"))
g.GET("/api/v1/reports/overview/tags", perm(handleOverviewTagDistribution, "reports:manage"))
// Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
@@ -192,7 +213,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage"))
// SLAs.
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
g.GET("/api/v1/sla", auth(handleGetSLAs))
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
@@ -213,6 +234,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// User notifications.
g.GET("/api/v1/notifications", auth(handleGetUserNotifications))
g.GET("/api/v1/notifications/stats", auth(handleGetUserNotificationStats))
g.PUT("/api/v1/notifications/{id}/read", auth(handleMarkNotificationAsRead))
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
+80 -7
View File
@@ -1,10 +1,13 @@
package main
import (
"encoding/json"
"net/mail"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -69,7 +72,7 @@ func handleCreateInbox(r *fastglue.Request) error {
// Clear passwords before returning.
if err := createdInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(createdInbox)
@@ -107,7 +110,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
// Clear passwords before returning.
if err := updatedInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(updatedInbox)
@@ -159,19 +162,89 @@ func handleDeleteInbox(r *fastglue.Request) error {
}
// validateInbox validates the inbox
func validateInbox(app *App, inbox imodels.Inbox) error {
func validateInbox(app *App, inb imodels.Inbox) error {
// Validate from address.
if _, err := mail.ParseAddress(inbox.From); err != nil {
if _, err := mail.ParseAddress(inb.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
}
if len(inbox.Config) == 0 {
if len(inb.Config) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
}
if inbox.Name == "" {
if inb.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "name"), nil)
}
if inbox.Channel == "" {
if inb.Channel == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
}
// Validate email channel config.
if inb.Channel == inbox.ChannelEmail {
if err := validateEmailConfig(app, inb.Config); err != nil {
return err
}
}
return nil
}
// validateEmailConfig validates the email inbox configuration.
func validateEmailConfig(app *App, configJSON json.RawMessage) error {
var cfg imodels.Config
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "config"), nil)
}
// Validate auth_type.
if cfg.AuthType != "" && cfg.AuthType != imodels.AuthTypePassword && cfg.AuthType != imodels.AuthTypeOAuth2 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "auth_type"), nil)
}
// Validate OAuth config if auth_type is oauth2.
if cfg.AuthType == imodels.AuthTypeOAuth2 {
if cfg.OAuth == nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "oauth"), nil)
}
if cfg.OAuth.Provider != string(oauth.ProviderGoogle) && cfg.OAuth.Provider != string(oauth.ProviderMicrosoft) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "oauth.provider"), nil)
}
if cfg.OAuth.ClientID == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "oauth.client_id"), nil)
}
}
// Validate SMTP configs.
for i, smtp := range cfg.SMTP {
if smtp.Host == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "smtp.host"), nil)
}
if smtp.Port <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "smtp.port"), nil)
}
// Validate auth_protocol for password auth.
if cfg.AuthType != imodels.AuthTypeOAuth2 {
validAuthProtocols := map[string]bool{"": true, "none": true, "plain": true, "login": true, "cram": true}
if !validAuthProtocols[cfg.SMTP[i].AuthProtocol] {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "smtp.auth_protocol"), nil)
}
}
}
// Validate IMAP configs.
for _, imap := range cfg.IMAP {
if imap.Host == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "imap.host"), nil)
}
if imap.Port <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "imap.port"), nil)
}
if imap.Mailbox == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "imap.mailbox"), nil)
}
// Validate tls_type.
validTLSTypes := map[string]bool{"none": true, "starttls": true, "tls": true}
if !validTLSTypes[imap.TLSType] {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "imap.tls_type"), nil)
}
}
return nil
}
+132 -38
View File
@@ -8,6 +8,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"html/template"
@@ -52,6 +53,7 @@ import (
kjson "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/rawbytes"
@@ -74,17 +76,41 @@ type constants struct {
MaxFileUploadSizeMB int
}
// Config loads config files into koanf.
// Config loads config from files and environment variables into koanf.
func initConfig(ko *koanf.Koanf) {
for _, f := range ko.Strings("config") {
log.Println("reading config file:", f)
if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
if os.IsNotExist(err) {
log.Fatal("error config file not found.")
log.Printf("WARNING: Config file not found. Continuing with defaults and environment variables.")
continue
}
log.Fatalf("error loading config from file: %v.", err)
}
}
// Load environment variables with `LIBREDESK_` prefix.
ko.Load(env.Provider(".", env.Opt{
Prefix: "LIBREDESK_",
TransformFunc: func(key, val string) (string, any) {
// Transform the key.
key = strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, "LIBREDESK_")), "__", ".")
return key, val
},
}), nil)
}
// validateConfig logs warnings/fatals for invalid config values.
func validateConfig(ko *koanf.Koanf) {
encKey := ko.MustString("app.encryption_key")
if len(encKey) != 32 {
log.Fatalf("encryption_key must be exactly 32 characters, got %d", len(encKey))
}
// Warn if using sample config value.
if encKey == sampleEncKey {
colorlog.Red("WARNING: You are using the sample encryption_key from config.sample.toml. Change it immediately. Generate a secure key with `openssl rand -hex 16`")
}
}
// initFlags initializes the commandline flags.
@@ -183,8 +209,9 @@ func loadSettings(m *setting.Manager) {
// initSettings inits setting manager.
func initSettings(db *sqlx.DB) *setting.Manager {
s, err := setting.New(setting.Opts{
DB: db,
Lo: initLogger("settings"),
DB: db,
Lo: initLogger("settings"),
EncryptionKey: ko.MustString("app.encryption_key"),
})
if err != nil {
log.Fatalf("error initializing setting manager: %v", err)
@@ -211,7 +238,6 @@ func initConversations(
status *status.Manager,
priority *priority.Manager,
hub *ws.Hub,
notif *notifier.Service,
db *sqlx.DB,
inboxStore *inbox.Manager,
userStore *user.Manager,
@@ -222,8 +248,9 @@ func initConversations(
automationEngine *automation.Engine,
template *tmpl.Manager,
webhook *webhook.Manager,
dispatcher *notifier.Dispatcher,
) *conversation.Manager {
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{
c, err := conversation.New(hub, i18n, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, dispatcher, conversation.Opts{
DB: db,
Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -292,13 +319,13 @@ func initBusinessHours(db *sqlx.DB, i18n *i18n.I18n) *businesshours.Manager {
}
// initSLA inits SLA manager.
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n) *sla.Manager {
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n, dispatcher *notifier.Dispatcher) *sla.Manager {
var lo = initLogger("sla")
m, err := sla.New(sla.Opts{
DB: db,
Lo: lo,
I18n: i18n,
}, teamManager, settings, businessHours, notifier, template, userManager)
}, teamManager, settings, businessHours, template, userManager, dispatcher)
if err != nil {
log.Fatalf("error initializing SLA manager: %v", err)
}
@@ -453,6 +480,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.M
log.Fatalf("error initializing s3 media store: %v", err)
}
case "fs":
// Default expiry to 1h if not set.
fsExpiry := ko.Duration("upload.fs.expiry")
if fsExpiry == 0 {
fsExpiry = 1 * time.Hour
}
store, err = fs.New(fs.Opts{
UploadURI: "/uploads",
UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")),
@@ -464,6 +496,8 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.M
}
return rootURL
},
SigningKey: ko.MustString("app.encryption_key"),
Expiry: fsExpiry,
})
if err != nil {
log.Fatalf("error initializing fs media store: %v", err)
@@ -487,7 +521,7 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.M
// initInbox initializes the inbox manager without registering inboxes.
func initInbox(db *sqlx.DB, i18n *i18n.I18n) *inbox.Manager {
var lo = initLogger("inbox-manager")
mgr, err := inbox.New(lo, db, i18n)
mgr, err := inbox.New(lo, db, i18n, ko.MustString("app.encryption_key"))
if err != nil {
log.Fatalf("error initializing inbox manager: %v", err)
}
@@ -523,12 +557,12 @@ func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conv
// initNotifier initializes the notifier service with available providers.
func initNotifier() *notifier.Service {
smtpCfg := email.SMTPConfig{}
smtpCfg := imodels.SMTPConfig{}
if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
log.Fatalf("error unmarshalling email notification provider config: %v", err)
}
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, emailnotifier.Opts{
emailNotifier, err := emailnotifier.New([]imodels.SMTPConfig{smtpCfg}, emailnotifier.Opts{
Lo: initLogger("email-notifier"),
FromEmail: ko.String("notification.email.email_address"),
})
@@ -543,9 +577,9 @@ func initNotifier() *notifier.Service {
return notifier.NewService(notifierProviders, ko.MustInt("notification.concurrency"), ko.MustInt("notification.queue_size"), initLogger("notifier"))
}
// initEmailInbox initializes the email inbox.
func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config email.Config
// initEmailInbox loads inbox config from DB and initializes the email inbox.
func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, mgr *inbox.Manager) (inbox.Inbox, error) {
var config imodels.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
@@ -570,10 +604,30 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name)
}
// Callback to persist refreshed tokens in DB.
tokenRefreshCallback := func(inboxID int, updatedConfig imodels.Config) error {
// Marshal updated config to JSON
updatedConfigJSON, err := json.Marshal(updatedConfig)
if err != nil {
log.Printf("ERROR: Failed to marshal updated config during token refresh for inbox %d: %v", inboxID, err)
return err
}
// Persist updated config to DB
if err := mgr.UpdateConfig(inboxID, updatedConfigJSON); err != nil {
log.Printf("ERROR: Failed to persist refreshed tokens during operation for inbox %d: %v", inboxID, err)
return err
}
log.Printf("INFO: Successfully persisted refreshed tokens during operation for inbox: %d", inboxID)
return nil
}
inbox, err := email.New(msgStore, usrStore, email.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("email_inbox"),
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("email_inbox"),
TokenRefreshCallback: tokenRefreshCallback,
})
if err != nil {
@@ -585,20 +639,22 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
return inbox, nil
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
// makeInboxInitializer creates an inbox initializer function.
func makeInboxInitializer(mgr *inbox.Manager) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) {
return func(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case inbox.ChannelEmail:
return initEmailInbox(inboxR, msgStore, usrStore, mgr)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
}
}
// reloadInboxes reloads all inboxes.
func reloadInboxes(app *App) error {
app.lo.Info("reloading inboxes")
return app.inbox.Reload(ctx, initializeInboxes)
return app.inbox.Reload(ctx, makeInboxInitializer(app.inbox))
}
// startInboxes registers the active inboxes and starts receiver for each.
@@ -606,7 +662,7 @@ func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.Messag
mgr.SetMessageStore(msgStore)
mgr.SetUserStore(usrStore)
if err := mgr.InitInboxes(initializeInboxes); err != nil {
if err := mgr.InitInboxes(makeInboxInitializer(mgr)); err != nil {
log.Fatalf("error initializing inboxes: %v", err)
}
@@ -684,9 +740,10 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
func initOIDC(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *oidc.Manager {
lo := initLogger("oidc")
o, err := oidc.New(oidc.Opts{
DB: db,
Lo: lo,
I18n: i18n,
DB: db,
Lo: lo,
I18n: i18n,
EncryptionKey: ko.MustString("app.encryption_key"),
}, settings)
if err != nil {
log.Fatalf("error initializing oidc: %v", err)
@@ -711,8 +768,19 @@ func initI18n(fs stuffbin.FileSystem) *i18n.I18n {
// initRedis inits redis DB.
func initRedis() *redis.Client {
// Load options from redis URL if set.
redisURL := ko.String("redis.url")
if redisURL != "" {
options, err := redis.ParseURL(redisURL)
if err != nil {
log.Fatalf("error parsing redis url: %v", err)
}
return redis.NewClient(options)
}
// Load from individual config options.
return redis.NewClient(&redis.Options{
Addr: ko.MustString("redis.address"),
Username: ko.String("redis.user"),
Password: ko.String("redis.password"),
DB: ko.Int("redis.db"),
})
@@ -787,9 +855,10 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager {
lo := initLogger("ai")
m, err := ai.New(ai.Opts{
DB: db,
Lo: lo,
I18n: i18n,
DB: db,
Lo: lo,
I18n: i18n,
EncryptionKey: ko.MustString("app.encryption_key"),
})
if err != nil {
log.Fatalf("error initializing AI manager: %v", err)
@@ -857,12 +926,13 @@ func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager {
var lo = initLogger("webhook")
m, err := webhook.New(webhook.Opts{
DB: db,
Lo: lo,
I18n: i18n,
Workers: ko.MustInt("webhook.workers"),
QueueSize: ko.MustInt("webhook.queue_size"),
Timeout: ko.MustDuration("webhook.timeout"),
DB: db,
Lo: lo,
I18n: i18n,
Workers: ko.MustInt("webhook.workers"),
QueueSize: ko.MustInt("webhook.queue_size"),
Timeout: ko.MustDuration("webhook.timeout"),
EncryptionKey: ko.MustString("app.encryption_key"),
})
if err != nil {
log.Fatalf("error initializing webhook manager: %v", err)
@@ -870,6 +940,30 @@ func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager {
return m
}
// initUserNotification inits user notification manager.
func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificationManager {
var lo = initLogger("user-notification")
m, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing user notification manager: %v", err)
}
return m
}
// initNotifDispatcher initializes the notification dispatcher.
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub) *notifier.Dispatcher {
return notifier.NewDispatcher(notifier.DispatcherOpts{
InApp: userNotification,
Outbound: outbound,
WSHub: wsHub,
Lo: initLogger("notification-dispatcher"),
})
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
+1 -1
View File
@@ -146,7 +146,7 @@ func handleApplyMacro(r *fastglue.Request) error {
}
// Enforce conversation access.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
conversation, err := app.conversation.GetConversation(0, conversationUUID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
+84 -66
View File
@@ -1,6 +1,7 @@
package main
import (
"cmp"
"context"
"fmt"
"log"
@@ -27,6 +28,7 @@ import (
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
"github.com/redis/go-redis/v9"
"github.com/abhinavxd/libredesk/internal/automation"
"github.com/abhinavxd/libredesk/internal/conversation"
@@ -62,40 +64,46 @@ var (
versionString string
)
const (
sampleEncKey = "your-32-char-random-string-here!"
)
// App is the global app context which is passed and injected in the http handlers.
type App struct {
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
activityLog *activitylog.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
importer *importer.Importer
redis *redis.Client
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
activityLog *activitylog.Manager
notifier *notifier.Service
userNotification *notifier.UserNotificationManager
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
importer *importer.Importer
// Global state that stores data on an available app update.
update *AppUpdate
@@ -165,6 +173,9 @@ func main() {
settings := initSettings(db)
loadSettings(settings)
// Validate config.
validateConfig(ko)
// Fallback for config typo. Logs a warning but continues to work with the incorrect key.
// Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo.
msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval"
@@ -178,6 +189,7 @@ func main() {
var (
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
draftRetentionDuration = cmp.Or(ko.Duration("conversation.draft_retention_duration"), 360*time.Hour)
automationWorkers = ko.MustInt("automation.worker_count")
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
@@ -201,9 +213,11 @@ func main() {
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier()
userNotification = initUserNotification(db, i18n)
notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub)
automation = initAutomationEngine(db, i18n)
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher)
autoassigner = initAutoAssigner(team, user, conversation)
)
automation.SetConversationStore(conversation)
@@ -219,40 +233,44 @@ func main() {
go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
go userNotification.RunNotificationCleaner(ctx)
var app = &App{
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
importer: importer.NewImporter(),
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db, i18n),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
lo: lo,
redis: rdb,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
userNotification: userNotification,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
importer: importer.NewImporter(),
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db, i18n),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
}
app.consts.Store(constants)
+48 -12
View File
@@ -13,6 +13,7 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/image"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/google/uuid"
"github.com/valyala/fasthttp"
@@ -20,10 +21,6 @@ import (
"github.com/zerodha/fastglue"
)
const (
thumbPrefix = "thumb_"
)
// handleMediaUpload handles media uploads.
func handleMediaUpload(r *fastglue.Request) error {
var (
@@ -70,6 +67,12 @@ func handleMediaUpload(r *fastglue.Request) error {
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
// Check if file is empty
if srcFileSize == 0 {
app.lo.Error("error: uploaded file is empty (0 bytes)")
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileEmpty"), nil, envelope.InputError)
}
// Check file size
consts := app.consts.Load().(*constants)
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
@@ -88,7 +91,7 @@ func handleMediaUpload(r *fastglue.Request) error {
// Delete files on any error.
var uuid = uuid.New()
thumbName := thumbPrefix + uuid.String()
thumbName := image.ThumbPrefix + uuid.String()
defer func() {
if cleanUp {
app.media.Delete(uuid.String())
@@ -105,7 +108,7 @@ func handleMediaUpload(r *fastglue.Request) error {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError)
}
thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile)
thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -124,8 +127,11 @@ func handleMediaUpload(r *fastglue.Request) error {
})
}
// Reset ptr.
file.Seek(0, 0)
_, err = app.media.Upload(uuid.String(), srcContentType, file)
// Override content type after upload (in case it was detected incorrectly).
_, srcContentType, err = app.media.Upload(uuid.String(), srcContentType, file)
if err != nil {
cleanUp = true
app.lo.Error("error uploading file", "error", err)
@@ -143,20 +149,29 @@ func handleMediaUpload(r *fastglue.Request) error {
}
// handleServeMedia serves uploaded media.
// Supports both authenticated access (with permission checks) and signed URL access (no permission checks).
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
authMethod = r.RequestCtx.UserValue("auth_method")
)
// If accessed via signed URL, skip permission checks and serve file directly.
if authMethod == "signed_url" {
return serveMediaFile(r, app, uuid, nil)
}
// Session/API key authenticated - perform full permission check.
auser := r.RequestCtx.UserValue("user").(amodels.User)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
media, err := getMediaByUUID(app, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -182,6 +197,22 @@ func handleServeMedia(r *fastglue.Request) error {
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
return serveMediaFile(r, app, uuid, &media)
}
// serveMediaFile serves the actual file content based on the storage provider.
// If media is nil, it will be fetched from DB.
func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.Media) error {
// Fetch media metadata from DB if not provided.
if media == nil {
m, err := getMediaByUUID(app, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
media = &m
}
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":
@@ -199,7 +230,7 @@ func handleServeMedia(r *fastglue.Request) error {
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
case "s3":
r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
r.RequestCtx.Redirect(app.media.GetURL(uuid, media.ContentType, media.Filename), http.StatusFound)
}
return nil
}
@@ -208,3 +239,8 @@ func handleServeMedia(r *fastglue.Request) error {
func bytesToMegabytes(bytes int64) float64 {
return float64(bytes) / 1024 / 1024
}
// getMediaByUUID fetches media metadata from DB, handling thumbnail prefix.
func getMediaByUUID(app *App, uuid string) (mmodels.Media, error) {
return app.media.Get(0, strings.TrimPrefix(uuid, image.ThumbPrefix))
}
+38 -17
View File
@@ -15,26 +15,40 @@ import (
)
type messageReq struct {
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
Mentions []cmodels.MentionInput `json:"mentions"`
}
// handleGetMessages returns messages for a conversation.
func handleGetMessages(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize = r.RequestCtx.QueryArgs().GetUintOrZero("page_size")
total = 0
private *bool
)
// Parse optional private filter (null = no filter)
if r.RequestCtx.QueryArgs().Has("private") {
p := r.RequestCtx.QueryArgs().GetBool("private")
private = &p
}
// Parse repeated type params: ?type=incoming&type=outgoing
var msgTypes []string
for _, v := range r.RequestCtx.QueryArgs().PeekMulti("type") {
msgTypes = append(msgTypes, string(v))
}
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -46,7 +60,7 @@ func handleGetMessages(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize)
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize, private, msgTypes)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -55,7 +69,8 @@ func handleGetMessages(r *fastglue.Request) error {
total = messages[i].Total
// Populate attachment URLs
for j := range messages[i].Attachments {
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
att := messages[i].Attachments[j]
messages[i].Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name)
}
// Redact CSAT survey link
messages[i].CensorCSATContent()
@@ -98,7 +113,8 @@ func handleGetMessage(r *fastglue.Request) error {
message.CensorCSATContent()
for j := range message.Attachments {
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
att := message.Attachments[j]
message.Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name)
}
return r.SendEnvelope(message)
@@ -187,6 +203,11 @@ func handleSendMessage(r *fastglue.Request) error {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
if m.ModelID.Int > 0 {
// Attachment is already associated with another model. Skip it.
app.lo.Warn("attachment already associated with another model, skipping", "media_id", m.ID, "model", m.Model.String, "model_id", m.ModelID.Int)
continue
}
media = append(media, m)
}
@@ -201,7 +222,7 @@ func handleSendMessage(r *fastglue.Request) error {
// Send private note.
if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message, req.Mentions)
if err != nil {
return sendErrorEnvelope(r, err)
}
+59
View File
@@ -2,6 +2,7 @@ package main
import (
"net/http"
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -220,3 +221,61 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle
return handler(r)
}
}
// authOrSignedURL allows access if user is authenticated OR if URL has valid signature.
// Used for media endpoints that support both access methods.
func authOrSignedURL(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
// First, try to authenticate normally.
user, err := authenticateUser(r, app)
if err == nil && user.ID > 0 {
// User is authenticated, set user context and proceed.
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
FirstName: user.FirstName,
LastName: user.LastName,
})
r.RequestCtx.SetUserValue("auth_method", "session")
return handler(r)
}
// Authentication failed, check for signed URL.
validator := app.media.SignedURLValidator()
if validator == nil {
// Store doesn't support signed URLs, require auth.
return r.SendErrorEnvelope(http.StatusUnauthorized,
app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Parse signature and expiry from query params.
sig := string(r.RequestCtx.QueryArgs().Peek("sig"))
expStr := string(r.RequestCtx.QueryArgs().Peek("exp"))
if sig == "" || expStr == "" {
return r.SendErrorEnvelope(http.StatusUnauthorized,
app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return r.SendErrorEnvelope(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalid", "name", "expiry"), nil, envelope.InputError)
}
// Get the UUID from the route.
uuid := r.RequestCtx.UserValue("uuid").(string)
// Validate signature.
if !validator(uuid, sig, exp) {
return r.SendErrorEnvelope(http.StatusForbidden,
app.i18n.T("media.invalidOrExpiredURL"), nil, envelope.PermissionError)
}
// Mark as signed URL access (no user context).
r.RequestCtx.SetUserValue("auth_method", "signed_url")
return handler(r)
}
}
+440
View File
@@ -0,0 +1,440 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"golang.org/x/oauth2"
)
const (
FlowTypeNewInbox = "new_inbox"
FlowTypeReconnect = "reconnect"
)
// OAuthCredentialsRequest represents the OAuth credentials from the request body.
type OAuthCredentialsRequest struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
TenantID string `json:"tenant_id,omitempty"` // Optional for Microsoft
FlowType string `json:"flow_type,omitempty"` // "new_inbox" or "reconnect"
InboxID int `json:"inbox_id,omitempty"` // Required for reconnect flow
}
// handleOAuthAuthorize initiates the OAuth authorization flow for creating a new email inbox.
func handleOAuthAuthorize(r *fastglue.Request) error {
var (
app = r.Context.(*App)
provider = r.RequestCtx.UserValue("provider").(string)
req OAuthCredentialsRequest
)
if provider != string(oauth.ProviderGoogle) && provider != string(oauth.ProviderMicrosoft) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Parse request body
if err := json.Unmarshal(r.RequestCtx.PostBody(), &req); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Validate credentials
if req.ClientID == "" || req.ClientSecret == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.badRequest"), nil, envelope.InputError)
}
if req.FlowType != FlowTypeNewInbox && req.FlowType != FlowTypeReconnect {
req.FlowType = FlowTypeNewInbox
}
// Build redirect URI
redirectURI := app.consts.Load().(*constants).AppBaseURL + "/api/v1/inboxes/oauth/" + provider + "/callback"
// Generate secure random state
state, err := stringutil.RandomAlphanumeric(32)
if err != nil {
app.lo.Error("Failed to generate OAuth state", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError)
}
// Store OAuth data in Redis with 15 min expiry
redisKey := "inbox_oauth:" + state
oauthData := map[string]any{
"provider": provider,
"redirect_uri": redirectURI,
"client_id": req.ClientID,
"client_secret": req.ClientSecret,
"flow_type": req.FlowType,
"inbox_id": req.InboxID,
}
// Add tenant ID for Microsoft if provided
if provider == string(oauth.ProviderMicrosoft) && req.TenantID != "" {
oauthData["tenant_id"] = req.TenantID
}
if err := app.redis.HSet(ctx, redisKey, oauthData).Err(); err != nil {
app.lo.Error("Failed to store OAuth state in Redis", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Set 15 min expiry (auto-cleanup)
if err := app.redis.Expire(ctx, redisKey, 15*time.Minute).Err(); err != nil {
app.lo.Error("Failed to set expiry for OAuth state in Redis", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Build authorization URL with scopes
authURL, err := oauth.BuildAuthorizationURL(
oauth.Provider(provider),
req.ClientID,
redirectURI,
state,
req.TenantID,
)
if err != nil {
app.lo.Error("Failed to build authorization URL", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, err.Error(), nil, envelope.InputError)
}
return r.SendEnvelope(authURL)
}
// handleOAuthCallback handles the OAuth callback and auto-creates an inbox.
func handleOAuthCallback(r *fastglue.Request) error {
var (
app = r.Context.(*App)
provider = r.RequestCtx.UserValue("provider").(string)
code = string(r.RequestCtx.QueryArgs().Peek("code"))
state = string(r.RequestCtx.QueryArgs().Peek("state"))
)
// Check if user denied authorization
if code == "" {
errorMsg := string(r.RequestCtx.QueryArgs().Peek("error"))
errorDesc := string(r.RequestCtx.QueryArgs().Peek("error_description"))
app.lo.Error("OAuth authorization failed", "error", errorMsg, "description", errorDesc)
return r.Redirect("/admin/inboxes?error=oauth_denied", fasthttp.StatusFound, nil, "")
}
if state == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Retrieve OAuth data from Redis
redisKey := "inbox_oauth:" + state
oauthData, err := app.redis.HGetAll(ctx, redisKey).Result()
if err != nil || len(oauthData) == 0 {
app.lo.Error("Invalid or expired OAuth state", "error", err, "state", state)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Delete key after retrieval (one-time use)
app.redis.Del(ctx, redisKey)
// Extract values from Redis hash
storedProvider := oauthData["provider"]
redirectURI := oauthData["redirect_uri"]
clientID := oauthData["client_id"]
clientSecret := oauthData["client_secret"]
tenantID := oauthData["tenant_id"] // Empty string if not set
flowType := oauthData["flow_type"] // "new_inbox" or "reconnect"
inboxIDStr := oauthData["inbox_id"] // Inbox ID for reconnect flow
// Validate provider matches URL parameter
if storedProvider != provider {
app.lo.Error("Provider mismatch", "stored", storedProvider, "url", provider)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
// Exchange authorization code for tokens
token, err := oauth.ExchangeCodeForToken(
context.Background(),
oauth.Provider(provider),
clientID,
clientSecret,
code,
redirectURI,
tenantID,
)
if err != nil {
app.lo.Error("Failed to exchange code for tokens", "error", err)
return r.Redirect("/admin/inboxes?error=token_exchange_failed", fasthttp.StatusFound, nil, "")
}
// Get user email from provider
userEmail, err := getUserEmailFromProvider(provider, token)
if err != nil {
app.lo.Error("Failed to get user email from provider", "error", err)
return r.Redirect("/admin/inboxes?error=email_fetch_failed", fasthttp.StatusFound, nil, "")
}
if userEmail == "" {
app.lo.Error("User email not found from provider")
return r.Redirect("/admin/inboxes?error=email_fetch_failed", fasthttp.StatusFound, nil, "")
}
// Check if inbox with this email already exists
existingInboxes, err := app.inbox.GetAll()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Extract email address for comparison (handles "Name <email>" format)
userEmailAddr, err := stringutil.ExtractEmail(userEmail)
if err != nil {
app.lo.Error("error extracting email address", "email", userEmail, "error", err)
// Fallback
userEmailAddr = userEmail
}
var existingInbox *imodels.Inbox
for i, existing := range existingInboxes {
existingEmailAddr, err := stringutil.ExtractEmail(existing.From)
if err != nil {
existingEmailAddr = existing.From
}
if existingEmailAddr == userEmailAddr {
existingInbox = &existingInboxes[i]
break
}
}
// Validate flow type matches actual state
// New inbox flow: reject if inbox already exists
if flowType == FlowTypeNewInbox && existingInbox != nil {
app.lo.Error("Inbox already exists for email", "email", userEmail)
return r.Redirect("/admin/inboxes?error=inbox_already_exists", fasthttp.StatusFound, nil, "")
}
// Reconnect flow: validate inbox_id and email match
if flowType == FlowTypeReconnect {
// Find the target inbox by ID
var targetInbox *imodels.Inbox
for i, existing := range existingInboxes {
if fmt.Sprintf("%d", existing.ID) == inboxIDStr {
targetInbox = &existingInboxes[i]
break
}
}
if targetInbox == nil {
app.lo.Error("Target inbox not found for reconnect", "inbox_id", inboxIDStr)
return r.Redirect("/admin/inboxes?error=inbox_not_found", fasthttp.StatusFound, nil, "")
}
// Verify the authorized email matches the target inbox's email
targetEmailAddr, _ := stringutil.ExtractEmail(targetInbox.From)
if targetEmailAddr != userEmailAddr {
app.lo.Error("Email mismatch during reconnect", "target_email", targetEmailAddr, "authorized_email", userEmailAddr)
return r.Redirect("/admin/inboxes?error=email_mismatch", fasthttp.StatusFound, nil, "")
}
existingInbox = targetInbox
}
// If inbox exists, update it with new OAuth tokens (reconnect flow)
if existingInbox != nil {
app.lo.Info("Updating existing inbox with new OAuth tokens", "email", userEmail, "inbox_id", existingInbox.ID)
// Parse existing config
var existingConfig imodels.Config
if err := json.Unmarshal(existingInbox.Config, &existingConfig); err != nil {
app.lo.Error("Failed to unmarshal existing config", "error", err)
return r.Redirect("/admin/inboxes?error=config_parse_failed", fasthttp.StatusFound, nil, "")
}
// Update OAuth section with new tokens
oauthConfig := &imodels.OAuthConfig{
Provider: provider,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: token.Expiry,
ClientID: clientID,
ClientSecret: clientSecret,
TenantID: tenantID,
}
existingConfig.OAuth = oauthConfig
existingConfig.AuthType = imodels.AuthTypeOAuth2
// Marshal updated config
configJSON, err := json.Marshal(existingConfig)
if err != nil {
app.lo.Error("Failed to marshal updated config", "error", err)
return r.Redirect("/admin/inboxes?error=config_update_failed", fasthttp.StatusFound, nil, "")
}
// Update inbox config directly (bypasses preservation logic that could corrupt OAuth tokens)
if err := app.inbox.UpdateConfig(existingInbox.ID, json.RawMessage(configJSON)); err != nil {
app.lo.Error("Failed to update inbox config", "error", err)
return r.Redirect("/admin/inboxes?error=inbox_update_failed", fasthttp.StatusFound, nil, "")
}
// Reload inboxes to apply new tokens
if err := reloadInboxes(app); err != nil {
app.lo.Error("Failed to reload inboxes", "error", err)
}
return r.Redirect("/admin/inboxes?success=oauth_reconnected", fasthttp.StatusFound, nil, "")
}
// Get provider-specific defaults
smtpConfig, imapConfig := getProviderDefaults(provider, userEmail)
// Create OAuth config for tokens
oauthConfig := &imodels.OAuthConfig{
Provider: provider,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: token.Expiry,
ClientID: clientID,
ClientSecret: clientSecret,
TenantID: tenantID,
}
// Create inbox config
config := imodels.Config{
SMTP: []imodels.SMTPConfig{smtpConfig},
IMAP: []imodels.IMAPConfig{imapConfig},
From: userEmail,
AuthType: imodels.AuthTypeOAuth2,
OAuth: oauthConfig,
}
configJSON, err := json.Marshal(config)
if err != nil {
app.lo.Error("Failed to marshal inbox config", "error", err)
return r.Redirect("/admin/inboxes?error=config_creation_failed", fasthttp.StatusFound, nil, "")
}
// Create inbox
newInbox := imodels.Inbox{
Name: fmt.Sprintf("%s Inbox", userEmail),
From: userEmail,
Channel: inbox.ChannelEmail,
Enabled: true,
CSATEnabled: false,
Config: json.RawMessage(configJSON),
}
_, err = app.inbox.Create(newInbox)
if err != nil {
app.lo.Error("Failed to create inbox", "error", err)
return r.Redirect("/admin/inboxes?error=inbox_creation_failed", fasthttp.StatusFound, nil, "")
}
// Reload inboxes to start the new inbox
if err := reloadInboxes(app); err != nil {
app.lo.Error("Failed to reload inboxes", "error", err)
}
return r.Redirect("/admin/inboxes?success=oauth_connected", fasthttp.StatusFound, nil, "")
}
// getUserEmailFromProvider fetches the user's email from the OAuth provider.
func getUserEmailFromProvider(provider string, token *oauth2.Token) (string, error) {
switch provider {
case string(oauth.ProviderMicrosoft):
idToken, ok := token.Extra("id_token").(string)
if !ok {
return "", fmt.Errorf("no id_token")
}
parts := strings.Split(idToken, ".")
if len(parts) < 2 {
return "", fmt.Errorf("invalid id_token")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var claims map[string]any
json.Unmarshal(payload, &claims)
if email, ok := claims["email"].(string); ok && email != "" {
return email, nil
}
if upn, ok := claims["preferred_username"].(string); ok {
return upn, nil
}
return "", fmt.Errorf("email not found")
case string(oauth.ProviderGoogle):
req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
if email, ok := result["email"].(string); ok {
return email, nil
}
return "", fmt.Errorf("email not found")
}
return "", fmt.Errorf("unsupported provider")
}
// getProviderDefaults returns provider-specific SMTP and IMAP configurations.
func getProviderDefaults(provider, emailAddr string) (imodels.SMTPConfig, imodels.IMAPConfig) {
var smtp imodels.SMTPConfig
var imap imodels.IMAPConfig
// Common settings
smtp.Username = emailAddr
smtp.AuthProtocol = "login"
smtp.TLSSkipVerify = false
smtp.MaxConns = 10
smtp.MaxMessageRetries = 2
smtp.IdleTimeout = "20s"
smtp.PoolWaitTimeout = "30s"
imap.Username = emailAddr
imap.Mailbox = "INBOX"
imap.ReadInterval = "5m"
imap.ScanInboxSince = "24h"
imap.TLSSkipVerify = false
// Provider-specific settings
switch provider {
case string(oauth.ProviderGoogle):
smtp.Host = "smtp.gmail.com"
smtp.Port = 587
smtp.TLSType = "starttls"
imap.Host = "imap.gmail.com"
imap.Port = 993
imap.TLSType = "tls"
case string(oauth.ProviderMicrosoft):
smtp.Host = "smtp.office365.com"
smtp.Port = 587
smtp.TLSType = "starttls"
imap.Host = "outlook.office365.com"
imap.Port = 993
imap.TLSType = "tls"
}
return smtp, imap
}
+8 -10
View File
@@ -2,11 +2,9 @@ package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -20,7 +18,7 @@ func handleGetAllOIDC(r *fastglue.Request) error {
}
// Replace secrets with dummy values.
for i := range out {
out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
out[i].ClearSecrets()
}
return r.SendEnvelope(out)
}
@@ -30,13 +28,13 @@ func handleGetOIDC(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
o, err := app.oidc.Get(id, false)
o, err := app.oidc.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
o.ClearSecrets()
return r.SendEnvelope(o)
}
@@ -66,7 +64,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
}
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
createdOIDC.ClearSecrets()
return r.SendEnvelope(createdOIDC)
}
@@ -79,7 +77,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
@@ -102,7 +100,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
}
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
updatedOIDC.ClearSecrets()
return r.SendEnvelope(updatedOIDC)
}
@@ -112,7 +110,7 @@ func handleDeleteOIDC(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.oidc.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
+39
View File
@@ -43,3 +43,42 @@ func handleOverviewSLA(r *fastglue.Request) error {
}
return r.SendEnvelope(sla)
}
// handleOverviewCSAT retrieves CSAT metrics for the dashboard.
func handleOverviewCSAT(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
csat, err := app.report.GetOverviewCSAT(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(csat)
}
// handleOverviewMessageVolume retrieves message volume metrics for the dashboard.
func handleOverviewMessageVolume(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
volume, err := app.report.GetOverviewMessageVolume(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(volume)
}
// handleOverviewTagDistribution retrieves tag distribution metrics for the dashboard.
func handleOverviewTagDistribution(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
tags, err := app.report.GetOverviewTagDistribution(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(tags)
}
+30
View File
@@ -3,8 +3,11 @@ package main
import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/role/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -43,6 +46,11 @@ func handleDeleteRole(r *fastglue.Request) error {
if err := app.role.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
// Invalidate all caches when role is deleted.
app.user.InvalidateAllAgentCache()
app.authz.InvalidateAllCache()
return r.SendEnvelope(true)
}
@@ -66,15 +74,37 @@ func handleCreateRole(r *fastglue.Request) error {
func handleUpdateRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = models.Role{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Get old role before update to compare permissions.
oldRole, err := app.role.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
updatedRole, err := app.role.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Log permission changes and invalidate caches.
added, removed := role.ComparePermissions(oldRole.Permissions, updatedRole.Permissions)
if len(added) > 0 || len(removed) > 0 {
// Invalidate all caches when role permissions change.
app.user.InvalidateAllAgentCache()
app.authz.InvalidateAllCache()
if err := app.activityLog.RolePermissionsChanged(auser.ID, auser.Email, ip, updatedRole.ID, updatedRole.Name, added, removed); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
return r.SendEnvelope(updatedRole)
}
+1 -1
View File
@@ -127,7 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
}
// If empty then retain previous password.
// Retain current password if not changed.
if req.Password == "" {
req.Password = cur.Password
}
+3
View File
@@ -36,6 +36,9 @@ var migList = []migFunc{
{"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0},
{"v0.7.4", migrations.V0_7_4},
{"v0.8.5", migrations.V0_8_5},
{"v0.9.1", migrations.V0_9_1},
{"v0.10.0", migrations.V0_10_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func handleGetUserNotifications(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
limit = 20
offset = 0
)
if l := r.RequestCtx.QueryArgs().GetUintOrZero("limit"); l > 0 && l <= 100 {
limit = l
}
if o := r.RequestCtx.QueryArgs().GetUintOrZero("offset"); o > 0 {
offset = o
}
notifications, err := app.userNotification.GetAll(auser.ID, limit, offset)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(notifications)
}
func handleGetUserNotificationStats(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
stats, err := app.userNotification.GetStats(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(stats)
}
func handleMarkNotificationAsRead(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.userNotification.MarkAsRead(id, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleMarkAllNotificationsAsRead(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err := app.userNotification.MarkAllAsRead(auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeleteNotification(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.userNotification.Delete(id, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeleteAllNotifications(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err := app.userNotification.DeleteAll(auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
+7 -4
View File
@@ -26,10 +26,6 @@ const (
maxAvatarSizeMB = 2
)
type updateAvailabilityRequest struct {
Status string `json:"status"`
}
type resetPasswordRequest struct {
Email string `json:"email"`
}
@@ -286,6 +282,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
}
}
// Log activity if password was changed.
if req.NewPassword != "" {
if err := app.activityLog.PasswordSet(auser.ID, auser.Email, ip, id, req.Email); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err)
+163 -9
View File
@@ -10,7 +10,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetUserViews returns all views for a user.
// handleGetUserViews returns all personal views for a user.
func handleGetUserViews(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -27,7 +27,7 @@ func handleGetUserViews(r *fastglue.Request) error {
return r.SendEnvelope(v)
}
// handleCreateUserView creates a view for a user.
// handleCreateUserView creates a personal view for a user.
func handleCreateUserView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -54,7 +54,7 @@ func handleCreateUserView(r *fastglue.Request) error {
return r.SendEnvelope(createdView)
}
// handleDeleteUserView deletes a view for a user.
// handleDeleteUserView deletes a personal view for a user.
func handleDeleteUserView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -72,8 +72,9 @@ func handleDeleteUserView(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
// Only allow deletion of personal views owned by the user
if err := validatePersonalViewOwnership(app, view, user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.view.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
@@ -81,7 +82,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// handleUpdateUserView updates a view for a user.
// handleUpdateUserView updates a personal view for a user.
func handleUpdateUserView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -109,12 +110,165 @@ func handleUpdateUserView(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
// Only allow update of personal views owned by the user
if err := validatePersonalViewOwnership(app, v, user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
updatedView, err := app.view.Update(id, view.Name, view.Filters)
updatedView, err := app.view.Update(id, view.Name, view.Filters, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedView)
}
// handleGetSharedViews returns shared views accessible to the current user.
func handleGetSharedViews(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
views, err := app.view.GetSharedViewsForUser(user.Teams.IDs())
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(views)
}
// handleGetAllSharedViews returns all shared views (admin only).
func handleGetAllSharedViews(r *fastglue.Request) error {
app := r.Context.(*App)
views, err := app.view.GetAllSharedViews()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(views)
}
// handleGetSharedView returns a single shared view (admin only).
func handleGetSharedView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
view, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Ensure it's a shared view (not a personal view)
if view.Visibility == vmodels.VisibilityUser {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError)
}
return r.SendEnvelope(view)
}
// handleCreateSharedView creates a shared view (admin only).
func handleCreateSharedView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
view = vmodels.View{}
)
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validation
if err := validateSharedView(app, view); err != nil {
return sendErrorEnvelope(r, err)
}
createdView, err := app.view.CreateSharedView(view.Name, view.Filters, view.Visibility, view.TeamID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(createdView)
}
// handleUpdateSharedView updates a shared view (admin only).
func handleUpdateSharedView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
view = vmodels.View{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Verify view exists and is shared
existingView, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if existingView.Visibility == vmodels.VisibilityUser {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError)
}
// Validation
if err := validateSharedView(app, view); err != nil {
return sendErrorEnvelope(r, err)
}
updatedView, err := app.view.UpdateSharedView(id, view.Name, view.Filters, view.Visibility, view.TeamID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedView)
}
// handleDeleteSharedView deletes a shared view (admin only).
func handleDeleteSharedView(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Verify view exists and is shared
existingView, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if existingView.Visibility == vmodels.VisibilityUser {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.view}"), nil, envelope.NotFoundError)
}
if err = app.view.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validatePersonalViewOwnership checks if the user owns the personal view.
func validatePersonalViewOwnership(app *App, view vmodels.View, userID int) error {
if view.UserID == nil || *view.UserID != userID || view.Visibility != vmodels.VisibilityUser {
return envelope.NewError(envelope.PermissionError, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil)
}
return nil
}
// validateSharedView validates the fields of a shared view.
func validateSharedView(app *App, view vmodels.View) error {
if view.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if string(view.Filters) == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil)
}
if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`visibility`"), nil)
}
if view.Visibility == vmodels.VisibilityTeam && (view.TeamID == nil || *view.TeamID <= 0) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`team_id`"), nil)
}
return nil
}
-9
View File
@@ -99,15 +99,6 @@ func handleUpdateWebhook(r *fastglue.Request) error {
return r.SendEnvelope(err)
}
// If secret is empty or contains dummy characters, fetch existing webhook and preserve the secret
if webhook.Secret == "" || strings.Contains(webhook.Secret, stringutil.PasswordDummy) {
existingWebhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
webhook.Secret = existingWebhook.Secret
}
updatedWebhook, err := app.webhook.Update(id, webhook)
if err != nil {
return sendErrorEnvelope(r, err)
+7
View File
@@ -6,6 +6,8 @@ log_level = "debug"
env = "dev"
# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel.
check_updates = true
# Encryption key. Generate using `openssl rand -hex 16` must be 32 characters long.
encryption_key = "your-32-char-random-string-here!"
# HTTP server.
[app.server]
@@ -76,8 +78,11 @@ max_lifetime = "300s"
# Redis.
[redis]
# url parameter overrides other redis config options
# url = "redis://user:password@host:port/db"
# If running locally, use `localhost:6379`.
address = "redis:6379"
user = ""
password = ""
db = 0
@@ -118,6 +123,8 @@ timeout = "15s"
[conversation]
# How often to check for conversations to unsnooze
unsnooze_interval = "5m"
# How long to keep drafts before deleting them from the database. (e.g. "360h", "48h")
draft_retention_period = "360h"
[sla]
# How often to evaluate SLA compliance for conversations
+1 -1
View File
@@ -16,7 +16,7 @@ describe('Login Component', () => {
"client_id": "xx",
"enabled": true,
"id": 1,
"logo_url": "/images/google-logo.png",
"logo_url": "/images/google-logo.svg",
"name": "Google",
"provider": "Google",
"provider_url": "https://accounts.google.com",
+3 -3
View File
@@ -1,6 +1,5 @@
{
"name": "libredesk",
"version": "0.8.0-beta",
"private": true,
"type": "module",
"scripts": {
@@ -27,7 +26,9 @@
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.11.2",
"@tiptap/extension-mention": "^2.11.2",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/suggestion": "^2.11.2",
"@tiptap/extension-table": "^2.11.5",
"@tiptap/extension-table-cell": "^2.11.5",
"@tiptap/extension-table-header": "^2.11.5",
@@ -47,13 +48,12 @@
"lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"qs": "^6.14.1",
"radix-vue": "^1.9.17",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0",
"vee-validate": "^4.15.0",
"vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",
+57 -78
View File
@@ -35,6 +35,9 @@ importers:
'@tiptap/extension-link':
specifier: ^2.11.2
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-mention':
specifier: ^2.11.2
version: 2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)(@tiptap/suggestion@2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2))
'@tiptap/extension-placeholder':
specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
@@ -56,6 +59,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^2.4.0
version: 2.11.2
'@tiptap/suggestion':
specifier: ^2.11.2
version: 2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/vue-3':
specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)(vue@3.5.13(typescript@5.7.3))
@@ -96,8 +102,8 @@ importers:
specifier: ^2.1.7
version: 2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
qs:
specifier: ^6.12.1
version: 6.13.1
specifier: ^6.14.1
version: 6.14.1
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.7.3))
@@ -113,9 +119,6 @@ importers:
vue:
specifier: ^3.4.37
version: 3.5.13(typescript@5.7.3)
vue-dompurify-html:
specifier: ^5.2.0
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n:
specifier: '9'
version: 9.14.5(vue@3.5.13(typescript@5.7.3))
@@ -967,6 +970,13 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-mention@2.27.1':
resolution: {integrity: sha512-8qwPIum0rMK/7BaHEN0+M/VkUn00LqhqyRO8JdC/EBVrUBgxKTQsUhIhSstgVAYzD1w7cCUfTFX4bYu9lcFMEQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/suggestion': ^2.7.0
'@tiptap/extension-ordered-list@2.11.2':
resolution: {integrity: sha512-TR8OqwKkQ0OCp40V9hcRJUcO1PSzCYWXy0mvW351lOYO8D6uE+1ouVkEV9qjXBC30sVCnQykSp/FR9UjsIuiVw==}
peerDependencies:
@@ -1025,6 +1035,12 @@ packages:
'@tiptap/starter-kit@2.11.2':
resolution: {integrity: sha512-FUIblP9BSmBzskf/aX7AIcUK5XP5Gi/VqUqm5evCkzlR1FrggLoy+vY+CX0me4oE/WYk4KAgIRXkE9tcbwotQA==}
'@tiptap/suggestion@2.27.1':
resolution: {integrity: sha512-yTy75ZMYgVWM18cl7YxLqMJ7TorQTGysSd1aKmBA9qd8uzYlvLMmHKE9qBDxM9HXODBz1DA/BLLm9esv2enmFw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/vue-3@2.11.2':
resolution: {integrity: sha512-lbWbT3PimpvKv8+dX2rf5OxjeLGzu/gq0sOi7uNoHMm+nEHa3ztaJSwvx/6WflU59Pt1dyFMvQPTx9zMbx6umQ==}
peerDependencies:
@@ -1209,9 +1225,6 @@ packages:
'@types/topojson@3.2.6':
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -1522,16 +1535,12 @@ packages:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
call-bind-apply-helpers@1.0.1:
resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
engines: {node: '>= 0.4'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.3:
resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
@@ -1912,9 +1921,6 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1968,10 +1974,6 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.0.0:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2206,10 +2208,6 @@ packages:
resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==}
engines: {node: '>= 0.10'}
get-intrinsic@1.2.7:
resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==}
engines: {node: '>= 0.4'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -2667,8 +2665,8 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
object-inspect@1.13.3:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
ohash@2.0.11:
@@ -2947,6 +2945,10 @@ packages:
resolution: {integrity: sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==}
engines: {node: '>=0.6'}
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -3447,11 +3449,6 @@ packages:
'@vue/composition-api':
optional: true
vue-dompurify-html@5.2.0:
resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
peerDependencies:
vue: ^3.0.0
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -3461,6 +3458,7 @@ packages:
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
peerDependencies:
vue: ^3.0.0
@@ -4306,6 +4304,12 @@ snapshots:
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
'@tiptap/extension-mention@2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)(@tiptap/suggestion@2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2))':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
'@tiptap/pm': 2.11.2
'@tiptap/suggestion': 2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-ordered-list@2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
@@ -4393,6 +4397,11 @@ snapshots:
'@tiptap/extension-text-style': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/pm': 2.11.2
'@tiptap/suggestion@2.27.1(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
'@tiptap/pm': 2.11.2
'@tiptap/vue-3@2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
@@ -4611,9 +4620,6 @@ snapshots:
'@types/topojson-simplify': 3.0.3
'@types/topojson-specification': 1.0.5
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
@@ -5017,20 +5023,15 @@ snapshots:
cachedir@2.4.0: {}
call-bind-apply-helpers@1.0.1:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.3:
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.1
get-intrinsic: 1.2.7
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
callsites@3.1.0: {}
@@ -5449,10 +5450,6 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.2.4:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -5499,10 +5496,6 @@ snapshots:
es-module-lexer@1.7.0: {}
es-object-atoms@1.0.0:
dependencies:
es-errors: 1.3.0
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -5804,19 +5797,6 @@ snapshots:
geojson@0.5.0: {}
get-intrinsic@1.2.7:
dependencies:
call-bind-apply-helpers: 1.0.1
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.0.0
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -6243,7 +6223,7 @@ snapshots:
object-hash@3.0.0: {}
object-inspect@1.13.3: {}
object-inspect@1.13.4: {}
ohash@2.0.11: {}
@@ -6531,6 +6511,10 @@ snapshots:
dependencies:
side-channel: 1.1.0
qs@6.14.1:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
quickselect@2.0.0: {}
@@ -6678,27 +6662,27 @@ snapshots:
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.3
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.3
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.2.7
object-inspect: 1.13.3
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.3
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.2.7
object-inspect: 1.13.3
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.3
object-inspect: 1.13.4
side-channel-list: 1.0.0
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
@@ -7086,11 +7070,6 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
dependencies:
dompurify: 3.2.4
vue: 3.5.13(typescript@5.7.3)
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.0(supports-color@8.1.1)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

+104
View File
@@ -0,0 +1,104 @@
<svg version="1.1" viewBox="0 0 268.1522 273.8827" overflow="hidden" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="a">
<stop offset="0" stop-color="#0fbc5c"/>
<stop offset="1" stop-color="#0cba65"/>
</linearGradient>
<linearGradient id="g">
<stop offset=".2312727" stop-color="#0fbc5f"/>
<stop offset=".3115468" stop-color="#0fbc5f"/>
<stop offset=".3660131" stop-color="#0fbc5e"/>
<stop offset=".4575163" stop-color="#0fbc5d"/>
<stop offset=".540305" stop-color="#12bc58"/>
<stop offset=".6993464" stop-color="#28bf3c"/>
<stop offset=".7712418" stop-color="#38c02b"/>
<stop offset=".8605665" stop-color="#52c218"/>
<stop offset=".9150327" stop-color="#67c30f"/>
<stop offset="1" stop-color="#86c504"/>
</linearGradient>
<linearGradient id="h">
<stop offset=".1416122" stop-color="#1abd4d"/>
<stop offset=".2475151" stop-color="#6ec30d"/>
<stop offset=".3115468" stop-color="#8ac502"/>
<stop offset=".3660131" stop-color="#a2c600"/>
<stop offset=".4456735" stop-color="#c8c903"/>
<stop offset=".540305" stop-color="#ebcb03"/>
<stop offset=".6156363" stop-color="#f7cd07"/>
<stop offset=".6993454" stop-color="#fdcd04"/>
<stop offset=".7712418" stop-color="#fdce05"/>
<stop offset=".8605661" stop-color="#ffce0a"/>
</linearGradient>
<linearGradient id="f">
<stop offset=".3159041" stop-color="#ff4c3c"/>
<stop offset=".6038179" stop-color="#ff692c"/>
<stop offset=".7268366" stop-color="#ff7825"/>
<stop offset=".884534" stop-color="#ff8d1b"/>
<stop offset="1" stop-color="#ff9f13"/>
</linearGradient>
<linearGradient id="b">
<stop offset=".2312727" stop-color="#ff4541"/>
<stop offset=".3115468" stop-color="#ff4540"/>
<stop offset=".4575163" stop-color="#ff4640"/>
<stop offset=".540305" stop-color="#ff473f"/>
<stop offset=".6993464" stop-color="#ff5138"/>
<stop offset=".7712418" stop-color="#ff5b33"/>
<stop offset=".8605665" stop-color="#ff6c29"/>
<stop offset="1" stop-color="#ff8c18"/>
</linearGradient>
<linearGradient id="d">
<stop offset=".4084578" stop-color="#fb4e5a"/>
<stop offset="1" stop-color="#ff4540"/>
</linearGradient>
<linearGradient id="c">
<stop offset=".1315461" stop-color="#0cba65"/>
<stop offset=".2097843" stop-color="#0bb86d"/>
<stop offset=".2972969" stop-color="#09b479"/>
<stop offset=".3962575" stop-color="#08ad93"/>
<stop offset=".4771242" stop-color="#0aa6a9"/>
<stop offset=".5684245" stop-color="#0d9cc6"/>
<stop offset=".667385" stop-color="#1893dd"/>
<stop offset=".7687273" stop-color="#258bf1"/>
<stop offset=".8585063" stop-color="#3086ff"/>
</linearGradient>
<linearGradient id="e">
<stop offset=".3660131" stop-color="#ff4e3a"/>
<stop offset=".4575163" stop-color="#ff8a1b"/>
<stop offset=".540305" stop-color="#ffa312"/>
<stop offset=".6156363" stop-color="#ffb60c"/>
<stop offset=".7712418" stop-color="#ffcd0a"/>
<stop offset=".8605665" stop-color="#fecf0a"/>
<stop offset=".9150327" stop-color="#fecf08"/>
<stop offset="1" stop-color="#fdcd01"/>
</linearGradient>
<linearGradient xlink:href="#a" id="s" x1="219.6997" y1="329.5351" x2="254.4673" y2="329.5351" gradientUnits="userSpaceOnUse"/>
<radialGradient xlink:href="#b" id="m" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,1.043001,1.455731,2.555422,290.5254,-400.6338)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/>
<radialGradient xlink:href="#c" id="n" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.512595,-4.45809,-1.692547,1.260616,870.8006,191.554)" cx="45.25866" cy="279.2738" fx="45.25866" fy="279.2738" r="71.46001"/>
<radialGradient xlink:href="#d" id="l" cx="304.0166" cy="118.0089" fx="304.0166" fy="118.0089" r="47.85445" gradientTransform="matrix(2.064353,-4.926832e-6,-2.901531e-6,2.592041,-297.6788,-151.7469)" gradientUnits="userSpaceOnUse"/>
<radialGradient xlink:href="#e" id="o" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.2485783,2.083138,2.962486,0.3341668,-255.1463,-331.1636)" cx="181.001" cy="177.2013" fx="181.001" fy="177.2013" r="71.46001"/>
<radialGradient xlink:href="#f" id="p" cx="207.6733" cy="108.0972" fx="207.6733" fy="108.0972" r="41.1025" gradientTransform="matrix(-1.249206,1.343263,-3.896837,-3.425693,880.5011,194.9051)" gradientUnits="userSpaceOnUse"/>
<radialGradient xlink:href="#g" id="r" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,-1.043001,1.455731,-2.555422,290.5254,838.6834)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/>
<radialGradient xlink:href="#h" id="j" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.081402,-1.93722,2.926737,-0.1162508,-215.1345,632.8606)" cx="154.8697" cy="145.9691" fx="154.8697" fy="145.9691" r="71.46001"/>
<filter id="q" x="-.04842873" y="-.0582241" width="1.096857" height="1.116448" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="1.700914"/>
</filter>
<filter id="k" x="-.01670084" y="-.01009856" width="1.033402" height="1.020197" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation=".2419367"/>
</filter>
<clipPath clipPathUnits="userSpaceOnUse" id="i">
<path d="M371.3784 193.2406H237.0825v53.4375h77.167c-1.2405 7.5627-4.0259 15.0024-8.1049 21.7862-4.6734 7.7723-10.4511 13.6895-16.373 18.1957-17.7389 13.4983-38.42 16.2584-52.7828 16.2584-36.2824 0-67.2833-23.2865-79.2844-54.9287-.4843-1.1482-.8059-2.3344-1.1975-3.5068-2.652-8.0533-4.101-16.5825-4.101-25.4474 0-9.226 1.5691-18.0575 4.4301-26.3985 11.2851-32.8967 42.9849-57.4674 80.1789-57.4674 7.4811 0 14.6854.8843 21.5173 2.6481 15.6135 4.0309 26.6578 11.9698 33.4252 18.2494l40.834-39.7111c-24.839-22.616-57.2194-36.3201-95.8444-36.3201-30.8782-.00066-59.3863 9.55308-82.7477 25.6992-18.9454 13.0941-34.4833 30.6254-44.9695 50.9861-9.75366 18.8785-15.09441 39.7994-15.09441 62.2934 0 22.495 5.34891 43.6334 15.10261 62.3374v.126c10.3023 19.8567 25.3678 36.9537 43.6783 49.9878 15.9962 11.3866 44.6789 26.5516 84.0307 26.5516 22.6301 0 42.6867-4.0517 60.3748-11.6447 12.76-5.4775 24.0655-12.6217 34.3012-21.8036 13.5247-12.1323 24.1168-27.1388 31.3465-44.4041 7.2297-17.2654 11.097-36.7895 11.097-57.957 0-9.858-.9971-19.8694-2.6881-28.9684Z" fill="#000"/>
</clipPath>
</defs>
<g transform="matrix(0.957922,0,0,0.985255,-90.17436,-78.85577)">
<g clip-path="url(#i)">
<path d="M92.07563 219.9585c.14844 22.14 6.5014 44.983 16.11767 63.4234v.1269c6.9482 13.3919 16.4444 23.9704 27.2604 34.4518l65.326-23.67c-12.3593-6.2344-14.2452-10.0546-23.1048-17.0253-9.0537-9.0658-15.8015-19.4735-20.0038-31.677h-.1693l.1693-.1269c-2.7646-8.0587-3.0373-16.6129-3.1393-25.5029Z" fill="url(#j)" filter="url(#k)"/>
<path d="M237.0835 79.02491c-6.4568 22.52569-3.988 44.42139 0 57.16129 7.4561.0055 14.6388.8881 21.4494 2.6464 15.6135 4.0309 26.6566 11.97 33.424 18.2496l41.8794-40.7256c-24.8094-22.58904-54.6663-37.2961-96.7528-37.33169Z" fill="url(#l)" filter="url(#k)"/>
<path d="M236.9434 78.84678c-31.6709-.00068-60.9107 9.79833-84.8718 26.35902-8.8968 6.149-17.0612 13.2521-24.3311 21.1509-1.9045 17.7429 14.2569 39.5507 46.2615 39.3702 15.5284-17.9373 38.4946-29.5427 64.0561-29.5427.0233 0 .046.0019.0693.002l-1.0439-57.33536c-.0472-.00003-.0929-.00406-.1401-.00406Z" fill="url(#m)" filter="url(#k)"/>
<path d="m341.4751 226.3788-28.2685 19.2848c-1.2405 7.5627-4.0278 15.0023-8.1068 21.7861-4.6734 7.7723-10.4506 13.6898-16.3725 18.196-17.7022 13.4704-38.3286 16.2439-52.6877 16.2553-14.8415 25.1018-17.4435 37.6749 1.0439 57.9342 22.8762-.0167 43.157-4.1174 61.0458-11.7965 12.9312-5.551 24.3879-12.7913 34.7609-22.0964 13.7061-12.295 24.4421-27.5034 31.7688-45.0003 7.3267-17.497 11.2446-37.2822 11.2446-58.7336Z" fill="url(#n)" filter="url(#k)"/>
<path d="M234.9956 191.2104v57.4981h136.0062c1.1962-7.8745 5.1523-18.0644 5.1523-26.5001 0-9.858-.9963-21.899-2.6873-30.998Z" fill="#3086ff" filter="url(#k)"/>
<path d="M128.3894 124.3268c-8.393 9.1191-15.5632 19.326-21.2483 30.3646-9.75351 18.8785-15.09402 41.8295-15.09402 64.3235 0 .317.02642.6271.02855.9436 4.31953 8.2244 59.66647 6.6495 62.45617 0-.0035-.3103-.0387-.6128-.0387-.9238 0-9.226 1.5696-16.0262 4.4306-24.3672 3.5294-10.2885 9.0557-19.7628 16.1223-27.9257 1.6019-2.0309 5.8748-6.3969 7.1214-9.0157.4749-.9975-.8621-1.5574-.9369-1.9085-.0836-.3927-1.8762-.0769-2.2778-.3694-1.2751-.9288-3.8001-1.4138-5.3334-1.8449-3.2772-.9215-8.7085-2.9536-11.7252-5.0601-9.5357-6.6586-24.417-14.6122-33.5047-24.2164Z" fill="url(#o)" filter="url(#k)"/>
<path d="M162.0989 155.8569c22.1123 13.3013 28.4714-6.7139 43.173-12.9771L179.698 90.21568c-9.4075 3.92642-18.2957 8.80465-26.5426 14.50442-12.316 8.5122-23.192 18.8995-32.1763 30.7204Z" fill="url(#p)" filter="url(#q)"/>
<path d="M171.0987 290.222c-29.6829 10.6413-34.3299 11.023-37.0622 29.2903 5.2213 5.0597 10.8312 9.74 16.7926 13.9835 15.9962 11.3867 46.766 26.5517 86.1178 26.5517.0462 0 .0904-.004.1366-.004v-59.1574c-.0298.0001-.064.002-.0938.002-14.7359 0-26.5113-3.8435-38.5848-10.5273-2.9768-1.6479-8.3775 2.7772-11.1229.799-3.7865-2.7284-12.8991 2.3508-16.1833-.9378Z" fill="url(#r)" filter="url(#k)"/>
<path d="M219.6997 299.0227v59.9959c5.506.6402 11.2361 1.0289 17.2472 1.0289 6.0259 0 11.8556-.3073 17.5204-.8723v-59.7481c-6.3482 1.0777-12.3272 1.461-17.4776 1.461-5.9318 0-11.7005-.6858-17.29-1.8654Z" opacity=".5" fill="url(#s)" filter="url(#k)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier">
<path fill="#F35325" d="M1 1h6.5v6.5H1V1z"/>
<path fill="#81BC06" d="M8.5 1H15v6.5H8.5V1z"/>
<path fill="#05A6F0" d="M1 8.5h6.5V15H1V8.5z"/>
<path fill="#FFBA08" d="M8.5 8.5H15V15H8.5V8.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 644 B

+31 -4
View File
@@ -1,8 +1,8 @@
<template>
<div class="flex w-full h-screen text-foreground">
<div class="flex w-full h-screen text-foreground bg-canvas p-1.5">
<!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r">
<ShadcnSidebar collapsible="none" class="border rounded-lg overflow-hidden">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
@@ -72,7 +72,21 @@
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarNavUser />
<SidebarMenu>
<SidebarMenuItem>
<Tooltip>
<TooltipTrigger as-child>
<NotificationBell />
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.notification', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarNavUser />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</ShadcnSidebar>
</SidebarProvider>
@@ -82,12 +96,13 @@
<Sidebar
:userTeams="userStore.teams"
:userViews="userViews"
:sharedViews="sharedViewStore.sharedViewList"
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => (openCreateConversationDialog = true)"
>
<div class="flex flex-col h-screen">
<div class="flex flex-col h-full rounded-lg overflow-hidden bg-background">
<!-- Show admin banner only in admin routes -->
<AdminBanner v-if="route.path.startsWith('/admin')" />
@@ -123,6 +138,7 @@ import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useSharedViewStore } from '@/stores/sharedView'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
@@ -150,6 +166,7 @@ import {
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
import NotificationBell from '@/components/sidebar/NotificationBell.vue'
const route = useRoute()
const emitter = useEmitter()
@@ -160,6 +177,7 @@ const teamStore = useTeamStore()
const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const sharedViewStore = useSharedViewStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
@@ -184,8 +202,10 @@ const initStores = async () => {
}
await Promise.allSettled([
getUserViews(),
sharedViewStore.loadSharedViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),
conversationStore.fetchAllDrafts(),
usersStore.fetchUsers(),
teamStore.fetchTeams(),
inboxStore.fetchInboxes(),
@@ -252,3 +272,10 @@ const refreshViews = (data) => {
}
}
</script>
<style scoped>
:deep(.group\/sidebar-wrapper) {
min-height: auto !important;
height: 100%;
}
</style>
+71 -1
View File
@@ -302,6 +302,7 @@ const updateConversationPriority = (uuid, data) =>
}
})
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const markConversationAsUnread = (uuid) => http.put(`/api/v1/conversations/${uuid}/mark-unread`)
const getConversationMessage = (cuuid, uuid) =>
http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) =>
@@ -343,6 +344,8 @@ const getAssignedConversations = (params) => http.get('/api/v1/conversations/ass
const getUnassignedConversations = (params) =>
http.get('/api/v1/conversations/unassigned', { params })
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
const getMentionedConversations = (params) =>
http.get('/api/v1/conversations/mentioned', { params })
const getViewConversations = (id, params) =>
http.get(`/api/v1/views/${id}/conversations`, { params })
const uploadMedia = (data) =>
@@ -354,6 +357,9 @@ const uploadMedia = (data) =>
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params })
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getOverviewCSAT = (params) => http.get('/api/v1/reports/overview/csat', { params })
const getOverviewMessageVolume = (params) => http.get('/api/v1/reports/overview/messages', { params })
const getOverviewTagDistribution = (params) => http.get('/api/v1/reports/overview/tags', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createInbox = (data) =>
http.post('/api/v1/inboxes', data, {
@@ -371,6 +377,16 @@ const updateInbox = (id, data) =>
}
})
const deleteInbox = (id) => http.delete(`/api/v1/inboxes/${id}`)
const saveDraft = (uuid, data) =>
http.post(`/api/v1/conversations/${uuid}/draft`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAllDrafts = () => http.get('/api/v1/drafts')
const deleteDraft = (uuid) => http.delete(`/api/v1/conversations/${uuid}/draft`)
const getCurrentUserViews = () => http.get('/api/v1/views/me')
const createView = (data) =>
http.post('/api/v1/views/me', data, {
@@ -385,6 +401,24 @@ const updateView = (id, data) =>
}
})
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getSharedViews = () => http.get('/api/v1/views/shared')
const getAllSharedViews = () => http.get('/api/v1/shared-views')
const getSharedView = (id) => http.get(`/api/v1/shared-views/${id}`)
const createSharedView = (data) =>
http.post('/api/v1/shared-views', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateSharedView = (id, data) =>
http.put(`/api/v1/shared-views/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteSharedView = (id) => http.delete(`/api/v1/shared-views/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
headers: {
@@ -431,6 +465,21 @@ const generateAPIKey = (id) =>
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
const initiateOAuthFlow = (provider, data) =>
http.post(`/api/v1/inboxes/oauth/${provider}/authorize`, data, {
headers: {
'Content-Type': 'application/json'
}
})
// User notifications (in-app)
const getNotifications = (params) => http.get('/api/v1/notifications', { params })
const getNotificationStats = () => http.get('/api/v1/notifications/stats')
const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/read`)
const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all')
const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`)
const deleteAllNotifications = () => http.delete('/api/v1/notifications')
export default {
login,
deleteUser,
@@ -466,11 +515,15 @@ export default {
getAssignedConversations,
getUnassignedConversations,
getAllConversations,
getMentionedConversations,
getTeamUnassignedConversations,
getViewConversations,
getOverviewCharts,
getOverviewCounts,
getOverviewSLA,
getOverviewCSAT,
getOverviewMessageVolume,
getOverviewTagDistribution,
getConversationParticipants,
getConversationMessage,
getConversationMessages,
@@ -491,6 +544,7 @@ export default {
updateContactCustomAttribute,
uploadMedia,
updateAssigneeLastSeen,
markConversationAsUnread,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
@@ -536,10 +590,19 @@ export default {
getUsersCompact,
getEmailNotificationSettings,
updateEmailNotificationSettings,
saveDraft,
getAllDrafts,
deleteDraft,
getCurrentUserViews,
createView,
updateView,
deleteView,
getSharedViews,
getAllSharedViews,
getSharedView,
createSharedView,
updateSharedView,
deleteSharedView,
getAiPrompts,
aiCompletion,
searchConversations,
@@ -567,5 +630,12 @@ export default {
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
revokeAPIKey,
initiateOAuthFlow,
getNotifications,
getNotificationStats,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications
}
+81 -43
View File
@@ -70,24 +70,24 @@
}
}
:root {
--sidebar-background: 0 0% 100%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 30%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-border: 240 5% 88%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-background: 240 5% 7%;
--sidebar-foreground: 0 0% 100%;
--sidebar-primary: 235 86% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-border: 240 5% 14%;
--sidebar-ring: 235 86% 65%;
}
:root {
@@ -131,36 +131,40 @@
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--private: 35 90% 94%;
--canvas: 240 5% 90%;
}
.dark {
--background: 240 5.9% 10%;
--foreground: 0 0% 98%;
--background: 240 7% 11%;
--foreground: 240 4% 94%;
--card: 240 5.9% 10%;
--card-foreground: 0 0% 98%;
--card: 240 5% 13%;
--card-foreground: 240 4% 94%;
--popover: 240 5.9% 10%;
--popover-foreground: 0 0% 98%;
--popover: 240 5% 15%;
--popover-foreground: 240 4% 94%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--primary: 240 4% 94%;
--primary-foreground: 240 7% 11%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--secondary: 240 5% 16%;
--secondary-foreground: 240 4% 94%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--muted: 240 5% 16%;
--muted-foreground: 240 4% 60%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--destructive: 1 100% 69%;
--destructive-foreground: 0 0% 100%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--border: 240 5% 18%;
--input: 240 5% 16%;
--ring: 240 4.9% 83.9%;
--private: 30 35% 18%;
--canvas: 240 7% 15%;
}
}
@@ -173,13 +177,14 @@
}
// Force pre blocks to wrap
pre, code {
pre,
code {
white-space: pre-wrap;
}
table {
width: 100% !important;
table-layout: auto !important;
max-width: 100% !important;
table-layout: fixed !important;
}
}
@@ -196,31 +201,55 @@
}
.loading-fade {
@apply opacity-50 transition-opacity duration-300
@apply opacity-50 transition-opacity duration-300;
}
// Scrollbar start
::-webkit-scrollbar {
width: 8px; /* Adjust width */
height: 8px; /* Adjust height */
* {
scrollbar-width: auto;
scrollbar-color: theme('colors.gray.300') transparent;
}
::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 3px;
border: 2px solid transparent;
background-clip: content-box;
.dark * {
scrollbar-color: theme('colors.gray.600') transparent;
}
::-webkit-scrollbar-thumb:hover {
background-color: #555; /* Hover effect */
}
@supports selector(::-webkit-scrollbar) {
* {
scrollbar-width: initial;
scrollbar-color: initial;
}
::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 3px;
.dark * {
scrollbar-color: initial;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
}
.dark *::-webkit-scrollbar-thumb {
@apply bg-gray-600;
}
.dark *::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}
}
// End Scrollbar
// Scrollbar end
.show-quoted-text {
blockquote {
@@ -244,3 +273,12 @@
@apply text-blue-500 hover:underline;
}
}
/* Fix collapsed sidebar visibility in padding gap.
* The sidebar is position:fixed and slides to left:-14rem when collapsed,
* but ml-[3.2rem] offset causes part to remain visible in the app's padding gap.
* Push it further left to fully hide it.
*/
[data-state="collapsed"][data-collapsible="offcanvas"] .sidebar-secondary {
left: calc((var(--sidebar-width) + 3.5rem) * -1.4) !important;
}
@@ -4,7 +4,13 @@
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
class="font-semibold"
:class="{ 'cursor-pointer select-none': header.column.getCanSort() }"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
@@ -40,9 +46,9 @@
</template>
<script setup>
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
import { FlexRender, getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import {
Table,
@@ -72,6 +78,8 @@ const emptyText = computed(
})
)
const sorting = ref([])
const table = useVueTable({
get data() {
return props.data
@@ -79,6 +87,16 @@ const table = useVueTable({
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel()
state: {
get sorting() {
return sorting.value
}
},
onSortingChange: (updaterOrValue) => {
sorting.value =
typeof updaterOrValue === 'function' ? updaterOrValue(sorting.value) : updaterOrValue
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
})
</script>
@@ -0,0 +1,122 @@
<template>
<div
v-if="items.length > 0"
class="mention-list bg-background border rounded-lg shadow-lg overflow-hidden max-h-60 overflow-y-auto"
>
<button
v-for="(item, index) in items"
:key="item.id"
class="mention-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-muted"
:class="{ 'bg-muted': index === selectedIndex }"
@click="selectItem(index)"
>
<span v-if="item.type === 'team'" class="text-lg">{{ item.emoji || '👥' }}</span>
<Avatar v-else class="w-6 h-6">
<AvatarImage :src="item.avatar_url" :alt="item.label" />
<AvatarFallback class="text-xs">{{ getInitials(item.label) }}</AvatarFallback>
</Avatar>
<span class="flex-1 truncate">{{ item.label }}</span>
<span class="text-xs text-muted-foreground">{{ getTypeLabel(item.type) }}</span>
</button>
</div>
<div v-else-if="query" class="mention-list bg-background border rounded-lg shadow-lg p-3">
<span class="text-sm text-muted-foreground">{{ $t('globals.messages.noResults') }}</span>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
const { t } = useI18n()
const props = defineProps({
items: {
type: Array,
default: () => []
},
command: {
type: Function,
required: true
},
query: {
type: String,
default: ''
}
})
const selectedIndex = ref(0)
const getInitials = (name) => {
if (!name) return '?'
const parts = name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return name.substring(0, 2).toUpperCase()
}
const getTypeLabel = (type) => {
if (type === 'agent') return t('globals.terms.agent').toLowerCase()
if (type === 'team') return t('globals.terms.team').toLowerCase()
return type
}
const selectItem = (index) => {
const item = props.items[index]
if (item) {
props.command({ id: item.id, type: item.type, label: item.label })
}
}
const upHandler = () => {
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
}
const downHandler = () => {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
}
const enterHandler = () => {
selectItem(selectedIndex.value)
}
watch(
() => props.items,
() => {
selectedIndex.value = 0
}
)
// Scroll selected item into view on keyboard navigation
watch(selectedIndex, () => {
nextTick(() => {
document.querySelector('.mention-item.bg-muted')?.scrollIntoView({ block: 'nearest' })
})
})
defineExpose({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
})
</script>
<style scoped>
.mention-list {
min-width: 200px;
}
</style>
+121 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="editor-wrapper h-full overflow-y-auto">
<div class="editor-wrapper h-full overflow-y-auto" :class="{ 'pointer-events-none': disabled }">
<BubbleMenu
:editor="editor"
:tippy-options="{ duration: 100 }"
@@ -76,9 +76,20 @@
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{{ editor?.isActive('link')
? $t('globals.messages.edit', { name: $t('globals.terms.link', 1).toLowerCase() + ' ' + $t('globals.terms.url', 1).toLowerCase() })
: $t('globals.messages.add', { name: $t('globals.terms.link', 1).toLowerCase() + ' ' + $t('globals.terms.url', 1).toLowerCase() })
{{
editor?.isActive('link')
? $t('globals.messages.edit', {
name:
$t('globals.terms.link', 1).toLowerCase() +
' ' +
$t('globals.terms.url', 1).toLowerCase()
})
: $t('globals.messages.add', {
name:
$t('globals.terms.link', 1).toLowerCase() +
' ' +
$t('globals.terms.url', 1).toLowerCase()
})
}}
</DialogTitle>
<DialogDescription></DialogDescription>
@@ -93,7 +104,12 @@
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="unsetLink" v-if="editor?.isActive('link')">
<Button
type="button"
variant="outline"
@click="unsetLink"
v-if="editor?.isActive('link')"
>
{{ $t('globals.messages.remove', { name: $t('globals.terms.link', 1) }) }}
</Button>
<Button type="submit">
@@ -116,7 +132,7 @@ import {
Bot,
List,
ListOrdered,
Link as LinkIcon,
Link as LinkIcon
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
@@ -138,10 +154,12 @@ import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Mention from '@tiptap/extension-mention'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import mentionSuggestion from './mentionSuggestion'
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
@@ -158,10 +176,22 @@ const props = defineProps({
aiPrompts: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
enableMentions: {
type: Boolean,
default: false
},
getSuggestions: {
type: Function,
default: null
}
})
const emit = defineEmits(['send', 'aiPromptSelected'])
const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
@@ -206,10 +236,27 @@ const CustomTableHeader = TableHeader.extend({
}
})
// Extend Mention to include 'type' attribute for agent/team distinction
const CustomMention = Mention.extend({
addAttributes() {
return {
...this.parent?.(),
type: {
default: null,
parseHTML: (element) => element.getAttribute('data-type'),
renderHTML: (attributes) => {
if (!attributes.type) return {}
return { 'data-type': attributes.type }
}
}
}
}
})
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
const buildExtensions = () => {
const extensions = [
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
@@ -217,12 +264,52 @@ const editor = useEditor({
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
CustomTableHeader,
// Always include mention extension - it gracefully handles missing getSuggestions
CustomMention.configure({
HTMLAttributes: {
class: 'mention'
},
suggestion: mentionSuggestion
})
]
return extensions
}
// Extract mentions from editor content
const extractMentions = () => {
if (!editor.value) return []
const mentions = []
const json = editor.value.getJSON()
const traverse = (node) => {
if (node.type === 'mention' && node.attrs) {
mentions.push({
id: node.attrs.id,
type: node.attrs.type
})
}
if (node.content) {
node.content.forEach(traverse)
}
}
if (json.content) {
json.content.forEach(traverse)
}
return mentions
}
const editor = useEditor({
extensions: buildExtensions(),
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
attributes: { class: 'outline-none' },
getSuggestions: props.getSuggestions,
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
event.stopPropagation()
@@ -240,6 +327,11 @@ const editor = useEditor({
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
// Emit mentions if enabled
if (props.enableMentions) {
emit('mentionsChanged', extractMentions())
}
}
})
@@ -287,6 +379,13 @@ const unsetLink = () => {
editor.value?.chain().focus().unsetLink().run()
showLinkDialog.value = false
}
// Expose focus method for parent components
const focus = () => {
editor.value?.commands.focus()
}
defineExpose({ focus, extractMentions })
</script>
<style lang="scss">
@@ -297,6 +396,7 @@ const unsetLink = () => {
color: #adb5bd;
pointer-events: none;
height: 0;
font-size: 0.875rem;
}
// Ensure the parent div has a proper height
@@ -334,5 +434,14 @@ const unsetLink = () => {
color: #003d7a;
}
}
// Mention styling
.mention {
background-color: hsl(var(--primary) / 0.1);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
color: hsl(var(--primary));
font-weight: 500;
}
}
</style>
</style>
@@ -0,0 +1,106 @@
import { VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue'
export default {
char: '@',
allowSpaces: true,
items: async ({ query, editor }) => {
// Get the suggestion handler from editor options
const getSuggestions = editor.options.editorProps?.getSuggestions
if (!getSuggestions) return []
const items = await getSuggestions(query)
return items
},
render: () => {
let component
let popup
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props: {
...props,
query: props.query
},
editor: props.editor
})
if (!props.clientRect) {
return
}
// Create popup container with CSS positioning
popup = document.createElement('div')
popup.style.position = 'fixed'
popup.style.zIndex = '9999'
if (component.element) {
popup.appendChild(component.element)
}
document.body.appendChild(popup)
// Position the popup
updatePosition(popup, props.clientRect)
},
onUpdate: (props) => {
component.updateProps({
...props,
query: props.query
})
if (!props.clientRect || !popup) {
return
}
updatePosition(popup, props.clientRect)
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
if (popup) {
popup.style.display = 'none'
}
return true
}
return component.ref?.onKeyDown(props)
},
onExit: () => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup)
}
component.destroy()
}
}
}
}
function updatePosition(popup, clientRect) {
const rect = clientRect()
if (!rect) return
// Position below the cursor
popup.style.left = `${rect.left}px`
popup.style.top = `${rect.bottom + 4}px`
// Ensure popup doesn't go off-screen to the right
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Adjust horizontal position if needed
if (popupRect.right > viewportWidth) {
popup.style.left = `${viewportWidth - popupRect.width - 8}px`
}
// If popup goes below viewport, show above cursor
if (popupRect.bottom > viewportHeight) {
popup.style.top = `${rect.top - popupRect.height - 4}px`
}
})
}
+3 -4
View File
@@ -3,7 +3,8 @@
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />
<img v-if="typeof icon === 'string'" :src="icon" class="w-6 h-6 mr-2" />
<component v-else :is="icon" size="24" class="mr-2 text-primary" />
<h3 class="text-lg font-medium">{{ title }}</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
@@ -11,12 +12,10 @@
</template>
<script setup>
import { defineEmits } from 'vue'
const props = defineProps({
title: String,
subTitle: String,
icon: Function,
icon: [Function, String],
onClick: Function
})
@@ -0,0 +1,40 @@
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<SidebarMenuButton size="md" class="relative" @click="handleOpen">
<Bell class="h-5 w-5" />
<span
v-if="notificationStore.unreadCount > 0"
class="absolute top-0.5 right-0.5 inline-flex size-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-medium text-destructive-foreground"
>
{{ notificationStore.unreadCount > 99 ? '99' : notificationStore.unreadCount }}
</span>
</SidebarMenuButton>
</PopoverTrigger>
<PopoverContent side="right" :side-offset="8" align="end" class="w-96 p-0">
<NotificationPanel @close="isOpen = false" />
</PopoverContent>
</Popover>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Bell } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { useNotificationStore } from '@/stores/notification'
import NotificationPanel from './NotificationPanel.vue'
const notificationStore = useNotificationStore()
const isOpen = ref(false)
onMounted(() => {
notificationStore.fetchStats()
})
const handleOpen = () => {
if (!isOpen.value) {
notificationStore.fetchNotifications()
}
}
</script>
@@ -0,0 +1,211 @@
<template>
<div class="flex flex-col max-h-[32rem]">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b">
<h3 class="font-semibold text-sm">{{ t('globals.terms.notification', 2) }}</h3>
<div class="flex items-center gap-2">
<Button
v-if="notificationStore.unreadCount > 0"
variant="ghost"
size="sm"
class="h-7 px-2"
:title="t('globals.messages.markAllAsRead')"
@click="handleMarkAllAsRead"
>
<CheckCheck class="h-3.5 w-3.5" />
</Button>
<Button
v-if="notificationStore.notifications.length > 0"
variant="ghost"
size="sm"
class="h-7 px-2"
:title="t('globals.messages.deleteAll')"
@click="handleDeleteAll"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Notification List -->
<div class="flex-1 overflow-y-auto">
<!-- Loading State -->
<div v-if="notificationStore.isLoading && notificationStore.notifications.length === 0" class="p-4">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="flex gap-3">
<Skeleton class="h-8 w-8 rounded-full" />
<div class="flex-1 space-y-2">
<Skeleton class="h-3 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-else-if="notificationStore.notifications.length === 0"
class="flex flex-col items-center justify-center py-8 text-muted-foreground"
>
<BellOff class="h-8 w-8 mb-2" />
<p class="text-sm">{{ t('globals.messages.noResults', { name: t('globals.terms.notification', 2) }) }}</p>
</div>
<!-- Notifications -->
<div v-else class="divide-y">
<div
v-for="notification in notificationStore.notifications"
:key="notification.id"
class="group relative px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors"
:class="{ 'opacity-60': notification.is_read }"
@click="handleNotificationClick(notification)"
>
<div class="flex gap-3">
<!-- Icon based on notification type -->
<div
class="flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center"
:class="getNotificationIconClass(notification.notification_type)"
>
<component :is="getNotificationIcon(notification.notification_type)" class="h-4 w-4" />
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="{ 'font-semibold': !notification.is_read }">
{{ notification.title }}
</p>
<p v-if="notification.body" class="text-xs text-muted-foreground mt-0.5">
{{ notification.body }}
</p>
<p class="text-xs text-muted-foreground mt-1">
{{ getRelativeTime(new Date(notification.created_at)) }}
</p>
</div>
<!-- Action buttons (visible on hover) -->
<div class="flex items-start gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
v-if="!notification.is_read"
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
@click.stop="handleMarkAsRead(notification)"
>
<Check class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:text-destructive"
@click.stop="handleDelete(notification)"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="notificationStore.hasMore && notificationStore.notifications.length > 0" class="p-2">
<Button
variant="ghost"
size="sm"
class="w-full"
:disabled="notificationStore.isLoading"
@click="notificationStore.loadMore"
>
{{ notificationStore.isLoading ? t('globals.messages.loading') : t('globals.terms.loadMore') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Bell,
BellOff,
Check,
CheckCheck,
X,
Trash2,
AtSign,
UserPlus,
AlertTriangle,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useNotificationStore } from '@/stores/notification'
import { getRelativeTime } from '@/utils/datetime'
const emit = defineEmits(['close'])
const router = useRouter()
const { t } = useI18n()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.fetchNotifications()
})
const getNotificationIcon = (type) => {
const icons = {
mention: AtSign,
assignment: UserPlus,
sla_warning: AlertTriangle,
sla_breach: AlertCircle
}
return icons[type] || Bell
}
const getNotificationIconClass = (type) => {
const classes = {
mention: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
assignment: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
sla_warning: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400',
sla_breach: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
}
return classes[type] || 'bg-muted text-muted-foreground'
}
const handleNotificationClick = async (notification) => {
// Mark as read if unread
if (!notification.is_read) {
await notificationStore.markAsRead(notification.id)
}
// Navigate to conversation if available
if (notification.conversation_uuid) {
emit('close')
router.push({
name: 'inbox-conversation',
params: {
type: notification.notification_type === 'mention' ? 'mentioned' : 'assigned',
uuid: notification.conversation_uuid
},
query: notification.message_uuid ? { scrollTo: notification.message_uuid } : {}
})
}
}
const handleMarkAsRead = async (notification) => {
await notificationStore.markAsRead(notification.id)
}
const handleMarkAllAsRead = async () => {
await notificationStore.markAllAsRead()
}
const handleDelete = async (notification) => {
await notificationStore.deleteNotification(notification.id)
}
const handleDeleteAll = async () => {
await notificationStore.deleteAll()
}
</script>
+92 -25
View File
@@ -19,8 +19,7 @@ import {
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail
SidebarProvider
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
import {
@@ -30,7 +29,8 @@ import {
Search,
Plus,
CircleDashed,
List
List,
AtSign
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -49,6 +49,7 @@ import {
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { filterNavItems } from '@/utils/nav-permissions'
import { permissions } from '@/constants/permissions'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -57,7 +58,8 @@ import { useConversationStore } from '@/stores/conversation'
defineProps({
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
userViews: { type: Array, default: () => [] },
sharedViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const conversationStore = useConversationStore()
@@ -176,6 +178,7 @@ watch(
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
const sharedViewInboxOpen = useStorage('sharedViewInboxOpen', true)
// Track which view is being hovered for ellipsis menu visibility
const hoveredViewId = ref(null)
@@ -195,7 +198,7 @@ const viewToDelete = ref(null)
<template
v-if="route.matched.some((record) => record.name && record.name.startsWith('contact'))"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<Sidebar collapsible="offcanvas" class="sidebar-secondary">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -224,7 +227,6 @@ const viewToDelete = ref(null)
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
@@ -235,7 +237,7 @@ const viewToDelete = ref(null)
route.matched.some((record) => record.name && record.name.startsWith('reports'))
"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<Sidebar collapsible="offcanvas" class="sidebar-secondary">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -260,13 +262,12 @@ const viewToDelete = ref(null)
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Admin Sidebar -->
<template v-if="route.matched.some((record) => record.name && record.name.startsWith('admin'))">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<Sidebar collapsible="offcanvas" class="sidebar-secondary">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -315,7 +316,7 @@ const viewToDelete = ref(null)
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ t(child.titleKey) }}</span>
<span>{{ t(child.titleKey, child.isTitleKeyPlural === true ? 2 : 1) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
@@ -326,13 +327,12 @@ const viewToDelete = ref(null)
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Account sidebar -->
<template v-if="isActiveParent('/account')">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<Sidebar collapsible="offcanvas" class="sidebar-secondary">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -360,13 +360,12 @@ const viewToDelete = ref(null)
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Inbox sidebar -->
<template v-if="route.path && isInboxRoute(route.path)">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<Sidebar collapsible="offcanvas" class="sidebar-secondary">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -403,32 +402,43 @@ const viewToDelete = ref(null)
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<a href="#" @click.prevent="navigateToInbox('assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/mentioned')">
<a href="#" @click.prevent="navigateToInbox('mentioned')">
<AtSign />
<span>
{{ t('globals.terms.mention', 2) }}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</a>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<a href="#" @click.prevent="navigateToInbox('all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</a>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -461,9 +471,9 @@ const viewToDelete = ref(null)
:is-active="route.params.teamID == team.id"
asChild
>
<a href="#" @click.prevent="navigateToTeamInbox(team.id)">
<router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
{{ team.emoji }}<span>{{ team.name }}</span>
</a>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -472,7 +482,7 @@ const viewToDelete = ref(null)
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen" v-if="userStore.can(permissions.VIEW_MANAGE)">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
@@ -507,7 +517,7 @@ const viewToDelete = ref(null)
:isActive="route.params.viewID == view.id"
asChild
>
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
<SidebarMenuAction
@click.stop
@@ -532,7 +542,48 @@ const viewToDelete = ref(null)
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</a>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<!-- Shared Views -->
<Collapsible
class="group/collapsible"
defaultOpen
v-model:open="sharedViewInboxOpen"
v-if="sharedViews.length"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
<router-link to="#" class="group/item !p-2">
<span>
{{ t('globals.terms.sharedView', 2) }}
</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub v-for="view in sharedViews" :key="view.id">
<SidebarMenuSubItem>
<SidebarMenuButton
size="sm"
:isActive="route.params.viewID == view.id"
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-words w-32 truncate" :title="view.name">{{
view.name
}}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -546,7 +597,7 @@ const viewToDelete = ref(null)
</template>
<!-- Main Content Area -->
<SidebarInset>
<SidebarInset class="bg-canvas !min-h-0 !h-full">
<slot></slot>
</SidebarInset>
</SidebarProvider>
@@ -569,3 +620,19 @@ const viewToDelete = ref(null)
</AlertDialogContent>
</AlertDialog>
</template>
<style scoped>
:deep(.sidebar-secondary) {
@apply border ml-[3.2rem] rounded-lg overflow-hidden;
top: 0.40rem !important;
bottom: 0.35rem !important;
height: auto !important;
}
/* Override SidebarProvider height */
:deep(.group\/sidebar-wrapper) {
min-height: auto !important;
height: 100%;
}
</style>
@@ -70,7 +70,6 @@
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -0,0 +1,17 @@
<script setup>
import { ContextMenuRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
dir: { type: String, required: false },
modal: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<ContextMenuRoot v-bind="forwarded">
<slot />
</ContextMenuRoot>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CheckIcon } from '@radix-icons/vue';
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
modelValue: { type: [Boolean, String], required: false },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select", "update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ContextMenuCheckboxItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<CheckIcon class="h-4 w-4" />
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
loop: { type: Boolean, required: false },
alignOffset: { type: Number, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>
@@ -0,0 +1,14 @@
<script setup>
import { ContextMenuGroup } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<ContextMenuGroup v-bind="props">
<slot />
</ContextMenuGroup>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ContextMenuItem, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
inset: { type: Boolean, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ContextMenuItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
props.class,
)
"
>
<slot />
</ContextMenuItem>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ContextMenuLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
inset: { type: Boolean, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ContextMenuLabel
v-bind="delegatedProps"
:class="
cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
props.class,
)
"
>
<slot />
</ContextMenuLabel>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { ContextMenuPortal } from "reka-ui";
const props = defineProps({
to: { type: null, required: false },
disabled: { type: Boolean, required: false },
defer: { type: Boolean, required: false },
forceMount: { type: Boolean, required: false },
});
</script>
<template>
<ContextMenuPortal v-bind="props">
<slot />
</ContextMenuPortal>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { ContextMenuRadioGroup, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
modelValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<ContextMenuRadioGroup v-bind="forwarded">
<slot />
</ContextMenuRadioGroup>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DotFilledIcon } from '@radix-icons/vue';
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: String, required: true },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ContextMenuRadioItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<DotFilledIcon class="h-4 w-4 fill-current" />
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuRadioItem>
</template>
@@ -0,0 +1,20 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ContextMenuSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<ContextMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-border', props.class)"
/>
</template>
@@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<span
:class="
cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)
"
>
<slot />
</span>
</template>
@@ -0,0 +1,17 @@
<script setup>
import { ContextMenuSub, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
defaultOpen: { type: Boolean, required: false },
open: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<ContextMenuSub v-bind="forwarded">
<slot />
</ContextMenuSub>
</template>
@@ -0,0 +1,53 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ContextMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
loop: { type: Boolean, required: false },
sideOffset: { type: Number, required: false },
alignOffset: { type: Number, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"entryFocus",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ContextMenuSubContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRightIcon } from '@radix-icons/vue';
import { ContextMenuSubTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
inset: { type: Boolean, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<ContextMenuSubTrigger
v-bind="forwardedProps"
:class="
cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
props.class,
)
"
>
<slot />
<ChevronRightIcon class="ml-auto h-4 w-4" />
</ContextMenuSubTrigger>
</template>
@@ -0,0 +1,17 @@
<script setup>
import { ContextMenuTrigger, useForwardProps } from "reka-ui";
const props = defineProps({
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
const forwardedProps = useForwardProps(props);
</script>
<template>
<ContextMenuTrigger v-bind="forwardedProps">
<slot />
</ContextMenuTrigger>
</template>
@@ -0,0 +1,14 @@
export { default as ContextMenu } from "./ContextMenu.vue";
export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue";
export { default as ContextMenuContent } from "./ContextMenuContent.vue";
export { default as ContextMenuGroup } from "./ContextMenuGroup.vue";
export { default as ContextMenuItem } from "./ContextMenuItem.vue";
export { default as ContextMenuLabel } from "./ContextMenuLabel.vue";
export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue";
export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue";
export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue";
export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue";
export { default as ContextMenuSub } from "./ContextMenuSub.vue";
export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue";
export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue";
export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue";
@@ -0,0 +1,42 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DragHandleDots2Icon } from '@radix-icons/vue';
import { SplitterResizeHandle, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
id: { type: String, required: false },
hitAreaMargins: { type: Object, required: false },
tabindex: { type: Number, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
withHandle: { type: Boolean, required: false },
});
const emits = defineEmits(["dragging"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class,
)
"
>
<template v-if="props.withHandle">
<div
class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"
>
<DragHandleDots2Icon class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SplitterGroup, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
id: { type: [String, null], required: false },
autoSaveId: { type: [String, null], required: false },
direction: { type: String, required: true },
keyboardResizeBy: { type: [Number, null], required: false },
storage: { type: Object, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["layout"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="
cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
props.class,
)
"
>
<slot />
</SplitterGroup>
</template>
@@ -0,0 +1,3 @@
export { default as ResizableHandle } from "./ResizableHandle.vue";
export { default as ResizablePanelGroup } from "./ResizablePanelGroup.vue";
export { SplitterPanel as ResizablePanel } from "reka-ui";
@@ -20,20 +20,26 @@ export function useActivityLogFilters () {
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: [{
label: 'Agent login',
label: t('activityLog.type.agentLogin'),
value: 'agent_login'
}, {
label: 'Agent logout',
label: t('activityLog.type.agentLogout'),
value: 'agent_logout'
}, {
label: 'Agent away',
label: t('activityLog.type.agentAway'),
value: 'agent_away'
}, {
label: 'Agent away reassigned',
label: t('activityLog.type.agentAwayReassigned'),
value: 'agent_away_reassigned'
}, {
label: 'Agent online',
label: t('activityLog.type.agentOnline'),
value: 'agent_online'
}, {
label: t('activityLog.type.agentPasswordSet'),
value: 'agent_password_set'
}, {
label: t('activityLog.type.agentRolePermissionsChanged'),
value: 'agent_role_permissions_changed'
}]
},
}))
+284
View File
@@ -0,0 +1,284 @@
import { ref, watch } from 'vue'
import { watchDebounced, useStorage, useEventListener } from '@vueuse/core'
import { useConversationStore } from '@/stores/conversation'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { getTextFromHTML } from '@/utils/strings'
import api from '@/api'
/**
* Validate macro actions have required structure
*/
const validateMacroActions = (actions) => {
if (!Array.isArray(actions)) return []
return actions.filter(action =>
action &&
'type' in action &&
'value' in action &&
Array.isArray(action.value) &&
'display_value' in action &&
Array.isArray(action.display_value)
)
}
/**
* Validate attachments have required structure
*/
const validateAttachments = (attachments) => {
if (!Array.isArray(attachments)) return []
return attachments.filter(attachment =>
attachment &&
'id' in attachment &&
'size' in attachment &&
'uuid' in attachment &&
'filename' in attachment &&
'content_type' in attachment
)
}
/**
* Check if draft has no meaningful content
*/
const isDraftEmpty = (draft) => {
if (!draft) return true
const textContent = getTextFromHTML(draft.content || '')
const hasAttachments = draft.meta?.attachments?.length > 0
const hasMacroActions = draft.meta?.macro_actions?.length > 0
return textContent.length === 0 && !hasAttachments && !hasMacroActions
}
/**
* Composable for managing draft state and persistence
* Saves to localStorage immediately, syncs to backend on conversation switch/send/unload
*
* @param key - Reactive reference to current draft key
* @param uploadedFiles - Optional reactive reference to uploaded files array
*/
export function useDraftManager (key, uploadedFiles = null) {
const conversationStore = useConversationStore()
const htmlContent = ref('')
const textContent = ref('')
const isLoading = ref(false)
const isDirty = ref(false)
const skipNextSave = ref(false)
const loadedAttachments = ref([])
const loadedMacroActions = ref([])
const isTransitioning = ref(false)
// Reactive localStorage for all drafts
const localDrafts = useStorage('libredesk_drafts', {})
/**
* Save draft to localStorage only
*/
const saveDraftLocal = (draftKey) => {
if (!draftKey) return
const macroActions = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions || []
const draftMeta = {}
if (macroActions.length > 0) {
draftMeta.macro_actions = macroActions
} else {
delete draftMeta.macro_actions
}
// Set only required attachment fields
if (uploadedFiles?.value?.length > 0) {
draftMeta.attachments = uploadedFiles.value.map(file => ({
id: file.id,
size: file.size,
uuid: file.uuid,
filename: file.filename,
content_type: file.content_type
}))
} else {
delete draftMeta.attachments
}
// Save to localStorage
localDrafts.value[draftKey] = { content: htmlContent.value, meta: draftMeta }
// Mark as dirty for backend sync
isDirty.value = true
}
/**
* Get draft from localStorage
*/
const getLocalDraft = (draftKey) => localDrafts.value[draftKey] || null
/**
* Remove draft from localStorage
*/
const removeLocalDraft = (draftKey) => {
if (localDrafts.value[draftKey]) {
delete localDrafts.value[draftKey]
}
}
/**
* Sync localStorage draft to backend
*/
const syncDraftToBackend = async (draftKey) => {
if (!draftKey || !isDirty.value) return
const localDraft = getLocalDraft(draftKey)
if (!localDraft) return
try {
if (isDraftEmpty(localDraft)) {
// Empty draft - delete instead of save
await api.deleteDraft(draftKey)
conversationStore.removeDraft(draftKey)
} else {
// Has content - save draft
await api.saveDraft(draftKey, localDraft)
conversationStore.setDraft(draftKey, localDraft)
}
isDirty.value = false
} catch (error) {
// Silent fail - will retry on next sync
}
}
/**
* Reset all draft state to initial values
*/
const resetState = () => {
htmlContent.value = ''
textContent.value = ''
isLoading.value = false
isDirty.value = false
loadedAttachments.value = []
loadedMacroActions.value = []
}
/**
* Load draft from store (pre-fetched on app init)
*/
const loadDraft = async (draftKey) => {
if (!draftKey) return
isLoading.value = true
isDirty.value = false
skipNextSave.value = true
try {
// Check if there's an unsynced localStorage draft - sync it first
const localDraft = getLocalDraft(draftKey)
if (localDraft && !isDraftEmpty(localDraft)) {
await api.saveDraft(draftKey, localDraft)
conversationStore.setDraft(draftKey, localDraft)
}
removeLocalDraft(draftKey)
// Load from store (drafts pre-fetched on app init)
const draft = conversationStore.getDraft(draftKey)
if (!draft) {
resetState()
return
}
const content = draft.content || ''
const meta = draft.meta || {}
// Check if draft is empty - if so, delete it and return
if (isDraftEmpty({ content, meta })) {
await api.deleteDraft(draftKey)
conversationStore.removeDraft(draftKey)
resetState()
return
}
htmlContent.value = content
textContent.value = ''
loadedAttachments.value = validateAttachments(meta.attachments)
loadedMacroActions.value = validateMacroActions(meta.macro_actions)
} catch (error) {
resetState()
} finally {
isLoading.value = false
}
}
/**
* Clear draft from both localStorage and backend
*/
const clearDraft = async (draftKey) => {
if (!draftKey) return
removeLocalDraft(draftKey)
try {
await api.deleteDraft(draftKey)
conversationStore.removeDraft(draftKey)
resetState()
} catch (error) {
// Silent fail
}
}
// Watch for key changes - sync to backend before switching
watch(
key,
async (newKey, oldKey) => {
// Block saves during transition to prevent race
isTransitioning.value = true
// Sync old draft to backend before switching
if (newKey !== oldKey && isDirty.value) {
await syncDraftToBackend(oldKey)
removeLocalDraft(oldKey)
}
// Load new draft from backend
if (newKey && newKey !== oldKey) {
await loadDraft(newKey)
} else if (!newKey && oldKey) {
resetState()
}
// Allow saves after debounce window passes (200ms > 100ms debounce)
setTimeout(() => {
isTransitioning.value = false
}, 200)
},
{ immediate: true }
)
// Watch changes in draft content/meta to save locally
const watchSources = [
htmlContent,
textContent,
() => conversationStore.macros[MACRO_CONTEXT.REPLY]
]
if (uploadedFiles) {
watchSources.push(uploadedFiles)
}
watchDebounced(
watchSources,
() => {
if (skipNextSave.value) {
skipNextSave.value = false
return
}
// Need to make sure not loading or transitioning, as during transition the `key` will change
if (!isLoading.value && !isTransitioning.value && key.value) {
saveDraftLocal(key.value)
}
},
{ debounce: 100, deep: true }
)
// Sync to backend when page is hidden (tab switch)
useEventListener(document, 'visibilitychange', async () => {
if (document.visibilityState === 'hidden' && isDirty.value && key.value) {
await syncDraftToBackend(key.value)
}
})
return {
htmlContent,
textContent,
isLoading,
clearDraft,
loadedAttachments,
loadedMacroActions
}
}
+15 -1
View File
@@ -127,6 +127,19 @@ export function useFileUpload (options = {}) {
}
}
/**
* Replace all media files with new files
* @param {Array} files - Array of file objects to set
*/
const setMediaFiles = (files) => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = files
} else {
mediaFiles.length = 0
mediaFiles.push(...files)
}
}
return {
// State
uploadingFiles: readonly(uploadingFiles),
@@ -137,6 +150,7 @@ export function useFileUpload (options = {}) {
handleFileUpload,
handleFileDelete,
uploadFiles,
clearMediaFiles
clearMediaFiles,
setMediaFiles
}
}
+4 -6
View File
@@ -1,7 +1,6 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useStorage } from '@vueuse/core'
import { useStorage, useDebounceFn } from '@vueuse/core'
export function useIdleDetection () {
const userStore = useUserStore()
@@ -11,16 +10,15 @@ export function useIdleDetection () {
const lastActivity = useStorage('last_active', Date.now())
const timer = ref(null)
// Debounce the goOnline to prevent it from being called too frequently
const goOnline = debounce(() => {
const goOnline = useDebounceFn(() => {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
}, 200)
function resetTimer () {
const resetTimer = useDebounceFn(() => {
lastActivity.value = Date.now()
}
}, 100)
function checkIdle () {
if (
+7
View File
@@ -0,0 +1,7 @@
// Authentication type constants
export const AUTH_TYPE_PASSWORD = 'password'
export const AUTH_TYPE_OAUTH2 = 'oauth2'
// OAuth provider constants
export const PROVIDER_GOOGLE = 'google'
export const PROVIDER_MICROSOFT = 'microsoft'
+8 -2
View File
@@ -3,7 +3,8 @@ export const CONVERSATION_LIST_TYPE = {
UNASSIGNED: 'unassigned',
TEAM_UNASSIGNED: 'team_unassigned',
VIEW: 'view',
ALL: 'all'
ALL: 'all',
MENTIONED: 'mentioned'
}
export const CONVERSATION_DEFAULT_STATUSES = {
@@ -13,4 +14,9 @@ export const CONVERSATION_DEFAULT_STATUSES = {
CLOSED: 'Closed',
}
export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES);
export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES);
export const MACRO_CONTEXT = {
REPLY: 'reply',
NEW_CONVERSATION: 'new-conversation'
}
+2 -1
View File
@@ -5,5 +5,6 @@ export const EMITTER_EVENTS = {
SHOW_SOONER: 'show-sooner',
NEW_MESSAGE: 'new-message',
SET_NESTED_COMMAND: 'set-nested-command',
CONVERSATION_SIDEBAR_TOGGLE: 'conversation-sidebar-toggle'
CONVERSATION_SIDEBAR_TOGGLE: 'conversation-sidebar-toggle',
SCROLL_TO_MESSAGE: 'scroll-to-message'
}
+42 -15
View File
@@ -18,87 +18,110 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.businessHour',
href: '/admin/business-hours',
permission: 'business_hours:manage'
permission: 'business_hours:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.slaPolicy',
href: '/admin/sla',
permission: 'sla:manage'
permission: 'sla:manage',
isTitleKeyPlural: true
}
]
},
{
titleKey: 'globals.terms.conversation',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.tag',
href: '/admin/conversations/tags',
permission: 'tags:manage'
permission: 'tags:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.macro',
href: '/admin/conversations/macros',
permission: 'macros:manage'
permission: 'macros:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.sharedView',
href: '/admin/conversations/shared-views',
permission: 'shared_views:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.status',
href: '/admin/conversations/statuses',
permission: 'status:manage'
permission: 'status:manage',
isTitleKeyPlural: true
}
]
},
{
titleKey: 'globals.terms.inbox',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.inbox',
href: '/admin/inboxes',
permission: 'inboxes:manage'
permission: 'inboxes:manage',
isTitleKeyPlural: true
}
]
},
{
titleKey: 'globals.terms.teammate',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.agent',
href: '/admin/teams/agents',
permission: 'users:manage'
permission: 'users:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.team',
href: '/admin/teams/teams',
permission: 'teams:manage'
permission: 'teams:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.role',
href: '/admin/teams/roles',
permission: 'roles:manage'
permission: 'roles:manage',
isTitleKeyPlural: true
},
{
titleKey: 'globals.terms.activityLog',
href: '/admin/teams/activity-log',
permission: 'activity_logs:manage'
permission: 'activity_logs:manage',
isTitleKeyPlural: true
}
]
},
{
titleKey: 'globals.terms.automation',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.automation',
href: '/admin/automations',
permission: 'automations:manage'
permission: 'automations:manage',
isTitleKeyPlural: true
}
]
},
{
titleKey: 'globals.terms.customAttribute',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.customAttribute',
href: '/admin/custom-attributes',
permission: 'custom_attributes:manage'
permission: 'custom_attributes:manage',
isTitleKeyPlural: true
}
]
},
@@ -114,11 +137,13 @@ export const adminNavItems = [
},
{
titleKey: 'globals.terms.template',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.template',
href: '/admin/templates',
permission: 'templates:manage'
permission: 'templates:manage',
isTitleKeyPlural: true
}
]
},
@@ -139,7 +164,8 @@ export const adminNavItems = [
{
titleKey: 'globals.terms.webhook',
href: '/admin/webhooks',
permission: 'webhooks:manage'
permission: 'webhooks:manage',
isTitleKeyPlural: true
}
]
}
@@ -155,6 +181,7 @@ export const accountNavItems = [
export const contactNavItems = [
{
titleKey: 'globals.terms.contact',
href: '/contacts'
href: '/contacts',
isTitleKeyPlural: true
}
]
+2
View File
@@ -5,6 +5,7 @@ export const permissions = {
CONVERSATIONS_READ_ALL: 'conversations:read_all',
CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',
CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox',
CONVERSATIONS_READ_TEAM_ALL: 'conversations:read_team_all',
CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee',
CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee',
CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority',
@@ -14,6 +15,7 @@ export const permissions = {
MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage',
SHARED_VIEWS_MANAGE: 'shared_views:manage',
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
STATUS_MANAGE: 'status:manage',
+1
View File
@@ -2,4 +2,5 @@ export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
NEW_NOTIFICATION: 'new_notification',
}
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import UserDataTableDropDown from '@/features/admin/agents/dataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('first_name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-agent', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('first_name')
)
)
}
},
{
@@ -3,9 +3,12 @@
<div class="flex justify-between space-y-1">
<div>
<span class="sub-title space-x-3 flex justify-center items-center">
<div class="text-base">
<router-link
:to="{ name: 'edit-automation', params: { id: rule.id } }"
class="text-base text-primary hover:underline"
>
{{ rule.name }}
</div>
</router-link>
<div class="mb-1">
<Badge v-if="rule.enabled" class="text-[9px]">{{ $t('globals.terms.enabled') }}</Badge>
<Badge v-else variant="secondary">{{ $t('globals.terms.disabled') }}</Badge>
@@ -95,7 +98,7 @@ const props = defineProps({
})
const navigateToEditRule = (id) => {
router.push({ path: `/admin/automations/${id}/edit` })
router.push({ name: 'edit-automation', params: { id } })
}
const handleDelete = () => {
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-business-hours', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -23,8 +23,14 @@
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="en"> English </SelectItem>
<SelectItem value="mr"> Marathi </SelectItem>
<SelectItem value="da">Danish</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="fa">Farsi</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="it">Italian</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="mr">Marathi</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -1,7 +1,7 @@
<template>
<form @submit="onSubmit" class="space-y-6 w-full">
<!-- Basic Fields -->
<FormField v-slot="{ componentField }" name="name">
<FormField v-if="showFormFields" v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
@@ -12,7 +12,7 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="from">
<FormField v-if="showFormFields" v-slot="{ componentField }" name="from">
<FormItem>
<FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
<FormControl>
@@ -30,7 +30,7 @@
</FormField>
<!-- Toggle Fields -->
<FormField v-slot="{ componentField, handleChange }" name="enabled">
<FormField v-if="showFormFields" v-slot="{ componentField, handleChange }" name="enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('globals.terms.enabled') }}</FormLabel>
@@ -42,28 +42,211 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="csat_enabled">
<FormField v-if="showFormFields" v-slot="{ componentField, handleChange }" name="csat_enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.csatSurveys') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.csatSurveys.description_1') }}<br />
{{ $t('admin.inbox.csatSurveys.description_2') }}
{{ $t('admin.inbox.csatSurveys.description_1') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
<p class="!mt-2 text-muted-foreground text-sm">
{{ $t('admin.inbox.csatSurveys.description_2') }}
</p>
</FormField>
<FormField v-if="setupMethod" v-slot="{ componentField }" name="auth_type">
<FormItem>
<FormControl>
<Input
type="hidden"
:value="setupMethod === 'manual' ? AUTH_TYPE_PASSWORD : AUTH_TYPE_OAUTH2"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<!-- Setup Method Selection -->
<div v-show="!isOAuthInbox && setupMethod === null" class="space-y-4">
<div class="space-y-2">
<h3 class="font-semibold text-lg">{{ $t('admin.inbox.oauth.chooseSetupMethod') }}</h3>
<p class="text-sm text-muted-foreground">
{{ $t('admin.inbox.oauth.selectConnectionMethod') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<MenuCard
class="shrink-0 w-92 max-w-none"
:title="$t('globals.terms.google')"
:subTitle="$t('admin.inbox.oauth.googleDescription')"
icon="/images/google-logo.svg"
@click="connectWithGoogle()"
/>
<MenuCard
class="shrink-0 w-92 max-w-none"
:title="$t('globals.terms.microsoft')"
:subTitle="$t('admin.inbox.oauth.microsoftDescription')"
icon="/images/microsoft-logo.svg"
@click="connectWithMicrosoft()"
/>
<MenuCard
class="shrink-0 w-92 max-w-none"
:title="$t('admin.inbox.oauth.otherProvider')"
:subTitle="$t('admin.inbox.oauth.otherProviderDescription')"
:icon="Mail"
@click="setupMethod = 'manual'"
/>
</div>
</div>
<!-- OAuth Connected Status -->
<div
v-show="isOAuthInbox"
class="box p-4 bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800"
>
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3 flex-1">
<CheckCircle2 class="w-5 h-5 text-green-600 flex-shrink-0" />
<div class="flex-1">
<p class="font-semibold text-green-900 dark:text-green-100">
{{ $t('admin.inbox.oauth.connectedVia', { provider: oauthProvider }) }}
</p>
<p class="text-sm text-green-700 dark:text-green-300">{{ oauthEmail }}</p>
<p
v-show="oauthClientId"
class="text-xs text-green-600 dark:text-green-400 font-mono mt-1"
>
{{ $t('globals.terms.clientID') }}: {{ oauthClientId.substring(0, 20) }}...{{ oauthClientId.slice(-8) }}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
@click="reconnectOAuth"
:disabled="isSubmittingOAuth"
class="ml-2 flex-shrink-0"
>
<RefreshCw class="w-4 h-4 mr-1" />
{{ $t('globals.terms.reconnect') }}
</Button>
</div>
</div>
<!-- OAuth IMAP Configuration -->
<div v-show="isOAuthInbox" class="box p-4 space-y-4">
<h3 class="font-semibold">{{ $t('admin.inbox.imapConfig') }}</h3>
<FormField v-slot="{ componentField }" name="imap.mailbox">
<FormItem>
<FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="INBOX" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.mailbox.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="imap.read_interval">
<FormItem>
<FormLabel>{{ $t('admin.inbox.imapScanInterval') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="1m" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.imapScanInterval.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="imap.scan_inbox_since">
<FormItem>
<FormLabel>{{ $t('admin.inbox.imapScanInboxSince') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="48h" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.imapScanInboxSince.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- OAuth SMTP Configuration -->
<div v-show="isOAuthInbox" class="box p-4 space-y-4">
<h3 class="font-semibold">{{ $t('admin.inbox.smtpConfig') }}</h3>
<FormField v-slot="{ componentField }" name="smtp.max_conns">
<FormItem>
<FormLabel>{{ $t('admin.inbox.maxConnections') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="10" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.maxConnections.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="smtp.max_msg_retries">
<FormItem>
<FormLabel>{{ $t('admin.inbox.maxRetries') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="3" v-bind="componentField" />
</FormControl>
<FormDescription>{{ $t('admin.inbox.maxRetries.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="smtp.idle_timeout">
<FormItem>
<FormLabel>{{ $t('admin.inbox.idleTimeout') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="25s" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.idleTimeout.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="smtp.wait_timeout">
<FormItem>
<FormLabel>{{ $t('admin.inbox.waitTimeout') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="60s" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.waitTimeout.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- IMAP Section -->
<div class="box p-4 space-y-4">
<div v-show="!isOAuthInbox && setupMethod === 'manual'" class="box p-4 space-y-4">
<h3 class="font-semibold">{{ $t('admin.inbox.imapConfig') }}</h3>
<FormField v-slot="{ componentField }" name="imap.host">
<FormItem>
<FormLabel>Host</FormLabel>
<FormLabel>{{ $t('globals.terms.host') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="imap.gmail.com" v-bind="componentField" />
</FormControl>
@@ -85,11 +268,7 @@
<FormItem>
<FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel>
<FormControl>
<Input
type="text"
placeholder="INBOX"
v-bind="componentField"
/>
<Input type="text" placeholder="INBOX" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.mailbox.description') }}
@@ -120,7 +299,7 @@
<FormField v-slot="{ componentField }" name="imap.tls_type">
<FormItem>
<FormLabel>TLS</FormLabel>
<FormLabel>{{ $t('globals.terms.tls') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
@@ -180,7 +359,7 @@
</div>
<!-- SMTP Section -->
<div class="box p-4 space-y-4">
<div v-show="!isOAuthInbox && setupMethod === 'manual'" class="box p-4 space-y-4">
<h3 class="font-semibold">{{ $t('admin.inbox.smtpConfig') }}</h3>
<FormField v-slot="{ componentField }" name="smtp.host">
@@ -279,7 +458,7 @@
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select protocol" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.protocol') })" />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">Login</SelectItem>
@@ -300,7 +479,7 @@
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select TLS" />
<SelectValue :placeholder="t('globals.messages.selectTLS')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">OFF</SelectItem>
@@ -346,10 +525,105 @@
{{ submitLabel }}
</Button>
</form>
<!-- OAuth Credentials Modal -->
<Dialog v-model:open="showOAuthModal">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{
flowType === 'reconnect'
? $t('admin.inbox.oauth.reconnectAccount', { provider: selectedProvider === PROVIDER_GOOGLE ? $t('globals.terms.google') : $t('globals.terms.microsoft') })
: $t('admin.inbox.oauth.connectAccount', { provider: selectedProvider === PROVIDER_GOOGLE ? $t('globals.terms.google') : $t('globals.terms.microsoft') })
}}
</DialogTitle>
<DialogDescription>
{{
flowType === 'reconnect'
? $t('admin.inbox.oauth.reconnectDescription')
: $t('admin.inbox.oauth.followSteps')
}}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div v-if="flowType === 'new_inbox'" class="space-y-4">
<p class="text-sm">
{{ $t('admin.inbox.oauth.step1CreateApp') }}
<a
:href="
selectedProvider === PROVIDER_GOOGLE
? 'https://console.cloud.google.com/apis/credentials'
: 'https://entra.microsoft.com/'
"
target="_blank"
class="text-primary underline"
>
{{
selectedProvider === PROVIDER_GOOGLE
? $t('admin.inbox.oauth.googleCloudConsole')
: $t('admin.inbox.oauth.microsoftAzurePortal')
}}
</a>
</p>
<div class="space-y-1">
<p class="text-sm">{{ $t('admin.inbox.oauth.step2AddCallback') }}</p>
<div class="flex items-center gap-2">
<Input :model-value="callbackUrl" readonly class="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(callbackUrl)"
>
{{ $t('globals.terms.copy') }}
</Button>
</div>
</div>
<p class="text-sm">{{ $t('admin.inbox.oauth.step3EnterCredentials') }}</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">{{ $t('globals.terms.clientID') }}</label>
<Input
v-model="oauthCredentials.client_id"
:placeholder="t('admin.inbox.oauth.enterClientID')"
:disabled="isSubmittingOAuth"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">{{ $t('globals.terms.clientSecret') }}</label>
<Input
v-model="oauthCredentials.client_secret"
type="password"
:placeholder="t('admin.inbox.oauth.enterClientSecret')"
:disabled="isSubmittingOAuth"
/>
</div>
<div v-if="selectedProvider === PROVIDER_MICROSOFT" class="space-y-2">
<label class="text-sm font-medium">{{ $t('globals.terms.tenantID') }}</label>
<Input v-model="oauthCredentials.tenant_id" :disabled="isSubmittingOAuth" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showOAuthModal = false" :disabled="isSubmittingOAuth">
{{ $t('globals.messages.cancel') }}
</Button>
<Button @click="submitOAuthCredentials" :disabled="isSubmittingOAuth">
{{ isSubmittingOAuth ? $t('globals.messages.connecting') : $t('globals.messages.continue') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, computed } from 'vue'
import { watch, computed, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
@@ -371,7 +645,28 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { CheckCircle2, RefreshCw, Mail } from 'lucide-vue-next'
import MenuCard from '@/components/layout/MenuCard.vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import {
AUTH_TYPE_PASSWORD,
AUTH_TYPE_OAUTH2,
PROVIDER_GOOGLE,
PROVIDER_MICROSOFT
} from '@/constants/auth.js'
import { handleHTTPError } from '@/utils/http'
import { useAppSettingsStore } from '@/stores/appSettings'
const props = defineProps({
initialValues: {
@@ -393,13 +688,48 @@ const props = defineProps({
})
const { t } = useI18n()
const emitter = useEmitter()
const appSettingsStore = useAppSettingsStore()
// OAuth detection
const isOAuthInbox = ref(false)
// Setup method selection: null | PROVIDER_GOOGLE | PROVIDER_MICROSOFT | 'manual'
const setupMethod = ref(null)
// OAuth modal state
const showOAuthModal = ref(false)
const selectedProvider = ref('')
const flowType = ref('new_inbox') // "new_inbox" or "reconnect"
const oauthCredentials = ref({
client_id: '',
client_secret: '',
tenant_id: ''
})
const isSubmittingOAuth = ref(false)
// Computed callback URL for OAuth
const callbackUrl = computed(() => {
const rootUrl = appSettingsStore.settings['app.root_url']
return `${rootUrl}/api/v1/inboxes/oauth/${selectedProvider.value}/callback`
})
// Show form fields when OAuth is connected or manual setup is selected
const showFormFields = computed(
() =>
isOAuthInbox.value ||
setupMethod.value === 'manual' ||
(props.initialValues?.imap && Object.keys(props.initialValues?.imap).length > 0)
)
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
validationSchema: computed(() => toTypedSchema(createFormSchema(t))),
initialValues: {
name: '',
from: '',
enabled: true,
csat_enabled: false,
auth_type: AUTH_TYPE_PASSWORD,
imap: {
host: 'imap.gmail.com',
port: 993,
@@ -428,6 +758,20 @@ const form = useForm({
}
})
// OAuth computed properties
const oauthProvider = computed(() => {
const provider = form.values.oauth?.provider
return provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'Unknown'
})
const oauthEmail = computed(() => {
return form.values.imap?.username || form.values.smtp?.username || ''
})
const oauthClientId = computed(() => {
return form.values.oauth?.client_id || ''
})
const submitLabel = computed(() => {
return props.submitLabel || t('globals.messages.save')
})
@@ -436,12 +780,97 @@ const onSubmit = form.handleSubmit(async (values) => {
await props.submitForm(values)
})
const connectWithGoogle = () => {
flowType.value = 'new_inbox'
selectedProvider.value = PROVIDER_GOOGLE
showOAuthModal.value = true
}
const connectWithMicrosoft = () => {
flowType.value = 'new_inbox'
selectedProvider.value = PROVIDER_MICROSOFT
showOAuthModal.value = true
}
const reconnectOAuth = () => {
const provider = form.values.oauth?.provider
const clientId = form.values.oauth?.client_id
const tenantId = form.values.oauth?.tenant_id
if (!provider) return
// Set flow type to reconnect
flowType.value = 'reconnect'
// Set provider and pre-fill credentials
selectedProvider.value = provider
oauthCredentials.value.client_id = clientId || ''
oauthCredentials.value.client_secret = '' // Always require user to re-enter secret
oauthCredentials.value.tenant_id = tenantId || ''
// Show modal for user to edit credentials
showOAuthModal.value = true
}
const submitOAuthCredentials = async () => {
if (!oauthCredentials.value.client_id || !oauthCredentials.value.client_secret) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('admin.inbox.oauth.clientIDSecretRequired')
})
return
}
try {
isSubmittingOAuth.value = true
const payload = {
...oauthCredentials.value,
flow_type: flowType.value
}
// Include inbox_id for reconnect flow (props.initialValues.id exists in edit mode)
if (flowType.value === 'reconnect' && props.initialValues?.id) {
payload.inbox_id = props.initialValues.id
}
const response = await api.initiateOAuthFlow(selectedProvider.value, payload)
window.location.href = response.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.copied')
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorCopying')
})
}
}
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) {
return
}
if (newValues.config?.auth_type === AUTH_TYPE_OAUTH2) {
isOAuthInbox.value = true
setupMethod.value = 'oauth'
} else {
isOAuthInbox.value = false
setupMethod.value = 'manual'
}
form.setValues(newValues)
},
{ deep: true, immediate: true }
@@ -1,11 +1,21 @@
import * as z from 'zod'
import { isGoDuration } from '@/utils/strings'
import { AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2 } from '@/constants/auth.js'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
from: z.string().min(1, t('globals.messages.required')),
enabled: z.boolean().optional(),
csat_enabled: z.boolean().optional(),
auth_type: z.enum([AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2]),
oauth: z.object({
access_token: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
expires_at: z.string().optional(),
provider: z.string().optional(),
refresh_token: z.string().optional()
}).optional(),
imap: z.object({
host: z.string().min(1, t('globals.messages.required')),
port: z.number().min(1).max(65535),
@@ -21,7 +31,6 @@ export const createFormSchema = (t) => z.object({
message: t('globals.messages.goDuration')
})
}),
smtp: z.object({
host: z.string().min(1, t('globals.messages.required')),
port: z.number().min(1).max(65535),
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-macro', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -55,10 +55,12 @@ import { Button } from '@/components/ui/button'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useRouter } from 'vue-router'
import { useMacroStore } from '@/stores/macro'
import api from '@/api/index.js'
const router = useRouter()
const emit = useEmitter()
const macroStore = useMacroStore()
const isDeleteOpen = ref(false)
const props = defineProps({
@@ -70,6 +72,9 @@ const props = defineProps({
const handleDelete = async () => {
await api.deleteMacro(props.macro.id)
await macroStore.loadMacros(true)
isDeleteOpen.value = false
emit.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'macros' })
}
@@ -77,4 +82,4 @@ const handleDelete = async () => {
const editMacro = () => {
router.push({ path: `/admin/conversations/macros/${props.macro.id}/edit` })
}
</script>
</script>
@@ -27,11 +27,13 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { Spinner } from '@/components/ui/spinner'
import { useAppSettingsStore } from '@/stores/appSettings'
const initialValues = ref({})
const { t } = useI18n()
const isLoading = ref(false)
const emitter = useEmitter()
const appSettingsStore = useAppSettingsStore()
onMounted(() => {
getNotificationSettings()
@@ -72,6 +74,7 @@ const submitForm = async (values) => {
description: t('admin.notification.restartApp')
})
await getNotificationSettings()
appSettingsStore.fetchSettings()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -102,6 +102,7 @@ const submitLabel = computed(() => {
return props.submitLabel || t('globals.messages.save')
})
// TODO: Prepare this by fetching all perms from the file, so we don't have to update this manually.
const permissions = ref([
{
name: t('globals.terms.conversation'),
@@ -121,6 +122,10 @@ const permissions = ref([
name: perms.CONVERSATIONS_READ_TEAM_INBOX,
label: t('admin.role.conversations.readTeamInbox')
},
{
name: perms.CONVERSATIONS_READ_TEAM_ALL,
label: t('admin.role.conversations.readTeamAll')
},
{
name: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
label: t('admin.role.conversations.updateUserAssignee')
@@ -168,7 +173,8 @@ const permissions = ref([
{ name: perms.AI_MANAGE, label: t('admin.role.ai.manage') },
{ name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') },
{ name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') },
{ name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') }
{ name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') },
{ name: perms.SHARED_VIEWS_MANAGE, label: t('admin.role.sharedViews.manage') }
]
},
{
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
export const createColumns = (t) => [
@@ -8,7 +9,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-role', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -0,0 +1,272 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription>{{ t('view.form.name.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="filters">
<FormItem>
<FormLabel>{{ t('globals.terms.filter', 2) }}</FormLabel>
<FormControl>
<FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
</FormControl>
<FormDescription>{{ t('view.form.filters.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="visibility"
:validate-on-blur="false"
:validate-on-change="false"
:validate-on-input="false"
:validate-on-mount="false"
:validate-on-model-update="false"
>
<FormItem>
<FormLabel>{{ t('globals.terms.visibility') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{{
t('globals.messages.all', {
name: t('globals.terms.agent', 2).toLowerCase()
})
}}</SelectItem>
<SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>{{ t('globals.terms.team') }}</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="tStore.options"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })"
type="team"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useTeamStore } from '@/stores/team'
import { OPERATOR, FIELD_TYPE } from '@/constants/filterConfig.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useI18n } from 'vue-i18n'
import { z } from 'zod'
const { conversationsListFilters } = useConversationFilters()
const { t } = useI18n()
const formLoading = ref(false)
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
}
})
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.initialValues.id ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const filterFields = computed(() =>
Object.entries(conversationsListFilters.value).map(([field, value]) => ({
model: 'conversations',
label: value.label,
field,
type: value.type,
operators: value.operators,
options: value.options ?? []
}))
)
const formSchema = toTypedSchema(
z
.object({
name: z
.string({
required_error: t('globals.messages.required')
})
.min(2, { message: t('view.form.name.length') })
.max(140, { message: t('view.form.name.length') }),
filters: z
.array(
z.object({
model: z.string().optional(),
field: z.string().optional(),
operator: z.string().optional(),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()]))
])
.optional()
})
)
.default([]),
visibility: z.enum(['all', 'team']),
team_id: z.string().nullable().optional()
})
.refine(
(data) => {
if (data.visibility === 'team') return !!data.team_id
return true
},
{ message: t('globals.messages.required'), path: ['team_id'] }
)
)
const form = useForm({
validationSchema: formSchema,
initialValues: {
visibility: props.initialValues.visibility || 'all'
}
})
const onSubmit = form.handleSubmit(async (values) => {
// Make sure at least one filter is selected
if (!values.filters || values.filters.length === 0) {
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
return
}
// Check for partial filters
const hasPartialFilters = values.filters.some(
(f) =>
!f.field ||
!f.operator ||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) &&
(!f.value || (Array.isArray(f.value) && f.value.length === 0)))
)
if (hasPartialFilters) {
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
return
}
// Serialize array values to JSON strings for backend
if (values.filters) {
values.filters = values.filters.map((filter) => {
if (Array.isArray(filter.value)) {
const numericValues = filter.value.map((v) => {
const num = Number(v)
return isNaN(num) ? v : num
})
return { ...filter, value: JSON.stringify(numericValues) }
}
return filter
})
}
// Clear team_id if visibility is 'all', otherwise convert to number
if (values.visibility === 'all') {
values.team_id = null
} else {
values.team_id = values.team_id ? Number(values.team_id) : null
}
props.submitForm(values)
})
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) return
// Deserialize multi-select filter values from JSON strings to arrays
const processedVal = { ...newValues }
if (processedVal.filters) {
processedVal.filters = processedVal.filters.map((filter) => {
const field = filterFields.value.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField && typeof filter.value === 'string') {
try {
const parsed = JSON.parse(filter.value)
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
return { ...filter, value: stringValues }
} catch (e) {
return filter
}
}
return filter
})
}
// Convert team_id to string for the select component
if (processedVal.team_id) {
processedVal.team_id = String(processedVal.team_id)
}
form.setValues(processedVal)
},
{ immediate: true }
)
</script>
@@ -0,0 +1,67 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const createColumns = (t) => [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-shared-view', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
accessorKey: 'visibility',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.visibility'))
},
cell: function ({ row }) {
const visibility = row.getValue('visibility')
const label = visibility === 'all' ? t('globals.messages.all', { name: t('globals.terms.agent', 2).toLowerCase() }) : t('globals.terms.team')
return h('div', { class: 'text-center capitalize' }, label)
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const sharedView = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
sharedView
})
)
}
}
]
@@ -0,0 +1,85 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only"></span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editSharedView">{{ $t('globals.messages.edit') }}</DropdownMenuItem>
<DropdownMenuItem @click="() => (isDeleteOpen = true)">
{{ $t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="isDeleteOpen" @update:open="isDeleteOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.sharedView') }) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useRouter } from 'vue-router'
import { useSharedViewStore } from '@/stores/sharedView'
import api from '@/api/index.js'
const router = useRouter()
const emit = useEmitter()
const sharedViewStore = useSharedViewStore()
const isDeleteOpen = ref(false)
const props = defineProps({
sharedView: {
type: Object,
required: true
}
})
const handleDelete = async () => {
await api.deleteSharedView(props.sharedView.id)
await sharedViewStore.refresh()
isDeleteOpen.value = false
emit.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'shared-views' })
}
const editSharedView = () => {
router.push({ name: 'edit-shared-view', params: { id: props.sharedView.id } })
}
</script>
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-sla', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import TeamDataTableDropdown from '@/features/admin/teams/TeamDataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const columns = [
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-team', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
@@ -9,7 +10,15 @@ export const createOutgoingEmailTableColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-template', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
@@ -60,7 +69,15 @@ export const createEmailNotificationTableColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-template', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
@@ -1,4 +1,5 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
import { Badge } from '@/components/ui/badge'
@@ -10,7 +11,15 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('name'))
return h('div', { class: 'text-center' },
h(RouterLink,
{
to: { name: 'edit-webhook', params: { id: row.original.id } },
class: 'text-primary hover:underline'
},
() => row.getValue('name')
)
)
}
},
{
+40 -35
View File
@@ -6,30 +6,30 @@
:class="{ 'overflow-hidden': nestedCommand === 'apply-macro' }"
>
<CommandEmpty>
<p class="text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
<p class="text-base text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
</CommandEmpty>
<!-- Snooze Options -->
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
<CommandItem value="1 hour" @select="handleSnooze(60)">
<CommandItem value="1 hour" @select="handleSnooze(60)" class="text-base py-3">
1 {{ $t('globals.terms.hour') }}
</CommandItem>
<CommandItem value="3 hours" @select="handleSnooze(180)"
>3 {{ $t('globals.terms.hour', 2) }}</CommandItem
>
<CommandItem value="6 hours" @select="handleSnooze(360)">
<CommandItem value="3 hours" @select="handleSnooze(180)" class="text-base py-3">
3 {{ $t('globals.terms.hour', 2) }}
</CommandItem>
<CommandItem value="6 hours" @select="handleSnooze(360)" class="text-base py-3">
6 {{ $t('globals.terms.hour', 2) }}
</CommandItem>
<CommandItem value="12 hours" @select="handleSnooze(720)">
<CommandItem value="12 hours" @select="handleSnooze(720)" class="text-base py-3">
12 {{ $t('globals.terms.hour', 2) }}
</CommandItem>
<CommandItem value="1 day" @select="handleSnooze(1440)">
<CommandItem value="1 day" @select="handleSnooze(1440)" class="text-base py-3">
1 {{ $t('globals.terms.day') }}
</CommandItem>
<CommandItem value="2 days" @select="handleSnooze(2880)">
<CommandItem value="2 days" @select="handleSnooze(2880)" class="text-base py-3">
2 {{ $t('globals.terms.day', 2) }}
</CommandItem>
<CommandItem value="pick date & time" @select="showCustomDialog">
<CommandItem value="pick date & time" @select="showCustomDialog" class="text-base py-3">
{{ $t('globals.messages.pickDateAndTime') }}
</CommandItem>
</CommandGroup>
@@ -52,11 +52,12 @@
:value="macro.label + '|' + index"
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:"
@pointerenter="highlightedMacro = macro"
class="px-3 py-3 rounded cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:"
>
<div class="flex items-center gap-2">
<Zap size="14" class="shrink-0" />
<span class="text-sm truncate w-full break-words whitespace-normal">{{
<div class="flex items-center gap-3">
<Zap size="18" class="shrink-0" />
<span class="text-base truncate w-full break-words whitespace-normal">{{
macro.label
}}</span>
</div>
@@ -65,61 +66,63 @@
<!-- Right Column: Macro Details (70%) -->
<div class="col-span-8 px-4 overflow-y-auto h-full pb-12">
<div class="space-y-3 text-xs">
<div class="space-y-4 text-base">
<!-- Reply Preview -->
<div v-if="replyContent" class="space-y-2">
<p class="text-xs font-semibold text-foreground">
<p class="text-base font-semibold text-foreground">
{{ $t('command.replyPreview') }}
</p>
<div
<Letter
:key="highlightedMacro?.value"
:html="replyContent"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
class="w-full min-h-200 p-2 bg-muted/50 rounded overflow-auto shadow native-html"
v-dompurify-html="replyContent"
/>
</div>
<!-- Actions -->
<div v-if="otherActions.length > 0" class="space-y-2">
<p class="text-xs font-semibold">
<p class="text-base font-semibold">
{{ $t('globals.terms.action', 2) }}
</p>
<div class="space-y-1.5 max-w-sm">
<div
v-for="action in otherActions"
:key="action.type"
class="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-muted/50 hover:bg-accent hover:text-accent-foreground transition duration-200 group"
class="flex items-center gap-3 px-3 py-2.5 rounded text-base bg-muted/50 hover:bg-accent hover:text-accent-foreground transition duration-200 group"
>
<div
class="p-1 rounded-full group-hover:bg-muted/20 transition duration-200"
class="p-1.5 rounded-full group-hover:bg-muted/20 transition duration-200"
>
<User v-if="action.type === 'assign_user'" :size="10" class="shrink-0" />
<User v-if="action.type === 'assign_user'" :size="14" class="shrink-0" />
<Users
v-else-if="action.type === 'assign_team'"
:size="10"
:size="14"
class="shrink-0"
/>
<Pin
v-else-if="action.type === 'set_status'"
:size="10"
:size="14"
class="shrink-0"
/>
<Rocket
v-else-if="action.type === 'set_priority'"
:size="10"
:size="14"
class="shrink-0"
/>
<Tags
v-else-if="action.type === 'add_tags'"
:size="10"
:size="14"
class="shrink-0"
/>
<Tags
v-else-if="action.type === 'set_tags'"
:size="10"
:size="14"
class="shrink-0"
/>
<Tags
v-else-if="action.type === 'remove_tags'"
:size="10"
:size="14"
class="shrink-0"
/>
</div>
@@ -133,7 +136,7 @@
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
>
<p class="text-xs text-muted-foreground italic">
<p class="text-base text-muted-foreground italic">
{{ $t('command.selectAMacro') }}
</p>
</div>
@@ -153,20 +156,21 @@
<CommandItem
value="apply-macro"
@select="setNestedCommand('apply-macro-to-existing-conversation')"
class="text-base py-3"
>
{{ $t('globals.messages.apply', { name: t('globals.terms.macro').toLowerCase() }) }}
</CommandItem>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')" class="text-base py-3">
{{ $t('globals.terms.snooze') }}
</CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation">
<CommandItem value="conv-resolve" @select="resolveConversation" class="text-base py-3">
{{ $t('globals.terms.resolve') }}
</CommandItem>
</CommandGroup>
</CommandList>
<!-- Navigation -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<div class="mt-2 px-4 py-2 text-sm text-gray-500 flex space-x-4">
<span> {{ $t('command.enterToSelect') }}</span>
<span> {{ $t('command.navigateWithArrows') }}</span>
<span> {{ $t('command.closeWithEsc') }}</span>
@@ -211,7 +215,7 @@ import { useMagicKeys } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { useMacroStore } from '@/stores/macro'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { CONVERSATION_DEFAULT_STATUSES, MACRO_CONTEXT } from '@/constants/conversation'
import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next'
import {
CommandDialog,
@@ -237,6 +241,7 @@ import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useI18n } from 'vue-i18n'
import { Letter } from 'vue-letter'
const conversationStore = useConversationStore()
const macroStore = useMacroStore()
@@ -267,9 +272,9 @@ function handleApplyMacro(macro) {
// Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
if (nestedCommand.value === 'apply-macro-to-new-conversation') {
conversationStore.setMacro(plainMacro, 'new-conversation')
conversationStore.setMacro(plainMacro, MACRO_CONTEXT.NEW_CONVERSATION)
} else {
conversationStore.setMacro(plainMacro, 'reply')
conversationStore.setMacro(plainMacro, MACRO_CONTEXT.REPLY)
}
toggleOpen()
}
@@ -99,7 +99,11 @@
<!-- Note content -->
<CardContent class="pt-4 pb-5">
<p class="whitespace-pre-wrap text-sm native-html" v-dompurify-html="note.note"></p>
<Letter
:html="note.note"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
class="whitespace-pre-wrap text-sm native-html"
/>
</CardContent>
</Card>
</div>
@@ -155,6 +159,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import { getInitials } from '@/utils/strings'
import { useUserStore } from '@/stores/user'
import { Letter } from 'vue-letter'
import api from '@/api'
const props = defineProps({ contactId: Number })
@@ -184,7 +184,7 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { ArrowDownWideNarrow } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { debounce } from '@/utils/debounce'
import { useDebounceFn } from '@vueuse/core'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
@@ -205,7 +205,7 @@ const emitter = useEmitter()
// Google-style pagination
const visiblePages = computed(() => getVisiblePages(page.value, totalPages.value))
const fetchContactsDebounced = debounce(() => {
const fetchContactsDebounced = useDebounceFn(() => {
fetchContacts()
}, 300)
@@ -185,10 +185,10 @@
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.getMacro('new-conversation').actions?.length > 0"
:actions="conversationStore.getMacro('new-conversation')?.actions || []"
v-if="conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).actions?.length > 0"
:actions="conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION)?.actions || []"
:onRemove="
(action) => conversationStore.removeMacroAction(action, 'new-conversation')
(action) => conversationStore.removeMacroAction(action, MACRO_CONTEXT.NEW_CONVERSATION)
"
class="mt-2 flex-shrink-0"
/>
@@ -245,6 +245,7 @@ import { useConversationStore } from '@/stores/conversation'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox'
@@ -328,7 +329,7 @@ const formSchema = z.object({
onUnmounted(() => {
clearTimeout(timeoutId)
clearMediaFiles()
conversationStore.resetMacro('new-conversation')
conversationStore.resetMacro(MACRO_CONTEXT.NEW_CONVERSATION)
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
command: null,
open: false
@@ -406,7 +407,7 @@ const createConversation = form.handleSubmit(async (values) => {
const conversationUUID = conversation.data.data.uuid
// Get macro from context, and set if any actions are available.
const macro = conversationStore.getMacro('new-conversation')
const macro = conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION)
if (conversationUUID !== '' && macro?.id && macro?.actions?.length > 0) {
try {
await api.applyMacro(conversationUUID, macro.id, macro.actions)
@@ -433,9 +434,9 @@ const createConversation = form.handleSubmit(async (values) => {
* Watches for changes in the macro id and update message content.
*/
watch(
() => conversationStore.getMacro('new-conversation').id,
() => conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).id,
() => {
form.setFieldValue('content', conversationStore.getMacro('new-conversation').message_content)
form.setFieldValue('content', conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).message_content)
},
{ deep: true }
)
+96 -20
View File
@@ -42,7 +42,7 @@
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
class="max-w-[60%] max-h-[75%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
:class="{ '!bg-[#FEF1E1] dark:!bg-[#4C3A24]': messageType === 'private_note' }"
:class="{ '!bg-private': messageType === 'private_note' }"
@escapeKeyDown="isEditorFullscreen = false"
:hide-close-button="true"
>
@@ -51,6 +51,7 @@
:isFullscreen="true"
:aiPrompts="aiPrompts"
:isSending="isSending"
:isDraftLoading="isDraftLoading"
:uploadingFiles="uploadingFiles"
:uploadedFiles="mediaFiles"
v-model:htmlContent="htmlContent"
@@ -61,6 +62,7 @@
v-model:emailErrors="emailErrors"
v-model:messageType="messageType"
v-model:showBcc="showBcc"
v-model:mentions="mentions"
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
@send="processSend"
@fileUpload="handleFileUpload"
@@ -74,13 +76,15 @@
<!-- Main Editor non-fullscreen -->
<div
class="bg-background text-card-foreground box m-2 px-2 pt-2 flex flex-col"
:class="{ '!bg-[#FEF1E1] dark:!bg-[#4C3A24]': messageType === 'private_note' }"
:class="{ '!bg-private': messageType === 'private_note' }"
v-if="!isEditorFullscreen"
>
<ReplyBoxContent
ref="replyBoxContentRef"
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:isDraftLoading="isDraftLoading"
:uploadingFiles="uploadingFiles"
:uploadedFiles="mediaFiles"
v-model:htmlContent="htmlContent"
@@ -91,6 +95,7 @@
v-model:emailErrors="emailErrors"
v-model:messageType="messageType"
v-model:showBcc="showBcc"
v-model:mentions="mentions"
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
@send="processSend"
@fileUpload="handleFileUpload"
@@ -102,10 +107,13 @@
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { ref, watch, computed, toRaw } from 'vue'
import { useStorage } from '@vueuse/core'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { useUserStore } from '@/stores/user'
import { useDraftManager } from '@/composables/useDraftManager'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useConversationStore } from '@/stores/conversation'
@@ -146,29 +154,42 @@ const emitter = useEmitter()
const userStore = useUserStore()
// Setup file upload composable
const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMediaFiles } =
useFileUpload({
linkedModel: 'messages'
})
const {
uploadingFiles,
handleFileUpload,
handleFileDelete,
mediaFiles,
clearMediaFiles,
setMediaFiles
} = useFileUpload({
linkedModel: 'messages'
})
// Setup draft management composable
const currentDraftKey = computed(() => conversationStore.current?.uuid || null)
const {
htmlContent,
textContent,
isLoading: isDraftLoading,
clearDraft,
loadedAttachments,
loadedMacroActions
} = useDraftManager(currentDraftKey, mediaFiles)
// Rest of existing state
const openAIKeyPrompt = ref(false)
const isOpenAIKeyUpdating = ref(false)
const isEditorFullscreen = ref(false)
const isSending = ref(false)
const messageType = ref('reply')
const messageType = useStorage('replyBoxMessageType', 'reply')
const to = ref('')
const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPrompts = ref([])
const htmlContent = ref('')
const textContent = ref('')
onMounted(async () => {
await fetchAiPrompts()
})
const replyBoxContentRef = ref(null)
const mentions = ref([])
/**
* Fetches AI prompts from the server.
@@ -185,6 +206,8 @@ const fetchAiPrompts = async () => {
}
}
fetchAiPrompts()
/**
* Handles the AI prompt selection event.
* Sends the selected prompt key and the current text content to the server for completion.
@@ -257,6 +280,8 @@ const processSend = async () => {
private: messageType.value === 'private_note',
message: message,
attachments: mediaFiles.value.map((file) => file.id),
// Include mentions only for private notes
mentions: messageType.value === 'private_note' ? mentions.value : [],
// Convert email addresses to array and remove empty strings.
cc: cc.value
.split(',')
@@ -278,8 +303,8 @@ const processSend = async () => {
}
// Apply macro actions if any, for macro errors just show toast and clear the editor.
const macroID = conversationStore.getMacro('reply')?.id
const macroActions = conversationStore.getMacro('reply')?.actions || []
const macroID = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.id
const macroActions = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions || []
if (macroID > 0 && macroActions.length > 0) {
try {
await api.applyMacro(conversationStore.current.uuid, macroID, macroActions)
@@ -299,14 +324,20 @@ const processSend = async () => {
} finally {
// If API has NOT errored clear state.
if (hasMessageSendingErrored === false) {
// Clear macro.
conversationStore.resetMacro('reply')
// Clear draft from backend.
clearDraft(currentDraftKey.value)
// Clear macro for this conversation reply.
conversationStore.resetMacro(MACRO_CONTEXT.REPLY)
// Clear media files.
clearMediaFiles()
// Clear any email errors.
emailErrors.value = []
// Clear mentions.
mentions.value = []
}
isSending.value = false
}
@@ -317,8 +348,40 @@ const processSend = async () => {
*/
watch(
() => conversationStore.getMacro('reply').id,
() => {
htmlContent.value = conversationStore.getMacro('reply').message_content
(newId) => {
// No macro set.
if (!newId) return
// If macro has message content, set it in the editor.
if (conversationStore.getMacro('reply').message_content) {
htmlContent.value = conversationStore.getMacro('reply').message_content
}
},
{ deep: true }
)
/**
* Watch loaded macro actions from draft and update conversation store.
*/
watch(
loadedMacroActions,
(actions) => {
if (actions.length > 0) {
conversationStore.setMacroActions([...toRaw(actions)], MACRO_CONTEXT.REPLY)
}
},
{ deep: true }
)
/**
* Watch for loaded attachments from draft and restore them to mediaFiles.
*/
watch(
loadedAttachments,
(attachments) => {
if (attachments.length > 0) {
setMediaFiles([...attachments])
}
},
{ deep: true }
)
@@ -352,4 +415,17 @@ watch(
},
{ deep: true, immediate: true }
)
// Clear media files and reset macro when conversation changes.
watch(
() => conversationStore.current?.uuid,
() => {
clearMediaFiles()
conversationStore.resetMacro(MACRO_CONTEXT.REPLY)
// Focus editor on conversation change
setTimeout(() => {
replyBoxContentRef.value?.focus()
}, 100)
}
)
</script>
@@ -84,22 +84,27 @@
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
ref="editorRef"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.ctrlK')"
:aiPrompts="aiPrompts"
:insertContent="insertContent"
:autoFocus="true"
:disabled="isDraftLoading"
:enableMentions="messageType === 'private_note'"
:getSuggestions="getSuggestions"
@aiPromptSelected="handleAiPromptSelected"
@send="handleSend"
@mentionsChanged="handleMentionsChanged"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.getMacro('reply')?.actions?.length > 0"
:actions="conversationStore.getMacro('reply').actions"
:onRemove="(action) => conversationStore.removeMacroAction(action, 'reply')"
v-if="conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions?.length > 0"
:actions="conversationStore.getMacro(MACRO_CONTEXT.REPLY).actions"
:onRemove="(action) => conversationStore.removeMacroAction(action, MACRO_CONTEXT.REPLY)"
class="mt-2"
/>
@@ -128,6 +133,7 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from '@/components/editor/TextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
@@ -141,6 +147,8 @@ import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { useI18n } from 'vue-i18n'
import { validateEmail } from '@/utils/strings'
import { useMacroStore } from '@/stores/macro'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
const messageType = defineModel('messageType', { default: 'reply' })
const to = defineModel('to', { default: '' })
@@ -150,7 +158,48 @@ const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const mentions = defineModel('mentions', { default: () => [] })
const macroStore = useMacroStore()
const usersStore = useUsersStore()
const teamStore = useTeamStore()
// Get suggestions for the mention dropdown
const getSuggestions = async (query) => {
// Only show suggestions in private note mode
if (messageType.value !== 'private_note') {
return []
}
await Promise.all([usersStore.fetchUsers(), teamStore.fetchTeams()])
const q = query.toLowerCase()
const users = usersStore.users
.filter((u) => u.enabled)
.filter((u) => `${u.first_name} ${u.last_name}`.toLowerCase().includes(q))
.map((u) => ({
id: u.id,
type: 'agent',
label: `${u.first_name} ${u.last_name}`.trim(),
avatar_url: u.avatar_url
}))
const teams = teamStore.teams
.filter((t) => t.name.toLowerCase().includes(q))
.map((t) => ({
id: t.id,
type: 'team',
label: t.name,
emoji: t.emoji
}))
return [...users, ...teams].slice(0, 25)
}
// Handle mentions changed from editor
const handleMentionsChanged = (newMentions) => {
mentions.value = newMentions
}
const props = defineProps({
isFullscreen: {
@@ -173,6 +222,11 @@ const props = defineProps({
type: Array,
required: false,
default: () => []
},
isDraftLoading: {
type: Boolean,
required: false,
default: false
}
})
@@ -189,6 +243,7 @@ const conversationStore = useConversationStore()
const emitter = useEmitter()
const { t } = useI18n()
const insertContent = ref(null)
const editorRef = ref(null)
const toggleBcc = async () => {
showBcc.value = !showBcc.value
@@ -211,7 +266,7 @@ const enableSend = computed(() => {
conversationStore.getMacro('reply')?.actions?.length > 0 ||
props.uploadedFiles.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
!props.uploadingFiles.length && !props.isDraftLoading
)
})
@@ -273,13 +328,23 @@ const handleAiPromptSelected = (key) => {
// Watch and update macro view based on message type this filters our macros.
watch(
messageType,
(newType) => {
(newType, oldType) => {
if (newType === 'reply') {
macroStore.setCurrentView('replying')
} else if (newType === 'private_note') {
macroStore.setCurrentView('adding_private_note')
}
// Focus editor on tab change
setTimeout(() => {
editorRef.value?.focus()
}, 50)
},
{ immediate: true }
)
// Expose focus method for parent components
const focus = () => {
editorRef.value?.focus()
}
defineExpose({ focus })
</script>
@@ -104,6 +104,7 @@
v-if="!conversationStore.conversations.errorMessage"
key="list"
class="divide-y divide-gray-200 dark:divide-gray-700"
:class="{ 'border-b dark:border-gray-700': hasConversations }"
>
<ConversationListItem
v-for="conversation in conversationStore.conversationsList"
@@ -1,112 +1,138 @@
<template>
<div
class="group relative px-4 p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 dark:hover:bg-accent/60"
:class="{
'bg-accent/60 border-l-4': conversation.uuid === currentConversation?.uuid
}"
@click="navigateToConversation(conversation.uuid)"
>
<div class="flex items-start gap-4">
<!-- Avatar -->
<Avatar class="w-12 h-12 rounded-full shadow">
<AvatarImage
:src="conversation.contact.avatar_url || ''"
class="object-cover"
v-if="conversation.contact.avatar_url || ''"
/>
<AvatarFallback>
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<!-- Content container -->
<div class="flex-1 min-w-0 space-y-2">
<!-- Contact name and last message time -->
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold truncate">
{{ contactFullName }}
</h3>
<span class="text-xs text-gray-400 whitespace-nowrap" v-if="conversation.last_message_at">
{{ relativeLastMessageTime }}
</span>
</div>
<!-- Inbox name -->
<p class="text-xs text-gray-400 flex items-center gap-1.5">
<Mail class="w-3.5 h-3.5 text-gray-400/80" />
<span>{{ conversation.inbox_name }}</span>
</p>
<!-- Message preview and unread count -->
<div class="flex items-start justify-between gap-2">
<div
class="text-sm flex items-center gap-1.5 flex-1 break-all text-gray-600 dark:text-gray-300"
>
<Reply
class="text-green-600 flex-shrink-0"
size="15"
v-if="conversation.last_message_sender === 'agent'"
<ContextMenu>
<ContextMenuTrigger asChild>
<router-link
:to="conversationRoute"
class="group relative block px-4 p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 dark:hover:bg-accent/60"
:class="{
'bg-accent/60': conversation.uuid === currentConversation?.uuid
}"
>
<div class="flex items-start gap-4">
<!-- Avatar -->
<Avatar class="w-12 h-12 rounded-full shadow">
<AvatarImage
:src="conversation.contact.avatar_url || ''"
class="object-cover"
v-if="conversation.contact.avatar_url || ''"
/>
{{ trimmedLastMessage }}
</div>
<div
v-if="conversation.unread_message_count > 0"
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
>
{{ conversation.unread_message_count }}
<AvatarFallback>
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<!-- Content container -->
<div class="flex-1 min-w-0 space-y-2">
<!-- Contact name and last message time -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<h3 class="text-sm font-semibold truncate">
{{ contactFullName }}
</h3>
<Pencil
v-if="hasDraftForConversation"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
/>
</div>
<span
class="text-xs text-gray-400 whitespace-nowrap"
v-if="conversation.last_message_at"
>
{{ relativeLastMessageTime }}
</span>
</div>
<!-- Inbox name -->
<p class="text-xs text-gray-400 flex items-center gap-1.5">
<Mail class="w-3.5 h-3.5 text-gray-400/80" />
<span>{{ conversation.inbox_name }}</span>
</p>
<!-- Message preview and unread count -->
<div class="flex items-start justify-between gap-2">
<div
class="text-sm flex items-center gap-1.5 flex-1 break-all text-gray-600 dark:text-gray-300"
>
<Reply
class="text-green-600 flex-shrink-0"
size="15"
v-if="conversation.last_message_sender === 'agent'"
/>
{{ trimmedLastMessage }}
</div>
<div
v-if="conversation.unread_message_count > 0"
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
>
{{ conversation.unread_message_count }}
</div>
</div>
<!-- SLA Badges -->
<div class="flex items-center">
<div :class="getSlaClass(frdStatus)">
<SlaBadge
:dueAt="conversation.first_response_deadline_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showExtra="false"
@status="frdStatus = $event"
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
/>
</div>
<div :class="getSlaClass(rdStatus)">
<SlaBadge
:dueAt="conversation.resolution_deadline_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showExtra="false"
@status="rdStatus = $event"
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
/>
</div>
<div :class="getSlaClass(nrdStatus)">
<SlaBadge
:dueAt="conversation.next_response_deadline_at"
:actualAt="conversation.next_response_met_at"
:label="'NRD'"
:showExtra="false"
@status="nrdStatus = $event"
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
/>
</div>
</div>
</div>
</div>
<!-- SLA Badges -->
<div class="flex items-center">
<div :class="getSlaClass(frdStatus)">
<SlaBadge
:dueAt="conversation.first_response_deadline_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showExtra="false"
@status="frdStatus = $event"
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
/>
</div>
<div :class="getSlaClass(rdStatus)">
<SlaBadge
:dueAt="conversation.resolution_deadline_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showExtra="false"
@status="rdStatus = $event"
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
/>
</div>
<div :class="getSlaClass(nrdStatus)">
<SlaBadge
:dueAt="conversation.next_response_deadline_at"
:actualAt="conversation.next_response_met_at"
:label="'NRD'"
:showExtra="false"
@status="nrdStatus = $event"
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
/>
</div>
</div>
</div>
</div>
</div>
</router-link>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem @click="handleMarkAsUnread">
<MailOpen class="w-4 h-4 mr-2" />
{{ $t('globals.messages.markAsUnread') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRoute } from 'vue-router'
import { getRelativeTime } from '@/utils/datetime'
import { Mail, Reply } from 'lucide-vue-next'
import { Mail, Reply, Pencil, MailOpen } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import SlaBadge from '@/features/sla/SlaBadge.vue'
import { useConversationStore } from '@/stores/conversation'
let timer = null
const now = ref(new Date())
const router = useRouter()
const route = useRoute()
const conversationStore = useConversationStore()
const frdStatus = ref('')
const rdStatus = ref('')
const nrdStatus = ref('')
@@ -117,21 +143,28 @@ const props = defineProps({
contactFullName: String
})
const navigateToConversation = (uuid) => {
const handleMarkAsUnread = () => {
conversationStore.markAsUnread(props.conversation.uuid)
}
const conversationRoute = computed(() => {
const baseRoute = route.name.includes('team')
? 'team-inbox-conversation'
: route.name.includes('view')
? 'view-inbox-conversation'
: 'inbox-conversation'
router.push({
return {
name: baseRoute,
params: {
uuid,
uuid: props.conversation.uuid,
...(baseRoute === 'team-inbox-conversation' && { teamID: route.params.teamID }),
...(baseRoute === 'view-inbox-conversation' && { viewID: route.params.viewID })
}
})
}
},
query: props.conversation.mentioned_message_uuid
? { scrollTo: props.conversation.mentioned_message_uuid }
: {}
}
})
onMounted(() => {
timer = setInterval(() => {
@@ -155,4 +188,8 @@ const relativeLastMessageTime = computed(() => {
? getRelativeTime(props.conversation.last_message_at, now.value)
: ''
})
const hasDraftForConversation = computed(() => {
return conversationStore.hasDraft(props.conversation.uuid)
})
</script>

Some files were not shown because too many files have changed in this diff Show More