mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
Compare commits
3 Commits
7858a9492d
...
7217086f3f
Author | SHA1 | Date | |
---|---|---|---|
|
7217086f3f | ||
|
98b3b54b6f | ||
|
aae8d1f793 |
29
cmd/chat.go
29
cmd/chat.go
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -26,10 +26,6 @@ const (
|
||||
maxAvatarSizeMB = 2
|
||||
)
|
||||
|
||||
type updateAvailabilityRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
73
frontend/apps/main/src/components/button/CopyButton.vue
Normal file
73
frontend/apps/main/src/components/button/CopyButton.vue
Normal 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>
|
@@ -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(() => {
|
||||
|
@@ -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: '' }
|
||||
|
@@ -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'
|
||||
|
@@ -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) => {
|
||||
|
@@ -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')),
|
||||
|
@@ -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 {
|
||||
|
@@ -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({
|
||||
|
@@ -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),
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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' })
|
||||
|
@@ -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'
|
||||
|
@@ -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'
|
||||
|
@@ -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">
|
||||
|
@@ -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'
|
||||
|
||||
|
@@ -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}`
|
||||
}
|
@@ -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>
|
||||
|
@@ -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'
|
||||
|
@@ -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'
|
||||
|
@@ -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,
|
||||
|
@@ -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",
|
||||
|
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
@@ -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}`
|
||||
}
|
||||
|
88
i18n/en.json
88
i18n/en.json
@@ -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",
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
|
@@ -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"`
|
||||
|
@@ -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
|
||||
|
@@ -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", "")
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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"`
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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})
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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;
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user