Compare commits

..

2 Commits

Author SHA1 Message Date
Abhinav Raut
beee4bace6 chore(imap-logs): add more context to error logs 2025-05-20 00:13:50 +05:30
Abhinav Raut
a29c707795 fix(imap-email): ignore incoming emails from inbox email address 2025-05-20 00:09:57 +05:30
288 changed files with 5467 additions and 10889 deletions

View File

@@ -12,8 +12,6 @@ on:
jobs: jobs:
crowdin: crowdin:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run on the original repository, not forks
if: github.event.repository.fork == false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -53,11 +53,6 @@ jobs:
- name: Configure app - name: Configure app
run: | run: |
cp config.sample.toml config.toml cp config.sample.toml config.toml
sed -i 's/host = "db"/host = "127.0.0.1"/' config.toml
sed -i 's/address = "redis:6379"/address = "localhost:6379"/' config.toml
- name: Run unit tests for frontend
run: cd frontend && pnpm test:run
- name: Install db schema and run tests - name: Install db schema and run tests
env: env:

View File

@@ -38,7 +38,7 @@ frontend-build: install-deps
.PHONY: run-backend .PHONY: run-backend
run-backend: run-backend:
@echo "→ Running backend..." @echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode. # Run the JS frontend server in development mode.
.PHONY: run-frontend .PHONY: run-frontend
@@ -52,8 +52,8 @@ run-frontend:
.PHONY: build-backend .PHONY: build-backend
build-backend: $(STUFFBIN) build-backend: $(STUFFBIN)
@echo "→ Building backend..." @echo "→ Building backend..."
@CGO_ENABLED=0 go build -a \ @CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \ -ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go -o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary. # Main build target: builds both frontend and backend, then stuffs static assets into the binary.

View File

