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 @@ - \ No newline at end of file + diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index e5d80238..b9dfe330 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag' import { useCustomAttributeStore } from './stores/customAttributes' import { useIdleDetection } from './composables/useIdleDetection' import { useNotificationStore } from './stores/notification' +import { useAiPromptStore } from '@main/stores/aiPrompt' import { useViewStore } from './stores/view' import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog' import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue' @@ -122,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue' import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue' import { useIsMobile } from '@shared-ui/composables' import api from '@main/api' +import { usePushNotifications } from '@/composables/usePushNotifications' const route = useRoute() const emitter = useEmitter() @@ -165,6 +167,8 @@ const openCreateConversationDialog = ref(false) const createConversationContact = ref(null) const { t } = useI18n() const notificationStore = useNotificationStore() +const aiPromptStore = useAiPromptStore() +const pushNotifications = usePushNotifications() // Update browser tab title with unread notification count. // Watch both unreadCount and route so the prefix is preserved after navigation. @@ -214,10 +218,17 @@ const initStores = async () => { inboxStore.fetchInboxes(), slaStore.fetchSlas(), tagStore.fetchTags(), - customAttributeStore.fetchCustomAttributes() + customAttributeStore.fetchCustomAttributes(), + aiPromptStore.fetchPrompts(), + refreshPushSubscription() ]) } +const refreshPushSubscription = async () => { + const { data } = await api.getNotificationPreferences() + await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints) +} + const createView = () => { view.value = {} openCreateViewForm.value = true diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index c07b387f..82aa0e9a 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -204,6 +204,12 @@ const updateRole = (id, data) => const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`) const getContacts = (params) => http.get('/api/v1/contacts', { params }) const getContact = (id) => http.get(`/api/v1/contacts/${id}`) +const createContact = (data) => + http.post('/api/v1/contacts', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, { headers: { @@ -470,6 +476,10 @@ const updateSharedView = (id, data) => const deleteSharedView = (id) => http.delete(`/api/v1/shared-views/${id}`) const getAiPrompts = () => http.get('/api/v1/ai/prompts') +const getAIPrompt = (id) => http.get(`/api/v1/ai/prompts/${id}`) +const createAIPrompt = (data) => http.post('/api/v1/ai/prompts', data) +const updateAIPrompt = (id, data) => http.put(`/api/v1/ai/prompts/${id}`, data) +const deleteAIPrompt = (id) => http.delete(`/api/v1/ai/prompts/${id}`) const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, { timeout: AI_TIMEOUT, headers: { @@ -624,6 +634,10 @@ const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/rea const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all') const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`) const deleteAllNotifications = () => http.delete('/api/v1/notifications') +const getNotificationPreferences = () => http.get('/api/v1/notifications/preferences') +const updateNotificationPreferences = (data) => http.put('/api/v1/notifications/preferences', data) +const createPushSubscription = (data) => http.post('/api/v1/notifications/push-subscriptions', data) +const deletePushSubscription = (endpoint) => http.delete('/api/v1/notifications/push-subscriptions', { data: { endpoint } }) export default { login, @@ -762,6 +776,10 @@ export default { updateSharedView, deleteSharedView, getAiPrompts, + getAIPrompt, + createAIPrompt, + updateAIPrompt, + deleteAIPrompt, aiCompletion, getAIConfig, updateAIConfig, @@ -824,6 +842,7 @@ export default { removeAssignee, getContacts, getContact, + createContact, updateContact, blockContact, deleteContact, @@ -862,5 +881,9 @@ export default { markAllNotificationsAsRead, deleteNotification, deleteAllNotifications, + getNotificationPreferences, + updateNotificationPreferences, + createPushSubscription, + deletePushSubscription, getContactPageVisits } diff --git a/frontend/apps/main/src/components/editor/ConversationEditor.vue b/frontend/apps/main/src/components/editor/ConversationEditor.vue index 08a8415a..a0ae2cf1 100644 --- a/frontend/apps/main/src/components/editor/ConversationEditor.vue +++ b/frontend/apps/main/src/components/editor/ConversationEditor.vue @@ -12,9 +12,9 @@ > @@ -41,17 +41,18 @@ const props = defineProps({ insertContent: String, messageType: String, autoFocus: { type: Boolean, default: true }, - aiPrompts: { type: Array, default: () => [] }, disabled: { type: Boolean, default: false }, enableMentions: { type: Boolean, default: false }, getSuggestions: { type: Function, default: null }, + enableConversationReferences: { type: Boolean, default: false }, + getConversationSuggestions: { type: Function, default: null }, enableInlineImages: { type: Boolean, default: false }, linkedModel: { type: String, default: 'messages' } }) const emit = defineEmits([ 'send', - 'aiPromptSelected', + 'aiGenerationChange', 'mentionsChanged', 'filesDropped', 'toggleMessageType' @@ -59,8 +60,6 @@ const emit = defineEmits([ const linkDialog = ref(null) -const emitPrompt = (key) => emit('aiPromptSelected', key) - // Suppress the formatting bubble when an image node is selected so it // doesn't fight with the image's own size/remove toolbar. const shouldShowBubble = ({ editor: e, state }) => { @@ -91,6 +90,8 @@ const { editor, extractMentions, focus } = useTextEditor({ linkedModel: props.linkedModel, getSuggestions: props.getSuggestions, enableMentions: () => props.enableMentions, + getConversationSuggestions: props.getConversationSuggestions, + conversationReferencesEnabled: () => props.enableConversationReferences, onSend: () => { emit('send') stopTyping() diff --git a/frontend/apps/main/src/components/editor/ConversationReferenceList.vue b/frontend/apps/main/src/components/editor/ConversationReferenceList.vue new file mode 100644 index 00000000..dc4ed428 --- /dev/null +++ b/frontend/apps/main/src/components/editor/ConversationReferenceList.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/apps/main/src/components/editor/EditorToolbar.vue b/frontend/apps/main/src/components/editor/EditorToolbar.vue index 79fab042..08085f95 100644 --- a/frontend/apps/main/src/components/editor/EditorToolbar.vue +++ b/frontend/apps/main/src/components/editor/EditorToolbar.vue @@ -1,8 +1,14 @@ diff --git a/frontend/apps/main/src/components/editor/conversationReference.js b/frontend/apps/main/src/components/editor/conversationReference.js new file mode 100644 index 00000000..e0bd80b5 --- /dev/null +++ b/frontend/apps/main/src/components/editor/conversationReference.js @@ -0,0 +1,27 @@ +import api from '@main/api' + +export const MIN_REFERENCE_QUERY_LENGTH = 3 +const SUGGESTION_LIMIT = 10 + +export const createLatestConversationSuggestionFetcher = (fetchSuggestions) => { + let latestRequestID = 0 + + return async (query) => { + const requestID = ++latestRequestID + const suggestions = await fetchSuggestions(query) + return requestID === latestRequestID ? suggestions : [] + } +} + +export const getConversationSuggestions = async (query, search = api.searchConversations) => { + const reference = query.trim() + if (reference.length < MIN_REFERENCE_QUERY_LENGTH) return [] + + const response = await search({ query: reference }) + return (response.data?.data || []).slice(0, SUGGESTION_LIMIT).map((conversation) => ({ + id: conversation.uuid, + label: conversation.reference_number, + subject: conversation.subject, + status: conversation.status + })) +} diff --git a/frontend/apps/main/src/components/editor/conversationReference.test.js b/frontend/apps/main/src/components/editor/conversationReference.test.js new file mode 100644 index 00000000..e3f5744c --- /dev/null +++ b/frontend/apps/main/src/components/editor/conversationReference.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import * as conversationReference from './conversationReference' + +const { getConversationSuggestions } = conversationReference + +describe('conversation reference suggestions', () => { + it('does not search until three characters are entered', async () => { + const search = vi.fn() + await expect(getConversationSuggestions('10', search)).resolves.toEqual([]) + expect(search).not.toHaveBeenCalled() + }) + + it('maps a conversation result to a suggestion', async () => { + const search = vi.fn().mockResolvedValue({ + data: { + data: [ + { + uuid: 'conversation-uuid', + reference_number: '108', + subject: 'Payment failed', + status: 'Open' + } + ] + } + }) + await expect(getConversationSuggestions('108', search)).resolves.toEqual([ + { id: 'conversation-uuid', label: '108', subject: 'Payment failed', status: 'Open' } + ]) + }) + + it('caps the suggestion list', async () => { + const rows = Array.from({ length: 30 }, (_, i) => ({ uuid: `u${i}`, reference_number: `${i}` })) + const search = vi.fn().mockResolvedValue({ data: { data: rows } }) + await expect(getConversationSuggestions('108', search)).resolves.toHaveLength(10) + }) + + it('discards a response superseded by a newer query', async () => { + let resolveFirst + let resolveSecond + const fetchSuggestions = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))) + const getLatestSuggestions = + conversationReference.createLatestConversationSuggestionFetcher(fetchSuggestions) + + const firstRequest = getLatestSuggestions('108') + const secondRequest = getLatestSuggestions('109') + + resolveSecond([{ label: '109' }]) + await expect(secondRequest).resolves.toEqual([{ label: '109' }]) + resolveFirst([{ label: '108' }]) + await expect(firstRequest).resolves.toEqual([]) + }) +}) diff --git a/frontend/apps/main/src/components/editor/conversationReferenceExtension.js b/frontend/apps/main/src/components/editor/conversationReferenceExtension.js new file mode 100644 index 00000000..6b071f0a --- /dev/null +++ b/frontend/apps/main/src/components/editor/conversationReferenceExtension.js @@ -0,0 +1,20 @@ +import Mention from '@tiptap/extension-mention' + +export const conversationReferenceHref = (uuid) => + `/inboxes/all/conversation/${encodeURIComponent(uuid)}` + +export const ConversationReference = Mention.extend({ + name: 'conversationReference', + // Link's a[href] mark rule is registered first at the default priority and would swallow the anchor. + parseHTML: () => [{ tag: 'a.ld-conversation-reference', priority: 51 }], + renderText: ({ node }) => `#${node.attrs.label}`, + renderHTML: ({ node, HTMLAttributes }) => [ + 'a', + { + ...HTMLAttributes, + href: conversationReferenceHref(node.attrs.id), + class: [HTMLAttributes.class, 'ld-conversation-reference'].filter(Boolean).join(' ') + }, + `#${node.attrs.label}` + ] +}) diff --git a/frontend/apps/main/src/components/editor/conversationReferenceExtension.test.js b/frontend/apps/main/src/components/editor/conversationReferenceExtension.test.js new file mode 100644 index 00000000..9d5515ab --- /dev/null +++ b/frontend/apps/main/src/components/editor/conversationReferenceExtension.test.js @@ -0,0 +1,39 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/vue-3' +import { buildConversationExtensions } from './editorExtensions' +import { conversationReferenceHref } from './conversationReferenceExtension' + +describe('conversation reference editor extension', () => { + it('builds a root-relative conversation link', () => { + expect(conversationReferenceHref('abc')).toBe('/inboxes/all/conversation/abc') + }) + + it('round-trips a conversation reference through HTML in the conversation editor', () => { + const content = + '

