From 70e6ebcc4820d8d924ef4f706afb1df018a7a1a0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 26 Jan 2026 02:52:28 +0530 Subject: [PATCH] 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 --- cmd/chat.go | 368 ++++++++++-------- cmd/middlewares.go | 6 +- cmd/widget_middleware.go | 26 +- cmd/widget_ws.go | 19 +- frontend/apps/widget/src/api/index.js | 60 ++- .../widget/src/components/MessageInput.vue | 7 +- .../src/components/MessageInputActions.vue | 6 +- internal/user/queries.sql | 25 +- internal/user/user.go | 18 +- 9 files changed, 334 insertions(+), 201 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 48855058..bd309d54 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -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) diff --git a/cmd/middlewares.go b/cmd/middlewares.go index c016e035..2e0e67f9 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -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) } diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go index 88046b0f..26a5ea23 100644 --- a/cmd/widget_middleware.go +++ b/cmd/widget_middleware.go @@ -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) } } diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go index 13165fab..4ece09e8 100644 --- a/cmd/widget_ws.go +++ b/cmd/widget_ws.go @@ -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 diff --git a/frontend/apps/widget/src/api/index.js b/frontend/apps/widget/src/api/index.js index 62fff15a..9e6a524f 100644 --- a/frontend/apps/widget/src/api/index.js +++ b/frontend/apps/widget/src/api/index.js @@ -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`) diff --git a/frontend/apps/widget/src/components/MessageInput.vue b/frontend/apps/widget/src/components/MessageInput.vue index 3fbcaf92..2a6085ec 100644 --- a/frontend/apps/widget/src/components/MessageInput.vue +++ b/frontend/apps/widget/src/components/MessageInput.vue @@ -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 diff --git a/frontend/apps/widget/src/components/MessageInputActions.vue b/frontend/apps/widget/src/components/MessageInputActions.vue index 72734c20..c86b8b50 100644 --- a/frontend/apps/widget/src/components/MessageInputActions.vue +++ b/frontend/apps/widget/src/components/MessageInputActions.vue @@ -12,7 +12,7 @@