@@ -5,17 +5,18 @@
Open source, self-hosted customer support desk. Single binary app. Open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/). Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![image](https://github.com/user-attachments/assets/8e434a02-8b33-41c8-8433-3c98d1d5b834)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested. > **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features ## Features
- **Multi Shared Inbox** - **Multi Inbox**
Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly. Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions** - **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents. Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation** - **Smart Automation**
@@ -30,16 +31,14 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria. Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management** - **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments. Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Custom attributes** - **Business Intelligence**
Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase. Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assist** - **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished. Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Activity logs** - **Activity logs**
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability. Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
- **Webhooks**
Integrate with external systems using real-time HTTP notifications for conversation and message events.
- **Command Bar** - **Command Bar**
Opens with a simple shortcut (CTRL+K) and lets you quickly perform actions on conversations. Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io) And more checkout - [libredesk.io](https://libredesk.io)
@@ -58,6 +57,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed. # Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml cp config.sample.toml config.toml
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background. # Run the services in the background.
docker compose up -d docker compose up -d
@@ -65,7 +66,7 @@ docker compose up -d
docker exec -it libredesk_app ./libredesk --set-system-user-password docker exec -it libredesk_app ./libredesk --set-system-user-password
``` ```
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command. Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation/) See [installation docs](https://libredesk.io/docs/installation/)
@@ -85,11 +86,6 @@ __________________
## Developers ## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/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, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
## Translators ## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk). You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

View File

@@ -5,11 +5,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type aiCompletionReq struct {
PromptKey string `json:"prompt_key"`
Content string `json:"content"`
}
type providerUpdateReq struct { type providerUpdateReq struct {
Provider string `json:"provider"` Provider string `json:"provider"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
@@ -18,15 +13,11 @@ type providerUpdateReq struct {
// handleAICompletion handles AI completion requests // handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error { func handleAICompletion(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
req = aiCompletionReq{} promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
content = string(r.RequestCtx.PostArgs().Peek("content"))
) )
resp, err := app.ai.Completion(promptKey, content)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
resp, err := app.ai.Completion(req.PromptKey, req.Content)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -9,10 +9,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type updateAutomationRuleExecutionModeReq struct {
Mode string `json:"mode"`
}
// handleGetAutomationRules gets all automation rules // handleGetAutomationRules gets all automation rules
func handleGetAutomationRules(r *fastglue.Request) error { func handleGetAutomationRules(r *fastglue.Request) error {
var ( var (
@@ -45,11 +41,10 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
) )
toggledRule, err := app.automation.ToggleRule(id) if err := app.automation.ToggleRule(id); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(toggledRule) return r.SendEnvelope(true)
} }
// handleUpdateAutomationRule updates an automation rule // handleUpdateAutomationRule updates an automation rule
@@ -67,11 +62,10 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
updatedRule, err := app.automation.UpdateRule(id, rule) if err = app.automation.UpdateRule(id, rule); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedRule) return r.SendEnvelope(true)
} }
// handleCreateAutomationRule creates a new automation rule // handleCreateAutomationRule creates a new automation rule
@@ -83,11 +77,10 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
if err := r.Decode(&rule, "json"); err != nil { if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
createdRule, err := app.automation.CreateRule(rule) if err := app.automation.CreateRule(rule); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdRule) return r.SendEnvelope(true)
} }
// handleDeleteAutomationRule deletes an automation rule // handleDeleteAutomationRule deletes an automation rule
@@ -125,20 +118,14 @@ func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type // handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error { func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
req = updateAutomationRuleExecutionModeReq{} mode = string(r.RequestCtx.PostArgs().Peek("mode"))
) )
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if req.Mode != amodels.ExecutionModeAll && req.Mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("automation.invalidRuleExecutionMode"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("automation.invalidRuleExecutionMode"), nil, envelope.InputError)
} }
// Only new conversation rules can be updated as they are the only ones that have execution mode. // Only new conversation rules can be updated as they are the only ones that have execution mode.
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, req.Mode); err != nil { if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, mode); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)

View File

@@ -55,12 +55,11 @@ func handleCreateBusinessHours(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
createdBusinessHours, err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays) if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdBusinessHours) return r.SendEnvelope(true)
} }
// handleDeleteBusinessHour deletes the business hour with the given id. // handleDeleteBusinessHour deletes the business hour with the given id.
@@ -94,9 +93,8 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
if businessHours.Name == "" { if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
} }
updatedBusinessHours, err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays) if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedBusinessHours) return r.SendEnvelope(true)
} }

View File

@@ -14,14 +14,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type createContactNoteReq struct {
Note string `json:"note"`
}
type blockContactReq struct {
Enabled bool `json:"enabled"`
}
// handleGetContacts returns a list of contacts from the database. // handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error { func handleGetContacts(r *fastglue.Request) error {
var ( var (
@@ -193,17 +185,12 @@ func handleCreateContactNote(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{} note = string(r.RequestCtx.PostArgs().Peek("note"))
) )
if len(note) == 0 {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
} }
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil { if err := app.user.CreateNote(contactID, auser.ID, note); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)
@@ -251,18 +238,12 @@ func handleBlockContact(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = blockContactReq{} enabled = r.RequestCtx.PostArgs().GetBool("enabled")
) )
if contactID <= 0 { if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
} }
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, enabled); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)

View File

@@ -1,7 +1,9 @@
package main package main
import ( import (
"encoding/json"
"strconv" "strconv"
"strings"
"time" "time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -9,48 +11,13 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil" "github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type assigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type teamAssigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type priorityUpdateReq struct {
Priority string `json:"priority"`
}
type statusUpdateReq struct {
Status string `json:"status"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
}
type tagsUpdateReq struct {
Tags []string `json:"tags"`
}
type createConversationRequest struct {
InboxID int `json:"inbox_id"`
AssignedAgentID int `json:"agent_id"`
AssignedTeamID int `json:"team_id"`
Email string `json:"contact_email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
}
// handleGetAllConversations retrieves all conversations. // handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error { func handleGetAllConversations(r *fastglue.Request) error {
var ( var (
@@ -324,15 +291,13 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateUserAssignee updates the user assigned to a conversation. // handleUpdateUserAssignee updates the user assigned to a conversation.
func handleUpdateUserAssignee(r *fastglue.Request) error { func handleUpdateUserAssignee(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = assigneeChangeReq{} assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
) )
if assigneeID == 0 {
if err := r.Decode(&req, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
app.lo.Error("error decoding assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
user, err := app.user.GetAgent(auser.ID, "") user, err := app.user.GetAgent(auser.ID, "")
@@ -340,20 +305,18 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := enforceConversationAccess(app, uuid, user) _, err = enforceConversationAccess(app, uuid, user)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Already assigned? if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
if conversation.AssignedUserID.Int == req.AssigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
@@ -363,16 +326,12 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = teamAssigneeChangeReq{}
) )
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err := r.Decode(&req, "json"); err != nil { if err != nil {
app.lo.Error("error decoding team assignee change request", "error", err) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
assigneeID := req.AssigneeID
user, err := app.user.GetAgent(auser.ID, "") user, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -383,37 +342,28 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
conversation, err := enforceConversationAccess(app, uuid, user) _, err = enforceConversationAccess(app, uuid, user)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Already assigned?
if conversation.AssignedTeamID.Int == assigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil { if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Evaluate automation rules on team assignment.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// handleUpdateConversationPriority updates the priority of a conversation. // handleUpdateConversationPriority updates the priority of a conversation.
func handleUpdateConversationPriority(r *fastglue.Request) error { func handleUpdateConversationPriority(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = priorityUpdateReq{} priority = string(r.RequestCtx.PostArgs().Peek("priority"))
) )
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding priority update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
priority := req.Priority
if priority == "" { if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
} }
@@ -430,26 +380,22 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// handleUpdateConversationStatus updates the status of a conversation. // handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error { func handleUpdateConversationStatus(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string) status = string(r.RequestCtx.PostArgs().Peek("status"))
auser = r.RequestCtx.UserValue("user").(amodels.User) snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
req = statusUpdateReq{} uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding status update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
status := req.Status
snoozedUntil := req.SnoozedUntil
// Validate inputs // Validate inputs
if status == "" { if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
@@ -484,6 +430,9 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
// If status is `Resolved`, send CSAT survey if enabled on inbox. // If status is `Resolved`, send CSAT survey if enabled on inbox.
if status == cmodels.StatusResolved { if status == cmodels.StatusResolved {
// Check if CSAT is enabled on the inbox and send CSAT survey message. // Check if CSAT is enabled on the inbox and send CSAT survey message.
@@ -503,19 +452,18 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// handleUpdateConversationtags updates conversation tags. // handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error { func handleUpdateConversationtags(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) tagNames = []string{}
uuid = r.RequestCtx.UserValue("uuid").(string) tagJSON = r.RequestCtx.PostArgs().Peek("tags")
req = tagsUpdateReq{} auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
) )
if err := r.Decode(&req, "json"); err != nil { if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error decoding tags update request", "error", err) app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
tagNames := req.Tags
user, err := app.user.GetAgent(auser.ID, "") user, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -586,11 +534,33 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil { if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Broadcast update.
app.conversation.BroadcastConversationUpdate(conversation.UUID, "contact.custom_attributes", attributes)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.conversation.GetDashboardCounts(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleDashboardCharts retrieves general dashboard chart data.
func handleDashboardCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
charts, err := app.conversation.GetDashboardChart(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it. // 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) { 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)
@@ -622,7 +592,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil { if err = app.conversation.RemoveConversationAssignee(uuid, "user"); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)
@@ -643,7 +613,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err = app.conversation.RemoveConversationAssignee(uuid, "team", user); err != nil { if err = app.conversation.RemoveConversationAssignee(uuid, "team"); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)
@@ -662,32 +632,36 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
// handleCreateConversation creates a new conversation and sends a message to it. // handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error { func handleCreateConversation(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{} inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
to = []string{email}
) )
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
to := []string{req.Email}
// Validate required fields // Validate required fields
if req.InboxID <= 0 { if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
} }
if req.Content == "" { if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
} }
if req.Email == "" { if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError)
} }
if req.FirstName == "" { if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError)
} }
if !stringutil.ValidEmail(req.Email) { if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
} }
@@ -697,7 +671,7 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Check if inbox exists and is enabled. // Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID) inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -707,11 +681,11 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(req.Email), Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(req.Email), SourceChannelID: null.StringFrom(email),
FirstName: req.FirstName, FirstName: firstName,
LastName: req.LastName, LastName: lastName,
InboxID: req.InboxID, InboxID: inboxID,
} }
if err := app.user.CreateContact(&contact); err != nil { if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
@@ -721,10 +695,10 @@ func handleCreateConversation(r *fastglue.Request) error {
conversationID, conversationUUID, err := app.conversation.CreateConversation( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID, contact.ContactChannelID,
req.InboxID, inboxID,
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
req.Subject, subject,
true, /** append reference number to subject **/ true, /** append reference number to subject **/
) )
if err != nil { if err != nil {
@@ -732,19 +706,8 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
} }
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
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)
}
media = append(media, m)
}
// Send reply to the created conversation. // Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails. // Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err) app.lo.Error("error deleting conversation", "error", err)
@@ -753,18 +716,14 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Assign the conversation to the agent or team. // Assign the conversation to the agent or team.
if req.AssignedAgentID > 0 { if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
} }
if req.AssignedTeamID > 0 { if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
}
// Trigger webhook event for conversation created.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation)
} }
// Send the created conversation back to the client.
conversation, _ := app.conversation.GetConversation(conversationID, "")
return r.SendEnvelope(conversation) return r.SendEnvelope(conversation)
} }

View File

@@ -70,11 +70,10 @@ func handleCreateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil { if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
createdAttr, err := app.customAttribute.Create(attribute) if err := app.customAttribute.Create(attribute); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdAttr) return r.SendEnvelope(true)
} }
// handleUpdateCustomAttribute updates an existing custom attribute in the database. // handleUpdateCustomAttribute updates an existing custom attribute in the database.
@@ -93,11 +92,10 @@ func handleUpdateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil { if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
updatedAttr, err := app.customAttribute.Update(id, attribute) if err = app.customAttribute.Update(id, attribute); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedAttr) return r.SendEnvelope(true)
} }
// handleDeleteCustomAttribute deletes a custom attribute from the database. // handleDeleteCustomAttribute deletes a custom attribute from the database.

View File

@@ -15,7 +15,7 @@ import (
// initHandlers initializes the HTTP routes and handlers for the application. // initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication. // Authentication.
g.POST("/api/v1/auth/login", handleLogin) g.POST("/api/v1/login", handleLogin)
g.GET("/logout", auth(handleLogout)) g.GET("/logout", auth(handleLogout))
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin) g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback) g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
@@ -37,6 +37,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC) g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage")) g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage")) g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage")) g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
@@ -110,8 +111,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage")) g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage")) g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage")) g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage"))
g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage"))
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword)) g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword)) g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
@@ -159,19 +158,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage")) g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage")) g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Webhooks.
g.GET("/api/v1/webhooks", perm(handleGetWebhooks, "webhooks:manage"))
g.GET("/api/v1/webhooks/{id}", perm(handleGetWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks", perm(handleCreateWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}", perm(handleUpdateWebhook, "webhooks:manage"))
g.DELETE("/api/v1/webhooks/{id}", perm(handleDeleteWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}/toggle", perm(handleToggleWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks/{id}/test", perm(handleTestWebhook, "webhooks:manage"))
// Reports. // Reports.
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage")) g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage")) g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
// Templates. // Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage")) g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))

View File

@@ -47,12 +47,11 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
} }
createdInbox, err := app.inbox.Create(inbox) if err := app.inbox.Create(inbox); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := validateInbox(app, createdInbox); err != nil { if err := validateInbox(app, inbox); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -60,13 +59,7 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
} }
// Clear passwords before returning. return r.SendEnvelope(true)
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 r.SendEnvelope(createdInbox)
} }
// handleUpdateInbox updates an inbox // handleUpdateInbox updates an inbox
@@ -89,7 +82,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
updatedInbox, err := app.inbox.Update(id, inbox) err = app.inbox.Update(id, inbox)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -98,13 +91,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
} }
// Clear passwords before returning. return r.SendEnvelope(inbox)
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 r.SendEnvelope(updatedInbox)
} }
// handleToggleInbox toggles an inbox // handleToggleInbox toggles an inbox
@@ -118,8 +105,7 @@ func handleToggleInbox(r *fastglue.Request) error {
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
} }
toggledInbox, err := app.inbox.Toggle(id) if err = app.inbox.Toggle(id); err != nil {
if err != nil {
return err return err
} }
@@ -127,13 +113,7 @@ func handleToggleInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
} }
// Clear passwords before returning return r.SendEnvelope(true)
if err := toggledInbox.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 r.SendEnvelope(toggledInbox)
} }
// handleDeleteInbox deletes an inbox // handleDeleteInbox deletes an inbox

View File

@@ -35,7 +35,6 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/setting"
@@ -45,7 +44,6 @@ import (
tmpl "github.com/abhinavxd/libredesk/internal/template" tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/view"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/abhinavxd/libredesk/internal/ws" "github.com/abhinavxd/libredesk/internal/ws"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
@@ -221,9 +219,8 @@ func initConversations(
csat *csat.Manager, csat *csat.Manager,
automationEngine *automation.Engine, automationEngine *automation.Engine,
template *tmpl.Manager, template *tmpl.Manager,
webhook *webhook.Manager,
) *conversation.Manager { ) *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, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{
DB: db, DB: db,
Lo: initLogger("conversation_manager"), Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -826,37 +823,6 @@ func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
return m return m
} }
// initReport inits report manager.
func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
lo := initLogger("report")
m, err := report.New(report.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing report manager: %v", err)
}
return m
}
// initWebhook inits webhook 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"),
})
if err != nil {
log.Fatalf("error initializing webhook manager: %v", err)
}
return m
}
// initLogger initializes a logf logger. // initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger { func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -9,30 +9,17 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// handleLogin logs in the user and returns the user. // handleLogin logs in the user and returns the user.
func handleLogin(r *fastglue.Request) error { func handleLogin(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
loginReq loginRequest
) )
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if loginReq.Email == "" || loginReq.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Verify email and password. // Verify email and password.
user, err := app.user.VerifyPassword(loginReq.Email, []byte(loginReq.Password)) user, err := app.user.VerifyPassword(email, password)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -81,12 +81,12 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
createdMacro, err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions) err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdMacro) return r.SendEnvelope(macro)
} }
// handleUpdateMacro updates a macro. // handleUpdateMacro updates a macro.
@@ -110,12 +110,11 @@ func handleUpdateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
updatedMacro, err := app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions) if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedMacro) return r.SendEnvelope(macro)
} }
// handleDeleteMacro deletes macro. // handleDeleteMacro deletes macro.
@@ -276,17 +275,13 @@ func validateMacro(app *App, macro models.Macro) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
} }
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil { if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
} }
for _, a := range act { for _, a := range act {
if len(a.Value) == 0 { if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil)
} }
} }
return nil return nil

View File

@@ -23,7 +23,6 @@ import (
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/view"
@@ -41,7 +40,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team" "github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
@@ -92,8 +90,6 @@ type App struct {
activityLog *activitylog.Manager activityLog *activitylog.Manager
notifier *notifier.Service notifier *notifier.Service
customAttribute *customAttribute.Manager customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
// Global state that stores data on an available app update. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
@@ -161,23 +157,13 @@ func main() {
settings := initSettings(db) settings := initSettings(db)
loadSettings(settings) loadSettings(settings)
// 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"
if ko.String(msgOutgoingScanIntervalKey) == "" {
if ko.String("message.message_outoing_scan_interval") != "" {
colorlog.Red("WARNING: typo in config key 'message.message_outoing_scan_interval' detected. Use 'message.message_outgoing_scan_interval' instead in your config.toml file. Support for this incorrect key will be removed in a future release.")
msgOutgoingScanIntervalKey = "message.message_outoing_scan_interval"
}
}
var ( var (
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval") autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval") unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
automationWorkers = ko.MustInt("automation.worker_count") automationWorkers = ko.MustInt("automation.worker_count")
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers") messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers") messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
messageOutgoingScanInterval = ko.MustDuration(msgOutgoingScanIntervalKey) messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval") slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName) lo = initLogger(appName)
rdb = initRedis() rdb = initRedis()
@@ -193,13 +179,12 @@ func main() {
inbox = initInbox(db, i18n) inbox = initInbox(db, i18n)
team = initTeam(db, i18n) team = initTeam(db, i18n)
businessHours = initBusinessHours(db, i18n) businessHours = initBusinessHours(db, i18n)
webhook = initWebhook(db, i18n)
user = initUser(i18n, db) user = initUser(i18n, db)
wsHub = initWS(user) wsHub = initWS(user)
notifier = initNotifier() notifier = initNotifier()
automation = initAutomationEngine(db, i18n) automation = initAutomationEngine(db, i18n)
sla = initSLA(db, team, settings, businessHours, notifier, template, user, 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) conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
autoassigner = initAutoAssigner(team, user, conversation) autoassigner = initAutoAssigner(team, user, conversation)
) )
automation.SetConversationStore(conversation) automation.SetConversationStore(conversation)
@@ -209,7 +194,6 @@ func main() {
go autoassigner.Run(ctx, autoAssignInterval) go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
go conversation.RunUnsnoozer(ctx, unsnoozeInterval) go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go webhook.Run(ctx)
go notifier.Run(ctx) go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval) go sla.Run(ctx, slaEvaluationInterval)
go sla.SendNotifications(ctx) go sla.SendNotifications(ctx)
@@ -240,14 +224,12 @@ func main() {
customAttribute: initCustomAttribute(db, i18n), customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n), authz: initAuthz(i18n),
view: initView(db), view: initView(db),
report: initReport(db, i18n),
csat: initCSAT(db, i18n), csat: initCSAT(db, i18n),
search: initSearch(db, i18n), search: initSearch(db, i18n),
role: initRole(db, i18n), role: initRole(db, i18n),
tag: initTag(db, i18n), tag: initTag(db, i18n),
macro: initMacro(db, i18n), macro: initMacro(db, i18n),
ai: initAI(db, i18n), ai: initAI(db, i18n),
webhook: webhook,
} }
app.consts.Store(constants) app.consts.Store(constants)
@@ -291,8 +273,6 @@ func main() {
autoassigner.Close() autoassigner.Close()
colorlog.Red("Shutting down notifier...") colorlog.Red("Shutting down notifier...")
notifier.Close() notifier.Close()
colorlog.Red("Shutting down webhook...")
webhook.Close()
colorlog.Red("Shutting down conversation...") colorlog.Red("Shutting down conversation...")
conversation.Close() conversation.Close()
colorlog.Red("Shutting down SLA...") colorlog.Red("Shutting down SLA...")

View File

@@ -4,6 +4,7 @@ import (
"strconv" "strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models" medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -131,6 +132,7 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string) cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{} req = messageReq{}
) )
@@ -151,7 +153,6 @@ func handleSendMessage(r *fastglue.Request) error {
} }
// Prepare attachments. // Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
if err != nil { if err != nil {
@@ -162,15 +163,16 @@ func handleSendMessage(r *fastglue.Request) error {
} }
if req.Private { if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message) if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(message) } else {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
} }
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil { return r.SendEnvelope(true)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
} }

View File

@@ -6,80 +6,30 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3" "github.com/zerodha/simplesessions/v3"
) )
// authenticateUser handles both API key and session-based authentication
// Returns the authenticated user or an error
// For session-based auth, CSRF is checked for POST/PUT/DELETE requests
func authenticateUser(r *fastglue.Request, app *App) (models.User, error) {
var user models.User
// Check for Authorization header first (API key authentication)
apiKey, apiSecret, err := r.ParseAuthHeader(fastglue.AuthBasic | fastglue.AuthToken)
if err == nil && len(apiKey) > 0 && len(apiSecret) > 0 {
user, err = app.user.ValidateAPIKey(string(apiKey), string(apiSecret))
if err != nil {
return user, err
}
return user, nil
}
// Session-based authentication - Check CSRF first.
method := string(r.RequestCtx.Method())
if method == "POST" || method == "PUT" || method == "DELETE" {
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken := string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "method", method, "cookie_token", cookieToken, "header_token", hdrToken)
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("auth.csrfTokenMismatch"), nil)
}
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return user, envelope.NewError(envelope.GeneralError, app.i18n.T("auth.invalidOrExpiredSession"), nil)
}
// Get agent user from cache or load it.
user, err = app.user.GetAgentCachedOrLoad(sessUser.ID)
if err != nil {
return user, err
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("user.accountDisabled"), nil)
}
return user, nil
}
// tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication. // tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication.
// Handlers can check if user exists in context optionally. // Handlers can check if user exists in context optionally.
// Supports both API key authentication (Authorization header) and session-based authentication.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
app := r.Context.(*App) app := r.Context.(*App)
// Try to authenticate user using shared authentication logic, but don't return errors // Try to validate session without returning error.
user, err := authenticateUser(r, app) userSession, err := app.auth.ValidateSession(r)
if err != nil { if err != nil || userSession.ID <= 0 {
// Authentication failed, but this is optional, so continue without user
return handler(r) return handler(r)
} }
// Set user in context if authentication succeeded. // Try to get user.
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return handler(r)
}
// Set user in context if found.
r.RequestCtx.SetUserValue("user", amodels.User{ r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID, ID: user.ID,
Email: user.Email.String, Email: user.Email.String,
@@ -91,25 +41,23 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
} }
} }
// auth validates the session or API key and adds the user to the request context. // auth validates the session and adds the user to the request context.
// Supports both API key authentication (Authorization header) and session-based authentication.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var app = r.Context.(*App) var app = r.Context.(*App)
// Authenticate user using shared authentication logic // Validate session and fetch user.
user, err := authenticateUser(r, app) userSession, err := app.auth.ValidateSession(r)
if err != nil { if err != nil || userSession.ID <= 0 {
if envErr, ok := err.(envelope.Error); ok { app.lo.Error("error validating session", "error", err)
if envErr.ErrorType == envelope.PermissionError { return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err)
} }
// Set user in the request context. // Set user in the request context.
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetUserValue("user", amodels.User{ r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID, ID: user.ID,
Email: user.Email.String, Email: user.Email.String,
@@ -121,24 +69,43 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
} }
} }
// perm checks if the user has the required permission to access the endpoint. // perm matches the CSRF token and checks if the user has the required permission to access the endpoint.
// Supports both API key authentication (Authorization header) and session-based authentication. // and sets the user in the request context.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler { func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var app = r.Context.(*App) var (
app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
// Authenticate user using shared authentication logic // Match CSRF token from cookie and header.
user, err := authenticateUser(r, app) if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.T("auth.csrfTokenMismatch"), nil, envelope.PermissionError)
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Get user from DB.
user, err := app.user.GetAgent(sessUser.ID, "")
if err != nil { if err != nil {
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("user.accountDisabled"), nil, envelope.PermissionError)
}
// Split the permission string into object and action and enforce it. // Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":") parts := strings.Split(perm, ":")
if len(parts) != 2 { if len(parts) != 2 {

View File

@@ -50,6 +50,18 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o) return r.SendEnvelope(o)
} }
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleCreateOIDC creates a new OIDC record. // handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error { func handleCreateOIDC(r *fastglue.Request) error {
var ( var (
@@ -60,13 +72,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
} }
// Test OIDC provider URL by performing a discovery. if err := app.oidc.Create(req); err != nil {
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
createdOIDC, err := app.oidc.Create(req)
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -74,11 +80,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil { if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
} }
return r.SendEnvelope("OIDC created successfully")
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
} }
// handleUpdateOIDC updates an OIDC record. // handleUpdateOIDC updates an OIDC record.
@@ -96,13 +98,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
} }
// Test OIDC provider URL by performing a discovery. if err = app.oidc.Update(id, req); err != nil {
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
updatedOIDC, err := app.oidc.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -110,11 +106,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil { if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
} }
return r.SendEnvelope(true)
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
} }
// handleDeleteOIDC deletes an OIDC record. // handleDeleteOIDC deletes an OIDC record.

View File

@@ -1,45 +0,0 @@
package main
import (
"strconv"
"github.com/zerodha/fastglue"
)
// handleOverviewCounts retrieves general dashboard counts for all users.
func handleOverviewCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.report.GetOverViewCounts()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleOverviewCharts retrieves general dashboard chart data.
func handleOverviewCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
charts, err := app.report.GetOverviewChart(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// handleOverviewSLA retrieves SLA data for the dashboard.
func handleOverviewSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
sla, err := app.report.GetOverviewSLA(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}

View File

@@ -55,11 +55,10 @@ func handleCreateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil { 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) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
createdRole, err := app.role.Create(req) if err := app.role.Create(req); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdRole) return r.SendEnvelope(true)
} }
// handleUpdateRole updates a role // handleUpdateRole updates a role
@@ -72,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil { 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) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
updatedRole, err := app.role.Update(id, req) if err := app.role.Update(id, req); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedRole) return r.SendEnvelope(true)
} }

View File

@@ -29,7 +29,7 @@ func handleGetSLA(r *fastglue.Request) error {
) )
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
sla, err := app.sla.Get(id) sla, err := app.sla.Get(id)
@@ -54,12 +54,11 @@ func handleCreateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
createdSLA, err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications) if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdSLA) return r.SendEnvelope("SLA created successfully.")
} }
// handleUpdateSLA updates the SLA with the given ID. // handleUpdateSLA updates the SLA with the given ID.
@@ -71,7 +70,7 @@ func handleUpdateSLA(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
if err := r.Decode(&sla, "json"); err != nil { if err := r.Decode(&sla, "json"); err != nil {
@@ -82,12 +81,11 @@ func handleUpdateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
updatedSLA, err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications) if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedSLA) return r.SendEnvelope("SLA updated successfully.")
} }
// handleDeleteSLA deletes the SLA with the given ID. // handleDeleteSLA deletes the SLA with the given ID.
@@ -97,7 +95,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
) )
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
if err = app.sla.Delete(id); err != nil { if err = app.sla.Delete(id); err != nil {
@@ -110,79 +108,51 @@ func handleDeleteSLA(r *fastglue.Request) error {
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails. // validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(app *App, sla *smodels.SLAPolicy) error { func validateSLA(app *App, sla *smodels.SLAPolicy) error {
if sla.Name == "" { if sla.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `name`"), nil)
} }
if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" { if sla.FirstResponseTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `first_response_time`"), nil)
}
if sla.ResolutionTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `resolution_time`"), nil)
} }
// Validate notifications if any. // Validate notifications if any
for _, n := range sla.Notifications { for _, n := range sla.Notifications {
if n.Type == "" { if n.Type == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `type`"), nil)
} }
if n.TimeDelayType == "" { if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay_type`"), nil)
}
if n.Metric == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
} }
if n.TimeDelayType != "immediately" { if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" { if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay`"), nil)
}
// Validate time delay duration.
td, err := time.ParseDuration(n.TimeDelay)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
if td.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
} }
} }
if len(n.Recipients) == 0 { if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `recipients`"), nil)
} }
} }
// Validate first response time duration string if not empty. // Validate time duration strings
if sla.FirstResponseTime.String != "" { frt, err := time.ParseDuration(sla.FirstResponseTime)
frt, err := time.ParseDuration(sla.FirstResponseTime.String) if err != nil {
if err != nil { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil) }
} if frt.Minutes() < 1 {
if frt.Minutes() < 1 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
} }
// Validate resolution time duration string if not empty. rt, err := time.ParseDuration(sla.ResolutionTime)
if sla.ResolutionTime.String != "" { if err != nil {
rt, err := time.ParseDuration(sla.ResolutionTime.String) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
// Compare with first response time if both are present.
if sla.FirstResponseTime.String != "" {
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
}
}
} }
if rt.Minutes() < 1 {
// Validate next response time duration string if not empty. return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
if sla.NextResponseTime.String != "" { }
nrt, err := time.ParseDuration(sla.NextResponseTime.String) if frt > rt {
if err != nil { return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
if nrt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
} }
return nil return nil

View File

@@ -33,12 +33,12 @@ func handleCreateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
createdStatus, err := app.status.Create(status.Name) err := app.status.Create(status.Name)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdStatus) return r.SendEnvelope(true)
} }
func handleDeleteStatus(r *fastglue.Request) error { func handleDeleteStatus(r *fastglue.Request) error {
@@ -74,10 +74,10 @@ func handleUpdateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
updatedStatus, err := app.status.Update(id, status.Name) err = app.status.Update(id, status.Name)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedStatus) return r.SendEnvelope(true)
} }