#108

' + const editor = new Editor({ + extensions: buildConversationExtensions({ getPlaceholder: () => '' }), + content + }) + expect(editor.getJSON().content[0].content[0]).toMatchObject({ + type: 'conversationReference', + attrs: { id: 'conversation-uuid', label: '108' } + }) + expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"') + editor.destroy() + }) + + it('derives the link from the conversation id, ignoring a stored href', () => { + const content = + '

#108

' + const editor = new Editor({ + extensions: buildConversationExtensions({ getPlaceholder: () => '' }), + content + }) + expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"') + expect(editor.getHTML()).not.toContain('evil.example') + editor.destroy() + }) +}) diff --git a/frontend/apps/main/src/components/editor/conversationReferenceSuggestion.js b/frontend/apps/main/src/components/editor/conversationReferenceSuggestion.js new file mode 100644 index 00000000..690df835 --- /dev/null +++ b/frontend/apps/main/src/components/editor/conversationReferenceSuggestion.js @@ -0,0 +1,10 @@ +import ConversationReferenceList from './ConversationReferenceList.vue' +import { createSuggestionRenderer } from './suggestionRenderer' + +export default { + char: '#', + allowSpaces: false, + allow: ({ editor }) => editor.options.editorProps?.conversationReferencesEnabled?.() ?? false, + items: ({ query, editor }) => editor.options.editorProps?.getConversationSuggestions?.(query) ?? [], + render: () => createSuggestionRenderer(ConversationReferenceList) +} diff --git a/frontend/apps/main/src/components/editor/editorExtensions.js b/frontend/apps/main/src/components/editor/editorExtensions.js index 82ce364c..e59d0f17 100644 --- a/frontend/apps/main/src/components/editor/editorExtensions.js +++ b/frontend/apps/main/src/components/editor/editorExtensions.js @@ -17,6 +17,8 @@ import { Callout } from './extensions/Callout' import { Details, DetailsSummary, DetailsContent } from './extensions/Collapsible' import { TrailingNode } from './extensions/TrailingNode' import mentionSuggestion from './mentionSuggestion' +import conversationReferenceSuggestion from './conversationReferenceSuggestion' +import { ConversationReference } from './conversationReferenceExtension' const lowlight = createLowlight(codeGrammars) @@ -142,6 +144,7 @@ export function buildConversationExtensions({ getPlaceholder }) { HTMLAttributes: { class: 'ld-mention' }, suggestion: mentionSuggestion }), + ConversationReference.configure({ suggestion: conversationReferenceSuggestion }), CustomTable.configure({ resizable: false }), TableRow, CustomTableCell, diff --git a/frontend/apps/main/src/components/editor/editorStyles.scss b/frontend/apps/main/src/components/editor/editorStyles.scss index d8513336..ac110396 100644 --- a/frontend/apps/main/src/components/editor/editorStyles.scss +++ b/frontend/apps/main/src/components/editor/editorStyles.scss @@ -55,7 +55,8 @@ } } - .ld-mention { + .ld-mention, + .ld-conversation-reference { background-color: hsl(var(--primary) / 0.1); border-radius: 0.25rem; padding: 0 0.25rem; diff --git a/frontend/apps/main/src/components/editor/mentionSuggestion.js b/frontend/apps/main/src/components/editor/mentionSuggestion.js index 2b6bffdc..b0e93750 100644 --- a/frontend/apps/main/src/components/editor/mentionSuggestion.js +++ b/frontend/apps/main/src/components/editor/mentionSuggestion.js @@ -1,111 +1,10 @@ -import { VueRenderer } from '@tiptap/vue-3' import MentionList from './MentionList.vue' +import { createSuggestionRenderer } from './suggestionRenderer' export default { char: '@', allowSpaces: true, - - allow: ({ editor }) => { - const enableMentions = editor.options.editorProps?.enableMentions - return enableMentions ? enableMentions() : false - }, - - items: async ({ query, editor }) => { - // Get the suggestion handler from editor options - const getSuggestions = editor.options.editorProps?.getSuggestions - if (!getSuggestions) return [] - - const items = await getSuggestions(query) - return items - }, - - render: () => { - let component - let popup - - return { - onStart: (props) => { - component = new VueRenderer(MentionList, { - props: { - ...props, - query: props.query - }, - editor: props.editor - }) - - if (!props.clientRect) { - return - } - - // Create popup container with CSS positioning - popup = document.createElement('div') - popup.style.position = 'fixed' - popup.style.zIndex = '9999' - if (component.element) { - popup.appendChild(component.element) - } - document.body.appendChild(popup) - - // Position the popup - updatePosition(popup, props.clientRect) - }, - - onUpdate: (props) => { - component.updateProps({ - ...props, - query: props.query - }) - - if (!props.clientRect || !popup) { - return - } - - updatePosition(popup, props.clientRect) - }, - - onKeyDown: (props) => { - if (props.event.key === 'Escape') { - if (popup) { - popup.style.display = 'none' - } - return true - } - - return component.ref?.onKeyDown(props) - }, - - onExit: () => { - if (popup && popup.parentNode) { - popup.parentNode.removeChild(popup) - } - component.destroy() - } - } - } -} - -function updatePosition(popup, clientRect) { - const rect = clientRect() - if (!rect) return - - // Position below the cursor - popup.style.left = `${rect.left}px` - popup.style.top = `${rect.bottom + 4}px` - - // Ensure popup doesn't go off-screen to the right - requestAnimationFrame(() => { - const popupRect = popup.getBoundingClientRect() - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - - // Adjust horizontal position if needed - if (popupRect.right > viewportWidth) { - popup.style.left = `${viewportWidth - popupRect.width - 8}px` - } - - // If popup goes below viewport, show above cursor - if (popupRect.bottom > viewportHeight) { - popup.style.top = `${rect.top - popupRect.height - 4}px` - } - }) + allow: ({ editor }) => editor.options.editorProps?.enableMentions?.() ?? false, + items: ({ query, editor }) => editor.options.editorProps?.getSuggestions?.(query) ?? [], + render: () => createSuggestionRenderer(MentionList) } diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.js b/frontend/apps/main/src/components/editor/suggestionRenderer.js new file mode 100644 index 00000000..2c9647ae --- /dev/null +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.js @@ -0,0 +1,64 @@ +import { VueRenderer } from '@tiptap/vue-3' + +export function createSuggestionRenderer(ListComponent) { + let component + let popup + let dismissed = false + + return { + onStart: (props) => { + dismissed = false + component = new VueRenderer(ListComponent, { + props: { ...props, query: props.query }, + editor: props.editor + }) + if (!props.clientRect) return + + popup = document.createElement('div') + popup.style.position = 'fixed' + popup.style.zIndex = '9999' + if (component.element) popup.appendChild(component.element) + document.body.appendChild(popup) + updatePosition(popup, props.clientRect) + }, + + onUpdate: (props) => { + component.updateProps({ ...props, query: props.query }) + if (!props.clientRect || !popup) return + updatePosition(popup, props.clientRect) + }, + + onKeyDown: (props) => { + if (dismissed) return false + if (props.event.key === 'Escape') { + dismissed = true + if (popup) popup.style.display = 'none' + return true + } + return component?.ref?.onKeyDown(props) ?? false + }, + + onExit: () => { + popup?.remove() + component.destroy() + } + } +} + +function updatePosition(popup, clientRect) { + const rect = clientRect() + if (!rect) return + + popup.style.left = `${rect.left}px` + popup.style.top = `${rect.bottom + 4}px` + + requestAnimationFrame(() => { + const popupRect = popup.getBoundingClientRect() + if (popupRect.right > window.innerWidth) { + popup.style.left = `${window.innerWidth - popupRect.width - 8}px` + } + if (popupRect.bottom > window.innerHeight) { + popup.style.top = `${rect.top - popupRect.height - 4}px` + } + }) +} diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.test.js b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js new file mode 100644 index 00000000..3ba1649b --- /dev/null +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSuggestionRenderer } from './suggestionRenderer' + +vi.mock('@tiptap/vue-3', () => ({ VueRenderer: vi.fn() })) + +describe('createSuggestionRenderer', () => { + it('ignores key events before the suggestion list starts', () => { + const renderer = createSuggestionRenderer({}) + + expect(renderer.onKeyDown({ event: { key: 'Enter' } })).toBe(false) + }) +}) diff --git a/frontend/apps/main/src/components/editor/useTextEditor.js b/frontend/apps/main/src/components/editor/useTextEditor.js index f2463e3d..d15830ac 100644 --- a/frontend/apps/main/src/components/editor/useTextEditor.js +++ b/frontend/apps/main/src/components/editor/useTextEditor.js @@ -13,6 +13,8 @@ export function useTextEditor({ linkedModel = 'messages', getSuggestions = null, enableMentions = () => false, + getConversationSuggestions = null, + conversationReferencesEnabled = () => false, onSend = () => {}, onToggleMessageType = null, onUpdate = () => {}, @@ -53,6 +55,8 @@ export function useTextEditor({ attributes: { class: 'outline-none' }, getSuggestions, enableMentions, + getConversationSuggestions, + conversationReferencesEnabled, handlePaste, handleDrop, handleKeyDown: (view, event) => { diff --git a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue index 8ea422dd..cbc13e14 100644 --- a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue +++ b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue @@ -62,9 +62,9 @@
@@ -119,6 +119,17 @@
+ +
+ + +
@@ -132,12 +143,15 @@ import { CheckCheck, X, Trash2, + Settings, AtSign, UserPlus, + MessageSquare, + RotateCcw, AlertTriangle, AlertCircle } from 'lucide-vue-next' -import { Button } from '@shared-ui/components/ui/button' +import { Button, buttonVariants } from '@shared-ui/components/ui/button' import { Skeleton } from '@shared-ui/components/ui/skeleton' import { useNotificationStore } from '@main/stores/notification' import { getRelativeTime } from '@shared-ui/utils/datetime.js' @@ -149,24 +163,22 @@ const { t } = useI18n() const notificationStore = useNotificationStore() -const getNotificationIcon = (type) => { - const icons = { - mention: AtSign, - assignment: UserPlus, - sla_warning: AlertTriangle, - sla_breach: AlertCircle - } - return icons[type] || Bell +const notificationStyles = { + mention: { icon: AtSign, class: 'text-primary' }, + assignment: { icon: UserPlus, class: 'text-accent-foreground' }, + new_reply: { icon: MessageSquare, class: 'text-primary' }, + new_reply_participating: { icon: MessageSquare, class: 'text-primary' }, + conversation_reopened: { icon: RotateCcw, class: 'text-accent-foreground' } } -const getNotificationIconClass = (type) => { - const classes = { - mention: 'text-primary', - assignment: 'text-accent-foreground', - sla_warning: 'text-destructive', - sla_breach: 'text-destructive' +const getNotificationStyle = (type) => { + if (type?.startsWith('sla_')) { + return { + icon: type.endsWith('_breach') ? AlertCircle : AlertTriangle, + class: 'text-destructive' + } } - return classes[type] || 'text-muted-foreground' + return notificationStyles[type] || { icon: Bell, class: 'text-muted-foreground' } } const handleNotificationClick = async (notification) => { diff --git a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue index 8f18dc99..717ab1a7 100644 --- a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue @@ -130,6 +130,7 @@ import { useUserStore } from '@main/stores/user' import { useIsMobile } from '@shared-ui/composables' import { useRouter } from 'vue-router' import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog' +import { useLogout } from '@main/composables/useLogout' import { useColorMode } from '@vueuse/core' @@ -139,8 +140,5 @@ const userStore = useUserStore() const router = useRouter() const { t } = useI18n() const shortcutsDialog = useKeyboardShortcutsDialog() - -const logout = () => { - window.location.href = '/logout' -} +const logout = useLogout() diff --git a/frontend/apps/main/src/composables/useLogout.js b/frontend/apps/main/src/composables/useLogout.js new file mode 100644 index 00000000..61f7de9a --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.js @@ -0,0 +1,10 @@ +import { usePushNotifications } from '@main/composables/usePushNotifications' + +const defaultBrowser = typeof window === 'undefined' ? {} : window + +export const createLogout = (pushNotifications, browser = defaultBrowser) => async () => { + await Promise.resolve(pushNotifications.disable()).catch(() => {}) + browser.location.href = '/logout' +} + +export const useLogout = () => createLogout(usePushNotifications()) diff --git a/frontend/apps/main/src/composables/useLogout.test.js b/frontend/apps/main/src/composables/useLogout.test.js new file mode 100644 index 00000000..c229baf9 --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { createLogout } from './useLogout' + +describe('createLogout', () => { + it('clears the push association before logging out', async () => { + const pushNotifications = { disable: vi.fn().mockResolvedValue() } + const browser = { location: { href: '' } } + + await createLogout(pushNotifications, browser)() + + expect(pushNotifications.disable).toHaveBeenCalled() + expect(browser.location.href).toBe('/logout') + }) + + it('logs out when clearing the push association fails', async () => { + const pushNotifications = { disable: vi.fn().mockRejectedValue(new Error('offline')) } + const browser = { location: { href: '' } } + + await createLogout(pushNotifications, browser)() + + expect(browser.location.href).toBe('/logout') + }) +}) diff --git a/frontend/apps/main/src/composables/usePushNotifications.js b/frontend/apps/main/src/composables/usePushNotifications.js new file mode 100644 index 00000000..642c9035 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.js @@ -0,0 +1,82 @@ +import { ref } from 'vue' +import api from '@main/api' + +const defaultBrowser = typeof window === 'undefined' ? {} : window + +export const urlBase64ToUint8Array = (value) => { + const padding = '='.repeat((4 - value.length % 4) % 4) + const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')) + return Uint8Array.from(raw, character => character.charCodeAt(0)) +} + +export const createPushNotifications = (apiClient = api, browser = defaultBrowser) => { + const supported = ref(Boolean(browser.Notification && browser.navigator?.serviceWorker && browser.PushManager)) + const permission = ref(browser.Notification?.permission || 'unsupported') + const enabled = ref(false) + let registration = null + + const registerWorker = async () => { + registration ??= await browser.navigator.serviceWorker.register('/sw.js') + return registration + } + + const existingWorker = async () => { + registration ??= await browser.navigator.serviceWorker.getRegistration('/sw.js') + return registration + } + + const save = async (subscription) => { + const value = subscription.toJSON() + await apiClient.createPushSubscription({ + endpoint: value.endpoint, + p256dh: value.keys.p256dh, + auth: value.keys.auth + }) + } + + const refresh = async (vapidPublicKey, savedEndpoints = []) => { + if (!supported.value || !vapidPublicKey) return + const worker = await registerWorker() + const subscription = await worker.pushManager.getSubscription() + enabled.value = Boolean(subscription) && savedEndpoints.includes(subscription.endpoint) + if (enabled.value) await save(subscription) + } + + const enable = async (vapidPublicKey) => { + if (!supported.value || !vapidPublicKey) return false + permission.value = await browser.Notification.requestPermission() + if (permission.value !== 'granted') return false + await registerWorker() + const worker = await browser.navigator.serviceWorker.ready + const existing = await worker.pushManager.getSubscription() + const subscription = existing || await worker.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) + }) + await save(subscription) + enabled.value = true + return true + } + + const disable = async () => { + if (!supported.value) return + const worker = await existingWorker() + const subscription = await worker?.pushManager.getSubscription() + if (!subscription) { + enabled.value = false + return + } + try { + await apiClient.deletePushSubscription(subscription.endpoint) + } finally { + await subscription.unsubscribe() + enabled.value = false + } + } + + return { supported, permission, enabled, refresh, enable, disable } +} + +const pushNotifications = createPushNotifications() + +export const usePushNotifications = () => pushNotifications diff --git a/frontend/apps/main/src/composables/usePushNotifications.test.js b/frontend/apps/main/src/composables/usePushNotifications.test.js new file mode 100644 index 00000000..827b4761 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.test.js @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPushNotifications } from './usePushNotifications' + +const makeBrowser = ({ permission = 'default', subscription = null } = {}) => { + const pushManager = { + getSubscription: vi.fn().mockResolvedValue(subscription), + subscribe: vi.fn() + } + const registration = { pushManager } + const browser = { + Notification: { + permission, + requestPermission: vi.fn().mockResolvedValue(permission) + }, + navigator: { + serviceWorker: { + register: vi.fn().mockResolvedValue(registration), + getRegistration: vi.fn().mockResolvedValue(registration), + ready: Promise.resolve(registration) + } + }, + PushManager: function PushManager () {} + } + return { browser, registration, pushManager } +} + +const subscription = { + endpoint: 'https://push.example/subscription', + toJSON: () => ({ + endpoint: 'https://push.example/subscription', + keys: { p256dh: 'public-key', auth: 'auth-secret' } + }), + unsubscribe: vi.fn().mockResolvedValue(true) +} + +describe('createPushNotifications', () => { + it('reports unsupported browsers without registering a worker', async () => { + const api = { createPushSubscription: vi.fn() } + const state = createPushNotifications(api, {}) + + await state.refresh('vapid-key') + + expect(state.supported.value).toBe(false) + expect(api.createPushSubscription).not.toHaveBeenCalled() + }) + + it('refreshes an existing subscription without requesting permission', async () => { + const { browser } = makeBrowser({ permission: 'granted', subscription }) + const api = { createPushSubscription: vi.fn().mockResolvedValue({}) } + const state = createPushNotifications(api, browser) + + await state.refresh('vapid-key', [subscription.endpoint]) + + expect(browser.Notification.requestPermission).not.toHaveBeenCalled() + expect(api.createPushSubscription).toHaveBeenCalledWith({ + endpoint: subscription.endpoint, + p256dh: 'public-key', + auth: 'auth-secret' + }) + expect(state.enabled.value).toBe(true) + }) + + it('does not claim a browser subscription the server does not hold for this user', async () => { + const { browser } = makeBrowser({ permission: 'granted', subscription }) + const api = { createPushSubscription: vi.fn().mockResolvedValue({}) } + const state = createPushNotifications(api, browser) + + await state.refresh('vapid-key', ['https://push.example/someone-else']) + + expect(api.createPushSubscription).not.toHaveBeenCalled() + expect(state.enabled.value).toBe(false) + }) + + it('subscribes only after permission is granted', async () => { + const { browser, pushManager } = makeBrowser({ permission: 'default' }) + browser.Notification.requestPermission.mockResolvedValue('granted') + pushManager.subscribe.mockResolvedValue(subscription) + const api = { createPushSubscription: vi.fn().mockResolvedValue({}) } + const state = createPushNotifications(api, browser) + + await state.enable('AQID') + + expect(pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: new Uint8Array([1, 2, 3]) + }) + expect(state.enabled.value).toBe(true) + }) + + it('does not subscribe when permission is denied', async () => { + const { browser, pushManager } = makeBrowser({ permission: 'default' }) + browser.Notification.requestPermission.mockResolvedValue('denied') + const state = createPushNotifications({}, browser) + + await state.enable('AQID') + + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(state.permission.value).toBe('denied') + }) + + it('waits for the first worker to become active before subscribing', async () => { + const { browser, registration, pushManager } = makeBrowser({ permission: 'granted' }) + let resolveReady + browser.navigator.serviceWorker.ready = new Promise(resolve => { + resolveReady = resolve + }) + pushManager.subscribe.mockResolvedValue(subscription) + const api = { createPushSubscription: vi.fn().mockResolvedValue({}) } + const state = createPushNotifications(api, browser) + + const enabling = state.enable('AQID') + await vi.waitFor(() => expect(browser.navigator.serviceWorker.register).toHaveBeenCalledOnce()) + + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(api.createPushSubscription).not.toHaveBeenCalled() + expect(state.enabled.value).toBe(false) + + resolveReady(registration) + await expect(enabling).resolves.toBe(true) + expect(pushManager.subscribe).toHaveBeenCalledOnce() + expect(api.createPushSubscription).toHaveBeenCalledOnce() + expect(state.enabled.value).toBe(true) + }) + + it('unsubscribes the current browser and deletes its endpoint', async () => { + const { browser } = makeBrowser({ permission: 'granted', subscription }) + const api = { + createPushSubscription: vi.fn().mockResolvedValue({}), + deletePushSubscription: vi.fn().mockResolvedValue({}) + } + const state = createPushNotifications(api, browser) + await state.refresh('vapid-key', [subscription.endpoint]) + + await state.disable() + + expect(subscription.unsubscribe).toHaveBeenCalled() + expect(api.deletePushSubscription).toHaveBeenCalledWith(subscription.endpoint) + expect(state.enabled.value).toBe(false) + }) + + it('unsubscribes the browser when clearing the server association fails', async () => { + subscription.unsubscribe.mockClear() + const { browser } = makeBrowser({ permission: 'granted', subscription }) + const api = { deletePushSubscription: vi.fn().mockRejectedValue(new Error('offline')) } + const state = createPushNotifications(api, browser) + + await expect(state.disable()).rejects.toThrow('offline') + + expect(subscription.unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/apps/main/src/constants/navIcons.js b/frontend/apps/main/src/constants/navIcons.js index a74a0868..8c171104 100644 --- a/frontend/apps/main/src/constants/navIcons.js +++ b/frontend/apps/main/src/constants/navIcons.js @@ -1,5 +1,6 @@ import { BarChart3, + Bell, Bot, BookOpen, CircleDot, @@ -23,6 +24,7 @@ import { Timer, UserRound, UsersRound, + WandSparkles, Webhook, Workflow, Wrench, @@ -33,6 +35,7 @@ import WhatsAppIcon from '@main/components/icons/WhatsAppIcon.vue' // Icon names referenced by string in navigation.js. export const navIconMap = { BarChart3, + Bell, Bot, BookOpen, CircleDot, @@ -56,6 +59,7 @@ export const navIconMap = { Timer, UserRound, UsersRound, + WandSparkles, Webhook, Workflow, Wrench, diff --git a/frontend/apps/main/src/constants/navigation.js b/frontend/apps/main/src/constants/navigation.js index 20714d78..027660f1 100644 --- a/frontend/apps/main/src/constants/navigation.js +++ b/frontend/apps/main/src/constants/navigation.js @@ -65,6 +65,13 @@ export const adminNavItems = [ isTitleKeyPlural: true, icon: 'NotebookText' }, + { + titleKey: 'admin.ai.editorPrompts', + href: '/admin/ai/editor-prompts', + permission: 'ai:manage', + isTitleKeyPlural: true, + icon: 'WandSparkles' + }, { titleKey: 'admin.ai.suggestions', href: '/admin/ai/suggestions', @@ -264,6 +271,12 @@ export const accountNavItems = [ titleKey: 'globals.terms.profile', href: '/account/profile', icon: 'CircleUser' + }, + { + titleKey: 'globals.terms.notification', + href: '/account/notifications', + isTitleKeyPlural: true, + icon: 'Bell' } ] diff --git a/frontend/apps/main/src/features/admin/ai/EditorPromptForm.vue b/frontend/apps/main/src/features/admin/ai/EditorPromptForm.vue new file mode 100644 index 00000000..477a30fd --- /dev/null +++ b/frontend/apps/main/src/features/admin/ai/EditorPromptForm.vue @@ -0,0 +1,98 @@ +