mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
Compare commits
84 Commits
9c43b8858c
...
95ae55dabd
Author | SHA1 | Date | |
---|---|---|---|
|
95ae55dabd | ||
|
0dedc0b68e | ||
|
0f207a0cd8 | ||
|
6840f73be4 | ||
|
bf1cf025e0 | ||
|
4a1e7af2fa | ||
|
3d76cce66a | ||
|
4b8f30184a | ||
|
e4018ddab8 | ||
|
02e8a43587 | ||
|
f3acc37405 | ||
|
562babf222 | ||
|
93e94432f5 | ||
|
ec63604163 | ||
|
f06da2a861 | ||
|
98f16854c8 | ||
|
cc36ef5a3a | ||
|
969d6ea4f9 | ||
|
326ccdf9d4 | ||
|
d6a8e76472 | ||
|
f95b374b74 | ||
|
a1db6ccb31 | ||
|
267a6027ee | ||
|
3471263710 | ||
|
7469e296d2 | ||
|
44ffc77c4e | ||
|
3ec061d8f1 | ||
|
48b8d14f8f | ||
|
6231a9e131 | ||
|
d63302843b | ||
|
a652f380b2 | ||
|
a4a9a9ccd3 | ||
|
71865e389e | ||
|
ae470be4c8 | ||
|
636742c34b | ||
|
de77c03f66 | ||
|
b7092744fd | ||
|
6f300bb073 | ||
|
a8ca12fb9a | ||
|
e4bec993e6 | ||
|
efc01be7d3 | ||
|
ec72c5af90 | ||
|
490417cf9d | ||
|
4f54db3d1b | ||
|
210b8bb53b | ||
|
a0e1ccf117 | ||
|
faf2082561 | ||
|
50baa8491b | ||
|
8e89e4e0d4 | ||
|
b15413b7ca | ||
|
701e5b2580 | ||
|
dbd4e97f7e | ||
|
007c332a7d | ||
|
4fcad4fd81 | ||
|
bece58bdec | ||
|
6d2d8f78d4 | ||
|
98492a1869 | ||
|
18b50b11c8 | ||
|
5a1628f710 | ||
|
12ebe32ba3 | ||
|
fce2587a9d | ||
|
7d92ac9cce | ||
|
3ce3c5e0ee | ||
|
35ad00ec51 | ||
|
9ec96be959 | ||
|
6ca36d611f | ||
|
5a87d24d72 | ||
|
7d4e7e68c3 | ||
|
5b941fd993 | ||
|
63e348e512 | ||
|
10a845dc81 | ||
|
0228989202 | ||
|
3f7d151d33 | ||
|
a516773b14 | ||
|
f6d3bd543f | ||
|
074d147bb6 | ||
|
c1c14f7f54 | ||
|
634fc66e9f | ||
|
78b8607d8f | ||
|
0dec822c1c | ||
|
958f5e38c0 | ||
|
550a3fa801 | ||
|
6bbfbe8cf6 | ||
|
f9ed326d72 |
31
.github/workflows/github-pages.yml
vendored
31
.github/workflows/github-pages.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Deploy MkDocs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
- run: pip install mkdocs-material
|
||||
|
||||
- run: |
|
||||
if [ -f requirements.txt ]; then
|
||||
pip install -r requirements.txt;
|
||||
fi
|
||||
|
||||
- run: cd docs && mkdocs build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/site
|
17
README.md
17
README.md
@@ -3,15 +3,13 @@
|
||||
|
||||
# Libredesk
|
||||
|
||||
Open source, self-hosted customer support desk. Single binary app.
|
||||
Modern, open source, self-hosted customer support desk. Single binary app.
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
|
||||
|
||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi Shared Inbox**
|
||||
@@ -67,7 +65,7 @@ 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.
|
||||
|
||||
See [installation docs](https://libredesk.io/docs/installation/)
|
||||
See [installation docs](https://docs.libredesk.io/getting-started/installation)
|
||||
|
||||
__________________
|
||||
|
||||
@@ -78,17 +76,12 @@ __________________
|
||||
- Run `./libredesk --set-system-user-password` to set the password for the System user.
|
||||
- Run `./libredesk` and visit `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://docs.libredesk.io/getting-started/installation)
|
||||
__________________
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
## 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)
|
||||
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
|
||||
|
||||
|
||||
## Translators
|
||||
|
63
cmd/config.go
Normal file
63
cmd/config.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
|
||||
func handleGetConfig(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// Get app settings
|
||||
settingsJSON, err := app.setting.GetByPrefix("app")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Unmarshal settings
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
|
||||
app.lo.Error("error unmarshalling settings", "err", err)
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
|
||||
}
|
||||
|
||||
// Filter to only include public fields needed for initial app load
|
||||
publicSettings := map[string]any{
|
||||
"app.lang": settings["app.lang"],
|
||||
"app.favicon_url": settings["app.favicon_url"],
|
||||
"app.logo_url": settings["app.logo_url"],
|
||||
"app.site_name": settings["app.site_name"],
|
||||
}
|
||||
|
||||
// Get all OIDC providers
|
||||
oidcProviders, err := app.oidc.GetAll()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Filter for enabled providers and remove client_secret
|
||||
enabledProviders := make([]map[string]any, 0)
|
||||
for _, provider := range oidcProviders {
|
||||
if provider.Enabled {
|
||||
providerMap := map[string]any{
|
||||
"id": provider.ID,
|
||||
"name": provider.Name,
|
||||
"provider": provider.Provider,
|
||||
"provider_url": provider.ProviderURL,
|
||||
"client_id": provider.ClientID,
|
||||
"logo_url": provider.ProviderLogoURL,
|
||||
"enabled": provider.Enabled,
|
||||
"redirect_uri": provider.RedirectURI,
|
||||
}
|
||||
enabledProviders = append(enabledProviders, providerMap)
|
||||
}
|
||||
}
|
||||
|
||||
// Add SSO providers to the response
|
||||
publicSettings["app.sso_providers"] = enabledProviders
|
||||
|
||||
return r.SendEnvelope(publicSettings)
|
||||
}
|
@@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
|
||||
phoneNumber = string(v[0])
|
||||
}
|
||||
phoneNumberCallingCode := ""
|
||||
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
|
||||
phoneNumberCallingCode = string(v[0])
|
||||
phoneNumberCountryCode := ""
|
||||
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
|
||||
phoneNumberCountryCode = string(v[0])
|
||||
}
|
||||
avatarURL := ""
|
||||
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
|
||||
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
if avatarURL == "null" {
|
||||
avatarURL = ""
|
||||
}
|
||||
if phoneNumberCallingCode == "null" {
|
||||
phoneNumberCallingCode = ""
|
||||
if phoneNumberCountryCode == "null" {
|
||||
phoneNumberCountryCode = ""
|
||||
}
|
||||
if phoneNumber == "null" {
|
||||
phoneNumber = ""
|
||||
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
Email: null.StringFrom(email),
|
||||
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
|
||||
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
|
||||
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
|
||||
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
|
||||
}
|
||||
|
||||
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
|
||||
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
// Upload avatar?
|
||||
files, ok := form.File["files"]
|
||||
if ok && len(files) > 0 {
|
||||
if err := uploadUserAvatar(r, &contact, files); err != nil {
|
||||
if err := uploadUserAvatar(r, contact, files); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
// Refetch contact and return it
|
||||
contact, err = app.user.GetContact(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(contact)
|
||||
}
|
||||
|
||||
// handleGetContactNotes returns all notes for a contact.
|
||||
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = createContactNoteReq{}
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
|
||||
n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
n, err = app.user.GetNote(n.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(n)
|
||||
}
|
||||
|
||||
// handleDeleteContactNote deletes a note for a contact.
|
||||
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
|
||||
|
||||
if err := app.user.DeleteNote(noteID, contactID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = blockContactReq{}
|
||||
)
|
||||
|
||||
@@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
|
||||
}
|
||||
|
||||
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
|
||||
|
||||
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
contact, err := app.user.GetContact(contactID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(contact)
|
||||
}
|
||||
|
@@ -49,6 +49,7 @@ type createConversationRequest struct {
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
Attachments []int `json:"attachments"`
|
||||
Initiator string `json:"initiator"` // "contact" | "agent"
|
||||
}
|
||||
|
||||
// handleGetAllConversations retrieves all conversations.
|
||||
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
|
||||
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
|
||||
prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
|
||||
conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
|
||||
return r.SendEnvelope(conv)
|
||||
}
|
||||
|
||||
@@ -644,14 +645,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// filterCurrentConv removes the current conversation from the list of conversations.
|
||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
|
||||
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
|
||||
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
|
||||
for i, c := range convs {
|
||||
if c.UUID == uuid {
|
||||
return append(convs[:i], convs[i+1:]...)
|
||||
}
|
||||
}
|
||||
return []cmodels.Conversation{}
|
||||
return []cmodels.PreviousConversation{}
|
||||
}
|
||||
|
||||
// handleCreateConversation creates a new conversation and sends a message to it.
|
||||
@@ -667,39 +668,17 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
if err := validateCreateConversationRequest(req, app); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
to := []string{req.Email}
|
||||
|
||||
// Validate required fields
|
||||
if req.InboxID <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
|
||||
}
|
||||
if req.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
|
||||
}
|
||||
if req.Email == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
|
||||
}
|
||||
if req.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
if !stringutil.ValidEmail(req.Email) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check if inbox exists and is enabled.
|
||||
inbox, err := app.inbox.GetDBRecord(req.InboxID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if !inbox.Enabled {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Find or create contact.
|
||||
contact := umodels.User{
|
||||
Email: null.StringFrom(req.Email),
|
||||
@@ -710,21 +689,21 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
// Create conversation first.
|
||||
conversationID, conversationUUID, err := app.conversation.CreateConversation(
|
||||
contact.ID,
|
||||
req.InboxID,
|
||||
"", /** last_message **/
|
||||
time.Now(), /** last_message_at **/
|
||||
req.Subject,
|
||||
true, /** append reference number to subject **/
|
||||
true, /** append reference number to subject? **/
|
||||
)
|
||||
if err != nil {
|
||||
app.lo.Error("error creating conversation", "error", err)
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
// Get media for the attachment ids.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
@@ -735,13 +714,29 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Send reply to the created conversation.
|
||||
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
|
||||
// Delete the conversation if reply fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
app.lo.Error("error deleting conversation", "error", err)
|
||||
// Send initial message based on the initiator of conversation.
|
||||
switch req.Initiator {
|
||||
case umodels.UserTypeAgent:
|
||||
// Queue reply.
|
||||
if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
|
||||
// Delete the conversation if msg queue fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
app.lo.Error("error deleting conversation", "error", err)
|
||||
}
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
|
||||
}
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
|
||||
case umodels.UserTypeContact:
|
||||
// Create contact message.
|
||||
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
|
||||
// Delete the conversation if message creation fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
app.lo.Error("error deleting conversation", "error", err)
|
||||
}
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
|
||||
}
|
||||
default:
|
||||
// Guard anyway.
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Assign the conversation to the agent or team.
|
||||
@@ -760,3 +755,36 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
|
||||
return r.SendEnvelope(conversation)
|
||||
}
|
||||
|
||||
// validateCreateConversationRequest validates the create conversation request fields.
|
||||
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
|
||||
if req.InboxID <= 0 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
|
||||
}
|
||||
if req.Content == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
|
||||
}
|
||||
if req.Email == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
|
||||
}
|
||||
if req.FirstName == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
|
||||
}
|
||||
if !stringutil.ValidEmail(req.Email) {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
|
||||
}
|
||||
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
|
||||
}
|
||||
|
||||
// Check if inbox exists and is enabled.
|
||||
inbox, err := app.inbox.GetDBRecord(req.InboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !inbox.Enabled {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
28
cmd/csat.go
28
cmd/csat.go
@@ -12,6 +12,9 @@ type csatResponse struct {
|
||||
Rating int `json:"rating"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
const (
|
||||
maxCsatFeedbackLength = 1000
|
||||
)
|
||||
|
||||
// handleShowCSAT renders the CSAT page for a given csat.
|
||||
func handleShowCSAT(r *fastglue.Request) error {
|
||||
@@ -24,7 +27,7 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -32,8 +35,8 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
if csat.ResponseTimestamp.Valid {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
"Title": app.i18n.T("globals.messages.thankYou"),
|
||||
"Message": app.i18n.T("csat.thankYouMessage"),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -42,14 +45,14 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Page not found",
|
||||
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Rate your interaction with us",
|
||||
"Title": app.i18n.T("csat.pageTitle"),
|
||||
"CSAT": map[string]interface{}{
|
||||
"UUID": csat.UUID,
|
||||
},
|
||||
@@ -74,7 +77,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -82,7 +85,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
if ratingI < 0 || ratingI > 5 {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -90,11 +93,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
if uuid == "" {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `uuid`",
|
||||
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Trim feedback if it exceeds max length
|
||||
if len(feedback) > maxCsatFeedbackLength {
|
||||
feedback = feedback[:maxCsatFeedbackLength]
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
@@ -105,8 +113,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
"Title": app.i18n.T("globals.messages.thankYou"),
|
||||
"Message": app.i18n.T("csat.thankYouMessage"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@@ -27,18 +27,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// i18n.
|
||||
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Public config for app initialization.
|
||||
g.GET("/api/v1/config", handleGetConfig)
|
||||
|
||||
// Media.
|
||||
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
||||
g.POST("/api/v1/media", auth(handleMediaUpload))
|
||||
|
||||
// Settings.
|
||||
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
|
||||
g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
|
||||
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
|
||||
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
|
||||
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
|
||||
|
||||
// OpenID connect single sign-on.
|
||||
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
||||
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
||||
@@ -157,7 +159,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||
|
||||
// Roles.
|
||||
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/v1/roles", auth(handleGetRoles))
|
||||
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||
|
@@ -19,6 +19,12 @@ func handleGetInboxes(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
for i := range inboxes {
|
||||
if err := inboxes[i].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(inboxes)
|
||||
}
|
||||
|
||||
|
21
cmd/init.go
21
cmd/init.go
@@ -272,11 +272,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
|
||||
}
|
||||
|
||||
// initViews inits view manager.
|
||||
func initView(db *sqlx.DB) *view.Manager {
|
||||
func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
|
||||
var lo = initLogger("view_manager")
|
||||
m, err := view.New(view.Opts{
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
DB: db,
|
||||
Lo: lo,
|
||||
I18n: i18n,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing view manager: %v", err)
|
||||
@@ -349,7 +350,7 @@ func initWS(user *user.Manager) *ws.Hub {
|
||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
|
||||
var (
|
||||
lo = initLogger("template")
|
||||
funcMap = getTmplFuncs(consts)
|
||||
funcMap = getTmplFuncs(consts, i18n)
|
||||
)
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
@@ -367,7 +368,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
|
||||
}
|
||||
|
||||
// getTmplFuncs returns the template functions.
|
||||
func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return consts.AppBaseURL
|
||||
@@ -387,6 +388,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
"SiteName": func() string {
|
||||
return consts.SiteName
|
||||
},
|
||||
"L": func() interface{} {
|
||||
return i18n
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +407,10 @@ func reloadSettings(app *App) error {
|
||||
app.lo.Error("error unmarshalling settings from DB", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
|
||||
app.Lock()
|
||||
err = ko.Load(confmap.Provider(out, "."), nil)
|
||||
app.Unlock()
|
||||
if err != nil {
|
||||
app.lo.Error("error loading settings into koanf", "error", err)
|
||||
return err
|
||||
}
|
||||
@@ -415,7 +422,7 @@ func reloadSettings(app *App) error {
|
||||
// reloadTemplates reloads the templates from the filesystem.
|
||||
func reloadTemplates(app *App) error {
|
||||
app.lo.Info("reloading templates")
|
||||
funcMap := getTmplFuncs(app.consts.Load().(*constants))
|
||||
funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing email templates", "error", err)
|
||||
|
@@ -100,6 +100,8 @@ type App struct {
|
||||
|
||||
// Global state that stores data on an available app update.
|
||||
update *AppUpdate
|
||||
// Flag to indicate if app restart is required for settings to take effect.
|
||||
restartRequired bool
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
@@ -248,7 +250,7 @@ func main() {
|
||||
activityLog: initActivityLog(db, i18n),
|
||||
customAttribute: initCustomAttribute(db, i18n),
|
||||
authz: initAuthz(i18n),
|
||||
view: initView(db),
|
||||
view: initView(db, i18n),
|
||||
report: initReport(db, i18n),
|
||||
csat: initCSAT(db, i18n),
|
||||
search: initSearch(db, i18n),
|
||||
|
@@ -2,11 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
medModels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -18,6 +21,7 @@ type messageReq struct {
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc"`
|
||||
SenderType string `json:"sender_type"`
|
||||
}
|
||||
|
||||
// handleGetMessages returns messages for a conversation.
|
||||
@@ -103,7 +107,7 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
// handleRetryMessage changes message status so it can be retried for sending.
|
||||
// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
|
||||
func handleRetryMessage(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -164,6 +168,31 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Contacts cannot send private messages
|
||||
if req.SenderType == umodels.UserTypeContact && req.Private {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check if user has permission to send messages as contact
|
||||
if req.SenderType == umodels.UserTypeContact {
|
||||
parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
|
||||
if len(parts) != 2 {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
|
||||
}
|
||||
ok, err := app.authz.Enforce(user, parts[0], parts[1])
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Get media for all attachments.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
@@ -174,6 +203,16 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Create contact message.
|
||||
if req.SenderType == umodels.UserTypeContact {
|
||||
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
// Send private note.
|
||||
if req.Private {
|
||||
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
|
||||
if err != nil {
|
||||
@@ -182,7 +221,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(message)
|
||||
}
|
||||
|
||||
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
|
||||
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
18
cmd/oidc.go
18
cmd/oidc.go
@@ -11,16 +11,6 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetAllEnabledOIDC returns all enabled OIDC records
|
||||
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
out, err := app.oidc.GetAllEnabled()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleGetAllOIDC returns all OIDC records
|
||||
func handleGetAllOIDC(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
|
||||
// Clear client secret before returning
|
||||
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
|
||||
|
||||
|
||||
return r.SendEnvelope(createdOIDC)
|
||||
}
|
||||
|
||||
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
|
||||
// Clear client secret before returning
|
||||
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
|
||||
|
||||
|
||||
return r.SendEnvelope(updatedOIDC)
|
||||
}
|
||||
|
||||
|
@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
|
||||
settings["app.update"] = app.update
|
||||
// Set app version.
|
||||
settings["app.version"] = versionString
|
||||
// Set restart required flag.
|
||||
settings["app.restart_required"] = app.restartRequired
|
||||
return r.SendEnvelope(settings)
|
||||
}
|
||||
|
||||
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Get current language before update.
|
||||
app.Lock()
|
||||
oldLang := ko.String("app.lang")
|
||||
app.Unlock()
|
||||
|
||||
// Remove any trailing slash `/` from the root url.
|
||||
req.RootURL = strings.TrimRight(req.RootURL, "/")
|
||||
|
||||
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
if err := reloadSettings(app); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
|
||||
}
|
||||
|
||||
// Check if language changed and reload i18n if needed.
|
||||
app.Lock()
|
||||
newLang := ko.String("app.lang")
|
||||
if oldLang != newLang {
|
||||
app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
|
||||
app.i18n = initI18n(app.fs)
|
||||
app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
|
||||
}
|
||||
app.Unlock()
|
||||
|
||||
if err := reloadTemplates(app); err != nil {
|
||||
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
|
||||
}
|
||||
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// If empty then retain previous password.
|
||||
if req.Password == "" {
|
||||
req.Password = cur.Password
|
||||
}
|
||||
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// No reload implemented, so user has to restart the app.
|
||||
// Email notification settings require app restart to take effect.
|
||||
app.Lock()
|
||||
app.restartRequired = true
|
||||
app.Unlock()
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
|
||||
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);
|
||||
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)
|
||||
}
|
||||
|
@@ -35,7 +35,8 @@ var migList = []migFunc{
|
||||
{"v0.5.0", migrations.V0_5_0},
|
||||
{"v0.6.0", migrations.V0_6_0},
|
||||
{"v0.7.0", migrations.V0_7_0},
|
||||
{"v0.8.0", migrations.V0_8_0},
|
||||
{"v0.7.4", migrations.V0_7_4},
|
||||
{"v0.9.0", migrations.V0_9_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
220
cmd/users.go
220
cmd/users.go
@@ -26,34 +26,38 @@ const (
|
||||
maxAvatarSizeMB = 2
|
||||
)
|
||||
|
||||
// Request structs for user-related endpoints
|
||||
|
||||
// UpdateAvailabilityRequest represents the request to update user availability
|
||||
type UpdateAvailabilityRequest struct {
|
||||
type updateAvailabilityRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest represents the password reset request
|
||||
type ResetPasswordRequest struct {
|
||||
type resetPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// SetPasswordRequest represents the set password request
|
||||
type SetPasswordRequest struct {
|
||||
type setPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AvailabilityRequest represents the request to update agent availability
|
||||
type AvailabilityRequest struct {
|
||||
type availabilityRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type agentReq struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
SendWelcomeEmail bool `json:"send_welcome_email"`
|
||||
Teams []string `json:"teams"`
|
||||
Roles []string `json:"roles"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AvailabilityStatus string `json:"availability_status"`
|
||||
NewPassword string `json:"new_password,omitempty"`
|
||||
}
|
||||
|
||||
// handleGetAgents returns all agents.
|
||||
func handleGetAgents(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
agents, err := app.user.GetAgents()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
|
||||
|
||||
// handleGetAgent returns an agent.
|
||||
func handleGetAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
@@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
ip = realip.FromRequest(r.RequestCtx)
|
||||
availReq AvailabilityRequest
|
||||
availReq availabilityRequest
|
||||
)
|
||||
|
||||
// Decode JSON request
|
||||
@@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Fetch entire agent
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
|
||||
// Same status?
|
||||
if agent.AvailabilityStatus == availReq.Status {
|
||||
return r.SendEnvelope(true)
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// Update availability status.
|
||||
// Update availability status
|
||||
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
// Fetch updated agent and return
|
||||
agent, err = app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// handleGetCurrentAgentTeams returns the teams of an agent.
|
||||
// handleGetCurrentAgentTeams returns the teams of current agent.
|
||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
teams, err := app.team.GetUserTeams(agent.ID)
|
||||
teams, err := app.team.GetUserTeams(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
form, err := r.RequestCtx.MultipartForm()
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing form data", "error", err)
|
||||
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
|
||||
// Upload avatar?
|
||||
if ok && len(files) > 0 {
|
||||
if err := uploadUserAvatar(r, &agent, files); err != nil {
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := uploadUserAvatar(r, agent, files); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
// Fetch updated agent and return.
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// handleCreateAgent creates a new agent.
|
||||
func handleCreateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
app = r.Context.(*App)
|
||||
req = agentReq{}
|
||||
)
|
||||
if err := r.Decode(&user, "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)
|
||||
}
|
||||
|
||||
if user.Email.String == "" {
|
||||
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)
|
||||
// Validate agent request
|
||||
if err := validateAgentRequest(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Roles == nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if user.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.user.CreateAgent(&user); err != nil {
|
||||
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Upsert user teams.
|
||||
if len(user.Teams) > 0 {
|
||||
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(req.Teams) > 0 {
|
||||
app.team.UpsertUserTeams(agent.ID, req.Teams)
|
||||
}
|
||||
|
||||
if user.SendWelcomeEmail {
|
||||
if req.SendWelcomeEmail {
|
||||
// Generate reset token.
|
||||
resetToken, err := app.user.SetResetPasswordToken(user.ID)
|
||||
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
|
||||
// Render template and send email.
|
||||
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
|
||||
"ResetToken": resetToken,
|
||||
"Email": user.Email.String,
|
||||
"Email": req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
app.lo.Error("error rendering template", "error", err)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
RecipientEmails: []string{user.Email.String},
|
||||
Subject: "Welcome to Libredesk",
|
||||
RecipientEmails: []string{req.Email},
|
||||
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
app.lo.Error("error sending notification message", "error", err)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
|
||||
// Refetch agent as other details might've changed.
|
||||
agent, err = app.user.GetAgent(agent.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// handleUpdateAgent updates an agent.
|
||||
func handleUpdateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
req = agentReq{}
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
ip = realip.FromRequest(r.RequestCtx)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&user, "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)
|
||||
}
|
||||
|
||||
if user.Email.String == "" {
|
||||
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 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if user.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
||||
// Validate agent request
|
||||
if err := validateAgentRequest(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agent, err := app.user.GetAgent(id, "")
|
||||
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
}
|
||||
oldAvailabilityStatus := agent.AvailabilityStatus
|
||||
|
||||
// Update agent.
|
||||
if err = app.user.UpdateAgent(id, user); err != nil {
|
||||
// Update agent with individual fields
|
||||
if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
defer app.authz.InvalidateUserCache(id)
|
||||
|
||||
// Create activity log if user availability status changed.
|
||||
if oldAvailabilityStatus != user.AvailabilityStatus {
|
||||
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
|
||||
if oldAvailabilityStatus != req.AvailabilityStatus {
|
||||
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
|
||||
app.lo.Error("error creating activity log", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert agent teams.
|
||||
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
|
||||
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
// Refetch agent and return.
|
||||
agent, err = app.user.GetAgent(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// handleDeleteAgent soft deletes an agent.
|
||||
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
resetReq ResetPasswordRequest
|
||||
resetReq resetPasswordRequest
|
||||
)
|
||||
if ok && auser.ID > 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
|
||||
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
agent, err := app.user.GetAgent(0, resetReq.Email)
|
||||
if err != nil {
|
||||
// Send 200 even if user not found, to prevent email enumeration.
|
||||
return r.SendEnvelope("Reset password email sent successfully.")
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
token, err := app.user.SetResetPasswordToken(agent.ID)
|
||||
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = SetPasswordRequest{}
|
||||
req setPasswordRequest
|
||||
)
|
||||
|
||||
if ok && agent.ID > 0 {
|
||||
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// uploadUserAvatar uploads the user avatar.
|
||||
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
|
||||
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
fileHeader := files[0]
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
app.lo.Error("error opening uploaded file", "error", err)
|
||||
app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
|
||||
}
|
||||
defer file.Close()
|
||||
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
|
||||
|
||||
// Check file size
|
||||
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
|
||||
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
|
||||
app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
|
||||
return envelope.NewError(
|
||||
envelope.InputError,
|
||||
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
|
||||
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
|
||||
meta := []byte("{}")
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
|
||||
if err != nil {
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
|
||||
}
|
||||
|
||||
// Delete current avatar.
|
||||
if user.AvatarURL.Valid {
|
||||
fileName := filepath.Base(user.AvatarURL.String)
|
||||
app.media.Delete(fileName)
|
||||
if err := app.media.Delete(fileName); err != nil {
|
||||
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save file path.
|
||||
path, err := stringutil.GetPathFromURL(media.URL)
|
||||
if err != nil {
|
||||
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
|
||||
app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
|
||||
}
|
||||
fmt.Println("path", path)
|
||||
|
||||
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// validateAgentRequest validates common agent request fields and normalizes the email
|
||||
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// Normalize email
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if req.Email == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if !stringutil.ValidEmail(req.Email) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if req.Roles == nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if req.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -1,30 +0,0 @@
|
||||
# API getting started
|
||||
|
||||
You can access the Libredesk API to interact with your instance programmatically.
|
||||
|
||||
## Generating API keys
|
||||
|
||||
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
|
||||
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
|
||||
3. **Save the credentials**: Keep both the API Key and API Secret secure
|
||||
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
|
||||
|
||||
## Using the API
|
||||
|
||||
LibreDesk supports two authentication schemes:
|
||||
|
||||
### Basic authentication
|
||||
```bash
|
||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
|
||||
-H "Authorization: Basic <base64_encoded_key:secret>"
|
||||
```
|
||||
|
||||
### Token authentication
|
||||
```bash
|
||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
|
||||
-H "Authorization: token your_api_key:your_api_secret"
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Complete API documentation with available endpoints and examples coming soon.
|
@@ -1,32 +0,0 @@
|
||||
# Developer Setup
|
||||
|
||||
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
- go
|
||||
- nodejs (if you are working on the frontend) and `pnpm`
|
||||
- redis
|
||||
- postgres database (>= 13)
|
||||
|
||||
### First time setup
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/abhinavxd/libredesk.git
|
||||
```
|
||||
|
||||
1. Copy `config.toml.sample` as `config.toml` and add your config.
|
||||
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
|
||||
|
||||
### Running the Dev Environment
|
||||
|
||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
|
||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
|
||||
|
||||
---
|
||||
|
||||
# Production Build
|
||||
|
||||
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.
|
Binary file not shown.
Before Width: | Height: | Size: 298 KiB |
@@ -1,17 +0,0 @@
|
||||
# Introduction
|
||||
|
||||
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">
|
||||
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Developers
|
||||
|
||||
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)
|
@@ -1,65 +0,0 @@
|
||||
# Installation
|
||||
|
||||
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
|
||||
|
||||
## Binary
|
||||
|
||||
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
|
||||
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
|
||||
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
|
||||
|
||||
!!! Tip
|
||||
To set the System user password during installation, set the environment variables:
|
||||
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
|
||||
|
||||
|
||||
## Docker
|
||||
|
||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
|
||||
|
||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||
|
||||
```shell
|
||||
# Download the compose file and the sample config file in the current directory.
|
||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
|
||||
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.
|
||||
cp config.sample.toml config.toml
|
||||
|
||||
# Run the services in the background.
|
||||
docker compose up -d
|
||||
|
||||
# Setting System user password.
|
||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
|
||||
```
|
||||
|
||||
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
|
||||
|
||||
|
||||
## Compiling from source
|
||||
|
||||
To compile the latest unreleased version (`main` branch):
|
||||
|
||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||
3. `cd libredesk && make`. This will generate the `libredesk` binary.
|
||||
|
||||
|
||||
## Nginx
|
||||
|
||||
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
|
||||
|
||||
```nginx
|
||||
client_max_body_size 100M;
|
||||
location / {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
```
|
@@ -1,57 +0,0 @@
|
||||
# Setting up SSO
|
||||
|
||||
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
|
||||
|
||||
!!! note
|
||||
User accounts must be created in Libredesk manually; signup is not supported.
|
||||
|
||||
## Generic Configuration Steps
|
||||
|
||||
Since each provider’s configuration might differ, consult your provider’s documentation for any additional or divergent settings.
|
||||
|
||||
1. Provider setup:
|
||||
In your provider’s admin console, create a new OpenID Connect application/client. Retrieve:
|
||||
- Client ID
|
||||
- Client Secret
|
||||
|
||||
2. Libredesk configuration:
|
||||
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
|
||||
- Provider URL (e.g., the URL of your OpenID provider)
|
||||
- Client ID
|
||||
- Client Secret
|
||||
- A descriptive name for the connection
|
||||
|
||||
3. Redirect URL:
|
||||
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your provider’s client settings.
|
||||
|
||||
## Provider Examples
|
||||
|
||||
#### Keycloak
|
||||
|
||||
1. Log in to your Keycloak Admin Console.
|
||||
|
||||
2. In Keycloak, navigate to Clients and click Create:
|
||||
|
||||
- Client ID (e.g., `libredesk-app`)
|
||||
- Client Protocol: `openid-connect`
|
||||
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
|
||||
- Under Authentication flow, uncheck everything except the standard flow
|
||||
- Click save
|
||||
|
||||
3. Go to the credentials tab:
|
||||
- Ensure client authenticator is set to `Client Id and Secret`
|
||||
- Note down the generated client secret
|
||||
|
||||
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
|
||||
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
|
||||
- Name (e.g., `Keycloak`)
|
||||
- Client ID
|
||||
- Client secret
|
||||
- Click save
|
||||
|
||||
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
|
||||
|
||||
6. Copy the generated Callback URL from Libredesk.
|
||||
|
||||
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
|
||||
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`
|
@@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Outgoing Email Template Expressions
|
||||
|
||||
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
|
||||
|
||||
### Conversation Variables
|
||||
|
||||
| Variable | Value |
|
||||
|---------------------------------|--------------------------------------------------------|
|
||||
| {{ .Conversation.ReferenceNumber }} | The unique reference number 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 |
|
||||
|
||||
### Contact Variables
|
||||
|
||||
| Variable | Value |
|
||||
|------------------------------|------------------------------------|
|
||||
| {{ .Contact.FirstName }} | First name of the contact/customer |
|
||||
| {{ .Contact.LastName }} | Last name of the contact/customer |
|
||||
| {{ .Contact.FullName }} | Full name of the contact/customer |
|
||||
| {{ .Contact.Email }} | Email address of the contact/customer |
|
||||
|
||||
### Recipient Variables
|
||||
|
||||
| Variable | Value |
|
||||
|--------------------------------|-----------------------------------|
|
||||
| {{ .Recipient.FirstName }} | First name of the recipient |
|
||||
| {{ .Recipient.LastName }} | Last name of the recipient |
|
||||
| {{ .Recipient.FullName }} | Full name 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
|
||||
|
||||
```html
|
||||
Dear {{ .Recipient.FirstName }},
|
||||
|
||||
{{ template "content" . }}
|
||||
|
||||
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.
|
||||
|
||||
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.
|
@@ -1,3 +0,0 @@
|
||||
# Translations / Internationalization
|
||||
|
||||
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)
|
@@ -1,18 +0,0 @@
|
||||
# Upgrade
|
||||
|
||||
!!! warning "Warning"
|
||||
Always take a backup of the Postgres database before upgrading Libredesk.
|
||||
|
||||
## Binary
|
||||
- Stop running libredesk binary.
|
||||
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
|
||||
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
|
||||
- Run `./libredesk` again.
|
||||
|
||||
## Docker
|
||||
|
||||
```shell
|
||||
docker compose down app
|
||||
docker compose pull
|
||||
docker compose up app -d
|
||||
```
|
@@ -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
|
@@ -1,38 +0,0 @@
|
||||
site_name: Libredesk Docs
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
font:
|
||||
text: Source Sans Pro
|
||||
code: Roboto Mono
|
||||
weights: [400, 700]
|
||||
direction: ltr
|
||||
palette:
|
||||
primary: white
|
||||
accent: red
|
||||
features:
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- content.code.copy
|
||||
extra:
|
||||
search:
|
||||
language: en
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- codehilite
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
nav:
|
||||
- Introduction: index.md
|
||||
- Getting Started:
|
||||
- Installation: installation.md
|
||||
- Upgrade Guide: upgrade.md
|
||||
- Email Templates: templating.md
|
||||
- SSO Setup: sso.md
|
||||
- Webhooks: webhooks.md
|
||||
- API Getting Started: api-getting-started.md
|
||||
- Contributions:
|
||||
- Developer Setup: developer-setup.md
|
||||
- Translate Libredesk: translations.md
|
@@ -88,8 +88,8 @@
|
||||
@create-conversation="() => (openCreateConversationDialog = true)"
|
||||
>
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Show app update only in admin routes -->
|
||||
<AppUpdate v-if="route.path.startsWith('/admin')" />
|
||||
<!-- Show admin banner only in admin routes -->
|
||||
<AdminBanner v-if="route.path.startsWith('/admin')" />
|
||||
|
||||
<!-- Common header for all pages -->
|
||||
<PageHeader />
|
||||
@@ -128,8 +128,7 @@ import { useCustomAttributeStore } from './stores/customAttributes'
|
||||
import { useIdleDetection } from './composables/useIdleDetection'
|
||||
import PageHeader from './components/layout/PageHeader.vue'
|
||||
import ViewForm from '@/features/view/ViewForm.vue'
|
||||
import AppUpdate from '@main/components/update/AppUpdate.vue'
|
||||
import api from './api'
|
||||
import AdminBanner from '@/components/banner/AdminBanner.vue'
|
||||
import { toast as sooner } from 'vue-sonner'
|
||||
import Sidebar from '@main/components/sidebar/Sidebar.vue'
|
||||
import Command from '@/features/command/CommandBox.vue'
|
||||
@@ -150,6 +149,7 @@ import {
|
||||
} from '@shared-ui/components/ui/sidebar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
|
||||
import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const emitter = useEmitter()
|
||||
|
@@ -122,7 +122,7 @@ const createOIDC = (data) =>
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
|
||||
const getConfig = () => http.get('/api/v1/config')
|
||||
const getAllOIDC = () => http.get('/api/v1/oidc')
|
||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
||||
const updateOIDC = (id, data) =>
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
updateSettings,
|
||||
createOIDC,
|
||||
getAllOIDC,
|
||||
getAllEnabledOIDC,
|
||||
getConfig,
|
||||
getOIDC,
|
||||
updateOIDC,
|
||||
deleteOIDC,
|
||||
|
63
frontend/apps/main/src/components/banner/AdminBanner.vue
Normal file
63
frontend/apps/main/src/components/banner/AdminBanner.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="border-b">
|
||||
<!-- Update notification -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
|
||||
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Download class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-sm text-foreground">
|
||||
<span>{{ $t('update.newUpdateAvailable') }}</span>
|
||||
<a
|
||||
:href="appSettingsStore.settings['app.update'].update.url"
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer"
|
||||
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.release_version }}
|
||||
</a>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ appSettingsStore.settings['app.update'].update.release_date }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Update description -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.update'].update.description"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart required notification -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.restart_required']"
|
||||
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Info class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-foreground">
|
||||
{{ $t('admin.banner.restartMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Download, Info } from 'lucide-vue-next'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
</script>
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.prevent="onClose"
|
||||
@click.stop="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"
|
||||
>
|
||||
|
@@ -129,6 +129,7 @@ const linkUrl = ref('')
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
insertContent: String,
|
||||
messageType: String,
|
||||
autoFocus: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
@@ -136,7 +137,7 @@ const props = defineProps({
|
||||
aiPrompts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['send', 'aiPromptSelected'])
|
||||
@@ -145,7 +146,9 @@ const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
// Set up typing indicator
|
||||
const conversationStore = useConversationStore()
|
||||
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
|
||||
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping, {
|
||||
get isPrivateMessage() { return props.messageType === 'private_note' }
|
||||
})
|
||||
|
||||
// To preseve the table styling in emails, need to set the table style inline.
|
||||
// Created these custom extensions to set the table style inline.
|
||||
|
@@ -52,8 +52,15 @@
|
||||
<div class="flex-1">
|
||||
<div v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<SelectTag
|
||||
v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-if="
|
||||
v-else-if="
|
||||
getFieldOptions(modelFilter).length > 0 &&
|
||||
modelFilter.field === 'assigned_user_id'
|
||||
"
|
||||
@@ -94,8 +101,9 @@
|
||||
<CloseButton :onClose="() => removeFilter(index)" />
|
||||
</div>
|
||||
|
||||
<!-- Button Container -->
|
||||
<div class="flex items-center justify-between pt-3">
|
||||
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
|
||||
<Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
{{
|
||||
$t('globals.messages.add', {
|
||||
@@ -104,15 +112,17 @@
|
||||
}}
|
||||
</Button>
|
||||
<div class="flex gap-2" v-if="showButtons">
|
||||
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
|
||||
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
|
||||
<Button variant="ghost" @click.stop="clearFilters">
|
||||
{{ $t('globals.messages.reset') }}
|
||||
</Button>
|
||||
<Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -125,8 +135,10 @@ import { Plus } from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CloseButton from '@main/components/button/CloseButton.vue'
|
||||
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
|
||||
import { FIELD_TYPE } from '@/constants/filterConfig'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
@@ -150,12 +162,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// On unmounted set valid filters
|
||||
modelValue.value = validFilters.value
|
||||
})
|
||||
|
||||
const getModel = (field) => {
|
||||
const fieldConfig = props.fields.find((f) => f.field === field)
|
||||
return fieldConfig?.model || ''
|
||||
}
|
||||
|
||||
// Set model for each filter
|
||||
// Set model for each filter and the default value
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
(filters) => {
|
||||
@@ -163,6 +180,15 @@ watch(
|
||||
if (filter.field && !filter.model) {
|
||||
filter.model = getModel(filter.field)
|
||||
}
|
||||
|
||||
// Multi select need arrays as their default value
|
||||
if (
|
||||
filter.field &&
|
||||
getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
|
||||
!Array.isArray(filter.value)
|
||||
) {
|
||||
filter.value = []
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -170,15 +196,20 @@ watch(
|
||||
|
||||
// Reset operator and value when field changes for a filter at a given index
|
||||
watch(
|
||||
() => modelValue.value.map((f) => f.field),
|
||||
(newFields, oldFields) => {
|
||||
newFields.forEach((field, index) => {
|
||||
if (field !== oldFields[index]) {
|
||||
modelValue.value[index].operator = ''
|
||||
modelValue.value[index].value = ''
|
||||
modelValue,
|
||||
(newFilters, oldFilters) => {
|
||||
// Skip first run
|
||||
if (!oldFilters) return
|
||||
|
||||
newFilters.forEach((filter, index) => {
|
||||
const oldFilter = oldFilters[index]
|
||||
if (oldFilter && filter.field !== oldFilter.field) {
|
||||
filter.operator = ''
|
||||
filter.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const addFilter = () => {
|
||||
@@ -197,7 +228,17 @@ const clearFilters = () => {
|
||||
}
|
||||
|
||||
const validFilters = computed(() => {
|
||||
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
|
||||
return modelValue.value.filter((filter) => {
|
||||
// For multi-select field type, allow empty array as a valid value
|
||||
const field = props.fields.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField) {
|
||||
return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
|
||||
}
|
||||
|
||||
return filter.field && filter.operator && filter.value
|
||||
})
|
||||
})
|
||||
|
||||
const getFieldOptions = (fieldValue) => {
|
||||
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
|
||||
const field = props.fields.find((f) => f.field === modelFilter.field)
|
||||
return field?.operators || []
|
||||
}
|
||||
|
||||
const getFieldType = (modelFilter) => {
|
||||
const field = props.fields.find((f) => f.field === modelFilter.field)
|
||||
return field?.type || ''
|
||||
}
|
||||
</script>
|
||||
|
@@ -4,9 +4,9 @@
|
||||
@click="handleClick">
|
||||
<div class="flex items-center mb-2">
|
||||
<component :is="icon" size="24" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
|
||||
<h3 class="text-lg font-medium">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{{ subTitle }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@@ -38,7 +38,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@shared-ui/components/ui/dropdown-menu'
|
||||
import { filterNavItems } from '../../utils/nav-permissions'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@shared-ui/components/ui/alert-dialog'
|
||||
import { filterNavItems } from '@/utils/nav-permissions'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -73,8 +83,17 @@ const editView = (view) => {
|
||||
emit('editView', view)
|
||||
}
|
||||
|
||||
const deleteView = (view) => {
|
||||
emit('deleteView', view)
|
||||
const openDeleteConfirmation = (view) => {
|
||||
viewToDelete.value = view
|
||||
isDeleteOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteView = () => {
|
||||
if (viewToDelete.value) {
|
||||
emit('deleteView', viewToDelete.value)
|
||||
isDeleteOpen.value = false
|
||||
viewToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation methods with conversation retention
|
||||
@@ -157,6 +176,13 @@ watch(
|
||||
const sidebarOpen = useStorage('mainSidebarOpen', true)
|
||||
const teamInboxOpen = useStorage('teamInboxOpen', true)
|
||||
const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
|
||||
// Track which view is being hovered for ellipsis menu visibility
|
||||
const hoveredViewId = ref(null)
|
||||
|
||||
// Track delete confirmation dialog state
|
||||
const isDeleteOpen = ref(false)
|
||||
const viewToDelete = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub v-for="view in userViews" :key="view.id">
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem
|
||||
@mouseenter="hoveredViewId = view.id"
|
||||
@mouseleave="hoveredViewId = null"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
:isActive="route.params.viewID == view.id"
|
||||
asChild
|
||||
>
|
||||
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
|
||||
<span class="break-words w-32 truncate">{{ view.name }}</span>
|
||||
<SidebarMenuAction :showOnHover="true" class="mr-3">
|
||||
<span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
|
||||
<SidebarMenuAction
|
||||
@click.stop
|
||||
:class="[
|
||||
'mr-3',
|
||||
'md:opacity-0',
|
||||
'data-[state=open]:opacity-100',
|
||||
{ 'md:opacity-100': hoveredViewId === view.id }
|
||||
]"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger asChild @click.prevent>
|
||||
<EllipsisVertical />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="() => editView(view)">
|
||||
<span>{{ t('globals.messages.edit') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="() => deleteView(view)">
|
||||
<DropdownMenuItem @click="() => openDeleteConfirmation(view)">
|
||||
<span>{{ t('globals.messages.delete') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
<slot></slot>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<!-- View Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="isDeleteOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDeleteView">
|
||||
{{ t('globals.messages.delete') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
|
||||
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{{ $t('update.newUpdateAvailable') }}:
|
||||
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
|
||||
appSettingsStore.settings['app.update'].update.release_date
|
||||
}})
|
||||
<a
|
||||
:href="appSettingsStore.settings['app.update'].update.url"
|
||||
target="_blank"
|
||||
nofollow
|
||||
noreferrer
|
||||
class="underline ml-2"
|
||||
>
|
||||
{{ $t('globals.messages.viewDetails') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
</script>
|
@@ -1,11 +1,12 @@
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '../stores/conversation'
|
||||
import { useInboxStore } from '../stores/inbox'
|
||||
import { useUsersStore } from '../stores/users'
|
||||
import { useTeamStore } from '../stores/team'
|
||||
import { useSlaStore } from '../stores/sla'
|
||||
import { useCustomAttributeStore } from '../stores/customAttributes'
|
||||
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useSlaStore } from '@/stores/sla'
|
||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export function useConversationFilters () {
|
||||
@@ -15,6 +16,7 @@ export function useConversationFilters () {
|
||||
const tStore = useTeamStore()
|
||||
const slaStore = useSlaStore()
|
||||
const customAttributeStore = useCustomAttributeStore()
|
||||
const tagStore = useTagStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const customAttributeDataTypeToFieldType = {
|
||||
@@ -69,6 +71,12 @@ export function useConversationFilters () {
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: iStore.options
|
||||
},
|
||||
tags: {
|
||||
label: t('globals.terms.tag', 2),
|
||||
type: FIELD_TYPE.MULTI_SELECT,
|
||||
operators: FIELD_OPERATORS.MULTI_SELECT,
|
||||
options: tagStore.tagOptions
|
||||
}
|
||||
}))
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
export const FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
TAG: 'tag',
|
||||
MULTI_SELECT: 'multi-select',
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
RICHTEXT: 'richtext',
|
||||
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
|
||||
OPERATOR.LESS_THAN
|
||||
],
|
||||
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
|
||||
MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ export const permissions = {
|
||||
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
|
||||
MESSAGES_READ: 'messages:read',
|
||||
MESSAGES_WRITE: 'messages:write',
|
||||
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
|
||||
VIEW_MANAGE: 'view:manage',
|
||||
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
|
||||
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
|
||||
|
@@ -1 +1,3 @@
|
||||
export const Roles = ["Admin", "Agent"]
|
||||
export const Roles = ["Admin", "Agent"]
|
||||
export const UserTypeAgent = "agent"
|
||||
export const UserTypeContact = "contact"
|
@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
|
||||
if (values.availability_status === 'active_group') {
|
||||
values.availability_status = 'online'
|
||||
}
|
||||
values.teams = values.teams.map((team) => ({ name: team }))
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('first_name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('last_name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.email'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('email'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.key'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('key'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.type'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('data_type'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('applies_to'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.provider'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('provider'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -140,6 +140,7 @@ const permissions = ref([
|
||||
{ name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
|
||||
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
|
||||
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
|
||||
{ name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
|
||||
{ name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
|
||||
]
|
||||
},
|
||||
|
@@ -8,7 +8,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -17,7 +17,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.description'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('description'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('description'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -57,9 +57,8 @@
|
||||
<Input type="number" placeholder="0" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of conversations that can be auto-assigned to an agent,
|
||||
conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
|
||||
for unlimited.
|
||||
Maximum number of conversations that can be auto-assigned to an agent, conversations in
|
||||
"Resolved" or "Closed" states do not count toward this limit. Set to 0 for unlimited.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -97,6 +96,7 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem :value = 0>None</SelectItem>
|
||||
<SelectItem v-for="bh in businessHours" :key="bh.id" :value="bh.id">
|
||||
{{ bh.name }}
|
||||
</SelectItem>
|
||||
@@ -121,6 +121,7 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem :value= 0>None</SelectItem>
|
||||
<SelectItem
|
||||
v-for="sla in slaStore.options"
|
||||
:key="sla.value"
|
||||
@@ -226,7 +227,11 @@ const fetchBusinessHours = async () => {
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
props.submitForm(values)
|
||||
props.submitForm({
|
||||
...values,
|
||||
business_hours_id: values.business_hours_id > 0 ? values.business_hours_id : null,
|
||||
sla_policy_id: values.sla_policy_id > 0 ? values.sla_policy_id: null
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
|
@@ -9,7 +9,7 @@ export const columns = [
|
||||
return h('div', { class: 'text-center' }, 'Name')
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20,7 +20,7 @@ export const columns = [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('created_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const columns = [
|
||||
cell: function ({ row }) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center font-medium' },
|
||||
{ class: 'text-center' },
|
||||
format(row.getValue('updated_at'), 'PPpp')
|
||||
)
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ export const createOutgoingEmailTableColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@ export const createEmailNotificationTableColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
|
||||
|
@@ -10,7 +10,7 @@ export const createColumns = (t) => [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -41,8 +41,8 @@
|
||||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex items-end">
|
||||
<FormField v-slot="{ componentField }" name="phone_number_calling_code">
|
||||
<FormItem class="w-20">
|
||||
<FormField v-slot="{ componentField }" name="phone_number_country_code">
|
||||
<FormItem class="w-max">
|
||||
<FormLabel class="flex items-center whitespace-nowrap">
|
||||
{{ t('globals.terms.phoneNumber') }}
|
||||
</FormLabel>
|
||||
@@ -58,13 +58,18 @@
|
||||
<div class="w-7 h-7 flex items-center justify-center">
|
||||
<span v-if="item.emoji">{{ item.emoji }}</span>
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }} ({{ item.value }})</span>
|
||||
<span class="text-sm">{{ item.label }} ({{ item.calling_code }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center mb-1">
|
||||
<span v-if="selected" class="text-xl leading-none">{{ selected.emoji }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="selected" class="text-lg">{{ selected.emoji }}</span>
|
||||
<span
|
||||
v-if="selected && selected.calling_code"
|
||||
class="text-xs text-muted-foreground"
|
||||
>({{ selected.calling_code }})</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
@@ -116,7 +121,8 @@ const userStore = useUserStore()
|
||||
|
||||
const allCountries = countries.map((country) => ({
|
||||
label: country.name,
|
||||
value: country.calling_code,
|
||||
emoji: country.emoji
|
||||
value: country.iso_2,
|
||||
emoji: country.emoji,
|
||||
calling_code: country.calling_code
|
||||
}))
|
||||
</script>
|
||||
|
@@ -33,13 +33,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="cancelAddNote"
|
||||
class="transition-all hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" @click="cancelAddNote"> Cancel </Button>
|
||||
<Button type="submit" :disabled="!newNote.trim()">
|
||||
{{ $t('globals.messages.save') + ' ' + $t('globals.terms.note').toLowerCase() }}
|
||||
</Button>
|
||||
@@ -53,13 +47,13 @@
|
||||
<Card
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
|
||||
class="overflow-hidden border-gray-2 dark:hover:border-gray-700 hover:border-gray-300 transition-all duration-200 box hover:shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<CardHeader class="bg-gray-50/50 dark:bg-secondary border-b p-2">
|
||||
<CardHeader class="bg-background border-b p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar class="border border-gray-200 shadow-sm">
|
||||
<Avatar class="border shadow-sm">
|
||||
<AvatarImage :src="note.avatar_url" />
|
||||
<AvatarFallback>
|
||||
{{ getInitials(note.first_name, note.last_name) }}
|
||||
|
@@ -29,7 +29,7 @@ export const createFormSchema = (t) => z.object({
|
||||
})
|
||||
})
|
||||
.nullable(),
|
||||
phone_number_calling_code: z.string().optional().nullable(),
|
||||
phone_number_country_code: z.string().optional().nullable(),
|
||||
avatar_url: z.string().optional().nullable(),
|
||||
email: z
|
||||
.string({
|
||||
|
@@ -1,5 +1,106 @@
|
||||
<template>
|
||||
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
|
||||
<p>{{ $t('conversation.placeholder') }}</p>
|
||||
<div class="placeholder-container">
|
||||
<Spinner v-if="isLoading" />
|
||||
<template v-else>
|
||||
<div v-if="showGettingStarted" class="getting-started-wrapper">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-semibold text-foreground mb-6">
|
||||
{{ $t('setup.completeYourSetup') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="checklist-item" :class="{ completed: hasInboxes }">
|
||||
<CheckCircle v-if="hasInboxes" class="check-icon completed" />
|
||||
<Circle v-else class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="flex-1 text-left ml-3 text-foreground">
|
||||
{{ $t('setup.createFirstInbox') }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="!hasInboxes"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="router.push({ name: 'inbox-list' })"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ $t('globals.messages.setUp') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="checklist-item" :class="{ completed: hasAgents, disabled: !hasInboxes }">
|
||||
<CheckCircle v-if="hasAgents" class="check-icon completed" />
|
||||
<Circle v-else class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="flex-1 text-left ml-3 text-foreground">
|
||||
{{ $t('setup.inviteTeammates') }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="!hasAgents && hasInboxes"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="router.push({ name: 'agent-list' })"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ $t('globals.messages.invite') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="placeholder-text">{{ $t('conversation.placeholder') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircle, Circle } from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
|
||||
const router = useRouter()
|
||||
const inboxStore = useInboxStore()
|
||||
const usersStore = useUsersStore()
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([inboxStore.fetchInboxes(), usersStore.fetchUsers()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const hasInboxes = computed(() => inboxStore.inboxes.length > 0)
|
||||
const hasAgents = computed(() => usersStore.users.length > 0)
|
||||
const showGettingStarted = computed(() => !hasInboxes.value || !hasAgents.value)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-container {
|
||||
@apply h-screen w-full flex items-center justify-center min-w-[400px] relative;
|
||||
}
|
||||
|
||||
.getting-started-wrapper {
|
||||
@apply w-full max-w-md mx-auto px-4;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
@apply flex items-center justify-between py-3 px-4 rounded-lg border border-border;
|
||||
}
|
||||
|
||||
.checklist-item.completed {
|
||||
@apply bg-muted/50;
|
||||
}
|
||||
|
||||
.checklist-item.disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
.check-icon.completed {
|
||||
@apply w-5 h-5 text-primary;
|
||||
}
|
||||
</style>
|
||||
|
@@ -10,7 +10,7 @@
|
||||
})
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogDescription/>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- Form Fields Section -->
|
||||
@@ -259,11 +259,12 @@ import {
|
||||
SelectValue
|
||||
} from '@shared-ui/components/ui/select'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFileUpload } from '../../composables/useFileUpload'
|
||||
import Editor from '@main/components/editor/TextEditor.vue'
|
||||
import { useMacroStore } from '../../stores/macro'
|
||||
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
|
||||
import api from '../../api'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import api from '@/api'
|
||||
|
||||
const dialogOpen = defineModel({
|
||||
required: false,
|
||||
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
|
||||
const createConversation = form.handleSubmit(async (values) => {
|
||||
loading.value = true
|
||||
try {
|
||||
// convert ids to numbers if they are not already
|
||||
// Convert ids to numbers if they are not already
|
||||
values.inbox_id = Number(values.inbox_id)
|
||||
values.team_id = values.team_id ? Number(values.team_id) : null
|
||||
values.agent_id = values.agent_id ? Number(values.agent_id) : null
|
||||
// array of attachment ids.
|
||||
// Array of attachment ids.
|
||||
values.attachments = mediaFiles.value.map((file) => file.id)
|
||||
// Initiator of this conversation is always agent
|
||||
values.initiator = UserTypeAgent
|
||||
const conversation = await api.createConversation(values)
|
||||
const conversationUUID = conversation.data.data.uuid
|
||||
|
||||
|
@@ -122,6 +122,7 @@ import { Input } from '@shared-ui/components/ui/input'
|
||||
import { useEmitter } from '../../composables/useEmitter'
|
||||
import { useFileUpload } from '../../composables/useFileUpload'
|
||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
@@ -252,6 +253,7 @@ const processSend = async () => {
|
||||
if (hasTextContent.value > 0 || mediaFiles.value.length > 0) {
|
||||
const message = htmlContent.value
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
sender_type: UserTypeAgent,
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: mediaFiles.value.map((file) => file.id),
|
||||
|
@@ -88,6 +88,7 @@
|
||||
<Editor
|
||||
v-model:htmlContent="htmlContent"
|
||||
v-model:textContent="textContent"
|
||||
:message-type="messageType"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.ctrlK')"
|
||||
:aiPrompts="aiPrompts"
|
||||
:insertContent="insertContent"
|
||||
|
@@ -43,11 +43,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="conversationStore.conversation.isTyping">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="conversationStore.conversation.isTyping" class="px-4 pb-4">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -13,7 +13,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_user_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
|
||||
"
|
||||
@select="selectAgent"
|
||||
type="user"
|
||||
/>
|
||||
@@ -22,7 +24,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_team_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
|
||||
"
|
||||
@select="selectTeam"
|
||||
type="team"
|
||||
/>
|
||||
@@ -31,7 +35,9 @@
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.priority_id"
|
||||
:items="priorityOptions"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.priority').toLowerCase() })
|
||||
"
|
||||
@select="selectPriority"
|
||||
type="priority"
|
||||
/>
|
||||
@@ -41,7 +47,9 @@
|
||||
v-if="conversationStore.current"
|
||||
v-model="conversationStore.current.tags"
|
||||
:items="tags.map((tag) => ({ label: tag, value: tag }))"
|
||||
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })"
|
||||
:placeholder="
|
||||
t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })
|
||||
"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -90,9 +98,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useConversationStore } from '../../../stores/conversation'
|
||||
import { useUsersStore } from '../../../stores/users'
|
||||
import { useTeamStore } from '../../../stores/team'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -118,6 +127,7 @@ const emitter = useEmitter()
|
||||
const conversationStore = useConversationStore()
|
||||
const usersStore = useUsersStore()
|
||||
const teamsStore = useTeamStore()
|
||||
const tagStore = useTagStore()
|
||||
const tags = ref([])
|
||||
// Save the accordion state in local storage
|
||||
const accordionState = useStorage('conversation-sidebar-accordion', [])
|
||||
@@ -171,15 +181,8 @@ watch(
|
||||
const priorityOptions = computed(() => conversationStore.priorityOptions)
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const resp = await api.getTags()
|
||||
tags.value = resp.data.data.map((item) => item.name)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
await tagStore.fetchTags()
|
||||
tags.value = tagStore.tags.map((item) => item.name)
|
||||
}
|
||||
|
||||
const handleAssignedUserChange = (id) => {
|
||||
|
@@ -72,12 +72,13 @@ import { computed } from 'vue'
|
||||
import { ViewVerticalIcon } from '@radix-icons/vue'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import { Mail, Phone, ExternalLink, IdCard } from 'lucide-vue-next'
|
||||
import { useEmitter } from '../../../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
|
||||
import { useConversationStore } from '../../../stores/conversation'
|
||||
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
|
||||
import countries from '@/constants/countries.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { useUserStore } from '../../../stores/user'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -87,8 +88,13 @@ const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const phoneNumber = computed(() => {
|
||||
const callingCode = conversation.value?.contact?.phone_number_calling_code || ''
|
||||
const countryCodeValue = conversation.value?.contact?.phone_number_country_code || ''
|
||||
const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable')
|
||||
return callingCode ? `${callingCode} ${number}` : number
|
||||
if (!countryCodeValue) return number
|
||||
|
||||
// Lookup calling code
|
||||
const country = countries.find((c) => c.iso_2 === countryCodeValue)
|
||||
const callingCode = country ? country.calling_code : countryCodeValue
|
||||
return `${callingCode} ${number}`
|
||||
})
|
||||
</script>
|
||||
|
@@ -8,7 +8,7 @@
|
||||
>
|
||||
{{ $t('conversation.sidebar.noPreviousConvo') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-else class="space-y-1">
|
||||
<router-link
|
||||
v-for="conversation in conversationStore.current.previous_conversations"
|
||||
:key="conversation.uuid"
|
||||
@@ -30,17 +30,40 @@
|
||||
{{ conversation.last_message }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
|
||||
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div class="flex gap-1 items-center text-xs text-muted-foreground">
|
||||
<span v-if="conversation.created_at">
|
||||
{{ getRelativeTime(new Date(conversation.created_at)) }}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span v-if="conversation.last_message_at">
|
||||
{{ getRelativeTime(new Date(conversation.last_message_at)) }}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div class="space-y-1 text-xs">
|
||||
<p>
|
||||
{{ $t('globals.terms.createdAt') }}:
|
||||
{{ formatFullTimestamp(new Date(conversation.created_at)) }}
|
||||
</p>
|
||||
<p v-if="conversation.last_message_at">
|
||||
{{ $t('globals.terms.lastMessageAt') }}:
|
||||
{{ formatFullTimestamp(new Date(conversation.last_message_at)) }}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useConversationStore } from '../../../stores/conversation'
|
||||
import { format } from 'date-fns'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
|
||||
import { formatFullTimestamp, getRelativeTime } from '@/utils/datetime'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
</script>
|
||||
|
@@ -83,7 +83,8 @@ import { handleHTTPError } from '../../utils/http'
|
||||
import { OPERATOR } from '../../constants/filterConfig.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { z } from 'zod'
|
||||
import api from '../../api'
|
||||
import { FIELD_TYPE } from '@/constants/filterConfig'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const { t } = useI18n()
|
||||
@@ -106,68 +107,88 @@ const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.string({
|
||||
required_error: t('globals.messages.required')
|
||||
})
|
||||
.min(2, { message: t('view.form.name.length') })
|
||||
.max(30, { message: t('view.form.name.length') }),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
model: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.filter').toLowerCase()
|
||||
})
|
||||
}),
|
||||
field: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.field').toLowerCase()
|
||||
})
|
||||
}),
|
||||
operator: z.string({
|
||||
required_error: t('globals.messages.required', {
|
||||
name: t('globals.terms.operator').toLowerCase()
|
||||
})
|
||||
}),
|
||||
value: z.union([z.string(), z.number(), z.boolean()]).optional()
|
||||
model: z.string().optional(),
|
||||
field: z.string().optional(),
|
||||
operator: z.string().optional(),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.union([z.string(), z.number()]))
|
||||
])
|
||||
.optional()
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
.refine((filters) => filters.length > 0, { message: t('view.form.filter.selectAtLeastOne') })
|
||||
.refine(
|
||||
(filters) =>
|
||||
filters.every(
|
||||
(f) =>
|
||||
f.model &&
|
||||
f.field &&
|
||||
f.operator &&
|
||||
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
|
||||
),
|
||||
{
|
||||
message: t('view.form.filter.partiallyFilled')
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
validateOnMount: false,
|
||||
validateOnInput: false,
|
||||
validateOnBlur: false
|
||||
validationSchema: formSchema
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
const validationResult = await form.validate()
|
||||
if (!validationResult.valid) return
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
// Make sure at least one filter is selected
|
||||
if (!values.filters || values.filters.length === 0) {
|
||||
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for partial filters
|
||||
const hasPartialFilters = values.filters.some(
|
||||
(f) =>
|
||||
!f.field ||
|
||||
!f.operator ||
|
||||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) && !f.value)
|
||||
)
|
||||
if (hasPartialFilters) {
|
||||
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const values = form.values
|
||||
// Serialize array values to JSON strings for backend
|
||||
if (values.filters) {
|
||||
values.filters = values.filters.map((filter) => {
|
||||
if (Array.isArray(filter.value)) {
|
||||
// Convert string IDs to numbers for backend (tags use string IDs in frontend)
|
||||
const numericValues = filter.value.map((v) => {
|
||||
const num = Number(v)
|
||||
return isNaN(num) ? v : num
|
||||
})
|
||||
return { ...filter, value: JSON.stringify(numericValues) }
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
|
||||
if (values.id) {
|
||||
await api.updateView(values.id, values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.updatedSuccessfully', {
|
||||
name: t('globals.terms.view')
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await api.createView(values)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.createdSuccessfully', {
|
||||
name: t('globals.terms.view')
|
||||
})
|
||||
})
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
|
||||
openDialog.value = false
|
||||
@@ -180,14 +201,36 @@ const onSubmit = async () => {
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set form values when view prop changes
|
||||
watch(
|
||||
() => view.value,
|
||||
(newVal) => {
|
||||
if (newVal && Object.keys(newVal).length) {
|
||||
form.setValues(newVal)
|
||||
// Deserialize multi-select filter values from JSON strings to arrays
|
||||
const processedVal = { ...newVal }
|
||||
if (processedVal.filters) {
|
||||
processedVal.filters = processedVal.filters.map((filter) => {
|
||||
// Multi-select fields need to be deserialized from JSON strings
|
||||
const field = filterFields.value.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField && typeof filter.value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filter.value)
|
||||
// Convert numbers back to strings (frontend uses string IDs)
|
||||
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
|
||||
return { ...filter, value: stringValues }
|
||||
} catch (e) {
|
||||
// If parsing fails, return as-is
|
||||
return filter
|
||||
}
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
form.setValues(processedVal)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
@@ -18,14 +18,14 @@ const setFavicon = (url) => {
|
||||
}
|
||||
|
||||
async function initApp () {
|
||||
const settings = (await api.getSettings('general')).data.data
|
||||
const config = (await api.getConfig()).data.data
|
||||
const emitter = mitt()
|
||||
const lang = settings['app.lang'] || 'en'
|
||||
const lang = config['app.lang'] || 'en'
|
||||
const langMessages = await api.getLanguage(lang)
|
||||
|
||||
// Set favicon.
|
||||
if (settings['app.favicon_url'])
|
||||
setFavicon(settings['app.favicon_url'])
|
||||
if (config['app.favicon_url'])
|
||||
setFavicon(config['app.favicon_url'])
|
||||
|
||||
// Initialize i18n.
|
||||
const i18nConfig = {
|
||||
@@ -42,9 +42,17 @@ async function initApp () {
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Store app settings in Pinia
|
||||
// Fetch and store app settings in store (after pinia is initialized)
|
||||
const settingsStore = useAppSettingsStore()
|
||||
settingsStore.setSettings(settings)
|
||||
|
||||
// Store the public config in the store
|
||||
settingsStore.setPublicConfig(config)
|
||||
|
||||
try {
|
||||
await settingsStore.fetchSettings('general')
|
||||
} catch (error) {
|
||||
// Pass
|
||||
}
|
||||
|
||||
// Add emitter to global properties.
|
||||
app.config.globalProperties.emitter = emitter
|
||||
|
@@ -1,12 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '@/api'
|
||||
|
||||
export const useAppSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
settings: {}
|
||||
settings: {},
|
||||
public_config: {}
|
||||
}),
|
||||
actions: {
|
||||
async fetchSettings (key = 'general') {
|
||||
try {
|
||||
const response = await api.getSettings(key)
|
||||
this.settings = response?.data?.data || {}
|
||||
return this.settings
|
||||
} catch (error) {
|
||||
// Pass
|
||||
}
|
||||
},
|
||||
async fetchPublicConfig () {
|
||||
try {
|
||||
const response = await api.getConfig()
|
||||
this.public_config = response?.data?.data || {}
|
||||
return this.public_config
|
||||
} catch (error) {
|
||||
// Pass
|
||||
}
|
||||
},
|
||||
setSettings (newSettings) {
|
||||
this.settings = newSettings
|
||||
},
|
||||
setPublicConfig (newPublicConfig) {
|
||||
this.public_config = newPublicConfig
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -690,9 +690,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversation.isTyping = is_typing
|
||||
}
|
||||
|
||||
function sendTyping (isTyping) {
|
||||
function sendTyping (isTyping, otherAttributes = {}) {
|
||||
// Send typing websocket message only if a conversation is open
|
||||
if (conversation.data?.uuid) {
|
||||
sendTypingIndicator(conversation.data.uuid, isTyping)
|
||||
sendTypingIndicator(conversation.data.uuid, isTyping, otherAttributes.isPrivateMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => {
|
||||
label: inb.name,
|
||||
value: String(inb.id)
|
||||
})))
|
||||
const fetchInboxes = async () => {
|
||||
if (inboxes.value.length) return
|
||||
const fetchInboxes = async (force = false) => {
|
||||
if (!force && inboxes.value.length) return
|
||||
try {
|
||||
const response = await api.getInboxes()
|
||||
inboxes.value = response?.data?.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
@@ -5,6 +5,7 @@ import { useEmitter } from '../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../constants/emitterEvents'
|
||||
import api from '../api'
|
||||
|
||||
// TODO: rename this store to agents
|
||||
export const useUsersStore = defineStore('users', () => {
|
||||
const users = ref([])
|
||||
const emitter = useEmitter()
|
||||
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
|
||||
value: String(user.id),
|
||||
avatar_url: user.avatar_url,
|
||||
})))
|
||||
const fetchUsers = async () => {
|
||||
if (users.value.length) return
|
||||
const fetchUsers = async (force = false) => {
|
||||
if (!force && users.value.length) return
|
||||
try {
|
||||
const response = await api.getUsersCompact()
|
||||
users.value = response?.data?.data || []
|
||||
|
@@ -1,16 +1,19 @@
|
||||
import { format, differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'
|
||||
import { format, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||
|
||||
export function getRelativeTime (timestamp, now = new Date()) {
|
||||
try {
|
||||
const mins = differenceInMinutes(now, timestamp)
|
||||
const hours = differenceInHours(now, timestamp)
|
||||
const days = differenceInDays(now, timestamp)
|
||||
const months = differenceInMonths(now, timestamp)
|
||||
const years = differenceInYears(now, timestamp)
|
||||
|
||||
if (mins === 0) return 'Just now'
|
||||
if (mins < 60) return `${mins} mins ago`
|
||||
if (hours < 24) return `${hours} hrs ago`
|
||||
if (days < 7) return `${days} days ago`
|
||||
return format(timestamp, 'MMMM d, yyyy h:mm a')
|
||||
if (mins === 0) return 'now'
|
||||
if (mins < 60) return `${mins}m`
|
||||
if (hours < 24) return `${hours}h`
|
||||
if (days < 31) return `${days}d`
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${years}y`
|
||||
} catch (error) {
|
||||
console.error('Error parsing time', error, 'timestamp', timestamp)
|
||||
return ''
|
||||
|
@@ -17,18 +17,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { createColumns } from '../../../features/admin/agents/dataTableColumns.js'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { createColumns } from '@/features/admin/agents/dataTableColumns.js'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import DataTable from '@main/components/datatable/DataTable.vue'
|
||||
import { handleHTTPError } from '../../../utils/http'
|
||||
import DataTable from '@/components/datatable/DataTable.vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { useEmitter } from '../../../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
|
||||
import api from '../../../api'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const usersStore = useUsersStore()
|
||||
const { t } = useI18n()
|
||||
const data = ref([])
|
||||
const emitter = useEmitter()
|
||||
@@ -40,11 +41,15 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.REFRESH_LIST)
|
||||
})
|
||||
|
||||
const getData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getUsers()
|
||||
data.value = response.data.data
|
||||
await usersStore.fetchUsers(true)
|
||||
data.value = usersStore.users
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
|
@@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue'
|
||||
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
|
||||
import api from '../../../api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import api from '@/api'
|
||||
|
||||
const initialValues = ref({})
|
||||
const isLoading = ref(false)
|
||||
const settingsStore = useAppSettingsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
const response = await api.getSettings('general')
|
||||
const data = response.data.data
|
||||
await settingsStore.fetchSettings('general')
|
||||
const data = settingsStore.settings
|
||||
isLoading.value = false
|
||||
initialValues.value = Object.keys(data).reduce((acc, key) => {
|
||||
// Remove 'app.' prefix
|
||||
|
@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format } from 'date-fns'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import api from '../../../api'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import api from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const emitter = useEmitter()
|
||||
const inboxStore = useInboxStore()
|
||||
const isLoading = ref(false)
|
||||
const data = ref([])
|
||||
|
||||
@@ -47,8 +49,8 @@ onMounted(async () => {
|
||||
const getInboxes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getInboxes()
|
||||
data.value = response.data.data
|
||||
await inboxStore.fetchInboxes(true)
|
||||
data.value = inboxStore.inboxes
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -67,7 +69,7 @@ const columns = [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.name'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('name'))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -76,7 +78,7 @@ const columns = [
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.channel'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center font-medium' }, row.getValue('channel'))
|
||||
return h('div', { class: 'text-center' }, row.getValue('channel'))
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<template #help>
|
||||
<p>Configure single sign-on with one or more OpenID Connect providers.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/sso/"
|
||||
href="https://docs.libredesk.io/configuration/sso"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
@@ -49,7 +49,7 @@
|
||||
<p>Design templates for customer communications and responses.</p>
|
||||
<p>Modify content for internal and external emails.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/templating/"
|
||||
href="https://docs.libredesk.io/configuration/email-templates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<p>Configure webhooks to receive real-time notifications when events occur in your Libredesk workspace.</p>
|
||||
<p>Webhooks allow you to integrate Libredesk with external services by sending HTTP POST requests when specific events happen.</p>
|
||||
<a
|
||||
href="https://libredesk.io/docs/webhooks/"
|
||||
href="https://docs.libredesk.io/configuration/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-style"
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<CardContent class="p-6 space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<CardTitle class="text-3xl font-bold text-foreground">
|
||||
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }}
|
||||
{{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
|
||||
</div>
|
||||
@@ -25,9 +25,8 @@
|
||||
>
|
||||
<img
|
||||
:src="oidcProvider.logo_url"
|
||||
:alt="oidcProvider.name"
|
||||
width="20"
|
||||
class="mr-2"
|
||||
alt=""
|
||||
v-if="oidcProvider.logo_url"
|
||||
/>
|
||||
{{ oidcProvider.name }}
|
||||
@@ -60,18 +59,28 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password" class="text-sm font-medium text-foreground">{{
|
||||
t('globals.terms.password')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('auth.enterPassword')"
|
||||
v-model="loginForm.password"
|
||||
:class="{ 'border-destructive': passwordHasError }"
|
||||
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<Label for="password" class="text-sm font-medium text-foreground">
|
||||
{{ t('globals.terms.password') }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('auth.enterPassword')"
|
||||
v-model="loginForm.password"
|
||||
:class="{ 'border-destructive': passwordHasError }"
|
||||
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded py-2 px-3 pr-10 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Eye v-if="!showPassword" class="w-5 h-5" />
|
||||
<EyeOff v-else class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -89,7 +98,9 @@
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="isLoading" class="flex items-center justify-center">
|
||||
<div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"
|
||||
></div>
|
||||
{{ t('auth.loggingIn') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.signInButton') }}</span>
|
||||
@@ -125,6 +136,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { EMITTER_EVENTS } from '../../constants/emitterEvents.js'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
|
||||
import { Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const emitter = useEmitter()
|
||||
const { t } = useI18n()
|
||||
@@ -133,6 +145,7 @@ const isLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const shakeCard = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const loginForm = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
@@ -159,8 +172,10 @@ onMounted(async () => {
|
||||
|
||||
const fetchOIDCProviders = async () => {
|
||||
try {
|
||||
const resp = await api.getAllEnabledOIDC()
|
||||
oidcProviders.value = resp.data.data
|
||||
const config = appSettingsStore.public_config
|
||||
if (config && config['app.sso_providers']) {
|
||||
oidcProviders.value = config['app.sso_providers'] || []
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -204,6 +219,9 @@ const loginAction = () => {
|
||||
if (resp?.data?.data) {
|
||||
userStore.setCurrentUser(resp.data.data)
|
||||
}
|
||||
// Also fetch general setting as user's logged in.
|
||||
appSettingsStore.fetchSettings('general')
|
||||
// Navigate to inboxes
|
||||
router.push({ name: 'inboxes' })
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@@ -5,7 +5,11 @@
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
|
||||
<div v-if="contact" class="flex justify-center space-y-4 w-full">
|
||||
<div
|
||||
v-if="contact"
|
||||
class="flex justify-center space-y-4 w-full"
|
||||
:class="{ 'loading-fade': formLoading }"
|
||||
>
|
||||
<div class="flex flex-col w-full mt-12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<AvatarUpload
|
||||
@@ -189,7 +193,7 @@ async function onUpload(file) {
|
||||
formData.append('last_name', form.values.last_name)
|
||||
formData.append('email', form.values.email)
|
||||
formData.append('phone_number', form.values.phone_number)
|
||||
formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
|
||||
formData.append('phone_number_country_code', form.values.phone_number_country_code)
|
||||
formData.append('enabled', form.values.enabled)
|
||||
const { data } = await api.updateContact(contact.value.id, formData)
|
||||
contact.value.avatar_url = data.avatar_url
|
||||
|
@@ -227,14 +227,15 @@ export class WebSocketClient {
|
||||
this.send(subscribeMessage)
|
||||
}
|
||||
|
||||
sendTypingIndicator (conversationUUID, isTyping) {
|
||||
sendTypingIndicator (conversationUUID, isTyping, isPrivateMessage) {
|
||||
if (!conversationUUID) return
|
||||
|
||||
const typingMessage = {
|
||||
type: WS_EVENT.TYPING,
|
||||
data: {
|
||||
conversation_uuid: conversationUUID,
|
||||
is_typing: isTyping
|
||||
is_typing: isTyping,
|
||||
is_private_message: isPrivateMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,5 +263,5 @@ export function initWS () {
|
||||
|
||||
export const sendMessage = message => wsClient?.send(message)
|
||||
export const subscribeToConversation = conversationUUID => wsClient?.subscribeToConversation(conversationUUID)
|
||||
export const sendTypingIndicator = (conversationUUID, isTyping) => wsClient?.sendTypingIndicator(conversationUUID, isTyping)
|
||||
export const sendTypingIndicator = (conversationUUID, isTyping, isPrivateMessage) => wsClient?.sendTypingIndicator(conversationUUID, isTyping, isPrivateMessage)
|
||||
export const closeWebSocket = () => wsClient?.close()
|
@@ -2,23 +2,33 @@
|
||||
|
||||
describe('Login Component', () => {
|
||||
beforeEach(() => {
|
||||
// Visit the login page
|
||||
cy.visit('/')
|
||||
|
||||
// Mock the API response for OIDC providers
|
||||
cy.intercept('GET', '**/api/v1/oidc/enabled', {
|
||||
cy.intercept('GET', '**/api/v1/config', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Google',
|
||||
logo_url: 'https://example.com/google-logo.png',
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
data: {
|
||||
"app.favicon_url": "http://localhost:9000/favicon.ico",
|
||||
"app.lang": "en",
|
||||
"app.logo_url": "http://localhost:9000/logo.png",
|
||||
"app.site_name": "Libredesk",
|
||||
"app.sso_providers": [
|
||||
{
|
||||
"client_id": "xx",
|
||||
"enabled": true,
|
||||
"id": 1,
|
||||
"logo_url": "/images/google-logo.png",
|
||||
"name": "Google",
|
||||
"provider": "Google",
|
||||
"provider_url": "https://accounts.google.com",
|
||||
"redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}).as('getOIDCProviders')
|
||||
|
||||
// Visit the login page
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('should display login form', () => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "libredesk",
|
||||
"version": "0.6.0-alpha",
|
||||
"version": "0.8.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -42,7 +42,7 @@
|
||||
"@unovis/vue": "^1.4.4",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
@@ -81,7 +81,7 @@
|
||||
"start-server-and-test": "^2.0.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vite": "^5.4.19",
|
||||
"vite": "^5.4.20",
|
||||
"vitest": "^3.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||
|
306
frontend/pnpm-lock.yaml
generated
306
frontend/pnpm-lock.yaml
generated
@@ -72,8 +72,8 @@ importers:
|
||||
specifier: ^12.4.0
|
||||
version: 12.4.0(typescript@5.7.3)
|
||||
axios:
|
||||
specifier: ^1.8.2
|
||||
version: 1.8.2(debug@4.4.0)
|
||||
specifier: ^1.12.0
|
||||
version: 1.12.0(debug@4.4.0)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.1
|
||||
@@ -118,7 +118,7 @@ importers:
|
||||
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
|
||||
vue-i18n:
|
||||
specifier: '9'
|
||||
version: 9.14.3(vue@3.5.13(typescript@5.7.3))
|
||||
version: 9.14.5(vue@3.5.13(typescript@5.7.3))
|
||||
vue-letter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
@@ -146,7 +146,7 @@ importers:
|
||||
version: 1.10.5
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.0.3
|
||||
version: 5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
|
||||
version: 5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
|
||||
'@vue/eslint-config-prettier':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
|
||||
@@ -184,8 +184,8 @@ importers:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
vite:
|
||||
specifier: ^5.4.19
|
||||
version: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
specifier: ^5.4.20
|
||||
version: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vitest:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
@@ -510,16 +510,16 @@ packages:
|
||||
'@internationalized/number@3.6.0':
|
||||
resolution: {integrity: sha512-PtrRcJVy7nw++wn4W2OuePQQfTqDzfusSuY1QTtui4wa7r+rGVtR75pO8CyKvHvzyQYi3Q1uO5sY0AsB4e65Bw==}
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
|
||||
'@intlify/core-base@9.14.5':
|
||||
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@9.14.3':
|
||||
resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
|
||||
'@intlify/message-compiler@9.14.5':
|
||||
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@9.14.3':
|
||||
resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
|
||||
'@intlify/shared@9.14.5':
|
||||
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
@@ -708,103 +708,108 @@ packages:
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.41.1':
|
||||
resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==}
|
||||
'@rollup/rollup-android-arm-eabi@4.50.2':
|
||||
resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.41.1':
|
||||
resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==}
|
||||
'@rollup/rollup-android-arm64@4.50.2':
|
||||
resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.41.1':
|
||||
resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==}
|
||||
'@rollup/rollup-darwin-arm64@4.50.2':
|
||||
resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.41.1':
|
||||
resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==}
|
||||
'@rollup/rollup-darwin-x64@4.50.2':
|
||||
resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.41.1':
|
||||
resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==}
|
||||
'@rollup/rollup-freebsd-arm64@4.50.2':
|
||||
resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.41.1':
|
||||
resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==}
|
||||
'@rollup/rollup-freebsd-x64@4.50.2':
|
||||
resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
|
||||
resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.50.2':
|
||||
resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
|
||||
resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.50.2':
|
||||
resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.41.1':
|
||||
resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==}
|
||||
'@rollup/rollup-linux-loong64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==}
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.41.1':
|
||||
resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.41.1':
|
||||
resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.41.1':
|
||||
resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==}
|
||||
'@rollup/rollup-linux-x64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.41.1':
|
||||
resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==}
|
||||
'@rollup/rollup-openharmony-arm64@4.50.2':
|
||||
resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.50.2':
|
||||
resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.41.1':
|
||||
resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.50.2':
|
||||
resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.41.1':
|
||||
resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.50.2':
|
||||
resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1136,8 +1141,8 @@ packages:
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/geojson@7946.0.15':
|
||||
resolution: {integrity: sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==}
|
||||
@@ -1451,8 +1456,8 @@ packages:
|
||||
aws4@1.13.2:
|
||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
||||
|
||||
axios@1.8.2:
|
||||
resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==}
|
||||
axios@1.12.0:
|
||||
resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==}
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||
@@ -1857,6 +1862,15 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decode-uri-component@0.2.2:
|
||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -2139,8 +2153,8 @@ packages:
|
||||
flatted@3.3.2:
|
||||
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
|
||||
|
||||
follow-redirects@1.15.9:
|
||||
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -2155,8 +2169,8 @@ packages:
|
||||
forever-agent@0.6.1:
|
||||
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
|
||||
|
||||
form-data@4.0.2:
|
||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
@@ -2992,8 +3006,8 @@ packages:
|
||||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
rollup@4.41.1:
|
||||
resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==}
|
||||
rollup@4.50.2:
|
||||
resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3089,9 +3103,9 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.4:
|
||||
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
|
||||
engines: {node: '>= 8'}
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
@@ -3355,8 +3369,8 @@ packages:
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@5.4.19:
|
||||
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
|
||||
vite@5.4.20:
|
||||
resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3439,8 +3453,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: '>=6.0.0'
|
||||
|
||||
vue-i18n@9.14.3:
|
||||
resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
|
||||
vue-i18n@9.14.5:
|
||||
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
@@ -3700,7 +3714,7 @@ snapshots:
|
||||
combined-stream: 1.0.8
|
||||
extend: 3.0.2
|
||||
forever-agent: 0.6.1
|
||||
form-data: 4.0.2
|
||||
form-data: 4.0.4
|
||||
http-signature: 1.4.0
|
||||
is-typedarray: 1.0.0
|
||||
isstream: 0.1.2
|
||||
@@ -3914,17 +3928,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.15
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
'@intlify/core-base@9.14.5':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.14.3
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/message-compiler': 9.14.5
|
||||
'@intlify/shared': 9.14.5
|
||||
|
||||
'@intlify/message-compiler@9.14.3':
|
||||
'@intlify/message-compiler@9.14.5':
|
||||
dependencies:
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/shared': 9.14.5
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@9.14.3': {}
|
||||
'@intlify/shared@9.14.5': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
@@ -4091,64 +4105,67 @@ snapshots:
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.41.1':
|
||||
'@rollup/rollup-android-arm-eabi@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.41.1':
|
||||
'@rollup/rollup-android-arm64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.41.1':
|
||||
'@rollup/rollup-darwin-arm64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.41.1':
|
||||
'@rollup/rollup-darwin-x64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.41.1':
|
||||
'@rollup/rollup-freebsd-arm64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.41.1':
|
||||
'@rollup/rollup-freebsd-x64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.41.1':
|
||||
'@rollup/rollup-linux-arm64-musl@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-loong64-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.41.1':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.41.1':
|
||||
'@rollup/rollup-linux-x64-gnu@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.41.1':
|
||||
'@rollup/rollup-linux-x64-musl@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.41.1':
|
||||
'@rollup/rollup-openharmony-arm64@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.41.1':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.41.1':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.50.2':
|
||||
optional: true
|
||||
|
||||
'@rushstack/eslint-patch@1.10.5': {}
|
||||
@@ -4513,7 +4530,7 @@ snapshots:
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/geojson@7946.0.15': {}
|
||||
|
||||
@@ -4659,9 +4676,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@vitest/expect@3.2.2':
|
||||
@@ -4672,13 +4689,13 @@ snapshots:
|
||||
chai: 5.2.0
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
|
||||
'@vitest/mocker@3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.2
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
|
||||
'@vitest/pretty-format@3.2.2':
|
||||
dependencies:
|
||||
@@ -4929,10 +4946,10 @@ snapshots:
|
||||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axios@1.8.2(debug@4.4.0):
|
||||
axios@1.12.0(debug@4.4.0):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.9(debug@4.4.0)
|
||||
form-data: 4.0.2
|
||||
follow-redirects: 1.15.11(debug@4.4.0)
|
||||
form-data: 4.0.4
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -5393,6 +5410,11 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
decode-uri-component@0.2.2:
|
||||
optional: true
|
||||
|
||||
@@ -5618,7 +5640,7 @@ snapshots:
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
@@ -5733,7 +5755,7 @@ snapshots:
|
||||
|
||||
flatted@3.3.2: {}
|
||||
|
||||
follow-redirects@1.15.9(debug@4.4.0):
|
||||
follow-redirects@1.15.11(debug@4.4.0):
|
||||
optionalDependencies:
|
||||
debug: 4.4.0(supports-color@8.1.1)
|
||||
|
||||
@@ -5744,11 +5766,12 @@ snapshots:
|
||||
|
||||
forever-agent@0.6.1: {}
|
||||
|
||||
form-data@4.0.2:
|
||||
form-data@4.0.4:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
@@ -6581,30 +6604,31 @@ snapshots:
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rollup@4.41.1:
|
||||
rollup@4.50.2:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.41.1
|
||||
'@rollup/rollup-android-arm64': 4.41.1
|
||||
'@rollup/rollup-darwin-arm64': 4.41.1
|
||||
'@rollup/rollup-darwin-x64': 4.41.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.41.1
|
||||
'@rollup/rollup-freebsd-x64': 4.41.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.41.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.41.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.41.1
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.41.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.41.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.41.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.41.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.41.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.41.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.50.2
|
||||
'@rollup/rollup-android-arm64': 4.50.2
|
||||
'@rollup/rollup-darwin-arm64': 4.50.2
|
||||
'@rollup/rollup-darwin-x64': 4.50.2
|
||||
'@rollup/rollup-freebsd-arm64': 4.50.2
|
||||
'@rollup/rollup-freebsd-x64': 4.50.2
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.50.2
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.50.2
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-arm64-musl': 4.50.2
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.50.2
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-x64-gnu': 4.50.2
|
||||
'@rollup/rollup-linux-x64-musl': 4.50.2
|
||||
'@rollup/rollup-openharmony-arm64': 4.50.2
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.50.2
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.50.2
|
||||
'@rollup/rollup-win32-x64-msvc': 4.50.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
@@ -6703,7 +6727,7 @@ snapshots:
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
source-map@0.7.4:
|
||||
source-map@0.7.6:
|
||||
optional: true
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
@@ -6778,11 +6802,11 @@ snapshots:
|
||||
stylus@0.57.0:
|
||||
dependencies:
|
||||
css: 3.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
glob: 7.2.3
|
||||
safer-buffer: 2.1.2
|
||||
sax: 1.2.4
|
||||
source-map: 0.7.4
|
||||
source-map: 0.7.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
@@ -6982,7 +7006,7 @@ snapshots:
|
||||
debug: 4.4.1
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
@@ -6994,11 +7018,11 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
|
||||
vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.49
|
||||
rollup: 4.41.1
|
||||
rollup: 4.50.2
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.5
|
||||
fsevents: 2.3.3
|
||||
@@ -7009,7 +7033,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/expect': 3.2.2
|
||||
'@vitest/mocker': 3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
|
||||
'@vitest/mocker': 3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
|
||||
'@vitest/pretty-format': 3.2.2
|
||||
'@vitest/runner': 3.2.2
|
||||
'@vitest/snapshot': 3.2.2
|
||||
@@ -7027,7 +7051,7 @@ snapshots:
|
||||
tinyglobby: 0.2.14
|
||||
tinypool: 1.1.0
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
@@ -7071,10 +7095,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-i18n@9.14.3(vue@3.5.13(typescript@5.7.3)):
|
||||
vue-i18n@9.14.5(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 9.14.3
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/core-base': 9.14.5
|
||||
'@intlify/shared': 9.14.5
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
@@ -7122,7 +7146,7 @@ snapshots:
|
||||
|
||||
wait-on@8.0.1(debug@4.4.0):
|
||||
dependencies:
|
||||
axios: 1.8.2(debug@4.4.0)
|
||||
axios: 1.12.0(debug@4.4.0)
|
||||
joi: 17.13.3
|
||||
lodash: 4.17.21
|
||||
minimist: 1.2.8
|
||||
|
@@ -137,10 +137,10 @@
|
||||
--background: 240 5.9% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card: 240 5.9% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 240 5.9% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
@@ -184,6 +184,10 @@
|
||||
@apply border shadow rounded;
|
||||
}
|
||||
|
||||
.loading-fade {
|
||||
@apply opacity-50 transition-opacity duration-300
|
||||
}
|
||||
|
||||
// Scrollbar start
|
||||
::-webkit-scrollbar {
|
||||
width: 8px; /* Adjust width */
|
||||
|
@@ -8,7 +8,7 @@
|
||||
:class="['w-full justify-between', buttonClass]"
|
||||
>
|
||||
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0">
|
||||
|
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<!-- idk why I named this select tag, should be named multi-select -->
|
||||
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
|
||||
<!-- Tags visible to the user -->
|
||||
<div class="flex gap-2 flex-wrap items-center px-3">
|
||||
@@ -24,6 +25,7 @@
|
||||
@keydown.enter.prevent
|
||||
@blur="handleBlur"
|
||||
@click="open = true"
|
||||
@input.stop
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useTypingIndicator(sendTypingCallback) {
|
||||
export function useTypingIndicator (sendTypingCallback, otherAttributes = {}) {
|
||||
const typingTimer = ref(null)
|
||||
const isCurrentlyTyping = ref(false)
|
||||
|
||||
const startTyping = () => {
|
||||
if (!isCurrentlyTyping.value) {
|
||||
isCurrentlyTyping.value = true
|
||||
sendTypingCallback?.(true)
|
||||
sendTypingCallback?.(true, otherAttributes)
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
@@ -22,10 +22,12 @@ export function useTypingIndicator(sendTypingCallback) {
|
||||
}
|
||||
|
||||
const stopTyping = () => {
|
||||
if (isCurrentlyTyping.value) {
|
||||
isCurrentlyTyping.value = false
|
||||
sendTypingCallback?.(false)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (isCurrentlyTyping.value) {
|
||||
isCurrentlyTyping.value = false
|
||||
sendTypingCallback?.(false, otherAttributes)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
if (typingTimer.value) {
|
||||
clearTimeout(typingTimer.value)
|
||||
|
63
frontend/src/components/banner/AdminBanner.vue
Normal file
63
frontend/src/components/banner/AdminBanner.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="border-b">
|
||||
<!-- Update notification -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
|
||||
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Download class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-sm text-foreground">
|
||||
<span>{{ $t('update.newUpdateAvailable') }}</span>
|
||||
<a
|
||||
:href="appSettingsStore.settings['app.update'].update.url"
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer"
|
||||
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.release_version }}
|
||||
</a>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ appSettingsStore.settings['app.update'].update.release_date }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Update description -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.update'].update.description"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart required notification -->
|
||||
<div
|
||||
v-if="appSettingsStore.settings['app.restart_required']"
|
||||
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Info class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-foreground">
|
||||
{{ $t('admin.banner.restartMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Download, Info } from 'lucide-vue-next'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
</script>
|
7
go.mod
7
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.3
|
||||
github.com/emersion/go-message v0.18.1
|
||||
github.com/fasthttp/websocket v1.5.9
|
||||
github.com/ferluci/fast-realip v1.0.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
@@ -28,7 +29,7 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1
|
||||
github.com/redis/go-redis/v9 v9.5.5
|
||||
github.com/rhnvrm/simples3 v0.9.1
|
||||
github.com/rhnvrm/simples3 v0.9.2
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/taion809/haikunator v0.0.0-20150324135039-4e414e676fd1
|
||||
@@ -51,12 +52,11 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/emersion/go-message v0.18.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
||||
github.com/fasthttp/router v1.5.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
@@ -72,6 +72,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
|
8
go.sum
8
go.sum
@@ -54,8 +54,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
@@ -144,6 +144,8 @@ github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1
|
||||
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
|
||||
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
github.com/rhnvrm/simples3 v0.9.2 h1:XrwsiMnwWf7t/kskvhMYXW6keqp5u3u6t5Va3ltzCQI=
|
||||
github.com/rhnvrm/simples3 v0.9.2/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -159,6 +161,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
|
30
i18n/en.json
30
i18n/en.json
@@ -199,6 +199,7 @@
|
||||
"globals.terms.usage": "Usage",
|
||||
"globals.terms.createdAt": "Created At",
|
||||
"globals.terms.updatedAt": "Updated At",
|
||||
"globals.terms.lastMessageAt": "Last message at",
|
||||
"globals.terms.pickDate": "Pick a date",
|
||||
"globals.terms.time": "Time",
|
||||
"globals.terms.listValues": "List values",
|
||||
@@ -212,6 +213,7 @@
|
||||
"globals.terms.credential": "Credential | Credentials",
|
||||
"globals.terms.unAuthorized": "Unauthorized",
|
||||
"globals.messages.noInternetConnection": "No internet connection",
|
||||
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
|
||||
"globals.messages.invalid": "Invalid {name}",
|
||||
"globals.messages.custom": "Custom {name}",
|
||||
"globals.messages.replying": "Replying",
|
||||
@@ -322,6 +324,8 @@
|
||||
"globals.messages.submit": "Submit",
|
||||
"globals.messages.send": "Send {name}",
|
||||
"globals.messages.update": "Update {name}",
|
||||
"globals.messages.setUp": "Set up",
|
||||
"globals.messages.invite": "Invite",
|
||||
"globals.messages.enable": "Enable",
|
||||
"globals.messages.disable": "Disable",
|
||||
"globals.messages.block": "Block {name}",
|
||||
@@ -350,6 +354,12 @@
|
||||
"globals.messages.submitFeedback": "Submit feedback",
|
||||
"globals.messages.thankYouFeedback": "✅ Thank you for your feedback!",
|
||||
"globals.messages.sending": "Sending...",
|
||||
"globals.messages.additionalFeedback": "Additional feedback (optional)",
|
||||
"globals.messages.pleaseSelect": "Please select {name} before submitting",
|
||||
"globals.messages.poweredBy": "Powered by",
|
||||
"globals.messages.thankYou": "Thank you!",
|
||||
"globals.messages.pageNotFound": "Page not found",
|
||||
"globals.messages.somethingWentWrong": "Something went wrong",
|
||||
"form.error.min": "Must be at least {min} characters",
|
||||
"form.error.max": "Must be at most {max} characters",
|
||||
"form.error.minmax": "Must be between {min} and {max} characters",
|
||||
@@ -383,6 +393,14 @@
|
||||
"conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
|
||||
"conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
|
||||
"csat.alreadySubmitted": "CSAT already submitted",
|
||||
"csat.rateYourInteraction": "Rate your recent interaction",
|
||||
"csat.rating.poor": "Poor",
|
||||
"csat.rating.fair": "Fair",
|
||||
"csat.rating.good": "Good",
|
||||
"csat.rating.great": "Great",
|
||||
"csat.rating.excellent": "Excellent",
|
||||
"csat.pageTitle": "Rate your interaction with us",
|
||||
"csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.",
|
||||
"auth.csrfTokenMismatch": "CSRF token mismatch",
|
||||
"auth.invalidOrExpiredSession": "Invalid or expired session",
|
||||
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
|
||||
@@ -439,7 +457,7 @@
|
||||
"admin.general.maxAllowedFileUploadSize.description": "Max allowed file upload size in MB.",
|
||||
"admin.general.maxAllowedFileUploadSize.valid": "Max allowed file upload size should be between 1 and 500 MB",
|
||||
"admin.general.allowedFileUploadExtensions": "Allowed File Upload Extensions",
|
||||
"admin.general.allowedFileUploadExtensions.description": "Allowed file upload extensions. Use `*` to allow all file types.",
|
||||
"admin.general.allowedFileUploadExtensions.description": "Use `*` to permit all file types. For example: `jpg, png, pdf`",
|
||||
"admin.businessHours.unauthorized": "You do not have permission to view business hours.",
|
||||
"admin.businessHours.setBusinessHours": "Set business hours",
|
||||
"admin.businessHours.alwaysOpen24x7": "Always open (24/7)",
|
||||
@@ -608,6 +626,7 @@
|
||||
"admin.role.conversations.updateTags": "Add or remove conversation tags",
|
||||
"admin.role.messages.read": "View conversation messages",
|
||||
"admin.role.messages.write": "Send messages in conversations",
|
||||
"admin.role.messages.writeAsContact": "Send messages as contact",
|
||||
"admin.role.view.manage": "Create and manage conversation views",
|
||||
"admin.role.generalSettings.manage": "Manage General Settings",
|
||||
"admin.role.notificationSettings.manage": "Manage Notification Settings",
|
||||
@@ -633,12 +652,13 @@
|
||||
"admin.role.contactNotes.write": "Add Contact Notes",
|
||||
"admin.role.contactNotes.delete": "Delete Contact Notes",
|
||||
"admin.role.customAttributes.manage": "Manage Custom Attributes",
|
||||
"admin.role.webhooks.manage": "Manage Webhooks",
|
||||
"admin.role.activityLog.manage": "Manage Activity Log",
|
||||
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
|
||||
"admin.automation.conversationUpdate": "Conversation Update",
|
||||
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
|
||||
"admin.automation.timeTriggers": "Time Triggers",
|
||||
"admin.automation.timeTriggers.description": "Rules that once an hour",
|
||||
"admin.automation.timeTriggers.description": "Rules that run once an hour",
|
||||
"admin.automation.match": "Match",
|
||||
"admin.automation.any": "ANY",
|
||||
"admin.automation.all": "ALL",
|
||||
@@ -658,6 +678,7 @@
|
||||
"admin.automation.event.message.incoming": "Incoming message",
|
||||
"admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.",
|
||||
"admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.",
|
||||
"admin.banner.restartMessage": "Some settings have been changed that require an application restart to take effect.",
|
||||
"admin.template.outgoingEmailTemplates": "Outgoing email templates",
|
||||
"admin.template.emailNotificationTemplates": "Email notification templates",
|
||||
"admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.",
|
||||
@@ -753,5 +774,8 @@
|
||||
"globals.days.wednesday": "Wednesday",
|
||||
"globals.days.thursday": "Thursday",
|
||||
"globals.days.friday": "Friday",
|
||||
"globals.days.saturday": "Saturday"
|
||||
"globals.days.saturday": "Saturday",
|
||||
"setup.completeYourSetup": "Complete your setup",
|
||||
"setup.createFirstInbox": "Create your first inbox",
|
||||
"setup.inviteTeammates": "Invite teammates"
|
||||
}
|
@@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int)
|
||||
return activityLogs, nil
|
||||
}
|
||||
|
||||
// Create adds a new activity log.
|
||||
func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
|
||||
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
|
||||
m.lo.Error("error inserting activity", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login records a login event for the given user.
|
||||
func (al *Manager) Login(userID int, email, ip string) error {
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentLogin,
|
||||
fmt.Sprintf("%s (#%d) logged in", email, userID),
|
||||
userID,
|
||||
@@ -105,7 +96,7 @@ func (al *Manager) Login(userID int, email, ip string) error {
|
||||
|
||||
// Logout records a logout event for the given user.
|
||||
func (al *Manager) Logout(userID int, email, ip string) error {
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentLogout,
|
||||
fmt.Sprintf("%s (#%d) logged out", email, userID),
|
||||
userID,
|
||||
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentAway, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentAwayReassigned, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentOnline, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
|
||||
return nil
|
||||
}
|
||||
|
||||
// create creates a new activity log in DB.
|
||||
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
|
||||
var activityLog models.ActivityLog
|
||||
if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
|
||||
m.lo.Error("error inserting activity log", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
|
||||
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
|
||||
var (
|
||||
|
@@ -2,10 +2,10 @@
|
||||
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
|
||||
|
||||
-- name: get-prompt
|
||||
SELECT id, key, title, content FROM ai_prompts where key = $1;
|
||||
SELECT id, created_at, updated_at, key, title, content FROM ai_prompts where key = $1;
|
||||
|
||||
-- name: get-prompts
|
||||
SELECT id, key, title FROM ai_prompts order by title;
|
||||
SELECT id, created_at, updated_at, key, title FROM ai_prompts order by title;
|
||||
|
||||
-- name: set-openai-key
|
||||
UPDATE ai_providers
|
||||
|
@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
|
||||
// EnforceMediaAccess checks for read access on linked model to media.
|
||||
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
|
||||
switch model {
|
||||
// TODO: Pick this table / model name from the package/models/models.go
|
||||
case "messages":
|
||||
allowed, err := e.Enforce(user, model, "read")
|
||||
if err != nil {
|
||||
|
@@ -15,6 +15,7 @@ const (
|
||||
PermConversationWrite = "conversations:write"
|
||||
PermMessagesRead = "messages:read"
|
||||
PermMessagesWrite = "messages:write"
|
||||
PermMessagesWriteAsContact = "messages:write_as_contact"
|
||||
|
||||
// View
|
||||
PermViewManage = "view:manage"
|
||||
@@ -102,6 +103,7 @@ var validPermissions = map[string]struct{}{
|
||||
PermConversationWrite: {},
|
||||
PermMessagesRead: {},
|
||||
PermMessagesWrite: {},
|
||||
PermMessagesWriteAsContact: {},
|
||||
PermViewManage: {},
|
||||
PermStatusManage: {},
|
||||
PermTagsManage: {},
|
||||
|
@@ -33,7 +33,7 @@ type conversationStore interface {
|
||||
|
||||
type teamStore interface {
|
||||
GetAll() ([]tmodels.Team, error)
|
||||
GetMembers(teamID int) ([]umodels.User, error)
|
||||
GetMembers(teamID int) ([]tmodels.TeamMember, error)
|
||||
}
|
||||
|
||||
// Engine represents a manager for assigning unassigned conversations
|
||||
|
@@ -232,12 +232,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
for _, ruleValue := range ruleValues {
|
||||
// Normalize rule value by collapsing multiple spaces
|
||||
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
) {
|
||||
conditionMet = true
|
||||
break
|
||||
|
||||
// Respect CaseSensitiveMatch flag
|
||||
if rule.CaseSensitiveMatch {
|
||||
if strings.Contains(normalizedInputText, normalizedRuleValue) {
|
||||
conditionMet = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
) {
|
||||
conditionMet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case models.RuleOperatorNotContains:
|
||||
@@ -249,12 +258,21 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
for _, ruleValue := range ruleValues {
|
||||
// Normalize rule value by collapsing multiple spaces
|
||||
normalizedRuleValue := strings.Join(strings.Fields(ruleValue), " ")
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
) {
|
||||
conditionMet = false
|
||||
break
|
||||
|
||||
// Respect CaseSensitiveMatch flag
|
||||
if rule.CaseSensitiveMatch {
|
||||
if strings.Contains(normalizedInputText, normalizedRuleValue) {
|
||||
conditionMet = false
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(
|
||||
strings.ToLower(normalizedInputText),
|
||||
strings.ToLower(normalizedRuleValue),
|
||||
) {
|
||||
conditionMet = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case models.RuleOperatorSet:
|
||||
|
1201
internal/automation/evaluator_test.go
Normal file
1201
internal/automation/evaluator_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user