feat(widget): merge visitor to contact when new JWT is available i.e. when user logs into a an external system.

- Prevent file uploads on initial message, user send a message first to enable attachments.
- fix thumbs not loading with signature and expiry
This commit is contained in:
Abhinav Raut
2026-01-26 02:52:28 +05:30
parent 8bdc30e6c1
commit 70e6ebcc48
9 changed files with 334 additions and 201 deletions
+202 -166
View File
@@ -67,50 +67,9 @@ type conversationResponseWithBusinessHours struct {
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
}
// validateLiveChatInbox validates inbox_id from query params and returns the inbox and parsed config.
// Used by public widget endpoints that don't require JWT authentication.
func validateLiveChatInbox(r *fastglue.Request) (imodels.Inbox, livechat.Config, error) {
app := r.Context.(*App)
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if inboxID <= 0 {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return imodels.Inbox{}, livechat.Config{}, sendErrorEnvelope(r, err)
}
if inbox.Channel != livechat.ChannelLiveChat {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
if !inbox.Enabled {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusInternalServerError,
app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"),
nil, envelope.GeneralError)
}
return inbox, config, nil
}
// -----------------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------------
// handleGetChatLauncherSettings returns the live chat launcher settings for the widget
func handleGetChatLauncherSettings(r *fastglue.Request) error {
@@ -206,12 +165,12 @@ func handleChatInit(r *fastglue.Request) error {
contactID int
conversationUUID string
isVisitor bool
config livechat.Config
newJWT string
)
// Parse inbox config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
config, err := parseLiveChatConfig(inbox)
if err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
@@ -230,53 +189,13 @@ func handleChatInit(r *fastglue.Request) error {
}
// User doesn't exist, create new contact
firstName := claims.FirstName
lastName := claims.LastName
email := claims.Email
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
// Marshal custom attributes
customAttribJSON, err := json.Marshal(mergedAttributes)
contactID, err = createExternalUser(app, *claims, req.FormData, config)
if err != nil {
app.lo.Error("error marshalling custom attributes", "error", err)
customAttribJSON = []byte("{}")
}
// Create new contact with external user ID.
var user = umodels.User{
FirstName: firstName,
LastName: lastName,
Email: null.NewString(email, email != ""),
ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""),
CustomAttributes: customAttribJSON,
}
err = app.user.CreateContact(&user)
if err != nil {
app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
contactID = user.ID
} else {
// User exists, update custom attributes from both JWT and form
// Don't override existing name and email.
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
if len(mergedAttributes) > 0 {
if err := app.user.SaveCustomAttributes(user.ID, mergedAttributes, false); err != nil {
app.lo.Error("error updating contact custom attributes", "contact_id", user.ID, "error", err)
// Don't fail the request for custom attributes update failure
}
}
processCustomAttributesForUser(app, user.ID, claims, req.FormData, config)
contactID = user.ID
}
isVisitor = false
@@ -289,96 +208,30 @@ func handleChatInit(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
// Update custom attributes from both JWT and form
if len(mergedAttributes) > 0 {
if err := app.user.SaveCustomAttributes(contactID, mergedAttributes, false); err != nil {
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
// Don't fail the request for custom attributes update failure
}
}
processCustomAttributesForUser(app, contactID, claims, req.FormData, config)
}
} else {
// Visitor user not authenticated, create a new visitor contact.
isVisitor = true
// Validate form data and get final name/email for new visitor
finalName, finalEmail, err := validateFormData(req.FormData, config, nil)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, err.Error(), nil, envelope.InputError)
}
// Process custom attributes from form data
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Marshal custom attributes for storage
var customAttribJSON []byte
if len(formCustomAttributes) > 0 {
customAttribJSON, err = json.Marshal(formCustomAttributes)
if err != nil {
app.lo.Error("error marshalling form custom attributes", "error", err)
customAttribJSON = []byte("{}")
var visitorErr error
contactID, newJWT, visitorErr = createVisitorContact(app, req.FormData, config, inbox)
if visitorErr != nil {
// Check if it's a validation error (from validateFormData)
if strings.Contains(visitorErr.Error(), "required") || strings.Contains(visitorErr.Error(), "invalid") {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, visitorErr.Error(), nil, envelope.InputError)
}
} else {
customAttribJSON = []byte("{}")
}
visitor := umodels.User{
Email: null.NewString(finalEmail, finalEmail != ""),
FirstName: finalName,
CustomAttributes: customAttribJSON,
}
if err := app.user.CreateVisitor(&visitor); err != nil {
app.lo.Error("error creating visitor contact", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
contactID = visitor.ID
secretToUse := []byte(inbox.Secret.String)
newJWT, err = generateUserJWTWithSecret(contactID, isVisitor, time.Now().Add(87600*time.Hour), secretToUse) // 10 years
if err != nil {
app.lo.Error("error generating visitor JWT", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
}
// Check conversation permissions based on user type.
var allowStartConversation, preventMultipleConversations bool
if isVisitor {
allowStartConversation = config.Visitors.AllowStartConversation
preventMultipleConversations = config.Visitors.PreventMultipleConversations
} else {
allowStartConversation = config.Users.AllowStartConversation
preventMultipleConversations = config.Users.PreventMultipleConversations
}
if !allowStartConversation {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
}
if preventMultipleConversations {
conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID)
if err != nil {
userType := "visitor"
if !isVisitor {
userType = "user"
}
app.lo.Error("error fetching "+userType+" conversations", "contact_id", contactID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
}
if len(conversations) > 0 {
userType := "visitor"
if !isVisitor {
userType = "user"
}
app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
if err := checkConversationPermissions(app, config, isVisitor, contactID, inboxID); err != nil {
if strings.Contains(err.Error(), "not allowed") || strings.Contains(err.Error(), "multiple conversations") {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
}
app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", inboxID, "is_visitor", isVisitor)
@@ -716,8 +569,8 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
}
// Make sure file upload is enabled for the inbox.
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
config, err := parseLiveChatConfig(inbox)
if err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
@@ -807,6 +660,189 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
return r.SendEnvelope(insertedMessage)
}
// -----------------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------------
// parseLiveChatConfig parses the livechat.Config from an inbox's Config JSON.
func parseLiveChatConfig(inbox imodels.Inbox) (livechat.Config, error) {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
return livechat.Config{}, fmt.Errorf("error parsing live chat config: %w", err)
}
return config, nil
}
// userTypeLabel returns "visitor" or "user" based on the isVisitor flag.
func userTypeLabel(isVisitor bool) string {
if isVisitor {
return "visitor"
}
return "user"
}
// validateLiveChatInbox validates inbox_id from query params and returns the inbox and parsed config.
// Used by public widget endpoints that don't require JWT authentication.
func validateLiveChatInbox(r *fastglue.Request) (imodels.Inbox, livechat.Config, error) {
app := r.Context.(*App)
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if inboxID <= 0 {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return imodels.Inbox{}, livechat.Config{}, sendErrorEnvelope(r, err)
}
if inbox.Channel != livechat.ChannelLiveChat {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
if !inbox.Enabled {
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusBadRequest,
app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"),
nil, envelope.InputError)
}
config, err := parseLiveChatConfig(inbox)
if err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
fasthttp.StatusInternalServerError,
app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"),
nil, envelope.GeneralError)
}
return inbox, config, nil
}
// processCustomAttributesForUser validates and saves custom attributes from JWT and form data for an existing user.
func processCustomAttributesForUser(app *App, contactID int, claims *Claims, formData map[string]any, config livechat.Config) {
formCustomAttributes := validateCustomAttributes(formData, config, app)
var jwtAttribs map[string]any
if claims != nil {
jwtAttribs = claims.CustomAttributes
}
mergedAttributes := mergeCustomAttributes(jwtAttribs, formCustomAttributes)
if len(mergedAttributes) > 0 {
if err := app.user.SaveCustomAttributes(contactID, mergedAttributes, false); err != nil {
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
// Don't fail the request for custom attributes update failure
}
}
}
// createExternalUser creates a new contact from JWT claims with external user ID.
// Returns the new contact ID.
func createExternalUser(app *App, claims Claims, formData map[string]any, config livechat.Config) (int, error) {
formCustomAttributes := validateCustomAttributes(formData, config, app)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
customAttribJSON, err := json.Marshal(mergedAttributes)
if err != nil {
app.lo.Error("error marshalling custom attributes", "error", err)
customAttribJSON = []byte("{}")
}
user := umodels.User{
FirstName: claims.FirstName,
LastName: claims.LastName,
Email: null.NewString(claims.Email, claims.Email != ""),
ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""),
CustomAttributes: customAttribJSON,
}
if err := app.user.CreateContact(&user); err != nil {
app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err)
return 0, err
}
return user.ID, nil
}
// createVisitorContact creates a new visitor contact from form data.
// Returns the contact ID and a new JWT for the visitor.
func createVisitorContact(app *App, formData map[string]any, config livechat.Config, inbox imodels.Inbox) (contactID int, jwt string, err error) {
// Validate form data and get final name/email for new visitor
finalName, finalEmail, err := validateFormData(formData, config, nil)
if err != nil {
return 0, "", err
}
// Process custom attributes from form data
formCustomAttributes := validateCustomAttributes(formData, config, app)
var customAttribJSON []byte
if len(formCustomAttributes) > 0 {
customAttribJSON, err = json.Marshal(formCustomAttributes)
if err != nil {
app.lo.Error("error marshalling form custom attributes", "error", err)
customAttribJSON = []byte("{}")
}
} else {
customAttribJSON = []byte("{}")
}
visitor := umodels.User{
Email: null.NewString(finalEmail, finalEmail != ""),
FirstName: finalName,
CustomAttributes: customAttribJSON,
}
if err := app.user.CreateVisitor(&visitor); err != nil {
app.lo.Error("error creating visitor contact", "error", err)
return 0, "", err
}
newJWT, err := generateUserJWTWithSecret(visitor.ID, true, time.Now().Add(87600*time.Hour), []byte(inbox.Secret.String)) // 10 years
if err != nil {
app.lo.Error("error generating visitor JWT", "error", err)
return 0, "", err
}
return visitor.ID, newJWT, nil
}
// checkConversationPermissions checks if the user is allowed to start a conversation based on inbox config.
// Returns an error if the user is not allowed to start a conversation.
func checkConversationPermissions(app *App, config livechat.Config, isVisitor bool, contactID, inboxID int) error {
var allowStartConversation, preventMultipleConversations bool
if isVisitor {
allowStartConversation = config.Visitors.AllowStartConversation
preventMultipleConversations = config.Visitors.PreventMultipleConversations
} else {
allowStartConversation = config.Users.AllowStartConversation
preventMultipleConversations = config.Users.PreventMultipleConversations
}
if !allowStartConversation {
return fmt.Errorf("not allowed to start conversation")
}
if preventMultipleConversations {
conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID)
if err != nil {
app.lo.Error("error fetching "+userTypeLabel(isVisitor)+" conversations", "contact_id", contactID, "error", err)
return fmt.Errorf("error checking existing conversations: %w", err)
}
if len(conversations) > 0 {
app.lo.Info(userTypeLabel(isVisitor)+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
return fmt.Errorf("multiple conversations not allowed")
}
}
return nil
}
// buildConversationResponseWithBusinessHours builds conversation response with business hours info
func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) {
widgetResp, err := app.conversation.BuildWidgetConversationResponse(conversation, true)
+5 -1
View File
@@ -7,6 +7,7 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/image"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -268,8 +269,11 @@ func authOrSignedURL(handler fastglue.FastRequestHandler) fastglue.FastRequestHa
// Get the UUID from the route.
uuid := r.RequestCtx.UserValue("uuid").(string)
// Strip thumb prefix for signature validation (thumbnails use the same signature as the original).
signatureUUID := strings.TrimPrefix(uuid, image.ThumbPrefix)
// Validate signature.
if !validator(uuid, sig, exp) {
if !validator(signatureUUID, sig, exp) {
return r.SendErrorEnvelope(http.StatusForbidden,
app.i18n.T("media.invalidOrExpiredURL"), nil, envelope.PermissionError)
}
+21 -5
View File
@@ -19,8 +19,9 @@ const (
ctxWidgetContactID = "widget_contact_id"
ctxWidgetInbox = "widget_inbox"
// Header sent in every widget request to identify the inbox
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
hdrWidgetVisitorJWT = "X-Libredesk-Visitor-JWT"
hdrClearVisitorJWT = "X-Libredesk-Clear-Visitor"
)
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
@@ -28,9 +29,7 @@ const (
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
app := r.Context.(*App)
// Always extract and validate inbox_id from custom header
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
@@ -98,6 +97,23 @@ func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) erro
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
// Merge visitor to contact if visitor JWT is provided.
visitorJWT := string(r.RequestCtx.Request.Header.Peek(hdrWidgetVisitorJWT))
if visitorJWT != "" && claims.ExternalUserID != "" && contactID > 0 {
visitorClaims, err := verifyStandardJWT(visitorJWT, inbox.Secret.String)
if err == nil && visitorClaims.IsVisitor && visitorClaims.UserID > 0 {
visitorID := visitorClaims.UserID
if visitorID != contactID {
if err := app.user.MergeVisitorToContact(visitorID, contactID); err != nil {
app.lo.Error("error merging visitor to contact", "visitor_id", visitorID, "contact_id", contactID, "error", err)
} else {
app.lo.Info("merged visitor to contact", "visitor_id", visitorID, "contact_id", contactID)
r.RequestCtx.Response.Header.Set(hdrClearVisitorJWT, "true")
}
}
}
}
return next(r)
}
}
+2 -17
View File
@@ -247,33 +247,18 @@ func handleWidgetTyping(app *App, msg *WidgetMessage) error {
return nil
}
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret.
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
if jwtToken == "" {
return Claims{}, fmt.Errorf("JWT token is empty")
}
if inboxID <= 0 {
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
}
// Get inbox to retrieve secret for JWT verification
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return Claims{}, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Secret.Valid {
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
}
// Use the existing verifyStandardJWT function which properly validates with inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
}
return claims, nil
return verifyStandardJWT(jwtToken, inbox.Secret.String)
}
// sendWidgetError sends an error message to the widget client
+57 -3
View File
@@ -1,4 +1,7 @@
import axios from 'axios'
import { parseJWT } from '@shared-ui/utils/string'
const VISITOR_JWT_KEY = 'libredesk_visitor_jwt'
function getInboxIDFromQuery () {
const params = new URLSearchParams(window.location.search)
@@ -6,6 +9,35 @@ function getInboxIDFromQuery () {
return inboxId ? parseInt(inboxId, 10) : null
}
export function setVisitorJWT (jwt) {
localStorage.setItem(VISITOR_JWT_KEY, jwt)
}
export function clearVisitorJWT () {
localStorage.removeItem(VISITOR_JWT_KEY)
}
export function getVisitorJWT () {
return localStorage.getItem(VISITOR_JWT_KEY)
}
// Returns visitor JWT if current user is authenticated (for merge).
function getVisitorJWTForMerge (sessionToken) {
const visitorJWT = getVisitorJWT()
if (!visitorJWT || !sessionToken) {
return null
}
try {
const claims = parseJWT(sessionToken)
if (claims && !claims.is_visitor && claims.external_user_id) {
return visitorJWT
}
} catch {
// Ignore JWT parse errors
}
return null
}
const http = axios.create({
timeout: 10000,
responseType: 'json'
@@ -21,21 +53,33 @@ http.interceptors.request.use((request) => {
if (request.url && request.url.includes('/api/v1/widget/')) {
const libredeskSession = localStorage.getItem('libredesk_session')
const inboxId = getInboxIDFromQuery()
// Add JWT to Authorization header
if (libredeskSession) {
request.headers['Authorization'] = `Bearer ${libredeskSession}`
}
// Add inbox ID to custom header
if (inboxId) {
request.headers['X-Libredesk-Inbox-ID'] = inboxId.toString()
}
const visitorJWTForMerge = getVisitorJWTForMerge(libredeskSession)
if (visitorJWTForMerge) {
request.headers['X-Libredesk-Visitor-JWT'] = visitorJWTForMerge
}
}
return request
})
http.interceptors.response.use((response) => {
if (response.headers['x-libredesk-clear-visitor']) {
clearVisitorJWT()
}
return response
})
const getWidgetSettings = (inboxID) => http.get('/api/v1/widget/chat/settings', {
params: { inbox_id: inboxID }
})
@@ -47,7 +91,7 @@ const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversat
const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`)
const uploadMedia = (conversationUUID, files) => {
const formData = new FormData()
// Only add conversation UUID to form data now
formData.append('conversation_uuid', conversationUUID)
@@ -72,9 +116,19 @@ const uploadMedia = (conversationUUID, files) => {
headers['X-Libredesk-Inbox-ID'] = inboxId.toString()
}
const visitorJWTForMerge = getVisitorJWTForMerge(libredeskSession)
if (visitorJWTForMerge) {
headers['X-Libredesk-Visitor-JWT'] = visitorJWTForMerge
}
return axios.post('/api/v1/widget/media/upload', formData, {
headers,
timeout: 30000
}).then((response) => {
if (response.headers['x-libredesk-clear-visitor']) {
clearVisitorJWT()
}
return response
})
}
const updateConversationLastSeen = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/update-last-seen`)
@@ -23,6 +23,7 @@
:fileUploadEnabled="config.features?.file_upload || false"
:emojiEnabled="config.features?.emoji || false"
:uploading="isUploading"
:canUploadFiles="!!chatStore.currentConversation?.uuid"
@fileUpload="handleFileUpload"
@emojiSelect="handleEmojiSelect"
/>
@@ -59,7 +60,7 @@ import { sendWidgetTyping } from '../websocket.js'
import { convertTextToHtml } from '@shared-ui/utils/string.js'
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
import MessageInputActions from './MessageInputActions.vue'
import api from '@widget/api/index.js'
import api, { setVisitorJWT } from '@widget/api/index.js'
const props = defineProps({
formData: {
@@ -98,9 +99,9 @@ const initChatConversation = async (messageText) => {
const resp = await api.initChatConversation(payload)
const { conversation, jwt, messages } = resp.data.data
// Set user session token if not already set.
if (!userStore.userSessionToken) {
if (!userStore.userSessionToken && jwt) {
userStore.setSessionToken(jwt)
setVisitorJWT(jwt)
}
// Add the new conversation to the list
@@ -12,7 +12,7 @@
<!-- File Upload Button -->
<Button
v-if="fileUploadEnabled"
v-if="fileUploadEnabled && canUploadFiles"
@click="triggerFileUpload"
:disabled="uploading"
variant="ghost"
@@ -67,6 +67,10 @@ const props = defineProps({
uploading: {
type: Boolean,
default: false
},
canUploadFiles: {
type: Boolean,
default: true
}
})
+24 -1
View File
@@ -320,6 +320,29 @@ FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
WHERE u.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND u.external_user_id = $1
GROUP BY u.id;
-- name: merge-visitor-to-contact
WITH transfer_conversations AS (
UPDATE conversations
SET contact_id = $2, updated_at = now()
WHERE contact_id = $1
RETURNING id
),
transfer_messages AS (
UPDATE conversation_messages
SET sender_id = $2
WHERE sender_id = $1 AND sender_type = 'contact'
RETURNING id
),
delete_visitor AS (
DELETE FROM users
WHERE id = $1 AND type = 'visitor'
RETURNING id
)
SELECT
(SELECT COUNT(*) FROM transfer_conversations) as conversations_transferred,
(SELECT COUNT(*) FROM transfer_messages) as messages_transferred,
(SELECT COUNT(*) FROM delete_visitor) as visitor_deleted;
+14 -4
View File
@@ -87,10 +87,11 @@ type queries struct {
InsertVisitor *sqlx.Stmt `query:"insert-visitor"`
ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
// API key queries
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
SetAPIKey *sqlx.Stmt `query:"set-api-key"`
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
SetAPIKey *sqlx.Stmt `query:"set-api-key"`
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
MergeVisitorToContact *sqlx.Stmt `query:"merge-visitor-to-contact"`
}
// New creates and returns a new instance of the Manager.
@@ -356,6 +357,15 @@ func (u *Manager) RevokeAPIKey(userID int) error {
return nil
}
// MergeVisitorToContact transfers conversations from visitor to contact and deletes the visitor.
func (u *Manager) MergeVisitorToContact(visitorID, contactID int) error {
if _, err := u.q.MergeVisitorToContact.Exec(visitorID, contactID); err != nil {
u.lo.Error("error merging visitor to contact", "visitor_id", visitorID, "contact_id", contactID, "error", err)
return fmt.Errorf("merging visitor to contact: %w", err)
}
return nil
}
// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
// Prompt for password and get hashed password