View File

@@ -35,12 +35,11 @@ func handleCreateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
createdTag, err := app.tag.Create(tag.Name) if err := app.tag.Create(tag.Name); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdTag) return r.SendEnvelope(true)
} }
// handleDeleteTag deletes a tag from the database. // handleDeleteTag deletes a tag from the database.
@@ -79,10 +78,9 @@ func handleUpdateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
updatedTag, err := app.tag.Update(id, tag.Name) if err = app.tag.Update(id, tag.Name); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedTag) return r.SendEnvelope(true)
} }

View File

@@ -4,8 +4,8 @@ import (
"strconv" "strconv"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/team/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -52,42 +52,41 @@ func handleGetTeam(r *fastglue.Request) error {
// handleCreateTeam creates a new team. // handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error { func handleCreateTeam(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
req = models.Team{} name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
) )
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
createdTeam, err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdTeam) return r.SendEnvelope(true)
} }
// handleUpdateTeam updates an existing team. // handleUpdateTeam updates an existing team.
func handleUpdateTeam(r *fastglue.Request) error { func handleUpdateTeam(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) name = string(r.RequestCtx.PostArgs().Peek("name"))
req = models.Team{} timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
) )
if id < 1 { if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
} }
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedTeam) return r.SendEnvelope(true)
} }
// handleDeleteTeam deletes a team // handleDeleteTeam deletes a team

View File

@@ -53,11 +53,10 @@ func handleCreateTemplate(r *fastglue.Request) error {
if req.Name == "" { if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
template, err := app.tmpl.Create(req) if err := app.tmpl.Create(req); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(template) return r.SendEnvelope(true)
} }
// handleUpdateTemplate updates a template. // handleUpdateTemplate updates a template.
@@ -77,11 +76,10 @@ func handleUpdateTemplate(r *fastglue.Request) error {
if req.Name == "" { if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
} }
updatedTemplate, err := app.tmpl.Update(id, req) if err = app.tmpl.Update(id, req); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedTemplate) return r.SendEnvelope(true)
} }
// handleDeleteTemplate deletes a template. // handleDeleteTemplate deletes a template.

View File

@@ -34,7 +34,6 @@ var migList = []migFunc{
{"v0.4.0", migrations.V0_4_0}, {"v0.4.0", migrations.V0_4_0},
{"v0.5.0", migrations.V0_5_0}, {"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0}, {"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0},
} }
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -26,29 +26,6 @@ const (
maxAvatarSizeMB = 2 maxAvatarSizeMB = 2
) )
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
Status string `json:"status"`
}
// handleGetAgents returns all agents. // handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error { func handleGetAgents(r *fastglue.Request) error {
var ( var (
@@ -90,37 +67,20 @@ func handleGetAgent(r *fastglue.Request) error {
// handleUpdateAgentAvailability updates the current agent availability. // handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error { func handleUpdateAgentAvailability(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) status = string(r.RequestCtx.PostArgs().Peek("status"))
availReq AvailabilityRequest ip = realip.FromRequest(r.RequestCtx)
) )
// Decode JSON request
if err := r.Decode(&availReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Same status?
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
}
// Update availability status. // Update availability status.
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Skip activity log if agent returns online from away (to avoid spam). // Create activity log.
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil { app.lo.Error("error creating activity log", "error", err)
app.lo.Error("error creating activity log", "error", err)
}
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)
@@ -185,11 +145,6 @@ func handleCreateAgent(r *fastglue.Request) error {
if user.Email.String == "" { if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
} }
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil { if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
@@ -199,6 +154,7 @@ func handleCreateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
} }
// Right now, only agents can be created.
if err := app.user.CreateAgent(&user); err != nil { if err := app.user.CreateAgent(&user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -247,9 +203,9 @@ func handleUpdateAgent(r *fastglue.Request) error {
user = models.User{} user = models.User{}
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
) )
if id == 0 { id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
} }
@@ -260,11 +216,6 @@ func handleUpdateAgent(r *fastglue.Request) error {
if user.Email.String == "" { if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
} }
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil { if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
@@ -285,9 +236,6 @@ func handleUpdateAgent(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Invalidate authz cache.
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed. // Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus { if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
@@ -380,23 +328,19 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
func handleResetPassword(r *fastglue.Request) error { func handleResetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
auser, ok = r.RequestCtx.UserValue("user").(amodels.User) auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest email = string(p.Peek("email"))
) )
if ok && auser.ID > 0 { if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
} }
// Decode JSON request if email == "" {
if err := r.Decode(&resetReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if resetReq.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
} }
agent, err := app.user.GetAgent(0, resetReq.Email) agent, err := app.user.GetAgent(0, email)
if err != nil { if err != nil {
// Send 200 even if user not found, to prevent email enumeration. // Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.") return r.SendEnvelope("Reset password email sent successfully.")
@@ -434,22 +378,20 @@ func handleSetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User) agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{} p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
) )
if ok && agent.ID > 0 { if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
} }
if err := r.Decode(&req, "json"); err != nil { if password == "" {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if req.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
} }
if err := app.user.ResetPassword(req.Token, req.Password); err != nil { if err := app.user.ResetPassword(token, password); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -519,61 +461,3 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
} }
return nil return nil
} }
// handleGenerateAPIKey generates a new API key for a user
func handleGenerateAPIKey(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)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate API key and secret
apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Return the API key and secret (only shown once)
response := struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{
APIKey: apiKey,
APISecret: apiSecret,
}
return r.SendEnvelope(response)
}
// handleRevokeAPIKey revokes a user's API key
func handleRevokeAPIKey(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)
}
// Check if user exists
_, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Revoke API key
if err := app.user.RevokeAPIKey(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -47,11 +47,10 @@ func handleCreateUserView(r *fastglue.Request) error {
if string(view.Filters) == "" { if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
} }
createdView, err := app.view.Create(view.Name, view.Filters, user.ID) if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(createdView) return r.SendEnvelope(true)
} }
// handleDeleteUserView deletes a view for a user. // handleDeleteUserView deletes a view for a user.
@@ -112,9 +111,8 @@ func handleUpdateUserView(r *fastglue.Request) error {
if v.UserID != user.ID { if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
} }
updatedView, err := app.view.Update(id, view.Name, view.Filters) if err = app.view.Update(id, view.Name, view.Filters); err != nil {
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(updatedView) return r.SendEnvelope(true)
} }

View File

@@ -1,191 +0,0 @@
package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetWebhooks returns all webhooks from the database.
func handleGetWebhooks(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
webhooks, err := app.webhook.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secrets.
for i := range webhooks {
if webhooks[i].Secret != "" {
webhooks[i].Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
}
return r.SendEnvelope(webhooks)
}
// handleGetWebhook returns a specific webhook by ID.
func handleGetWebhook(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)
}
webhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secret in the response.
if webhook.Secret != "" {
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(webhook)
}
// handleCreateWebhook creates a new webhook in the database.
func handleCreateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
)
if err := r.Decode(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
return r.SendEnvelope(err)
}
webhook, err := app.webhook.Create(webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(webhook)
}
// handleUpdateWebhook updates an existing webhook in the database.
func handleUpdateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
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(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
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)
}
// Clear secret before returning
updatedWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedWebhook)
}
// handleDeleteWebhook deletes a webhook from the database.
func handleDeleteWebhook(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)
}
if err := app.webhook.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleWebhook toggles the active status of a webhook.
func handleToggleWebhook(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)
}
toggledWebhook, err := app.webhook.Toggle(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
toggledWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(toggledWebhook)
}
// handleTestWebhook sends a test payload to a webhook.
func handleTestWebhook(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)
}
if err := app.webhook.SendTestWebhook(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateWebhook validates the webhook data.
func validateWebhook(app *App, webhook models.Webhook) error {
if webhook.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if webhook.URL == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`url`"), nil)
}
if len(webhook.Events) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`events`"), nil)
}
return nil
}

View File

