mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 21:39:00 +00:00
Merge remote-tracking branch 'origin/main' into feat/bulk-agent-import
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Contributing to Libredesk
|
||||
|
||||
Thanks for your interest in contributing.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
- **All PRs must align with the project’s 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Modern, open source, self-hosted customer support desk. Single binary app.
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+57
-78
@@ -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 |
@@ -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 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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'
|
||||
}]
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user