Compare commits

...

3 Commits

Author SHA1 Message Date
Abhinav Raut
7217086f3f add instructions for authenticated users for live chat widget 2025-10-05 15:28:03 +05:30
Abhinav Raut
98b3b54b6f Add installation tab to live chat form for copying widget js snippet
New common CopyButton component for copying text to clipboard
2025-10-05 14:25:41 +05:30
Abhinav Raut
aae8d1f793 Consider visitors and contacts same, as the only difference is that visitors are not authenticated.
Show a warning in sidebar when a conversation with a visitor is opened.

Move string.js utils to shared-ui utils

Modify user fetch queries to allow optional filter by multiple user types not just one.

Fix capitalizaiton for live chat en translations
2025-10-05 12:28:47 +05:30
42 changed files with 431 additions and 220 deletions

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"maps"
"math"
@@ -613,7 +614,7 @@ func handleChatSendMessage(r *fastglue.Request) error {
}
// Fetch sender.
sender, err := app.user.Get(senderID, "", "")
sender, err := app.user.Get(senderID, "", []string{})
if err != nil {
app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
@@ -777,7 +778,7 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
}
// Get sender user for ProcessIncomingMessage
sender, err := app.user.Get(senderID, "", "")
sender, err := app.user.Get(senderID, "", []string{})
if err != nil {
app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
@@ -847,22 +848,30 @@ func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.C
// resolveUserIDFromClaims resolves the actual user ID from JWT claims,
// handling both regular user_id and external_user_id cases
func resolveUserIDFromClaims(app *App, claims Claims) (int, error) {
// If UserID is already set and valid, use it directly
if claims.UserID > 0 {
return claims.UserID, nil
}
// If UserID is not set but ExternalUserID is available, resolve it
if claims.ExternalUserID != "" {
user, err := app.user.Get(claims.UserID, "", []string{})
if err != nil {
app.lo.Error("error fetching user by user ID", "user_id", claims.UserID, "error", err)
return 0, errors.New("error fetching user")
}
if !user.Enabled {
return 0, errors.New("user is disabled")
}
return user.ID, nil
} else if claims.ExternalUserID != "" {
user, err := app.user.GetByExternalID(claims.ExternalUserID)
if err != nil {
app.lo.Error("error fetching user by external ID", "external_user_id", claims.ExternalUserID, "error", err)
return 0, fmt.Errorf("user not found for external_user_id %s: %w", claims.ExternalUserID, err)
return 0, errors.New("error fetching user")
}
if !user.Enabled {
return 0, errors.New("user is disabled")
}
return user.ID, nil
}
return 0, fmt.Errorf("no valid user ID found in JWT claims")
return 0, errors.New("error fetching user")
}
// verifyJWT verifies and validates a JWT token with proper signature verification

View File

@@ -276,11 +276,16 @@ func handleBlockContact(r *fastglue.Request) error {
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
contact, err := app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
contact, err := app.user.GetContact(contactID, "")
if err := app.user.ToggleEnabled(contactID, contact.Type, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
contact, err = app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -26,10 +26,6 @@ const (
maxAvatarSizeMB = 2
)
type updateAvailabilityRequest struct {
Status string `json:"status"`
}
type resetPasswordRequest struct {
Email string `json:"email"`
}

View File

@@ -0,0 +1,73 @@
<template>
<Button :variant="variant" :size="size" type="button" @click="handleCopy" :class="buttonClass">
<Copy v-if="!copied" class="w-4 h-4" />
<Check v-else class="w-4 h-4 text-green-500" />
<span v-if="showText">
{{ copied ? copiedText : copyText }}
</span>
</Button>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Copy, Check } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const props = defineProps({
textToCopy: {
type: String,
required: true
},
variant: {
type: String,
default: 'secondary'
},
size: {
type: String,
default: 'sm'
},
showText: {
type: Boolean,
default: true
},
copyText: {
type: String,
default: null
},
copiedText: {
type: String,
default: null
},
resetDelay: {
type: Number,
default: 2000
},
class: {
type: String,
default: ''
}
})
const { t } = useI18n()
const copied = ref(false)
const buttonClass = computed(() => props.class)
const copyText = computed(() => props.copyText || t('globals.terms.copy'))
const copiedText = computed(() => props.copiedText || t('globals.terms.copied'))
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(props.textToCopy)
copied.value = true
if (props.resetDelay > 0) {
setTimeout(() => {
copied.value = false
}, props.resetDelay)
}
} catch (err) {
console.error('Failed to copy:', err)
}
}
</script>

View File