@@ -1,124 +1,80 @@
# App.
[app] [app]
# Log level: info, debug, warn, error, fatal
log_level = "debug" log_level = "debug"
# Environment: dev, prod.
# Setting to "dev" will enable color logging in terminal.
env = "dev" 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 check_updates = true
# HTTP server. # HTTP server.
[app.server] [app.server]
# Address to bind the HTTP server to.
address = "0.0.0.0:9000" address = "0.0.0.0:9000"
# Unix socket path (leave empty to use TCP address instead)
socket = "" socket = ""
# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing! # Do NOT disable secure cookies in production environment if you don't know
# exactly what you're doing!
disable_secure_cookies = false disable_secure_cookies = false
# Request read and write timeouts.
read_timeout = "5s" read_timeout = "5s"
write_timeout = "5s" write_timeout = "5s"
# Maximum request body size in bytes (100MB) max_body_size = 500000000
# If you are using proxy, you may need to configure them to allow larger request bodies.
max_body_size = 104857600
# Size of the read buffer for incoming requests
read_buffer_size = 4096 read_buffer_size = 4096
# Keepalive settings.
keepalive_timeout = "10s" keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`. # File upload provider to use, either `fs` or `s3`.
[upload] [upload]
provider = "fs" provider = "fs"
# Filesystem provider. # Filesytem provider.
[upload.fs] [upload.fs]
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
upload_path = 'uploads' upload_path = 'uploads'
# S3 provider. # S3 provider.
[upload.s3] [upload.s3]
# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
# Leave empty to use default AWS endpoints.
url = "" url = ""
# AWS S3 credentials, keep empty to use attached IAM roles.
access_key = "" access_key = ""
secret_key = "" secret_key = ""
# AWS region, e.g., "us-east-1", "eu-west-1", etc.
region = "ap-south-1" region = "ap-south-1"
# S3 bucket name where files will be stored. bucket = "bucket"
bucket = "bucket-name"
# Optional prefix path within the S3 bucket where files will be stored.
# Example, if set to "uploads/media", files will be stored under that path.
# Useful for organizing files inside a shared bucket.
bucket_path = "" bucket_path = ""
# S3 signed URL expiry duration (e.g., "30m", "1h") expiry = "6h"
expiry = "30m"
# Postgres. # Postgres.
[db] [db]
# If running locally, use `localhost`. # If using docker compose, use the service name as the host. e.g. db
host = "db" host = "127.0.0.1"
# Database port, default is 5432.
port = 5432 port = 5432
# Update the following values with your database credentials. # Update the following values with your database credentials.
user = "libredesk" user = "libredesk"
password = "libredesk" password = "libredesk"
database = "libredesk" database = "libredesk"
ssl_mode = "disable" ssl_mode = "disable"
# Maximum number of open database connections
max_open = 30 max_open = 30
# Maximum number of idle connections in the pool
max_idle = 30 max_idle = 30
# Maximum time a connection can be reused before being closed
max_lifetime = "300s" max_lifetime = "300s"
# Redis. # Redis.
[redis] [redis]
# If running locally, use `localhost:6379`. # If using docker compose, use the service name as the host. e.g. redis:6379
address = "redis:6379" address = "127.0.0.1:6379"
password = "" password = ""
db = 0 db = 0
[message] [message]
# Number of workers processing outgoing message queue
outgoing_queue_workers = 10 outgoing_queue_workers = 10
# Number of workers processing incoming message queue
incoming_queue_workers = 10 incoming_queue_workers = 10
# How often to scan for outgoing messages to process, keep it low to process messages quickly. message_outoing_scan_interval = "50ms"
message_outgoing_scan_interval = "50ms"
# Maximum number of messages that can be queued for incoming processing
incoming_queue_size = 5000 incoming_queue_size = 5000
# Maximum number of messages that can be queued for outgoing processing
outgoing_queue_size = 5000 outgoing_queue_size = 5000
[notification] [notification]
# Number of concurrent notification workers
concurrency = 2 concurrency = 2
# Maximum number of notifications that can be queued
queue_size = 2000 queue_size = 2000
[automation] [automation]
# Number of workers processing automation rules
worker_count = 10 worker_count = 10
[autoassigner] [autoassigner]
# How often to run automatic conversation assignment
autoassign_interval = "5m" autoassign_interval = "5m"
[webhook]
# Number of webhook delivery workers
workers = 5
# Maximum number of webhook deliveries that can be queued
queue_size = 10000
# HTTP timeout for webhook requests
timeout = "15s"
[conversation] [conversation]
# How often to check for conversations to unsnooze
unsnooze_interval = "5m" unsnooze_interval = "5m"
[sla] [sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m" evaluation_interval = "5m"

View File

@@ -4,10 +4,9 @@ Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend us
### Pre-requisites ### Pre-requisites
- go - `go`
- nodejs (if you are working on the frontend) and `pnpm` - `nodejs` (if you are working on the frontend) and `pnpm`
- redis - Postgres database (>= 13)
- postgres database (>= 13)
### First time setup ### First time setup

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,17 +1,13 @@
# Introduction # Introduction
Libredesk is an open-source, self-hosted customer support desk — single binary app. Libredesk is an open source, self-hosted customer support desk. Single binary app.
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
<a href="https://libredesk.io"> <div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" /> <a href="https://libredesk.io">
</a> <img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
</div> </div>
## Developers ## Developers
Libredesk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
Libredesk is licensed under AGPLv3. Contributions are welcome.
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
- Setup guide: [Developer setup](developer-setup.md)
- Stack: Go backend, Vue 3 frontend (Shadcn UI)

View File

@@ -27,6 +27,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed. # Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml cp config.sample.toml config.toml
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background. # Run the services in the background.
docker compose up -d docker compose up -d

View File

@@ -1,6 +1,6 @@
# Templating # Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects. Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, and recipient objects.
## Outgoing Email Template Expressions ## Outgoing Email Template Expressions
@@ -8,53 +8,36 @@ If you want to customize the look of outgoing emails, you can do so in the Admin
### Conversation Variables ### Conversation Variables
| Variable | Value | | Variable | Value |
|---------------------------------|--------------------------------------------------------| |---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation | | {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation | | {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.Priority }} | The priority level of the conversation | | {{ .Conversation.UUID }} | The unique identifier of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables ### Contact Variables
| Variable | Value |
| Variable | Value |
|------------------------------|------------------------------------| |------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer | | {{ .Contact.FirstName }} | First name of the contact/customer |
| {{ .Contact.LastName }} | Last name of the contact/customer | | {{ .Contact.LastName }} | Last name of the contact/customer |
| {{ .Contact.FullName }} | Full name of the contact/customer | | {{ .Contact.FullName }} | Full name of the contact/customer |
| {{ .Contact.Email }} | Email address of the contact/customer | | {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables ### Recipient Variables
| Variable | Value |
| Variable | Value |
|--------------------------------|-----------------------------------| |--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient | | {{ .Recipient.FirstName }} | First name of the recipient |
| {{ .Recipient.LastName }} | Last name of the recipient | | {{ .Recipient.LastName }} | Last name of the recipient |
| {{ .Recipient.FullName }} | Full name of the recipient | | {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient | | {{ .Recipient.Email }} | Email address of the recipient |
### Author Variables
| Variable | Value |
|------------------------------|-----------------------------------|
| {{ .Author.FirstName }} | First name of the message author |
| {{ .Author.LastName }} | Last name of the message author |
| {{ .Author.FullName }} | Full name of the message author |
| {{ .Author.Email }} | Email address of the message author |
### Example outgoing email template ### Example outgoing email template
```html ```html
Dear {{ .Recipient.FirstName }}, Dear {{ .Recipient.FirstName }}
{{ template "content" . }} {{ template "content" . }}
Best regards, Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
``` ```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending. Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent. Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -1,3 +1,3 @@
# Translations / Internationalization # Translations / Internationalization
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk) You can help translate libreDesk into different languages by contributing here: [LibreDesk Translation Project](https://crowdin.com/project/libredesk)

View File

@@ -1,222 +0,0 @@
# Webhooks
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
## Overview
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
## Webhook Configuration
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
2. Click **Create Webhook**
3. Configure the following:
- **Name**: A descriptive name for your webhook
- **URL**: The endpoint URL where webhook payloads will be sent
- **Events**: Select which events you want to subscribe to
- **Secret**: Optional secret key for signature verification
- **Status**: Enable or disable the webhook
## Security
### Signature Verification
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
To verify the signature:
```python
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected_signature}", signature)
```
### Headers
Each webhook request includes the following headers:
- `Content-Type`: `application/json`
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
- `X-Signature-256`: HMAC signature (if secret is configured)
## Available Events
### Conversation Events
#### `conversation.created`
Triggered when a new conversation is created.
**Sample Payload:**
```json
{
"event": "conversation.created",
"timestamp": "2025-06-15T10:30:00Z",
"payload": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"subject": "Help with account setup",
"inbox_name": "Support",
"inbox_channel": "email",
"contact": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact"
},
"custom_attributes": {},
"tags": []
}
}
```
#### `conversation.status_changed`
Triggered when a conversation's status is updated.
**Sample Payload:**
```json
{
"event": "conversation.status_changed",
"timestamp": "2025-06-15T10:35:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_status": "Open",
"new_status": "Resolved",
"snooze_until": "",
"actor_id": 789
}
}
```
#### `conversation.assigned`
Triggered when a conversation is assigned to a user.
**Sample Payload:**
```json
{
"event": "conversation.assigned",
"timestamp": "2025-06-15T10:32:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"assigned_to": 789,
"actor_id": 789
}
}
```
#### `conversation.unassigned`
Triggered when a conversation is unassigned from a user.
**Sample Payload:**
```json
{
"event": "conversation.unassigned",
"timestamp": "2025-06-15T10:40:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"actor_id": 789
}
}
```
#### `conversation.tags_changed`
Triggered when tags are added or removed from a conversation.
**Sample Payload:**
```json
{
"event": "conversation.tags_changed",
"timestamp": "2025-06-15T10:45:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_tags": ["bug", "priority"],
"new_tags": ["bug", "priority", "resolved"],
"actor_id": 789
}
}
```
### Message Events
#### `message.created`
Triggered when a new message is created in a conversation.
**Sample Payload:**
```json
{
"event": "message.created",
"timestamp": "2025-06-15T10:33:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:33:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today?</p>",
"text_content": "Hello! How can I help you today?",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
#### `message.updated`
Triggered when an existing message is updated.
**Sample Payload:**
```json
{
"event": "message.updated",
"timestamp": "2025-06-15T10:34:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:34:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today? (Updated)</p>",
"text_content": "Hello! How can I help you today? (Updated)",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
## Delivery and Retries
- Webhooks requests timeout can be configured in the `config.toml` file
- Failed deliveries are not automatically retried
- Webhook delivery runs in a background worker pool for better performance
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
## Testing Webhooks
You can test your webhook configuration using tools like:
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads

View File

@@ -1,11 +1,13 @@
site_name: Libredesk Docs site_name: Libredesk Documentation
theme: theme:
name: material name: material
language: en language: en
font: font:
text: Source Sans Pro text: Source Sans Pro
code: Roboto Mono code: Roboto Mono
weights: [400, 700] weights:
- 400
- 700
direction: ltr direction: ltr
palette: palette:
primary: white primary: white
@@ -14,9 +16,9 @@ theme:
- navigation.indexes - navigation.indexes
- navigation.sections - navigation.sections
- content.code.copy - content.code.copy
extra: extra:
search: search:
language: en language: en
markdown_extensions: markdown_extensions:
- admonition - admonition
@@ -28,10 +30,9 @@ nav:
- Introduction: index.md - Introduction: index.md
- Getting Started: - Getting Started:
- Installation: installation.md - Installation: installation.md
- Upgrade Guide: upgrade.md - Upgrade: upgrade.md
- Email Templates: templating.md - Templating: templating.md
- SSO Setup: sso.md - SSO: sso.md
- Webhooks: webhooks.md - Contributors:
- Contributions: - Developer setup: developer-setup.md
- Developer Setup: developer-setup.md - Translations: translations.md
- Translate Libredesk: translations.md

View File

@@ -38,7 +38,7 @@ describe('Login Component', () => {
it('should show error for invalid login attempt', () => { it('should show error for invalid login attempt', () => {
// Mock failed login API call // Mock failed login API call
cy.intercept('POST', '**/api/v1/auth/login', { cy.intercept('POST', '**/api/v1/login', {
statusCode: 401, statusCode: 401,
body: { body: {
message: 'Invalid credentials' message: 'Invalid credentials'
@@ -61,7 +61,7 @@ describe('Login Component', () => {
it('should login successfully with correct credentials', () => { it('should login successfully with correct credentials', () => {
// Mock successful login API call // Mock successful login API call
cy.intercept('POST', '**/api/v1/auth/login', { cy.intercept('POST', '**/api/v1/login', {
statusCode: 200, statusCode: 200,
body: { body: {
data: { data: {
@@ -111,7 +111,7 @@ describe('Login Component', () => {
it('should show loading state during login', () => { it('should show loading state during login', () => {
// Mock slow API response // Mock slow API response
cy.intercept('POST', '**/api/v1/auth/login', { cy.intercept('POST', '**/api/v1/login', {
statusCode: 200, statusCode: 200,
body: { body: {
data: { data: {
@@ -132,7 +132,7 @@ describe('Login Component', () => {
// Check if loading state is shown // Check if loading state is shown
cy.contains('Logging in...').should('be.visible') cy.contains('Logging in...').should('be.visible')
cy.get('.animate-spin').should('be.visible') cy.get('svg.animate-spin').should('be.visible')
// Wait for API call to finish // Wait for API call to finish
cy.wait('@slowLogin') cy.wait('@slowLogin')

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" <link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet"> rel="stylesheet">
</head> </head>

View File

@@ -7,8 +7,6 @@
"dev": "pnpm exec vite", "dev": "pnpm exec vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:ci": "cypress run --e2e --headless", "test:e2e:ci": "cypress run --e2e --headless",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
@@ -18,8 +16,6 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/auto-animate": "^0.8.2", "@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5", "@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0", "@radix-icons/vue": "^1.0.0",
@@ -37,12 +33,12 @@
"@tiptap/vue-3": "^2.4.0", "@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4", "@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4", "@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.15.0", "@vee-validate/zod": "^4.13.2",
"@vueuse/core": "^12.4.0", "@vueuse/core": "^12.4.0",
"axios": "^1.8.2", "axios": "^1.8.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.2", "codeflask": "^1.4.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"lucide-vue-next": "^0.378.0", "lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
@@ -51,7 +47,7 @@
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"vee-validate": "^4.15.0", "vee-validate": "^4.13.2",
"vue": "^3.4.37", "vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0", "vue-dompurify-html": "^5.2.0",
"vue-i18n": "9", "vue-i18n": "9",
@@ -61,7 +57,7 @@
"vue-sonner": "^1.3.0", "vue-sonner": "^1.3.0",
"vue3-emoji-picker": "^1.1.8", "vue3-emoji-picker": "^1.1.8",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"zod": "^3.24.1" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.3.3",
@@ -78,8 +74,7 @@
"start-server-and-test": "^2.0.3", "start-server-and-test": "^2.0.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vite": "^5.4.19", "vite": "^5.4.18"
"vitest": "^3.2.2"
}, },
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
} }

739
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex w-full h-screen text-foreground"> <div class="flex w-full h-screen">
<!-- Icon sidebar always visible --> <!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50"> <SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r"> <ShadcnSidebar collapsible="none" class="border-r">
@@ -8,64 +8,38 @@
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<Tooltip> <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<TooltipTrigger as-child> <router-link :to="{ name: 'inboxes' }">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')"> <Inbox />
<router-link :to="{ name: 'inboxes' }"> </router-link>
<Inbox /> </SidebarMenuButton>
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.inbox', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.can('contacts:read_all')"> <SidebarMenuItem>
<Tooltip> <SidebarMenuButton
<TooltipTrigger as-child> asChild
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')"> :isActive="route.path.startsWith('/contacts')"
<router-link :to="{ name: 'contacts' }"> v-if="userStore.can('contacts:read_all')"
<BookUser /> >
</router-link> <router-link :to="{ name: 'contacts' }">
</SidebarMenuButton> <BookUser />
</TooltipTrigger> </router-link>
<TooltipContent side="right"> </SidebarMenuButton>
<p>{{ t('globals.terms.contact', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasReportTabPermissions"> <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
<Tooltip> <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<TooltipTrigger as-child> <router-link :to="{ name: 'reports' }">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')"> <FileLineChart />
<router-link :to="{ name: 'reports' }"> </router-link>
<FileLineChart /> </SidebarMenuButton>
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.report', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions"> <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<Tooltip> <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<TooltipTrigger as-child> <router-link
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')"> :to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
<router-link >
:to="{ <Shield />
name: userStore.can('general_settings:manage') ? 'general' : 'admin' </router-link>
}" </SidebarMenuButton>
>
<Shield />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.admin') }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
@@ -106,7 +80,7 @@
<Command /> <Command />
<!-- Create conversation dialog --> <!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" /> <CreateConversation v-model="openCreateConversationDialog" />
</template> </template>
<script setup> <script setup>
@@ -148,7 +122,6 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider SidebarProvider
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue' import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
const route = useRoute() const route = useRoute()
@@ -212,6 +185,7 @@ const deleteView = async (view) => {
}) })
} catch (err) { } catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(err).message description: handleHTTPError(err).message
}) })

View File

@@ -1,7 +1,9 @@
<template> <template>
<TooltipProvider :delay-duration="150"> <TooltipProvider :delay-duration="150">
<Toaster class="pointer-events-auto" position="top-center" richColors /> <div class="!font-jakarta">
<RouterView /> <Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</div>
</TooltipProvider> </TooltipProvider>
</template> </template>

View File

@@ -7,15 +7,15 @@ const http = axios.create({
}) })
function getCSRFToken () { function getCSRFToken () {
const name = 'csrf_token=' const name = 'csrf_token=';
const cookies = document.cookie.split(';') const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
let c = cookies[i].trim() let c = cookies[i].trim();
if (c.indexOf(name) === 0) { if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length) return c.substring(name.length, c.length);
} }
} }
return '' return '';
} }
// Request interceptor. // Request interceptor.
@@ -27,20 +27,15 @@ http.interceptors.request.use((request) => {
// Set content type for POST/PUT requests if the content type is not set. // Set content type for POST/PUT requests if the content type is not set.
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) { if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
request.headers['Content-Type'] = 'application/json' request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data) request.data = qs.stringify(request.data)
} }
return request return request
}) })
const getCustomAttributes = (appliesTo) => const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', {
http.get('/api/v1/custom-attributes', { params: { applies_to: appliesTo }
params: { applies_to: appliesTo } })
})
const createCustomAttribute = (data) => const createCustomAttribute = (data) =>
http.post('/api/v1/custom-attributes', data, { http.post('/api/v1/custom-attributes', data, {
headers: { headers: {
@@ -59,8 +54,7 @@ const searchConversations = (params) => http.get('/api/v1/conversations/search',
const searchMessages = (params) => http.get('/api/v1/messages/search', { params }) const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params }) const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email') const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) => const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
http.put('/api/v1/settings/notifications/email', data)
const getPriorities = () => http.get('/api/v1/priorities') const getPriorities = () => http.get('/api/v1/priorities')
const getStatuses = () => http.get('/api/v1/statuses') const getStatuses = () => http.get('/api/v1/statuses')
const createStatus = (data) => http.post('/api/v1/statuses', data) const createStatus = (data) => http.post('/api/v1/statuses', data)
@@ -87,12 +81,11 @@ const updateTemplate = (id, data) =>
const getAllBusinessHours = () => http.get('/api/v1/business-hours') const getAllBusinessHours = () => http.get('/api/v1/business-hours')
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`) const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
const createBusinessHours = (data) => const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
http.post('/api/v1/business-hours', data, { headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
})
const updateBusinessHours = (id, data) => const updateBusinessHours = (id, data) =>
http.put(`/api/v1/business-hours/${id}`, data, { http.put(`/api/v1/business-hours/${id}`, data, {
headers: { headers: {
@@ -103,18 +96,16 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
const getAllSLAs = () => http.get('/api/v1/sla') const getAllSLAs = () => http.get('/api/v1/sla')
const getSLA = (id) => http.get(`/api/v1/sla/${id}`) const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
const createSLA = (data) => const createSLA = (data) => http.post('/api/v1/sla', data, {
http.post('/api/v1/sla', data, { headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
}) const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, {
const updateSLA = (id, data) => headers: {
http.put(`/api/v1/sla/${id}`, data, { 'Content-Type': 'application/json'
headers: { }
'Content-Type': 'application/json' })
}
})
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`) const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
const createOIDC = (data) => const createOIDC = (data) =>
http.post('/api/v1/oidc', data, { http.post('/api/v1/oidc', data, {
@@ -122,6 +113,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled') const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc') const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`) const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -139,11 +131,7 @@ const updateSettings = (key, data) =>
} }
}) })
const getSettings = (key) => http.get(`/api/v1/settings/${key}`) const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/auth/login`, data, { const login = (data) => http.post(`/api/v1/login`, data)
headers: {
'Content-Type': 'application/json'
}
})
const getAutomationRules = (type) => const getAutomationRules = (type) =>
http.get(`/api/v1/automations/rules`, { http.get(`/api/v1/automations/rules`, {
params: { type: type } params: { type: type }
@@ -169,12 +157,7 @@ const updateAutomationRuleWeights = (data) =>
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const updateAutomationRulesExecutionMode = (data) => const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data)
http.put(`/api/v1/automations/rules/execution-mode`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getRoles = () => http.get('/api/v1/roles') const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`) const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) => const createRole = (data) =>
@@ -192,29 +175,16 @@ const updateRole = (id, data) =>
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`) const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getContacts = (params) => http.get('/api/v1/contacts', { params }) const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`) const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const updateContact = (id, data) => const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, {
http.put(`/api/v1/contacts/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data, {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'multipart/form-data'
} }
}) })
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data)
const getTeam = (id) => http.get(`/api/v1/teams/${id}`) const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams') const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, { const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
headers: { const createTeam = (data) => http.post('/api/v1/teams', data)
'Content-Type': 'application/json'
}
})
const createTeam = (data) => http.post('/api/v1/teams', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamsCompact = () => http.get('/api/v1/teams/compact') const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`) const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const updateUser = (id, data) => const updateUser = (id, data) =>
@@ -235,21 +205,9 @@ const getUser = (id) => http.get(`/api/v1/agents/${id}`)
const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar') const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
const getCurrentUser = () => http.get('/api/v1/agents/me') const getCurrentUser = () => http.get('/api/v1/agents/me')
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams') const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data, { const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data)
headers: { const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
'Content-Type': 'application/json' const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
}
})
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const setPassword = (data) => http.post('/api/v1/agents/set-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`) const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const createUser = (data) => const createUser = (data) =>
http.post('/api/v1/agents', data, { http.post('/api/v1/agents', data, {
@@ -258,56 +216,28 @@ const createUser = (data) =>
} }
}) })
const getTags = () => http.get('/api/v1/tags') const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data, { const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
headers: { const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
'Content-Type': 'application/json' const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
} const updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data,
}) {
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const removeAssignee = (uuid, assignee_type) => const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data,
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`) {
const updateContactCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) =>
http.post('/api/v1/conversations', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationPriority = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/priority`, data, {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`) const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) => const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`) const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const retryMessage = (cuuid, uuid) => const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) =>
http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const sendMessage = (uuid, data) => const sendMessage = (uuid, data) =>
http.post(`/api/v1/conversations/${uuid}/messages`, data, { http.post(`/api/v1/conversations/${uuid}/messages`, data, {
headers: { headers: {
@@ -318,33 +248,28 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`) const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getAllMacros = () => http.get('/api/v1/macros') const getAllMacros = () => http.get('/api/v1/macros')
const getMacro = (id) => http.get(`/api/v1/macros/${id}`) const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const createMacro = (data) => const createMacro = (data) => http.post('/api/v1/macros', data, {
http.post('/api/v1/macros', data, { headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
}) const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
const updateMacro = (id, data) => headers: {
http.put(`/api/v1/macros/${id}`, data, { 'Content-Type': 'application/json'
headers: { }
'Content-Type': 'application/json' })
}
})
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`) const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
const applyMacro = (uuid, id, data) => const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, { headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
})
const getTeamUnassignedConversations = (teamID, params) => const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params }) http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params }) const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
const getUnassignedConversations = (params) => const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
http.get('/api/v1/conversations/unassigned', { params })
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params }) const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
const getViewConversations = (id, params) => const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
http.get(`/api/v1/views/${id}/conversations`, { params })
const uploadMedia = (data) => const uploadMedia = (data) =>
http.post('/api/v1/media', data, { http.post('/api/v1/media', data, {
headers: { headers: {
@@ -352,8 +277,7 @@ const uploadMedia = (data) =>
} }
}) })
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts') const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params }) const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`) const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createInbox = (data) => const createInbox = (data) =>
http.post('/api/v1/inboxes', data, { http.post('/api/v1/inboxes', data, {
@@ -386,50 +310,12 @@ const updateView = (id, data) =>
}) })
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`) const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts') const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, { const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
headers: { const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
'Content-Type': 'application/json'
}
})
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`) const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data, { const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
headers: {
'Content-Type': 'application/json'
}
})
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`) const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params }) const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
const getWebhooks = () => http.get('/api/v1/webhooks')
const getWebhook = (id) => http.get(`/api/v1/webhooks/${id}`)
const createWebhook = (data) =>
http.post('/api/v1/webhooks', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateWebhook = (id, data) =>
http.put(`/api/v1/webhooks/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteWebhook = (id) => http.delete(`/api/v1/webhooks/${id}`)
const toggleWebhook = (id) => http.put(`/api/v1/webhooks/${id}/toggle`)
const testWebhook = (id) => http.post(`/api/v1/webhooks/${id}/test`)
const generateAPIKey = (id) =>
http.post(`/api/v1/agents/${id}/api-key`, {}, {
headers: {
'Content-Type': 'application/json'
}
})
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
export default { export default {
login, login,
@@ -470,7 +356,6 @@ export default {
getViewConversations, getViewConversations,
getOverviewCharts, getOverviewCharts,
getOverviewCounts, getOverviewCounts,
getOverviewSLA,
getConversationParticipants, getConversationParticipants,
getConversationMessage, getConversationMessage,
getConversationMessages, getConversationMessages,
@@ -517,6 +402,7 @@ export default {
getAllEnabledOIDC, getAllEnabledOIDC,
getOIDC, getOIDC,
updateOIDC, updateOIDC,
testOIDC,
deleteOIDC, deleteOIDC,
getTemplate, getTemplate,
getTemplates, getTemplates,
@@ -558,14 +444,5 @@ export default {
getContactNotes, getContactNotes,
createContactNote, createContactNote,
deleteContactNote, deleteContactNote,
getActivityLogs, getActivityLogs
getWebhooks,
getWebhook,
createWebhook,
updateWebhook,
deleteWebhook,
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
} }

View File

@@ -13,20 +13,12 @@
min-height: 100%; min-height: 100%;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
@apply bg-background text-foreground;
}
@media (max-width: 768px) { @media (max-width: 768px) {
html,
body {
overflow-x: auto; overflow-x: auto;
} }
} }
* {
@apply border-border;
}
.native-html { .native-html {
p { p {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -69,39 +61,10 @@
} }
} }
} }
:root { }
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 10%;
--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-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-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%;
}
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
// Theme.
@layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
@@ -134,7 +97,7 @@
} }
.dark { .dark {
--background: 240 5.9% 10%; --background: 240 10% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
@@ -164,8 +127,64 @@
} }
} }
@layer base {
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
}
// Shake animation
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-5px);
}
25% {
transform: translateX(5px);
}
35% {
transform: translateX(-5px);
}
45% {
transform: translateX(5px);
}
55% {
transform: translateX(-5px);
}
65% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
85% {
transform: translateX(5px);
}
95% {
transform: translateX(-5px);
}
100% {
transform: translateX(0);
}
}
.animate-shake {
animation: shake 0.5s infinite;
}
.message-bubble { .message-bubble {
@apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm; @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded-xl;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
table { table {
width: 100% !important; width: 100% !important;
table-layout: fixed !important; table-layout: fixed !important;
@@ -181,7 +200,7 @@
} }
.box { .box {
@apply border shadow rounded; @apply border shadow rounded-lg;
} }
// Scrollbar start // Scrollbar start
@@ -207,6 +226,85 @@
} }
// End Scrollbar // End Scrollbar
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
}
.ql-container {
margin: 0 !important;
}
.ql-container .ql-editor {
height: 300px !important;
border-radius: var(--radius) !important;
@apply rounded-lg rounded-t-none;
}
.ql-toolbar {
@apply rounded-t-lg;
}
.blinking-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--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-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-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%;
}
}
a[data-active='true'] {
background-color: hsl(var(--sidebar-background)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
a[data-active='false']:hover {
background-color: hsl(var(--sidebar-accent)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
// Sidebar end
.show-quoted-text { .show-quoted-text {
blockquote { blockquote {
@apply block; @apply block;
@@ -219,6 +317,37 @@
} }
} }
.dot-loader {
display: inline-flex;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
margin: 0 2px;
animation: dot-flashing 1s infinite linear alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] { [data-radix-popper-content-wrapper] {
z-index: 9999 !important; z-index: 9999 !important;
} }

View File

@@ -1,24 +0,0 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -1,61 +0,0 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<!-- Items -->
<template #item="{ item }">
<div class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="item.emoji">{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<!-- Selected -->
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="rounded border shadow"> <div class="rounded-md border shadow">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">

View File

@@ -1,13 +1,10 @@
<template> <template>
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" /> <div ref="codeEditor" id="code-editor" class="code-editor" />
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import CodeFlask from 'codeflask'
import { html } from '@codemirror/lang-html'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -16,38 +13,45 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const codeEditor = ref(null)
const data = ref('') const data = ref('')
let editorView = null const flask = ref(null)
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => { const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark' const el = document.createElement('code-flask')
el.attachShadow({ mode: 'open' })
el.shadowRoot.innerHTML = `
<style>
.codeflask .codeflask__flatten {
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
.codeflask .token.tag { font-weight: bold; }
.codeflask .token.attr-name { color: #111; }
.codeflask .token.attr-value { color: #000 !important; }
</style>
<div id="area"></div>
`
codeEditor.value.appendChild(el)
editorView = new EditorView({ flask.value = new CodeFlask(el.shadowRoot.getElementById('area'), {
doc: body, language: props.language,
extensions: [ lineNumbers: false,
basicSetup, styleParent: el.shadowRoot,
html(), readonly: props.disabled
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
}) })
flask.value.onUpdate((v) => {
emit('update:modelValue', v)
data.value = v
})
flask.value.updateCode(body)
nextTick(() => { nextTick(() => {
editorView?.focus() document.querySelector('code-flask').shadowRoot.querySelector('textarea').focus()
}) })
} }
@@ -57,9 +61,7 @@ onMounted(() => {
watch(() => props.modelValue, (newVal) => { watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) { if (newVal !== data.value) {
editorView?.dispatch({ flask.value.updateCode(newVal)
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
} }
}) })
</script> </script>

View File

@@ -11,12 +11,8 @@
<!-- Field --> <!-- Field -->
<div class="flex-1"> <div class="flex-1">
<Select v-model="modelFilter.field"> <Select v-model="modelFilter.field">
<SelectTrigger> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue <SelectValue :placeholder="t('form.field.selectField')" />
:placeholder="
t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })
"
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -31,12 +27,8 @@
<!-- Operator --> <!-- Operator -->
<div class="flex-1"> <div class="flex-1">
<Select v-model="modelFilter.operator" v-if="modelFilter.field"> <Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue <SelectValue :placeholder="t('form.field.selectOperator')" />
:placeholder="
t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })
"
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -52,46 +44,79 @@
<div class="flex-1"> <div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator"> <div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'"> <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectComboBox <ComboBox
v-if=" v-if="getFieldOptions(modelFilter).length > 0"
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value" v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)" :items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })" :placeholder="t('form.field.select')"
type="user" >
/> <template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<SelectComboBox <div class="flex items-center gap-1">
v-else-if=" <Avatar class="w-6 h-6">
getFieldOptions(modelFilter).length > 0 && <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
modelFilter.field === 'assigned_team_id' <AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
" </Avatar>
v-model="modelFilter.value" <span>{{ item.label }}</span>
:items="getFieldOptions(modelFilter)" </div>
:placeholder="t('globals.messages.select', { name: '' })" </div>
type="team" <div v-else-if="modelFilter.field === 'assigned_team_id'">
/> <div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<SelectComboBox <span>{{ item.label }}</span>
v-else-if="getFieldOptions(modelFilter).length > 0" </div>
v-model="modelFilter.value" </div>
:items="getFieldOptions(modelFilter)" <div v-else>
:placeholder="t('globals.messages.select', { name: '' })" {{ item.label }}
/> </div>
</template>
<template #selected="{ selected }">
<div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input <Input
v-else v-else
v-model="modelFilter.value" v-model="modelFilter.value"
:placeholder="t('globals.terms.value')" class="bg-transparent hover:bg-slate-100"
:placeholder="t('form.field.value')"
type="text" type="text"
/> />
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<CloseButton :onClose="() => removeFilter(index)" />
<button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
</div> </div>
<div class="flex items-center justify-between pt-3"> <div class="flex items-center justify-between pt-3">
@@ -104,8 +129,8 @@
}} }}
</Button> </Button>
<div class="flex gap-2" v-if="showButtons"> <div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button> <Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button> <Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -121,12 +146,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { Plus } from 'lucide-vue-next' import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue' import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs" class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick"> @click="handleClick">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" /> <component :is="icon" size="24" class="mr-2 text-primary" />
@@ -11,7 +11,7 @@
</template> </template>
<script setup> <script setup>
import { defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue'
const props = defineProps({ const props = defineProps({
title: String, title: String,

View File

@@ -1,8 +1,8 @@
<template> <template>
<div v-if="!isHidden"> <div v-if="!isHidden">
<div class="flex items-center space-x-4 h-12 px-2"> <div class="flex items-center space-x-4 h-12 px-2">
<SidebarTrigger class="cursor-pointer" /> <SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-xl font-semibold"> <span class="text-xl font-semibold text-gray-800">
{{ title }} {{ title }}
</span> </span>
</div> </div>

View File

@@ -5,7 +5,7 @@ import {
accountNavItems, accountNavItems,
contactNavItems contactNavItems
} from '@/constants/navigation' } from '@/constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { import {
Sidebar, Sidebar,
@@ -14,6 +14,7 @@ import {
SidebarHeader, SidebarHeader,
SidebarInset, SidebarInset,
SidebarMenu, SidebarMenu,
SidebarSeparator,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
@@ -27,10 +28,10 @@ import {
ChevronRight, ChevronRight,
EllipsisVertical, EllipsisVertical,
User, User,
UserSearch,
UsersRound,
Search, Search,
Plus, Plus
CircleDashed,
List
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
DropdownMenu, DropdownMenu,
@@ -40,31 +41,20 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions' import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useConversationStore } from '@/stores/conversation'
defineProps({ defineProps({
userTeams: { type: Array, default: () => [] }, userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] } userViews: { type: Array, default: () => [] }
}) })
const userStore = useUserStore() const userStore = useUserStore()
const conversationStore = useConversationStore()
const settingsStore = useAppSettingsStore() const settingsStore = useAppSettingsStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation']) const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
}
const openCreateViewDialog = () => { const openCreateViewDialog = () => {
emit('createView') emit('createView')
} }
@@ -77,83 +67,18 @@ const deleteView = (view) => {
emit('deleteView', view) emit('deleteView', view)
} }
// Navigation methods with conversation retention
const navigateToInbox = (type) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'inbox-conversation',
params: {
type,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'inbox',
params: { type }
})
}
}
const navigateToTeamInbox = (teamID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'team-inbox-conversation',
params: {
teamID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'team-inbox',
params: { teamID }
})
}
}
const navigateToViewInbox = (viewID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'view-inbox-conversation',
params: {
viewID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'view-inbox',
params: { viewID }
})
}
}
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can)) const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can)) const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can)) const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
// For auto opening admin collapsibles when a child route is active const isActiveParent = (parentHref) => {
const openAdminCollapsible = ref(null) return route.path.startsWith(parentHref)
const toggleAdminCollapsible = (titleKey) => { }
openAdminCollapsible.value = openAdminCollapsible.value === titleKey ? null : titleKey
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
} }
// Watch for route changes and update the active collapsible
watch(
[() => route.path, filteredAdminNavItems],
() => {
const activeItem = filteredAdminNavItems.value.find((item) => {
if (!item.children) return isActiveParent(item.href)
return item.children.some((child) => isActiveParent(child.href))
})
if (activeItem) {
openAdminCollapsible.value = activeItem.titleKey
}
},
{ immediate: true }
)
// Sidebar open state in local storage
const sidebarOpen = useStorage('mainSidebarOpen', true) const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true) const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true) const viewInboxOpen = useStorage('viewInboxOpen', true)
@@ -173,25 +98,24 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/contacts')" asChild>
<span class="font-semibold text-xl"> <div>
{{ t('globals.terms.contact', 2) }} <span class="font-semibold text-xl">
</span> {{ t('globals.terms.contact', 2) }}
</div> </span>
</div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey"> <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild> <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href"> <router-link :to="item.href">
<span>{{ <span>{{ t(item.titleKey) }}</span>
t('globals.messages.all', {
name: t(item.titleKey, 2).toLowerCase()
})
}}</span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -213,14 +137,17 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
<span class="font-semibold text-xl"> <div>
{{ t('globals.terms.report', 2) }} <span class="font-semibold text-xl">
</span> {{ t('navigation.reports') }}
</div> </span>
</div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -244,18 +171,21 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="flex flex-col items-start justify-between w-full px-1"> <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<span class="font-semibold text-xl"> <div class="flex items-center justify-between w-full">
{{ t('globals.terms.admin') }} <span class="font-semibold text-xl">
</span> {{ t('navigation.admin') }}
</span>
</div>
<!-- App version --> <!-- App version -->
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground ml-2">
({{ settingsStore.settings['app.version'] }}) ({{ settingsStore.settings['app.version'] }})
</div> </div>
</div> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -273,12 +203,11 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<Collapsible <Collapsible
v-else v-else
class="group/collapsible" class="group/collapsible"
:open="openAdminCollapsible === item.titleKey" :default-open="isActiveParent(item.href)"
@update:open="toggleAdminCollapsible(item.titleKey)"
> >
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)"> <SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ t(item.titleKey, item.isTitleKeyPlural === true ? 2 : 1) }}</span> <span>{{ t(item.titleKey) }}</span>
<ChevronRight <ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/> />
@@ -310,14 +239,17 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
<span class="font-semibold text-xl"> <div>
{{ t('globals.terms.account') }} <span class="font-semibold text-xl">
</span> {{ t('navigation.account') }}
</div> </span>
</div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -344,20 +276,28 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="flex items-center justify-between w-full px-1"> <SidebarMenuButton asChild>
<div class="font-semibold text-xl"> <div class="flex items-center justify-between w-full">
<span>{{ t('globals.terms.inbox') }}</span> <div class="font-semibold text-xl">
<span>{{ t('navigation.inbox') }}</span>
</div>
<div class="ml-auto">
<div class="flex items-center space-x-2">
<router-link :to="{ name: 'search' }">
<button
class="flex items-center bg-accent p-2 rounded-full hover:scale-110 transition-transform duration-100"
>
<Search size="15" stroke-width="2.5" />
</button>
</router-link>
</div>
</div>
</div> </div>
<div class="mr-1 mt-1 hover:scale-110 transition-transform"> </SidebarMenuButton>
<router-link :to="{ name: 'search' }">
<Search size="18" stroke-width="2.5" />
</router-link>
</div>
</div>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -377,32 +317,32 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<a href="#" @click.prevent="navigateToInbox('assigned')"> <router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<User /> <User />
<span>{{ t('globals.terms.myInbox') }}</span> <span>{{ t('navigation.myInbox') }}</span>
</a> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<a href="#" @click.prevent="navigateToInbox('unassigned')"> <router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<CircleDashed /> <UserSearch />
<span> <span>
{{ t('globals.terms.unassigned') }} {{ t('navigation.unassigned') }}
</span> </span>
</a> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<a href="#" @click.prevent="navigateToInbox('all')"> <router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<List /> <UsersRound />
<span> <span>
{{ t('globals.messages.all') }} {{ t('navigation.all') }}
</span> </span>
</a> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -419,7 +359,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<router-link to="#"> <router-link to="#">
<!-- <Users /> --> <!-- <Users /> -->
<span> <span>
{{ t('globals.terms.teamInbox', 2) }} {{ t('navigation.teamInboxes') }}
</span> </span>
<ChevronRight <ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
@@ -435,9 +375,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:is-active="route.params.teamID == team.id" :is-active="route.params.teamID == team.id"
asChild asChild
> >
<a href="#" @click.prevent="navigateToTeamInbox(team.id)"> <router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
{{ team.emoji }}<span>{{ team.name }}</span> {{ team.emoji }}<span>{{ team.name }}</span>
</a> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
</SidebarMenuSub> </SidebarMenuSub>
@@ -448,18 +388,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<!-- Views --> <!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen"> <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem> <SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger as-child>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<router-link to="#" class="group/item !p-2"> <router-link to="#" class="group/item">
<!-- <SlidersHorizontal /> --> <!-- <SlidersHorizontal /> -->
<span> <span>
{{ t('globals.terms.view', 2) }} {{ t('navigation.views') }}
</span> </span>
<div> <div>
<Plus <Plus
size="18" size="18"
@click.stop="openCreateViewDialog" @click.stop="openCreateViewDialog"
class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1" class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/> />
</div> </div>
<ChevronRight <ChevronRight
@@ -478,7 +418,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:isActive="route.params.viewID == view.id" :isActive="route.params.viewID == view.id"
asChild 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">{{ view.name }}</span> <span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3"> <SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu> <DropdownMenu>
@@ -487,15 +427,15 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)"> <DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span> <span>{{ t('globals.buttons.edit') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)"> <DropdownMenuItem @click="() => deleteView(view)">
<span>{{ t('globals.messages.delete') }}</span> <span>{{ t('globals.buttons.delete') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuAction> </SidebarMenuAction>
</a> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
</SidebarMenuSub> </SidebarMenuSub>

View File

@@ -2,12 +2,12 @@
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<SidebarMenuButton <SidebarMenuButton
size="md" size="lg"
class="p-0" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
> >
<Avatar class="h-8 w-8 rounded relative overflow-visible"> <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" /> <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
<AvatarFallback class="rounded"> <AvatarFallback class="rounded-lg">
{{ userStore.getInitials }} {{ userStore.getInitials }}
</AvatarFallback> </AvatarFallback>
<div <div
@@ -30,65 +30,51 @@
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56" class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom" side="bottom"
:side-offset="4" :side-offset="4"
> >
<DropdownMenuLabel class="font-normal space-y-2 px-2"> <DropdownMenuLabel class="p-0 font-normal space-y-1">
<!-- User header --> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<div class="flex items-center gap-2 py-1.5 text-left text-sm"> <Avatar class="h-8 w-8 rounded-lg">
<Avatar class="h-8 w-8 rounded">
<AvatarImage :src="userStore.avatar" alt="U" /> <AvatarImage :src="userStore.avatar" alt="U" />
<AvatarFallback class="rounded"> <AvatarFallback class="rounded-lg">
{{ userStore.getInitials }} {{ userStore.getInitials }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div class="flex-1 flex flex-col leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span> <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span> <span class="truncate text-xs">{{ userStore.email }}</span>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<!-- Dark-mode toggle --> <!-- Away switch is checked with 'away_manual' or 'away_and_reassigning' -->
<div class="flex items-center justify-between text-sm"> <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<div class="flex items-center gap-2"> <span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
<Sun v-else size="16" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
</div>
<Switch <Switch
:checked="mode === 'dark'" :checked="
@update:checked="(val) => (mode = val ? 'dark' : 'light')" ['away_manual', 'away_and_reassigning'].includes(userStore.user.availability_status)
"
@update:checked="
(val) => {
const newStatus = val ? 'away_manual' : 'online'
userStore.updateUserAvailability(newStatus)
}
"
/> />
</div> </div>
<!-- Reassign Replies Switch is checked with 'away_and_reassigning' -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3"> <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<!-- Away toggle --> <span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<div class="flex items-center justify-between text-sm"> <Switch
<span class="text-muted-foreground">{{ t('navigation.away') }}</span> :checked="userStore.user.availability_status === 'away_and_reassigning'"
<Switch @update:checked="
:checked=" (val) => {
['away_manual', 'away_and_reassigning'].includes( const newStatus = val ? 'away_and_reassigning' : 'away_manual'
userStore.user.availability_status userStore.updateUserAvailability(newStatus)
) }
" "
@update:checked=" />
(val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online')
"
/>
</div>
<!-- Reassign toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked="
(val) =>
userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual')
"
/>
</div>
</div> </div>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
@@ -96,7 +82,7 @@
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })"> <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" /> <CircleUserRound size="18" class="mr-2" />
{{ t('globals.terms.account') }} {{ t('navigation.account') }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -122,13 +108,10 @@ import {
import { SidebarMenuButton } from '@/components/ui/sidebar' import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next' import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()

View File

@@ -1,41 +1,24 @@
<template> <template>
<table class="min-w-full table-fixed divide-y divide-border"> <table class="min-w-full table-fixed divide-y divide-gray-200">
<thead class="bg-muted"> <thead class="bg-gray-50">
<tr> <tr>
<th <th
v-for="(header, index) in headers" v-for="(header, index) in headers"
:key="index" :key="index"
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
> >
{{ header }} {{ header }}
</th> </th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th> <th scope="col" class="relative px-6 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-background divide-y divide-border"> <tbody class="bg-white divide-y divide-gray-200">
<!-- Loading State --> <template v-if="data.length === 0">
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-6 py-3 text-sm font-medium text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<tr> <tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center"> <td :colspan="headers.length + 1" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground"> <span class="text-md text-gray-500">
{{ {{
$t('globals.messages.noResults', { $t('globals.messages.noResults', {
name: $t('globals.terms.result', 2).toLowerCase() name: $t('globals.terms.result', 2).toLowerCase()
@@ -46,18 +29,16 @@
</td> </td>
</tr> </tr>
</template> </template>
<!-- Data Rows -->
<template v-else> <template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent"> <tr v-for="(item, index) in data" :key="index">
<td <td
v-for="key in keys" v-for="key in keys"
:key="key" :key="key"
class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words" class="px-6 py-4 text-sm font-medium text-gray-900 whitespace-normal break-words"
> >
{{ item[key] }} {{ item[key] }}
</td> </td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground"> <td class="px-6 py-4 text-sm text-gray-500" v-if="showDelete">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)"> <Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</Button> </Button>
@@ -70,9 +51,8 @@
<script setup> <script setup>
import { Trash2 } from 'lucide-vue-next' import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
defineProps({ defineProps({
headers: { headers: {
@@ -93,14 +73,6 @@ defineProps({
showDelete: { showDelete: {
type: Boolean, type: Boolean,
default: true default: true
},
loading: {
type: Boolean,
default: false
},
skeletonRows: {
type: Number,
default: 5
} }
}) })

View File

@@ -14,7 +14,7 @@
<!-- Delete Icon --> <!-- Delete Icon -->
<X <X
class="absolute top-1 right-1 rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity" class="absolute top-1 right-1 bg-white rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
size="20" size="20"
@click.stop="emit('remove')" @click.stop="emit('remove')"
v-if="src" v-if="src"

View File

@@ -1,16 +1,25 @@
<script setup> <script setup>
import { Primitive } from 'reka-ui' import { Primitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.' import { buttonVariants } from '.'
import { Loader2 } from 'lucide-vue-next' import { cn } from '@/lib/utils'
import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
const props = defineProps({ const props = defineProps({
variant: { type: null, required: false }, variant: { type: null, required: false },
size: { type: null, required: false }, size: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: 'button' }, as: { type: null, required: false, default: 'button' },
isLoading: { type: Boolean, required: false, default: false }, isLoading: { type: Boolean, required: false, default: false }
disabled: { type: Boolean, required: false, default: false } })
const isDisabled = ref(false)
const computedClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class, {
'cursor-not-allowed opacity-50': props.isLoading || isDisabled.value
})
}) })
</script> </script>
@@ -18,22 +27,10 @@ const props = defineProps({
<Primitive <Primitive
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="computedClass"
cn( :disabled="isLoading || isDisabled"
buttonVariants({ variant, size }),
'relative',
{ 'text-transparent': isLoading },
props.class
)
"
:disabled="isLoading || disabled"
> >
<slot /> <DotLoader v-if="isLoading" />
<span <slot v-else />
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
>
<Loader2 class="h-5 w-5 animate-spin" />
</span>
</Primitive> </Primitive>
</template> </template>

View File

@@ -1,34 +1,31 @@
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'; export { default as Button } from './Button.vue'
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
'bg-primary text-primary-foreground shadow hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2', xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default'
}, }
}, }
); )

View File

@@ -1,116 +0,0 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedDays" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[140px] h-8 text-xs">
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.day', 2)
})
"
/>
</SelectTrigger>
<SelectContent class="text-xs">
<SelectItem value="0">{{ $t('globals.terms.today') }}</SelectItem>
<SelectItem value="1">
{{
$t('globals.messages.lastNItems', {
n: 1,
name: t('globals.terms.day', 1).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="2">
{{
$t('globals.messages.lastNItems', {
n: 2,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="7">
{{
$t('globals.messages.lastNItems', {
n: 7,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="30">
{{
$t('globals.messages.lastNItems', {
n: 30,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="90">
{{
$t('globals.messages.lastNItems', {
n: 90,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="custom">
{{
$t('globals.messages.custom', {
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
</SelectContent>
</Select>
<div v-if="selectedDays === 'custom'" class="flex items-center gap-2">
<Input
v-model="customDaysInput"
type="number"
min="1"
max="365"
class="w-20 h-8"
@blur="handleCustomDaysChange"
@keyup.enter="handleCustomDaysChange"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const { t } = useI18n()
const emit = defineEmits(['filterChange'])
const selectedDays = ref('30')
const customDaysInput = ref('')
const handleFilterChange = (value) => {
if (value === 'custom') {
customDaysInput.value = '30'
emit('filterChange', 30)
} else {
emit('filterChange', parseInt(value))
}
}
const handleCustomDaysChange = () => {
const days = parseInt(customDaysInput.value)
if (days && days > 0 && days <= 365) {
emit('filterChange', days)
} else {
customDaysInput.value = '30'
emit('filterChange', 30)
}
}
handleFilterChange(selectedDays.value)
</script>

View File

@@ -1 +0,0 @@
export { default as DateFilter } from './DateFilter.vue'

View File

@@ -1,19 +1,19 @@
<script setup> <script setup>
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
defaultValue: { type: [String, Number], required: false }, defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false }, modelValue: { type: [String, Number], required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const emits = defineEmits(['update:modelValue']); const emits = defineEmits(['update:modelValue'])
const modelValue = useVModel(props, 'modelValue', emits, { const modelValue = useVModel(props, 'modelValue', emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue
}); })
</script> </script>
<template> <template>
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
:class=" :class="
cn( cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class, props.class
) )
" "
/> />

View File

@@ -1 +1 @@
export { default as Input } from './Input.vue'; export { default as Input } from './Input.vue'

View File

@@ -1,11 +1,7 @@
<template> <template>
<span class="inline-flex items-center"> <span class="dot-loader">
<span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span> <span class="dot"></span>
<span <span class="dot"></span>
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]" <span class="dot"></span>
></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
></span>
</span> </span>
</template> </template>

View File

@@ -3,7 +3,7 @@
<!-- Tags visible to the user --> <!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3"> <div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue"> <TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
<TagsInputItemText /> <TagsInputItemText/>
<TagsInputItemDelete /> <TagsInputItemDelete />
</TagsInputItem> </TagsInputItem>
</div> </div>
@@ -23,7 +23,6 @@
:class="tags.length > 0 ? 'mt-2' : ''" :class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent @keydown.enter.prevent
@blur="handleBlur" @blur="handleBlur"
@click="open = true"
/> />
</ComboboxInput> </ComboboxInput>
</ComboboxAnchor> </ComboboxAnchor>
@@ -100,14 +99,11 @@ const open = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
// Get all options that are not already selected and match the search term // Get all options that are not already selected and match the search term
// If not search term is provided, return all available options
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const available = props.items.filter((item) => !tags.value.includes(item.value)) return props.items.filter(
(item) =>
if (!searchTerm.value) return available !tags.value.includes(item.value) &&
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
return available.filter((item) =>
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
) )
}) })
@@ -131,8 +127,6 @@ const handleSelect = (event) => {
// Custom filter function to filter items based on the search term // Custom filter function to filter items based on the search term
const filterFunc = (remainingItemValues, term) => { const filterFunc = (remainingItemValues, term) => {
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value)) const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
return remainingItems return remainingItems.filter((item) => item.label.toLowerCase().includes(term.toLowerCase())).map(item => item.value)
.filter((item) => item.label.toLowerCase().includes(term.toLowerCase()))
.map((item) => item.value)
} }
</script> </script>

View File

@@ -1,17 +1,21 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { Separator } from 'reka-ui'; import { Separator } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
orientation: { type: String, required: false, default: 'horizontal' }, orientation: { type: String, required: false },
decorative: { type: Boolean, required: false, default: true }, decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>
@@ -20,8 +24,8 @@ const delegatedProps = reactiveOmit(props, 'class');
:class=" :class="
cn( cn(
'shrink-0 bg-border', 'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full', props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
props.class, props.class
) )
" "
/> />

View File

@@ -1 +1 @@
export { default as Separator } from './Separator.vue'; export { default as Separator } from './Separator.vue'

View File

@@ -1,14 +1,14 @@
<script setup> <script setup>
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'; import { DialogRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({ const props = defineProps({
open: { type: Boolean, required: false }, open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false }, defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false }, modal: { type: Boolean, required: false }
}); })
const emits = defineEmits(['update:open']); const emits = defineEmits(['update:open'])
const forwarded = useForwardPropsEmits(props, emits); const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { DialogClose } from 'reka-ui'; import { DialogClose } from 'radix-vue'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,19 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { Cross2Icon } from '@radix-icons/vue';
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits
} from 'reka-ui'; } from 'radix-vue'
import { cn } from '@/lib/utils'; import { Cross2Icon } from '@radix-icons/vue'
import { sheetVariants } from '.'; import { sheetVariants } from '.'
import { cn } from '@/lib/utils'
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false
}); })
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false },
@@ -22,8 +22,8 @@ const props = defineProps({
trapFocus: { type: Boolean, required: false }, trapFocus: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false }, disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
const emits = defineEmits([ const emits = defineEmits([
'escapeKeyDown', 'escapeKeyDown',
@@ -31,12 +31,16 @@ const emits = defineEmits([
'focusOutside', 'focusOutside',
'interactOutside', 'interactOutside',
'openAutoFocus', 'openAutoFocus',
'closeAutoFocus', 'closeAutoFocus'
]); ])
const delegatedProps = reactiveOmit(props, 'class', 'side'); const delegatedProps = computed(() => {
const { class: _, side, ...delegated } = props
const forwarded = useForwardPropsEmits(delegatedProps, emits); return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>

View File

@@ -1,15 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { DialogDescription } from 'reka-ui'; import { DialogDescription } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>

View File

@@ -1,20 +1,13 @@
<script setup> <script setup>
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false }
}); })
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,15 +1,13 @@
<script setup> <script setup>
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false }
}); })
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,15 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { DialogTitle } from 'reka-ui'; import { DialogTitle } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { DialogTrigger } from 'reka-ui'; import { DialogTrigger } from 'radix-vue'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,13 +1,13 @@
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority'
export { default as Sheet } from './Sheet.vue'; export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'; export { default as SheetTrigger } from './SheetTrigger.vue'
export { default as SheetContent } from './SheetContent.vue'; export { default as SheetClose } from './SheetClose.vue'
export { default as SheetDescription } from './SheetDescription.vue'; export { default as SheetContent } from './SheetContent.vue'
export { default as SheetFooter } from './SheetFooter.vue'; export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetHeader } from './SheetHeader.vue'; export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTitle } from './SheetTitle.vue'; export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'; export { default as SheetFooter } from './SheetFooter.vue'
export const sheetVariants = cva( export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
@@ -19,11 +19,11 @@ export const sheetVariants = cva(
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right: right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
}, }
}, },
defaultVariants: { defaultVariants: {
side: 'right', side: 'right'
}, }
}, }
); )

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { cn } from '@/lib/utils';
import { Sheet, SheetContent } from '@/components/ui/sheet'; import { Sheet, SheetContent } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'; import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils';
defineOptions({ defineOptions({
@@ -12,6 +12,7 @@ const props = defineProps({
variant: { type: String, required: false, default: 'sidebar' }, variant: { type: String, required: false, default: 'sidebar' },
collapsible: { type: String, required: false, default: 'offcanvas' }, collapsible: { type: String, required: false, default: 'offcanvas' },
class: { type: null, required: false }, class: { type: null, required: false },
collapseOnMobile: { type: Boolean, required: false, default: true },
}); });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -32,7 +33,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
</div> </div>
<Sheet <Sheet
v-else-if="isMobile" v-else-if="isMobile && collapseOnMobile"
:open="openMobile" :open="openMobile"
v-bind="$attrs" v-bind="$attrs"
@update:open="setOpenMobile" @update:open="setOpenMobile"
@@ -54,7 +55,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div <div
v-else v-else
class="group peer hidden md:block" :class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
:data-state="state" :data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''" :data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant" :data-variant="variant"
@@ -76,7 +77,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div <div
:class=" :class="
cn( cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex', 'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
collapseOnMobile ? 'hidden' : '',
side === 'left' side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',

View File

@@ -1,12 +1,12 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, class?: HTMLAttributes['class']
as: { type: null, required: false }, }>()
class: { type: null, required: false },
});
</script> </script>
<template> <template>
@@ -14,14 +14,12 @@ const props = defineProps({
data-sidebar="group-action" data-sidebar="group-action"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="cn(
cn( 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'after:absolute after:-inset-2 after:md:hidden',
'after:absolute after:-inset-2 after:md:hidden', 'group-data-[collapsible=icon]:hidden',
'group-data-[collapsible=icon]:hidden', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,13 +1,17 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<div data-sidebar="group-content" :class="cn('w-full text-sm', props.class)"> <div
data-sidebar="group-content"
:class="cn('w-full text-sm', props.class)"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,12 +1,12 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, class?: HTMLAttributes['class']
as: { type: null, required: false }, }>()
class: { type: null, required: false },
});
</script> </script>
<template> <template>
@@ -14,13 +14,10 @@ const props = defineProps({
data-sidebar="group-label" data-sidebar="group-label"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="cn(
cn( 'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', props.class)"
props.class,
)
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,21 +1,20 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<Input <Input
data-sidebar="input" data-sidebar="input"
:class=" :class="cn(
cn( 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</Input> </Input>

View File

@@ -1,20 +1,19 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<main <main
:class=" :class="cn(
cn( 'relative flex min-h-svh flex-1 flex-col bg-background',
'relative flex min-h-svh flex-1 flex-col bg-background', 'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</main> </main>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,31 +1,30 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'radix-vue'
const props = defineProps({ const props = withDefaults(defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, showOnHover?: boolean
as: { type: null, required: false, default: 'button' }, class?: HTMLAttributes['class']
showOnHover: { type: Boolean, required: false }, }>(), {
class: { type: null, required: false }, as: 'button',
}); })
</script> </script>
<template> <template>
<Primitive <Primitive
data-sidebar="menu-action" data-sidebar="menu-action"
:class=" :class="cn(
cn( 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0', 'after:absolute after:-inset-2 after:md:hidden',
'after:absolute after:-inset-2 after:md:hidden', 'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5',
'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden',
'group-data-[collapsible=icon]:hidden', showOnHover
showOnHover && && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', props.class,
props.class, )"
)
"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
> >

View File

@@ -1,25 +1,24 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<div <div
data-sidebar="menu-badge" data-sidebar="menu-badge"
:class=" :class="cn(
cn( 'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5',
'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden',
'group-data-[collapsible=icon]:hidden', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</div> </div>

View File

@@ -1,40 +1,31 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { import { type Component, computed } from 'vue'
Tooltip, import SidebarMenuButtonChild, { type SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
TooltipContent, import { useSidebar } from './utils'
TooltipTrigger,
} from '@/components/ui/tooltip';
import SidebarMenuButtonChild from './SidebarMenuButtonChild.vue';
import { useSidebar } from './utils';
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); })
const props = defineProps({ const props = withDefaults(defineProps<SidebarMenuButtonProps & {
variant: { type: null, required: false, default: 'default' }, tooltip?: string | Component
size: { type: null, required: false, default: 'default' }, }>(), {
isActive: { type: Boolean, required: false }, as: 'button',
class: { type: null, required: false }, variant: 'default',
asChild: { type: Boolean, required: false }, size: 'default',
as: { type: null, required: false, default: 'button' }, })
tooltip: { type: null, required: false },
});
const { isMobile, state } = useSidebar(); const { isMobile, state } = useSidebar()
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { tooltip, ...delegated } = props; const { tooltip, ...delegated } = props
return delegated; return delegated
}); })
</script> </script>
<template> <template>
<SidebarMenuButtonChild <SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
v-if="!tooltip"
v-bind="{ ...delegatedProps, ...$attrs }"
>
<slot /> <slot />
</SidebarMenuButtonChild> </SidebarMenuButtonChild>

