diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 59993f38..b20d6b97 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,6 +9,7 @@ Thanks for your interest in contributing.
- Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed.
- Anything larger should start with an issue. See below for why.
- Keep changes focused on a single concern wherever possible.
+- For translations, please contribute through [Crowdin](https://crowdin.com/project/libredesk) instead of opening PRs for translation changes.
## Pull Requests
diff --git a/ROADMAP.md b/ROADMAP.md
index 210fcff5..ad5a1f81 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,9 +4,10 @@ The open-source customer support platform you actually own. One inbox for email,
## Near Term
- WhatsApp channel - WIP
+- Web / Customer Portal to manage tickets - WIP
+- 2FA for password login - WIP
+- Telegram channel - TODO
## Mid Term
-- Portal to manage tickets - TODO
-- Conversation reports - TODO
+- Extensive conversation reports - TODO
- Contact merging - TODO
-- 2FA for password login - TODO
diff --git a/cmd/ai.go b/cmd/ai.go
index e2c0bfab..999ae50f 100644
--- a/cmd/ai.go
+++ b/cmd/ai.go
@@ -45,6 +45,11 @@ type suggestTagsReq struct {
ConversationUUID string `json:"conversation_uuid"`
}
+type promptReq struct {
+ Title string `json:"title"`
+ Content string `json:"content"`
+}
+
type snippetReq struct {
Title string `json:"title"`
Content string `json:"content"`
@@ -81,6 +86,65 @@ func handleGetAIPrompts(r *fastglue.Request) error {
return r.SendEnvelope(resp)
}
+func handleGetAIPrompt(r *fastglue.Request) error {
+ app := r.Context.(*App)
+ id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
+ if err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
+ }
+ p, err := app.ai.GetPromptByID(id)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(p)
+}
+
+func handleCreateAIPrompt(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ req promptReq
+ )
+ if err := r.Decode(&req, "json"); err != nil {
+ return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
+ }
+ p, err := app.ai.CreatePrompt(req.Title, req.Content)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(p)
+}
+
+func handleUpdateAIPrompt(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ req promptReq
+ )
+ id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
+ if err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
+ }
+ if err := r.Decode(&req, "json"); err != nil {
+ return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
+ }
+ p, err := app.ai.UpdatePrompt(id, req.Title, req.Content)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(p)
+}
+
+func handleDeleteAIPrompt(r *fastglue.Request) error {
+ app := r.Context.(*App)
+ id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
+ if err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
+ }
+ if err := app.ai.DeletePrompt(id); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
// handleGetAIConfig returns the sanitized provider config for a type (completion/embedding).
func handleGetAIConfig(r *fastglue.Request) error {
app := r.Context.(*App)
diff --git a/cmd/chat.go b/cmd/chat.go
index 113a0dd0..91a0eb79 100644
--- a/cmd/chat.go
+++ b/cmd/chat.go
@@ -283,7 +283,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process post-message hooks for the new conversation and initial message.
- if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
+ if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil {
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
}
diff --git a/cmd/config.go b/cmd/config.go
index 8a04a219..788bd6ba 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -4,9 +4,12 @@ import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
+ oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/zerodha/fastglue"
)
+const localLoginEnabledKey = "auth.local_login_enabled"
+
// 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)
@@ -58,6 +61,23 @@ func handleGetConfig(r *fastglue.Request) error {
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
+ publicSettings["auth.local_login_enabled"] = isLocalLoginEnabled(oidcProviders)
return r.SendEnvelope(publicSettings)
}
+
+func localLoginDisabledInConfig() bool {
+ return ko.Exists(localLoginEnabledKey) && !ko.Bool(localLoginEnabledKey)
+}
+
+func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool {
+ if !localLoginDisabledInConfig() {
+ return true
+ }
+ for _, provider := range providers {
+ if provider.Enabled {
+ return false
+ }
+ }
+ return true
+}
diff --git a/cmd/config_test.go b/cmd/config_test.go
new file mode 100644
index 00000000..fcca43fa
--- /dev/null
+++ b/cmd/config_test.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "testing"
+
+ oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
+)
+
+func TestIsLocalLoginEnabled(t *testing.T) {
+ tests := []struct {
+ name string
+ configured *bool
+ providers []oidcmodels.OIDC
+ want bool
+ }{
+ {name: "defaults to enabled", want: true},
+ {name: "remains enabled when configured", configured: boolPointer(true), want: true},
+ {name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true},
+ {name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ko.Delete(localLoginEnabledKey)
+ if tt.configured != nil {
+ if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil {
+ t.Fatalf("set config: %v", err)
+ }
+ }
+ t.Cleanup(func() { ko.Delete(localLoginEnabledKey) })
+
+ if got := isLocalLoginEnabled(tt.providers); got != tt.want {
+ t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func boolPointer(value bool) *bool {
+ return &value
+}
diff --git a/cmd/contacts.go b/cmd/contacts.go
index d9060a43..71faa029 100644
--- a/cmd/contacts.go
+++ b/cmd/contacts.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "mime/multipart"
"path/filepath"
"strconv"
"strings"
@@ -24,6 +25,23 @@ type blockContactReq struct {
Enabled bool `json:"enabled"`
}
+func handleCreateContact(r *fastglue.Request) error {
+ var app = r.Context.(*App)
+
+ contact, _, err := contactFromForm(r)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ if err := app.user.CreateContact(&contact); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ created, err := app.user.GetContactOrVisitor(contact.ID, "")
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(created)
+}
+
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
@@ -84,75 +102,9 @@ func handleUpdateContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
- form, err := r.RequestCtx.MultipartForm()
+ contactToUpdate, form, err := contactFromForm(r)
if err != nil {
- app.lo.Error("error parsing form data", "error", err)
- return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError)
- }
-
- // Parse form data
- firstName := ""
- if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
- firstName = string(v[0])
- }
- lastName := ""
- if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
- lastName = string(v[0])
- }
- email := ""
- if v, ok := form.Value["email"]; ok && len(v) > 0 {
- email = strings.TrimSpace(string(v[0]))
- }
- phoneNumber := ""
- if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
- phoneNumber = string(v[0])
- }
- phoneNumberCountryCode := ""
- if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
- phoneNumberCountryCode = string(v[0])
- }
- country := ""
- if v, ok := form.Value["country"]; ok && len(v) > 0 {
- country = string(v[0])
- }
- avatarURL := ""
- if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
- avatarURL = string(v[0])
- }
-
- // Set nulls to empty strings.
- if avatarURL == "null" {
- avatarURL = ""
- }
- if phoneNumberCountryCode == "null" {
- phoneNumberCountryCode = ""
- }
- if phoneNumber == "null" {
- phoneNumber = ""
- }
- if country == "null" {
- country = ""
- }
-
- // Validate mandatory fields.
- if email == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError)
- }
- if !stringutil.ValidEmail(email) {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError)
- }
- if firstName == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError)
- }
-
- contactToUpdate := models.User{
- FirstName: firstName,
- LastName: lastName,
- Email: null.StringFrom(email),
- AvatarURL: null.NewString(avatarURL, avatarURL != ""),
- PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
- PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
- Country: null.NewString(country, country != ""),
+ return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -160,7 +112,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Delete avatar?
- if avatarURL == "" && contact.AvatarURL.Valid {
+ if !contactToUpdate.AvatarURL.Valid && contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
contact.AvatarURL.Valid = false
@@ -168,8 +120,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Upload avatar?
- files, ok := form.File["files"]
- if ok && len(files) > 0 {
+ if files := form.File["files"]; len(files) > 0 {
if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -366,3 +317,51 @@ func handleBlockContact(r *fastglue.Request) error {
}
return r.SendEnvelope(contact)
}
+
+func contactFromForm(r *fastglue.Request) (models.User, *multipart.Form, error) {
+ var app = r.Context.(*App)
+
+ form, err := r.RequestCtx.MultipartForm()
+ if err != nil {
+ app.lo.Error("error parsing form data", "error", err)
+ return models.User{}, nil, envelope.NewError(envelope.GeneralError, app.i18n.T("errors.parsingRequest"), nil)
+ }
+
+ value := func(key string) string {
+ v := ""
+ if vals := form.Value[key]; len(vals) > 0 {
+ v = strings.TrimSpace(vals[0])
+ }
+ // The edit page sends cleared fields as the string "null".
+ if v == "null" {
+ return ""
+ }
+ return v
+ }
+ optional := func(key string) null.String {
+ v := value(key)
+ return null.NewString(v, v != "")
+ }
+
+ email := value("email")
+ if email == "" {
+ return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "email"), nil)
+ }
+ if !stringutil.ValidEmail(email) {
+ return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
+ }
+ firstName := value("first_name")
+ if firstName == "" {
+ return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil)
+ }
+
+ return models.User{
+ FirstName: firstName,
+ LastName: value("last_name"),
+ Email: null.StringFrom(email),
+ AvatarURL: optional("avatar_url"),
+ PhoneNumber: optional("phone_number"),
+ PhoneNumberCountryCode: optional("phone_number_country_code"),
+ Country: optional("country"),
+ }, form, nil
+}
diff --git a/cmd/csat.go b/cmd/csat.go
index 5efe3fab..707bcc49 100644
--- a/cmd/csat.go
+++ b/cmd/csat.go
@@ -86,9 +86,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
}
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
+ // Surface only known, user-safe messages; never leak raw driver errors
+ // (e.g. a malformed UUID triggers a Postgres syntax error string).
+ msg := app.i18n.T("globals.messages.somethingWentWrong")
+ if e, ok := err.(envelope.Error); ok {
+ msg = e.Error()
+ }
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
- "ErrorMessage": err.Error(),
+ "ErrorMessage": msg,
},
})
}
@@ -152,10 +158,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
- // Trim feedback if it exceeds max length.
- if len(req.Feedback) > maxCsatFeedbackLength {
- req.Feedback = req.Feedback[:maxCsatFeedbackLength]
- }
+ req.Feedback = truncateFeedback(req.Feedback)
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
@@ -187,9 +190,7 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return 0, "", nil, "csat.pleaseFillRequired"
}
- if len(feedback) > maxCsatFeedbackLength {
- feedback = feedback[:maxCsatFeedbackLength]
- }
+ feedback = truncateFeedback(feedback)
// Collect extra form fields into meta, skipping the known fields.
meta := make(map[string]string)
@@ -220,3 +221,14 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return rating, feedback, metaJSON, ""
}
+
+func truncateFeedback(s string) string {
+ if len(s) <= maxCsatFeedbackLength {
+ return s
+ }
+ r := []rune(s)
+ if len(r) <= maxCsatFeedbackLength {
+ return s
+ }
+ return string(r[:maxCsatFeedbackLength])
+}
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 76a1031f..713b7a5c 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -152,6 +152,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Contacts.
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all"))
+ g.POST("/api/v1/contacts", perm(handleCreateContact, "contacts:write"))
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
@@ -251,6 +252,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completions.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
+ g.GET("/api/v1/ai/prompts/{id}", perm(handleGetAIPrompt, "ai:manage"))
+ g.POST("/api/v1/ai/prompts", perm(handleCreateAIPrompt, "ai:manage"))
+ g.PUT("/api/v1/ai/prompts/{id}", perm(handleUpdateAIPrompt, "ai:manage"))
+ g.DELETE("/api/v1/ai/prompts/{id}", perm(handleDeleteAIPrompt, "ai:manage"))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
// AI provider config (completion / embedding).
@@ -348,6 +353,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
+ g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences))
+ g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences))
+ g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription))
+ g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -405,6 +414,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
+ g.GET("/manifest.webmanifest", serveManifest)
+ g.GET("/sw.js", serveServiceWorker)
g.GET("/static/public/{all:*}", serveStaticFiles)
// Public pages.
@@ -524,6 +535,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
+func serveManifest(r *fastglue.Request) error {
+ return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
+}
+
+func serveServiceWorker(r *fastglue.Request) error {
+ r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
+ return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
+}
+
+func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
+ app := r.Context.(*App)
+ file, err := app.fs.Get(filepath.Join(frontendDir, name))
+ if err != nil {
+ return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
+ }
+ r.RequestCtx.Response.Header.Set("Content-Type", contentType)
+ r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
+ r.RequestCtx.SetBody(file.ReadBytes())
+ return nil
+}
+
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
diff --git a/cmd/init.go b/cmd/init.go
index f727cca4..415d5fca 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -40,6 +40,7 @@ import (
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
notifier "github.com/abhinavxd/libredesk/internal/notification"
+ notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
@@ -1242,6 +1243,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
return m
}
+// initNotificationPreference inits the notification preference manager.
+func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager {
+ m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{
+ DB: db,
+ Lo: initLogger("notification-preference"),
+ I18n: i18n,
+ })
+ if err != nil {
+ log.Fatalf("error initializing notification preference manager: %v", err)
+ }
+ return m
+}
+
// initImporter inits the importer manager.
func initImporter(i18n *i18n.I18n) *importer.Importer {
return importer.New(importer.Opts{
@@ -1250,14 +1264,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer {
})
}
+func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue {
+ q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{
+ DB: db,
+ Outbound: outbound,
+ Lo: initLogger("notification-email-queue"),
+ })
+ if err != nil {
+ log.Fatalf("error initializing notification email queue: %v", err)
+ }
+ return q
+}
+
+func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager {
+ m, err := notifier.NewPushManager(notifier.PushManagerOpts{
+ DB: db,
+ Settings: settings,
+ Lo: initLogger("push-notification"),
+ I18n: i18n,
+ RootURL: ko.String("app.root_url"),
+ Concurrency: ko.MustInt("notification.concurrency"),
+ QueueSize: ko.MustInt("notification.queue_size"),
+ })
+ if err != nil {
+ log.Fatalf("error initializing push notification manager: %v", err)
+ }
+ return m
+}
+
// initNotifDispatcher initializes the notification dispatcher.
-func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
+func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
+ providers := []notificationchannels.Provider{
+ notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")),
+ }
+ if emailEnabled {
+ providers = append(providers, notificationchannels.NewEmail(emailQueue))
+ }
+ providers = append(providers, notificationchannels.NewPush(push))
return notifier.NewDispatcher(notifier.DispatcherOpts{
- InApp: userNotification,
- Outbound: outbound,
- WSHub: wsHub,
- EmailEnabled: emailEnabled,
- Lo: initLogger("notification-dispatcher"),
+ Pipeline: notificationchannels.NewPipeline(providers...),
+ Prefs: prefs,
})
}
diff --git a/cmd/login.go b/cmd/login.go
index 2cf3968e..250cb943 100644
--- a/cmd/login.go
+++ b/cmd/login.go
@@ -21,6 +21,17 @@ func handleLogin(r *fastglue.Request) error {
loginReq loginRequest
)
+ if localLoginDisabledInConfig() {
+ oidcProviders, err := app.oidc.GetAll()
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ if !isLocalLoginEnabled(oidcProviders) {
+ return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("auth.localLoginDisabled"), nil, envelope.PermissionError)
+ }
+ app.lo.Warn("local login is disabled in config but no OIDC provider is enabled, allowing password login")
+ }
+
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
diff --git a/cmd/main.go b/cmd/main.go
index e09b7164..d175e70d 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -128,6 +128,8 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
userNotification *notifier.UserNotificationManager
+ notificationPref *notifier.PreferenceManager
+ pushNotification *notifier.PushManager
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
@@ -257,7 +259,10 @@ func main() {
wsHub = initWS(user)
notifier = initNotifier()
userNotification = initUserNotification(db, i18n)
- notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled"))
+ notificationPreference = initNotificationPreference(db, i18n)
+ pushNotification = initPushNotification(db, settings, i18n)
+ notificationEmailQueue = initNotificationEmailQueue(db, notifier)
+ notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled"))
automation = initAutomationEngine(db, i18n)
ai = initAI(ctx, db, i18n, ssrfControl)
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
@@ -295,6 +300,10 @@ func main() {
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
go userNotification.RunNotificationCleaner(ctx)
go helpCenter.RunSearchLogCleaner(ctx)
+ if ko.Bool("notification.email.enabled") {
+ go notificationEmailQueue.Run(ctx)
+ }
+ go pushNotification.Run(ctx)
go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10))
go ai.Run(ctx)
@@ -341,6 +350,8 @@ func main() {
userNotification: userNotification,
whatsappClient: waClient,
whatsappTemplate: waTemplates,
+ notificationPref: notificationPreference,
+ pushNotification: pushNotification,
wsHub: wsHub,
}
app.consts.Store(constants)
diff --git a/cmd/pwa_test.go b/cmd/pwa_test.go
new file mode 100644
index 00000000..65c1c7f9
--- /dev/null
+++ b/cmd/pwa_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/knadh/stuffbin"
+ "github.com/valyala/fasthttp"
+ "github.com/zerodha/fastglue"
+)
+
+func TestServePWAFiles(t *testing.T) {
+ fs, err := stuffbin.NewLocalFS("/",
+ "../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
+ "../frontend/public/sw.js:frontend/dist/main/sw.js",
+ )
+ if err != nil {
+ t.Fatalf("creating test filesystem: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ handler fastglue.FastRequestHandler
+ contentType string
+ wantServiceWorkerHdr bool
+ }{
+ {"manifest", serveManifest, "application/manifest+json", false},
+ {"service worker", serveServiceWorker, "application/javascript", true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := &fasthttp.RequestCtx{}
+ req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
+ if err := tc.handler(req); err != nil {
+ t.Fatalf("serving file: %v", err)
+ }
+ if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
+ t.Errorf("content type = %q, want %q", got, tc.contentType)
+ }
+ if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
+ t.Errorf("cache control = %q, want no-cache", got)
+ }
+ if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
+ t.Error("service worker scope header is missing")
+ }
+ if strings.TrimSpace(string(ctx.Response.Body())) == "" {
+ t.Error("response body is empty")
+ }
+ })
+ }
+}
diff --git a/cmd/settings.go b/cmd/settings.go
index d55082cd..dcd1fa0c 100644
--- a/cmd/settings.go
+++ b/cmd/settings.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
+ "github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
+ if !httputil.IsValidHTTPURL(req.RootURL) {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
+ }
app.Lock()
oldLang := ko.String("app.lang")
diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go
index caadb242..3f39d96b 100644
--- a/cmd/user_notifications.go
+++ b/cmd/user_notifications.go
@@ -5,6 +5,8 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
+ notifier "github.com/abhinavxd/libredesk/internal/notification"
+ nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
}
return r.SendEnvelope(true)
}
+
+func handleGetNotificationPreferences(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ )
+ prefs, err := app.notificationPref.GetMatrix(auser.ID)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
+ if err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(map[string]any{
+ "preferences": prefs,
+ "email_enabled": ko.Bool("notification.email.enabled"),
+ "vapid_public_key": app.pushNotification.PublicKey(),
+ "push_endpoints": pushEndpoints,
+ })
+}
+
+func handleUpdateNotificationPreferences(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ prefs []nmodels.NotificationPreference
+ )
+ if err := r.Decode(&prefs, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
+ app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
+func handleCreatePushSubscription(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ subscription notifier.PushSubscriptionInput
+ )
+ if err := r.Decode(&subscription, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
+func handleDeletePushSubscription(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ request struct {
+ Endpoint string `json:"endpoint"`
+ }
+ )
+ if err := r.Decode(&request, "json"); err != nil {
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+ }
+ if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
diff --git a/config.sample.toml b/config.sample.toml
index 50d5bcc7..f09ec80c 100644
--- a/config.sample.toml
+++ b/config.sample.toml
@@ -34,6 +34,13 @@ read_buffer_size = 65536
# Keepalive settings.
keepalive_timeout = "10s"
+[auth]
+# Allow users to sign in with their libredesk email and password.
+# Disable this only after configuring and enabling at least one OIDC provider.
+# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout.
+# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login.
+local_login_enabled = true
+
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html
index 781f17db..63340480 100644
--- a/frontend/apps/main/index.html
+++ b/frontend/apps/main/index.html
@@ -5,6 +5,9 @@
+
+
+
@@ -14,4 +17,4 @@