@@ -1,18 +1,20 @@
<template>
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
<div ref="codeEditor" @click="editorView?.focus()" :class="readOnly ? 'w-full border rounded-md' : 'w-full h-[28rem] border rounded-md'" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { html } from '@codemirror/lang-html'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: Boolean
disabled: Boolean,
readOnly: Boolean
})
const emit = defineEmits(['update:modelValue'])
@@ -22,33 +24,37 @@ const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark'
const langExtension = props.language === 'javascript' ? javascript() : html()
const isEditable = !props.disabled && !props.readOnly
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
html(),
langExtension,
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.editable.of(isEditable),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'&': { height: props.readOnly ? 'auto' : '100%' },
'.cm-editor': { height: props.readOnly ? 'auto' : '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
if (!update.docChanged || props.readOnly) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
nextTick(() => {
editorView?.focus()
})
if (!props.readOnly) {
nextTick(() => {
editorView?.focus()
})
}
}
onMounted(() => {

View File

@@ -132,28 +132,24 @@
<Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
<Button
type="button"
<CopyButton
:text-to-copy="newAPIKeyData.api_key"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_key)"
>
<Copy class="w-4 h-4" />
</Button>
:show-text="false"
/>
</div>
</div>
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
<Button
type="button"
<CopyButton
:text-to-copy="newAPIKeyData.api_secret"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_secret)"
>
<Copy class="w-4 h-4" />
</Button>
:show-text="false"
/>
</div>
</div>
<Alert>
@@ -312,8 +308,15 @@ import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, AlertTriangle } from 'lucide-vue-next'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form/index.js'
import CopyButton from '@/components/button/CopyButton.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
@@ -493,17 +496,6 @@ const revokeAPIKey = async () => {
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.copied')
})
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const closeAPIKeyModal = () => {
showAPIKeyDialog.value = false
newAPIKeyData.value = { api_key: '', api_secret: '' }

View File

@@ -100,7 +100,7 @@ import {
} from '@shared-ui/components/ui/select'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import { getTextFromHTML } from '../../../utils/strings.js'
import { getTextFromHTML } from '@shared-ui/utils/string'
import { useI18n } from 'vue-i18n'
import Editor from '@main/components/editor/TextEditor.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'

View File

@@ -2,16 +2,15 @@
<form @submit="onSubmit" class="space-y-6 w-full">
<!-- Main Tabs -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2 h-auto">
<TabsList class="flex flex-wrap gap-1 h-auto p-1 w-fit">
<TabsTrigger value="general">{{ $t('admin.inbox.livechat.tabs.general') }}</TabsTrigger>
<TabsTrigger value="appearance">{{
$t('admin.inbox.livechat.tabs.appearance')
}}</TabsTrigger>
<TabsTrigger value="appearance">{{ $t('admin.inbox.livechat.tabs.appearance') }}</TabsTrigger>
<TabsTrigger value="messages">{{ $t('admin.inbox.livechat.tabs.messages') }}</TabsTrigger>
<TabsTrigger value="features">{{ $t('admin.inbox.livechat.tabs.features') }}</TabsTrigger>
<TabsTrigger value="security">{{ $t('admin.inbox.livechat.tabs.security') }}</TabsTrigger>
<TabsTrigger value="prechat">{{ $t('admin.inbox.livechat.tabs.prechat') }}</TabsTrigger>
<TabsTrigger value="users">{{ $t('admin.inbox.livechat.tabs.users') }}</TabsTrigger>
<TabsTrigger value="security">{{ $t('admin.inbox.livechat.tabs.security') }}</TabsTrigger>
<TabsTrigger value="installation">{{ $t('admin.inbox.livechat.tabs.installation') }}</TabsTrigger>
</TabsList>
<div class="mt-8">
@@ -677,6 +676,54 @@
</div>
</Tabs>
</div>
<!-- Installation Tab -->
<div v-show="activeTab === 'installation'" class="space-y-6">
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.installation.instructions.title') }}
</h4>
<ol class="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li>{{ $t('admin.inbox.livechat.installation.instructions.step1') }}</li>
<li>{{ $t('admin.inbox.livechat.installation.instructions.step2') }}</li>
</ol>
</div>
<!-- Basic Installation -->
<div class="relative">
<CodeEditor :modelValue="integrationSnippet" language="html" :readOnly="true" />
<CopyButton :text-to-copy="integrationSnippet" class="absolute top-3 right-3" />
</div>
<!-- Authenticated Users Section -->
<div class="space-y-4 pt-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.installation.authenticated.title') }}
</h4>
<p class="text-sm text-muted-foreground">
{{ $t('admin.inbox.livechat.installation.authenticated.jwt.description') }}
</p>
<div class="relative">
<CodeEditor :modelValue="jwtPayloadExample" language="javascript" :readOnly="true" />
<CopyButton :text-to-copy="jwtPayloadExample" class="absolute top-3 right-3" />
</div>
<p class="text-sm text-muted-foreground">
{{ $t('admin.inbox.livechat.installation.authenticated.implementation.description') }}
</p>
<div class="relative">
<CodeEditor :modelValue="authenticatedIntegrationSnippet" language="html" :readOnly="true" />
<CopyButton :text-to-copy="authenticatedIntegrationSnippet" class="absolute top-3 right-3" />
</div>
<p class="text-sm text-warning">
{{ $t('admin.inbox.livechat.installation.authenticated.secret.note') }}
</p>
</div>
</div>
</div>
</Tabs>
@@ -715,6 +762,9 @@ import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import PreChatFormConfig from './PreChatFormConfig.vue'
import { useAppSettingsStore } from '@/stores/appSettings'
import CopyButton from '@/components/button/CopyButton.vue'
import CodeEditor from '@/components/editor/CodeEditor.vue'
const props = defineProps({
initialValues: {
@@ -742,10 +792,62 @@ const externalLinks = ref([])
const prechatConfig = ref({})
const inboxStore = useInboxStore()
const appSettingsStore = useAppSettingsStore()
const emailInboxes = computed(() =>
inboxStore.inboxes.filter((inbox) => inbox.channel === 'email' && inbox.enabled)
)
// Get base URL from app settings
const baseUrl = computed(() => {
return appSettingsStore.settings?.['app.root_url'] || window.location.origin
})
// Generate integration snippet
const integrationSnippet = computed(() => {
const inboxId = props.initialValues?.id || '<INBOX_ID>'
return `<script src="${baseUrl.value}/widget.js"><\/script>
<script>
// Initialize the Libredesk widget
const widget = initLibredeskWidget({
baseUrl: '${baseUrl.value}',
inboxID: ${inboxId}
});
<\/script>`
})
// JWT payload example
const jwtPayloadExample = computed(() => {
return `{
"external_user_id": "your_app_user_123", // Required: Your application's unique user ID
"email": "user@example.com", // Optional: User's email
"first_name": "John", // Optional: User's first name
"last_name": "Doe", // Optional: User's last name
"custom_attributes": { // Optional: Custom attributes
"plan": "premium",
"company": "Acme Inc"
}
}`
})
// Authenticated integration snippet
const authenticatedIntegrationSnippet = computed(() => {
const inboxId = props.initialValues?.id || '<INBOX_ID>'
return `<script src="${baseUrl.value}/widget.js"><\/script>
<script>
// Your server should generate this JWT token
// Sign it with the secret key from the Security tab
const userJWT = 'YOUR_SIGNED_JWT_TOKEN_HERE';
// Initialize the Libredesk widget with authentication
const widget = initLibredeskWidget({
baseUrl: '${baseUrl.value}',
inboxID: ${inboxId},
libredesk_user_jwt: userJWT // Pass the signed JWT token
});
<\/script>`
})
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
@@ -845,9 +947,10 @@ const updateExternalLinks = () => {
form.setFieldValue('config.external_links', externalLinks.value)
}
// Fetch inboxes on mount for the linked email inbox dropdown
// Fetch inboxes and app settings on mount
onMounted(() => {
inboxStore.fetchInboxes()
appSettingsStore.fetchPublicConfig()
})
const onSubmit = form.handleSubmit(async (values) => {

View File

@@ -1,5 +1,5 @@
import * as z from 'zod'
import { isGoDuration } from '../../../utils/strings'
import { isGoDuration } from '@shared-ui/utils/string'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),

View File

@@ -158,7 +158,7 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '../../../composables/useConversationFilters.js'
import { useUsersStore } from '../../../stores/users.js'
import { useTeamStore } from '../../../stores/team.js'
import { getTextFromHTML } from '../../../utils/strings.js'
import { getTextFromHTML } from '@shared-ui/utils/string'
import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import {

View File

@@ -1,5 +1,5 @@
import * as z from 'zod'
import { getTextFromHTML } from '../../../utils/strings.js'
import { getTextFromHTML } from '@shared-ui/utils/string'
const actionSchema = () => z.array(
z.object({

View File

@@ -1,5 +1,5 @@
import * as z from 'zod';
import { isGoDuration } from '../../../utils/strings';
import { isGoDuration } from '@shared-ui/utils/string';
export const createFormSchema = (t) => z.object({
enabled: z.boolean().default(false),

View File

@@ -1,5 +1,5 @@
import * as z from 'zod'
import { isGoHourMinuteDuration } from '../../../utils/strings'
import { isGoHourMinuteDuration } from '@shared-ui/utils/string'
export const createFormSchema = (t) =>
z

View File

@@ -153,7 +153,7 @@ import { useI18n } from 'vue-i18n'
import { useEmitter } from '../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../constants/emitterEvents.js'
import { handleHTTPError } from '../../utils/http'
import { getInitials } from '../../utils/strings'
import { getInitials } from '@shared-ui/utils/string'
import { useUserStore } from '../../stores/user'
import api from '../../api'

View File

@@ -73,10 +73,15 @@
</AvatarFallback>
</Avatar>
<div class="space-y-1 overflow-hidden">
<h4 class="text-sm font-semibold truncate">
{{ contact.first_name }} {{ contact.last_name }}
</h4>
<div class="space-y-1 overflow-hidden flex-1">
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold truncate">
{{ contact.first_name }} {{ contact.last_name }}
</h4>
<Badge v-if="contact.type" variant="secondary" class="text-xs px-1.5 py-0">
{{ contact.type.titleCase() }}
</Badge>
</div>
<p class="text-xs text-muted-foreground truncate">
{{ contact.email }}
</p>
@@ -163,6 +168,7 @@ import { ref, computed, onMounted } from 'vue'
import { Card } from '@shared-ui/components/ui/card'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Pagination,
PaginationEllipsis,

View File

@@ -142,7 +142,7 @@ import AttachmentsPreview from '@/features/conversation/message/attachment/Attac
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { useI18n } from 'vue-i18n'
import { validateEmail } from '../../utils/strings'
import { validateEmail } from '@shared-ui/utils/string'
import { useMacroStore } from '../../stores/macro'
const messageType = defineModel('messageType', { default: 'reply' })

View File

@@ -88,7 +88,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/u
import { Spinner } from '@shared-ui/components/ui/spinner'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { useConversationStore } from '@/stores/conversation'
import { revertCIDToImageSrc } from '@/utils/strings'
import { revertCIDToImageSrc } from '@shared-ui/utils/string'
import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
import MessageEnvelope from './MessageEnvelope.vue'

View File

@@ -64,6 +64,13 @@
{{ conversation.contact.external_user_id }}
</span>
</div>
<div
v-if="conversation?.contact?.type === 'visitor'"
class="text-sm text-amber-600 flex gap-2 items-center bg-amber-50 dark:bg-amber-900/20 p-2 rounded"
>
<AlertCircle size="16" class="flex-shrink-0" />
<span>{{ t('contact.identityNotVerified') }} ({{ t('globals.terms.visitor', 1) }})</span>
</div>
</div>
</template>
@@ -72,7 +79,7 @@ import { computed } from 'vue'
import { ViewVerticalIcon } from '@radix-icons/vue'
import { Button } from '@shared-ui/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
import { Mail, Phone, ExternalLink, AlertCircle, IdCard } from 'lucide-vue-next'
import countries from '@/constants/countries.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex justify-between h-full">
<div class="w-full lg:w-8/12">
<div class="w-full xl:w-8/12 pr-6">
<slot name="content" />
</div>
<div class="hidden lg:block rounded w-3/12 p-2 space-y-2 self-start">

View File

@@ -6,7 +6,7 @@ import router from './router'
import mitt from 'mitt'
import api from './api'
import '@shared-ui/assets/styles/main.scss'
import './utils/strings.js'
import '@shared-ui/utils/string'
import VueDOMPurifyHTML from 'vue-dompurify-html'
import Root from './Root.vue'

View File

@@ -1,67 +0,0 @@
// Adds titleCase property to string.
String.prototype.titleCase = function () {
return this.toLowerCase()
.split(' ')
.map(function (word) {
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
}
/**
* Replaces the `src` attribute of all <img> tags with the class `inline-image`
* to use the value of the `title` attribute as a Content-ID (cid).
* The resulting `src` will be in the format `cid:content_id`
*
* @param {string} htmlString - The input HTML string.
* @returns {string} - The updated HTML string with `src` replaced by `cid:title`.
*/
export function transformImageSrcToCID (htmlString) {
return htmlString.replace(/(<img\s+class="inline-image"[^>]*?src=")[^"]*(".*?title=")([^"]*)("[^>]*?>)/g, '$1cid:$3$2$3$4');
}
/**
* Reverts the `src` attribute of all <img> tags with the class `inline-image`
* from the `cid:filename` format to `/uploads/filename`, where the filename is stored in the `title` attribute.
*
* @param {string} htmlString - The input HTML string.
* @returns {string} - The updated HTML string with `cid:title` replaced by `/uploads/title`.
*/
export function revertCIDToImageSrc (htmlString) {
return htmlString.replace(/(<img\s+class="inline-image"[^>]*?src=")cid:([^"]*)(".*?title=")\2("[^>]*?>)/g, '$1/uploads/$2$3$2$4');
}
export function validateEmail (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export const isGoDuration = (value) => {
if (value === '') return false
const regex = /^(\d+h)?(\d+m)?(\d+s)?$/
return regex.test(value)
}
export const isGoHourMinuteDuration = (value) => {
const regex = /^([0-9]+h|[0-9]+m)$/
return regex.test(value)
}
const template = document.createElement('template')
export function getTextFromHTML (htmlString) {
try {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text.trim()
} catch (error) {
console.error('Error converting HTML to text:', error)
return ''
}
}
export function getInitials (firstName = '', lastName = '') {
const firstInitial = firstName.charAt(0).toUpperCase() || ''
const lastInitial = lastName.charAt(0).toUpperCase() || ''
return `${firstInitial}${lastInitial}`
}

View File

@@ -5,8 +5,14 @@
</template>
<template #help>
<p>Add email inboxes by configuring IMAP and SMTP settings.</p>
<p>Each added inbox creates a new email channel for receiving customer emails.</p>
<div class="flex flex-col justify-evenly h-full gap-4">
<p class="font-medium text-2xl">{{ $t('admin.inbox.help.title') }}</p>
<p>{{ $t('admin.inbox.help.description') }}</p>
<ul class="list-disc list-inside space-y-2">
<li>{{ $t('admin.inbox.help.email') }}</li>
<li>{{ $t('admin.inbox.help.livechat') }}</li>
</ul>
</div>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -64,7 +64,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '../../utils/http'
import api from '../../api'
import { validateEmail } from '../../utils/strings'
import { validateEmail } from '@shared-ui/utils/string'
import { useTemporaryClass } from '../../composables/useTemporaryClass'
import { Button } from '@shared-ui/components/ui/button'
import { Error } from '@shared-ui/components/ui/error'

View File

@@ -123,7 +123,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '../../utils/http'
import api from '../../api'
import { validateEmail } from '../../utils/strings'
import { validateEmail } from '@shared-ui/utils/string'
import { useTemporaryClass } from '../../composables/useTemporaryClass'
import { Button } from '@shared-ui/components/ui/button'
import { Error } from '@shared-ui/components/ui/error'

View File

@@ -20,10 +20,13 @@
:label="t('globals.messages.upload')"
/>
<div>
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold text-gray-900 dark:text-foreground">
{{ contact.first_name }} {{ contact.last_name }}
</h2>
<Badge v-if="contact.type" variant="secondary">
{{ contact.type.titleCase() }}
</Badge>
</div>
<div class="text-xs text-gray-500">
@@ -93,6 +96,7 @@ import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { AvatarUpload } from '@shared-ui/components/ui/avatar'
import { Button } from '@shared-ui/components/ui/button'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Dialog,
DialogContent,

View File

@@ -22,6 +22,7 @@
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",

View File

@@ -11,6 +11,9 @@ importers:
'@codemirror/lang-html':
specifier: ^6.4.9
version: 6.4.9
'@codemirror/lang-javascript':
specifier: ^6.2.4
version: 6.2.4
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3

View File

@@ -1,3 +1,13 @@
// Adds titleCase property to string.
String.prototype.titleCase = function () {
return this.toLowerCase()
.split(' ')
.map(function (word) {
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
}
export function convertTextToHtml (text) {
const div = document.createElement('div')
div.innerText = text
@@ -9,3 +19,49 @@ export function parseJWT (token) {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(base64))
}
/**
* Reverts the `src` attribute of all <img> tags with the class `inline-image`
* from the `cid:filename` format to `/uploads/filename`, where the filename is stored in the `title` attribute.
*
* @param {string} htmlString - The input HTML string.
* @returns {string} - The updated HTML string with `cid:title` replaced by `/uploads/title`.
*/
export function revertCIDToImageSrc (htmlString) {
return htmlString.replace(/(<img\s+class="inline-image"[^>]*?src=")cid:([^"]*)(".*?title=")\2("[^>]*?>)/g, '$1/uploads/$2$3$2$4');
}
export function validateEmail (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export const isGoDuration = (value) => {
if (value === '') return false
const regex = /^(\d+h)?(\d+m)?(\d+s)?$/
return regex.test(value)
}
export const isGoHourMinuteDuration = (value) => {
const regex = /^([0-9]+h|[0-9]+m)$/
return regex.test(value)
}
const template = document.createElement('template')
export function getTextFromHTML (htmlString) {
try {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text.trim()
} catch (error) {
console.error('Error converting HTML to text:', error)
return ''
}
}
export function getInitials (firstName = '', lastName = '') {
const firstInitial = firstName.charAt(0).toUpperCase() || ''
const lastInitial = lastName.charAt(0).toUpperCase() || ''
return `${firstInitial}${lastInitial}`
}

View File

@@ -3,6 +3,7 @@
"_.name": "English (en)",
"globals.terms.user": "User | Users",
"globals.terms.contact": "Contact | Contacts",
"globals.terms.visitor": "Visitor | Visitors",
"globals.terms.agent": "Agent | Agents",
"globals.terms.team": "Team | Teams",
"globals.terms.message": "Message | Messages",
@@ -97,6 +98,8 @@
"globals.terms.expand": "Expand",
"globals.terms.collapse": "Collapse",
"globals.terms.home": "Home",
"globals.terms.copy": "Copy",
"globals.terms.copied": "Copied",
"globals.terms.url": "URL | URLs",
"globals.terms.rootURL": "Root URL",
"globals.terms.key": "Key | Keys",
@@ -525,83 +528,95 @@
"admin.inbox.chooseChannel": "Choose a channel",
"admin.inbox.configureChannel": "Configure channel",
"admin.inbox.createEmailInbox": "Create Email Inbox",
"admin.inbox.livechatConfig": "Live Chat Configuration",
"admin.inbox.livechatConfig": "Live chat configuration",
"admin.inbox.help.title": "Manage inboxes",
"admin.inbox.help.description": "Configure and manage different communication channels for customer interactions",
"admin.inbox.help.email": "Configure IMAP/SMTP settings to set up email support and receive customer emails",
"admin.inbox.help.livechat": "Create live chat widgets that can be embedded on your website for real-time customer support",
"admin.inbox.livechat.tabs.general": "General",
"admin.inbox.livechat.tabs.appearance": "Appearance",
"admin.inbox.livechat.tabs.messages": "Messages",
"admin.inbox.livechat.tabs.features": "Features",
"admin.inbox.livechat.tabs.security": "Security",
"admin.inbox.livechat.tabs.prechat": "Pre-Chat Form",
"admin.inbox.livechat.tabs.prechat": "Pre-chat form",
"admin.inbox.livechat.tabs.users": "Users",
"admin.inbox.livechat.tabs.installation": "Installation",
"admin.inbox.livechat.installation.instructions.title": "Installation instructions",
"admin.inbox.livechat.installation.instructions.step1": "Copy the code snippet below",
"admin.inbox.livechat.installation.instructions.step2": "Paste it just before the closing `body` tag on every page where you want the live chat widget to appear",
"admin.inbox.livechat.installation.authenticated.title": "For authenticated users",
"admin.inbox.livechat.installation.authenticated.jwt.description": "To identify logged-in users, generate a JWT on your server with the following payload structure:",
"admin.inbox.livechat.installation.authenticated.implementation.description": "Then pass the signed JWT token to the widget initialization:",
"admin.inbox.livechat.installation.authenticated.secret.note": "Important: Sign the JWT with the secret key configured in the Security tab",
"admin.inbox.livechat.logoUrl": "Logo URL",
"admin.inbox.livechat.logoUrl.description": "URL of the logo to display in the chat widget",
"admin.inbox.livechat.secretKey": "Secret Key",
"admin.inbox.livechat.secretKey": "Secret key",
"admin.inbox.livechat.secretKey.description": "Set a secret key to secure the chat widget.",
"admin.inbox.livechat.launcher": "Launcher",
"admin.inbox.livechat.launcher.position": "Position",
"admin.inbox.livechat.launcher.position.left": "Left",
"admin.inbox.livechat.launcher.position.right": "Right",
"admin.inbox.livechat.launcher.logo": "Launcher Logo",
"admin.inbox.livechat.launcher.spacing.side": "Side Spacing",
"admin.inbox.livechat.launcher.logo": "Launcher logo",
"admin.inbox.livechat.launcher.spacing.side": "Side spacing",
"admin.inbox.livechat.launcher.spacing.side.description": "Distance from the side of the screen in pixels",
"admin.inbox.livechat.launcher.spacing.bottom": "Bottom Spacing",
"admin.inbox.livechat.launcher.spacing.bottom": "Bottom spacing",
"admin.inbox.livechat.launcher.spacing.bottom.description": "Distance from the bottom of the screen in pixels",
"admin.inbox.livechat.messages": "Messages",
"admin.inbox.livechat.greetingMessage": "Greeting Message",
"admin.inbox.livechat.introductionMessage": "Introduction Message",
"admin.inbox.livechat.chatIntroduction": "Chat Introduction",
"admin.inbox.livechat.greetingMessage": "Greeting message",
"admin.inbox.livechat.introductionMessage": "Introduction message",
"admin.inbox.livechat.chatIntroduction": "Chat introduction",
"admin.inbox.livechat.chatIntroduction.description": "Default: Ask us anything, or share your feedback.",
"admin.inbox.livechat.chatReplyExpectationMessage": "Chat reply expectation message",
"admin.inbox.livechat.chatReplyExpectationMessage.description": "Message shown to customers during business hours about expected reply times",
"admin.inbox.livechat.officeHours": "Office Hours",
"admin.inbox.livechat.showOfficeHoursInChat": "Show Office Hours in Chat",
"admin.inbox.livechat.officeHours": "Office hours",
"admin.inbox.livechat.showOfficeHoursInChat": "Show office hours in chat",
"admin.inbox.livechat.showOfficeHoursInChat.description": "Show when the team will be next available",
"admin.inbox.livechat.showOfficeHoursAfterAssignment": "Show Office Hours After Team Assignment",
"admin.inbox.livechat.showOfficeHoursAfterAssignment": "Show office hours after team assignment",
"admin.inbox.livechat.showOfficeHoursAfterAssignment.description": "Show office hours after conversation is assigned to a team",
"admin.inbox.livechat.showPoweredBy": "Show Powered By",
"admin.inbox.livechat.showPoweredBy": "Show powered by",
"admin.inbox.livechat.showPoweredBy.description": "Show \"Powered by Libredesk\" in the chat widget",
"admin.inbox.livechat.noticeBanner": "Notice Banner",
"admin.inbox.livechat.noticeBanner.enabled": "Enable Notice Banner",
"admin.inbox.livechat.noticeBanner.text": "Notice Banner Text",
"admin.inbox.livechat.noticeBanner": "Notice banner",
"admin.inbox.livechat.noticeBanner.enabled": "Enable notice banner",
"admin.inbox.livechat.noticeBanner.text": "Notice banner text",
"admin.inbox.livechat.colors": "Colors",
"admin.inbox.livechat.colors.primary": "Primary Color",
"admin.inbox.livechat.colors.background": "Background Color",
"admin.inbox.livechat.darkMode": "Dark Mode",
"admin.inbox.livechat.colors.primary": "Primary color",
"admin.inbox.livechat.colors.background": "Background color",
"admin.inbox.livechat.darkMode": "Dark mode",
"admin.inbox.livechat.darkMode.description": "Enable dark mode for the chat widget",
"admin.inbox.livechat.features": "Features",
"admin.inbox.livechat.features.fileUpload": "File Upload",
"admin.inbox.livechat.features.fileUpload": "File upload",
"admin.inbox.livechat.features.fileUpload.description": "Allow users to upload files in chat",
"admin.inbox.livechat.features.emoji": "Emoji Support",
"admin.inbox.livechat.features.emoji": "Emoji support",
"admin.inbox.livechat.features.emoji.description": "Allow users to use emojis in chat",
"admin.inbox.livechat.features.allowCloseConversation": "Allow Close Conversation",
"admin.inbox.livechat.features.allowCloseConversation": "Allow close conversation",
"admin.inbox.livechat.features.allowCloseConversation.description": "Allow users to close their own conversations",
"admin.inbox.livechat.externalLinks": "External Links",
"admin.inbox.livechat.externalLinks.add": "Add External Link",
"admin.inbox.livechat.externalLinks": "External links",
"admin.inbox.livechat.externalLinks.add": "Add external link",
"admin.inbox.livechat.externalLinks.description": "Add helpful links that will be displayed in the chat widget",
"admin.inbox.livechat.trustedDomains": "Trusted Domains",
"admin.inbox.livechat.trustedDomains.list": "Domain List",
"admin.inbox.livechat.trustedDomains": "Trusted domains",
"admin.inbox.livechat.trustedDomains.list": "Domain list",
"admin.inbox.livechat.trustedDomains.description": "Specify your trusted domains and subdomains, one per line. Use an asterisk wildcard to trust all subdomains: *.example.com. Leaving this field empty will allowing widget to be embedded on any domain.",
"admin.inbox.livechat.userSettings": "User Settings",
"admin.inbox.livechat.userSettings": "User settings",
"admin.inbox.livechat.userSettings.visitors": "Visitors",
"admin.inbox.livechat.userSettings.users": "Users",
"admin.inbox.livechat.startConversationButtonText": "Start Conversation Button Text",
"admin.inbox.livechat.allowStartConversation": "Allow Start Conversation",
"admin.inbox.livechat.startConversationButtonText": "Start conversation button text",
"admin.inbox.livechat.allowStartConversation": "Allow start conversation",
"admin.inbox.livechat.allowStartConversation.visitors.description": "Allow visitors to start new conversations",
"admin.inbox.livechat.allowStartConversation.users.description": "Allow users users to start new conversations",
"admin.inbox.livechat.preventMultipleConversations": "Prevent Multiple Conversations",
"admin.inbox.livechat.preventMultipleConversations": "Prevent multiple conversations",
"admin.inbox.livechat.preventMultipleConversations.visitors.description": "Prevent visitors from starting multiple conversations simultaneously",
"admin.inbox.livechat.preventMultipleConversations.users.description": "Prevent users users from starting multiple conversations simultaneously",
"admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent Replying to Closed Conversations",
"admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent replying to closed conversations",
"admin.inbox.livechat.preventReplyingToClosedConversations.visitors.description": "Prevent visitors from replying to closed conversations",
"admin.inbox.livechat.preventReplyingToClosedConversations.users.description": "Prevent users users from replying to closed conversations",
"admin.inbox.livechat.prechatForm.enabled": "Enable Pre-Chat Form",
"admin.inbox.livechat.prechatForm.enabled": "Enable pre-chat form",
"admin.inbox.livechat.prechatForm.enabled.description": "Show a form to collect information before chat starts",
"admin.inbox.livechat.prechatForm.title": "Form Title",
"admin.inbox.livechat.prechatForm.title": "Form title",
"admin.inbox.livechat.prechatForm.title.description": "Title displayed above the pre-chat form",
"admin.inbox.livechat.prechatForm.fields": "Form Fields",
"admin.inbox.livechat.prechatForm.addField": "Add Field",
"admin.inbox.livechat.prechatForm.fields": "Form fields",
"admin.inbox.livechat.prechatForm.addField": "Add field",
"admin.inbox.livechat.prechatForm.noFields": "No fields configured. Add fields from custom attributes.",
"admin.inbox.livechat.prechatForm.availableFields": "Available Custom Attributes",
"admin.inbox.livechat.prechatForm.availableFields": "Available custom attributes",
"admin.inbox.livechat.conversationContinuity": "Conversation continuity email inbox",
"admin.inbox.livechat.conversationContinuity.description": "When contacts go offline, replies will be sent from this email inbox. The contacts can continue the same conversation by replying to the email or in the chat widget when they return to your site.",
"admin.inbox.livechat.continuityEmailFooter": "Reply directly to this email to continue the conversation.",
@@ -766,6 +781,7 @@
"contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.",
"contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
"contact.alreadyExistsWithEmail": "Another contact with same email already exists",
"contact.identityNotVerified": "Identity not verified",
"contact.notes.empty": "No notes yet",
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
"globals.days.sunday": "Sunday",

View File

@@ -183,8 +183,7 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
// create creates a new activity log in DB.
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
var activityLog models.ActivityLog
if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
m.lo.Error("error inserting activity log", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
}

View File

@@ -121,7 +121,7 @@ type teamStore interface {
}
type userStore interface {
Get(int, string, string) (umodels.User, error)
Get(int, string, []string) (umodels.User, error)
GetAgent(int, string) (umodels.User, error)
GetContact(int, string) (umodels.User, error)
GetVisitor(int) (umodels.User, error)
@@ -1460,7 +1460,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati
if cachedUser, ok := userCache[msg.SenderID]; ok {
user = cachedUser
} else {
user, err = m.userStore.Get(msg.SenderID, "", "")
user, err = m.userStore.Get(msg.SenderID, "", []string{})
if err != nil {
m.lo.Error("error fetching message sender user", "sender_id", msg.SenderID, "conversation_uuid", conversation.UUID, "error", err)
} else {

View File

@@ -424,12 +424,7 @@ func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID, contactID
return models.Message{}, envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil)
}
sourceID, err := stringutil.GenerateEmailMessageID(conversationUUID, inboxRecord.From)
if err != nil {
m.lo.Error("error generating source message id", "error", err)
return message, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
}
var sourceID string
switch inboxRecord.Channel {
case inbox.ChannelEmail:
// Add `to`, `cc`, and `bcc` recipients to meta map.
@@ -746,7 +741,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
}
} else {
// Conversation found validate sender email matches contact email
contact, err := m.userStore.Get(conversation.ContactID, "", "")
contact, err := m.userStore.Get(conversation.ContactID, "", []string{})
if err != nil {
m.lo.Error("error fetching conversation contact", "contact_id", conversation.ContactID, "error", err)
return models.Message{}, fmt.Errorf("fetching conversation contact: %w", err)

View File

@@ -166,7 +166,7 @@ type Conversation struct {
InboxName string `db:"inbox_name" json:"inbox_name"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
Meta json.RawMessage `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`

View File

@@ -11,7 +11,7 @@ VALUES($1, $2, $3, $4, $5, $6)
RETURNING *
-- name: get-inbox
SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where id = $1 and deleted_at is NULL;
SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", secret FROM inboxes where id = $1 and deleted_at is NULL;
-- name: update
UPDATE inboxes

View File

@@ -29,7 +29,7 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
// GetAgent retrieves an agent by ID and also caches it for future requests.
func (u *Manager) GetAgent(id int, email string) (models.User, error) {
agent, err := u.Get(id, email, models.UserTypeAgent)
agent, err := u.Get(id, email, []string{models.UserTypeAgent})
if err != nil {
return models.User{}, err
}
@@ -95,7 +95,7 @@ func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string)
u.lo.Error("error creating user", "error", err)
return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil)
}
return u.Get(id, "", models.UserTypeAgent)
return u.Get(id, "", []string{models.UserTypeAgent})
}
// UpdateAgent updates an agent with individual field parameters
@@ -162,5 +162,5 @@ func (u *Manager) markInactiveAgentsOffline() {
// GetAllAgents returns a list of all agents.
func (u *Manager) GetAgents() ([]models.UserCompact, error) {
// Some dirty hack.
return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "")
return u.GetAllUsers(1, 999999999, []string{models.UserTypeAgent}, "desc", "users.updated_at", "")
}

View File

@@ -47,7 +47,7 @@ func (u *Manager) UpdateContact(id int, user models.User) error {
// GetContact retrieves a contact by ID.
func (u *Manager) GetContact(id int, email string) (models.User, error) {
return u.Get(id, email, models.UserTypeContact)
return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor})
}
// GetAllContacts returns a list of all contacts.
@@ -61,5 +61,5 @@ func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filters
if pageSize < 1 {
pageSize = 10
}
return u.GetAllUsers(page, pageSize, models.UserTypeContact, order, orderBy, filtersJSON)
return u.GetAllUsers(page, pageSize, []string{models.UserTypeContact, models.UserTypeVisitor}, order, orderBy, filtersJSON)
}

View File

@@ -58,7 +58,7 @@ type User struct {
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
Password null.String `db:"password" json:"-"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
Roles pq.StringArray `db:"roles" json:"roles"`

View File

@@ -61,7 +61,7 @@ LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
WHERE u.deleted_at IS NULL
AND ($1 = 0 OR u.id = $1)
AND ($2 = '' OR u.email = $2)
AND ($3 = '' OR u.type::text = $3)
AND (cardinality($3::text[]) = 0 OR u.type::text = ANY($3::text[]))
GROUP BY u.id;
-- name: set-user-password
@@ -192,7 +192,7 @@ SET first_name = COALESCE($2, first_name),
phone_number = $6,
phone_number_country_code = $7,
updated_at = now()
WHERE id = $1 and type = 'contact';
WHERE id = $1 and type in ('contact', 'visitor');
-- name: get-notes
SELECT
@@ -301,7 +301,7 @@ SELECT
u.availability_status,
u.last_active_at,
u.last_login_at,
u.phone_number_calling_code,
u.phone_number_country_code,
u.phone_number,
u.external_user_id,
u.custom_attributes,

View File

@@ -111,22 +111,22 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
// VerifyPassword authenticates an user by email and password, returning the user if successful.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User
if err := u.q.GetUser.Get(&user, 0, email, models.UserTypeAgent); err != nil {
if err := u.q.GetUser.Get(&user, 0, email, pq.Array([]string{models.UserTypeAgent})); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
u.lo.Error("error fetching user from db", "error", err)
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
}
if err := u.verifyPassword(password, user.Password); err != nil {
if err := u.verifyPassword(password, user.Password.String); err != nil {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
return user, nil
}
// GetAllUsers returns a list of all users.
func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
query, qArgs, err := u.makeUserListQuery(page, pageSize, userType, order, orderBy, filtersJSON)
func (u *Manager) GetAllUsers(page, pageSize int, userTypes []string, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
query, qArgs, err := u.makeUserListQuery(page, pageSize, userTypes, order, orderBy, filtersJSON)
if err != nil {
u.lo.Error("error creating user list query", "error", err)
return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
@@ -152,14 +152,14 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin
return users, nil
}
// Get retrieves an user by ID or email.
func (u *Manager) Get(id int, email, type_ string) (models.User, error) {
// Get retrieves an user by ID or email or type. At least one of ID or email must be provided.
func (u *Manager) Get(id int, email string, userType []string) (models.User, error) {
if id == 0 && email == "" {
return models.User{}, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.user}"), nil)
}
var user models.User
if err := u.q.GetUser.Get(&user, id, email, type_); err != nil {
if err := u.q.GetUser.Get(&user, id, email, pq.Array(userType)); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.NotFoundError, u.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"), nil)
}
@@ -171,7 +171,7 @@ func (u *Manager) Get(id int, email, type_ string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.Get(0, models.SystemUserEmail, models.UserTypeAgent)
return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent})
}
// GetByExternalID retrieves a user by external user ID.
@@ -459,9 +459,9 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
}
// makeUserListQuery generates a query to fetch users based on the provided filters.
func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) {
func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, order, orderBy, filtersJSON string) (string, []interface{}, error) {
var qArgs []any
qArgs = append(qArgs, pq.Array([]string{typ}))
qArgs = append(qArgs, pq.Array(userTypes))
return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,

View File

@@ -28,5 +28,5 @@ func (u *Manager) CreateVisitor(user *models.User) error {
// GetVisitor retrieves a visitor user by ID
func (u *Manager) GetVisitor(id int) (models.User, error) {
return u.Get(id, "", models.UserTypeVisitor)
return u.Get(id, "", []string{models.UserTypeVisitor})
}

View File

@@ -7,6 +7,7 @@ DROP TYPE IF EXISTS "message_status" CASCADE; CREATE TYPE "message_status" AS EN
DROP TYPE IF EXISTS "content_type" CASCADE; CREATE TYPE "content_type" AS ENUM ('text','html');
DROP TYPE IF EXISTS "conversation_assignment_type" CASCADE; CREATE TYPE "conversation_assignment_type" AS ENUM ('Round robin','Manual');
DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM ('email_outgoing', 'email_notification');
-- Visitors are unauthenticated contacts.
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact', 'visitor');
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');

View File

@@ -413,7 +413,7 @@
window.LibredeskWidget = new LibredeskWidget(window.LibredeskConfig);
}
window.initLibreDeskWidget = function (config = {}) {
window.initLibredeskWidget = function (config = {}) {
if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) {
console.warn('Libredesk Widget is already initialized');
return window.LibredeskWidget;
@@ -426,7 +426,7 @@
if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) {
window.LibredeskWidget.showChat();
} else {
console.warn('Libredesk Widget is not initialized. Call initLibreDeskWidget() first.');
console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.');
}
};
@@ -434,7 +434,7 @@
if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) {
window.LibredeskWidget.hideChat();
} else {
console.warn('Libredesk Widget is not initialized. Call initLibreDeskWidget() first.');
console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.');
}
};
@@ -442,7 +442,7 @@
if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) {
window.LibredeskWidget.toggle();
} else {
console.warn('Libredesk Widget is not initialized. Call initLibreDeskWidget() first.');
console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.');
}
};
@@ -450,7 +450,7 @@
if (window.LibredeskWidget && window.LibredeskWidget instanceof LibredeskWidget) {
return window.LibredeskWidget.isChatVisible;
} else {
console.warn('Libredesk Widget is not initialized. Call initLibreDeskWidget() first.');
console.warn('Libredesk Widget is not initialized. Call initLibredeskWidget() first.');
return false;
}
};