View File

@@ -1,16 +1,21 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { sidebarMenuButtonVariants } from '.'; import { Primitive, type PrimitiveProps } from 'radix-vue'
import { type SidebarMenuButtonVariants, sidebarMenuButtonVariants } from '.'
const props = defineProps({ export interface SidebarMenuButtonProps extends PrimitiveProps {
variant: { type: null, required: false, default: 'default' }, variant?: SidebarMenuButtonVariants['variant']
size: { type: null, required: false, default: 'default' }, size?: SidebarMenuButtonVariants['size']
isActive: { type: Boolean, required: false }, isActive?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
asChild: { type: Boolean, required: false }, }
as: { type: null, required: false, default: 'button' },
}); const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
as: 'button',
variant: 'default',
size: 'default',
})
</script> </script>
<template> <template>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,16 +1,16 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue'; import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'; import { computed, type HTMLAttributes } from 'vue'
const props = defineProps({ const props = defineProps<{
showIcon: { type: Boolean, required: false }, showIcon?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const width = computed(() => { const width = computed(() => {
return `${Math.floor(Math.random() * 40) + 50}%`; return `${Math.floor(Math.random() * 40) + 50}%`
}); })
</script> </script>
<template> <template>

View File

@@ -1,21 +1,20 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<ul <ul
data-sidebar="menu-badge" data-sidebar="menu-badge"
:class=" :class="cn(
cn( 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden',
'group-data-[collapsible=icon]:hidden', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</ul> </ul>

View File

@@ -1,14 +1,17 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = withDefaults(defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, size?: 'sm' | 'md'
as: { type: null, required: false, default: 'a' }, isActive?: boolean
size: { type: String, required: false, default: 'md' }, class?: HTMLAttributes['class']
isActive: { type: Boolean, required: false }, }>(), {
class: { type: null, required: false }, as: 'a',
}); size: 'md',
})
</script> </script>
<template> <template>
@@ -18,16 +21,14 @@ const props = defineProps({
:as-child="asChild" :as-child="asChild"
:data-size="size" :data-size="size"
:data-active="isActive" :data-active="isActive"
:class=" :class="cn(
cn( 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs',
size === 'sm' && 'text-xs', size === 'md' && 'text-sm',
size === 'md' && 'text-sm', 'group-data-[collapsible=icon]:hidden',
'group-data-[collapsible=icon]:hidden', props.class,
props.class, )"
)
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"></script> <script setup lang="ts">
</script>
<template> <template>
<li> <li>

View File

@@ -1,64 +1,57 @@
<script setup> <script setup lang="ts">
import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'; import { cn } from '@/lib/utils'
import { TooltipProvider } from 'reka-ui'; import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
import { computed, ref } from 'vue'; import { TooltipProvider } from 'radix-vue'
import { cn } from '@/lib/utils'; import { computed, type HTMLAttributes, type Ref, ref } from 'vue'
import { import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from './utils';
const props = defineProps({ const props = withDefaults(defineProps<{
defaultOpen: { type: Boolean, required: false, default: true }, defaultOpen?: boolean
open: { type: Boolean, required: false, default: undefined }, open?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>(), {
defaultOpen: true,
open: undefined,
})
const emits = defineEmits(['update:open']); const emits = defineEmits<{
'update:open': [open: boolean]
}>()
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)')
const openMobile = ref(false); const openMobile = ref(false)
const open = useVModel(props, 'open', emits, { const open = useVModel(props, 'open', emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: props.open === undefined, passive: (props.open === undefined) as false,
}); }) as Ref<boolean>
function setOpen(value) { function setOpen(value: boolean) {
open.value = value; // emits('update:open', value) open.value = value // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
} }
function setOpenMobile(value) { function setOpenMobile(value: boolean) {
openMobile.value = value; openMobile.value = value
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener('keydown', (event) => { useEventListener('keydown', (event: KeyboardEvent) => {
if ( if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.preventDefault()
(event.metaKey || event.ctrlKey) toggleSidebar()
) {
event.preventDefault();
toggleSidebar();
} }
}); })
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => (open.value ? 'expanded' : 'collapsed')); const state = computed(() => open.value ? 'expanded' : 'collapsed')
provideSidebarContext({ provideSidebarContext({
state, state,
@@ -68,7 +61,7 @@ provideSidebarContext({
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}); })
</script> </script>
<template> <template>
@@ -78,12 +71,7 @@ provideSidebarContext({
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class=" :class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
cn(
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
props.class,
)
"
v-bind="$attrs" v-bind="$attrs"
> >
<slot /> <slot />

View File

@@ -1,12 +1,13 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { useSidebar } from './utils'; import { cn } from '@/lib/utils'
import { useSidebar } from './utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar()
</script> </script>
<template> <template>
@@ -15,17 +16,15 @@ const { toggleSidebar } = useSidebar();
aria-label="Toggle Sidebar" aria-label="Toggle Sidebar"
:tabindex="-1" :tabindex="-1"
title="Toggle Sidebar" title="Toggle Sidebar"
:class=" :class="cn(
cn( 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex', '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', props.class,
props.class, )"
)
"
@click="toggleSidebar" @click="toggleSidebar"
> >
<slot /> <slot />

View File

@@ -1,10 +1,11 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,14 +1,15 @@
<script setup> <script setup lang="ts">
import { ViewVerticalIcon } from '@radix-icons/vue'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'
import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'
import { useSidebar } from './utils'; import { PanelLeft } from 'lucide-vue-next'
import { useSidebar } from './utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar()
</script> </script>
<template> <template>
@@ -19,7 +20,7 @@ const { toggleSidebar } = useSidebar();
:class="cn('h-7 w-7', props.class)" :class="cn('h-7 w-7', props.class)"
@click="toggleSidebar" @click="toggleSidebar"
> >
<ViewVerticalIcon /> <PanelLeft />
<span class="sr-only">Toggle Sidebar</span> <span class="sr-only">Toggle Sidebar</span>
</Button> </Button>
</template> </template>

View File

@@ -1,49 +0,0 @@
import { cva } from 'class-variance-authority';
export { default as Sidebar } from './Sidebar.vue';
export { default as SidebarContent } from './SidebarContent.vue';
export { default as SidebarFooter } from './SidebarFooter.vue';
export { default as SidebarGroup } from './SidebarGroup.vue';
export { default as SidebarGroupAction } from './SidebarGroupAction.vue';
export { default as SidebarGroupContent } from './SidebarGroupContent.vue';
export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue';
export { default as SidebarHeader } from './SidebarHeader.vue';
export { default as SidebarInput } from './SidebarInput.vue';
export { default as SidebarInset } from './SidebarInset.vue';
export { default as SidebarMenu } from './SidebarMenu.vue';
export { default as SidebarMenuAction } from './SidebarMenuAction.vue';
export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue';
export { default as SidebarMenuButton } from './SidebarMenuButton.vue';
export { default as SidebarMenuItem } from './SidebarMenuItem.vue';
export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue';
export { default as SidebarMenuSub } from './SidebarMenuSub.vue';
export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue';
export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue';
export { default as SidebarProvider } from './SidebarProvider.vue';
export { default as SidebarRail } from './SidebarRail.vue';
export { default as SidebarSeparator } from './SidebarSeparator.vue';
export { default as SidebarTrigger } from './SidebarTrigger.vue';
export { useSidebar } from './utils';
export const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);

View File

@@ -1,10 +0,0 @@
import { createContext } from 'reka-ui';
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
export const [useSidebar, provideSidebarContext] = createContext('Sidebar');

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