@escalated-dev/escalated 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -3
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +19 -5
- package/pages.json +1 -1
- package/src/components/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +4 -3
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +7 -2
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +34 -3
- package/src/utils/flash.js +4 -0
- package/src/widget/EscalatedWidget.vue +145 -71
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -9,9 +9,12 @@ const props = defineProps({
|
|
|
9
9
|
|
|
10
10
|
const open = ref(false);
|
|
11
11
|
const loading = ref(false);
|
|
12
|
+
const error = ref('');
|
|
12
13
|
|
|
13
14
|
async function exportAs(format) {
|
|
15
|
+
if (loading.value) return;
|
|
14
16
|
loading.value = true;
|
|
17
|
+
error.value = '';
|
|
15
18
|
try {
|
|
16
19
|
const separator = props.exportUrl.includes('?') ? '&' : '?';
|
|
17
20
|
const url = props.exportUrl ? `${props.exportUrl}${separator}format=${format}&days=${props.periodDays}` : '#';
|
|
@@ -28,6 +31,7 @@ async function exportAs(format) {
|
|
|
28
31
|
URL.revokeObjectURL(a.href);
|
|
29
32
|
} else {
|
|
30
33
|
const response = await fetch(url);
|
|
34
|
+
if (!response.ok) throw new Error(`Export failed (${response.status}). Please try again.`);
|
|
31
35
|
const blob = await response.blob();
|
|
32
36
|
const a = document.createElement('a');
|
|
33
37
|
a.href = URL.createObjectURL(blob);
|
|
@@ -35,6 +39,8 @@ async function exportAs(format) {
|
|
|
35
39
|
a.click();
|
|
36
40
|
URL.revokeObjectURL(a.href);
|
|
37
41
|
}
|
|
42
|
+
} catch {
|
|
43
|
+
error.value = 'Export failed. Please try again.';
|
|
38
44
|
} finally {
|
|
39
45
|
loading.value = false;
|
|
40
46
|
open.value = false;
|
|
@@ -44,7 +50,10 @@ async function exportAs(format) {
|
|
|
44
50
|
|
|
45
51
|
<template>
|
|
46
52
|
<div class="relative">
|
|
53
|
+
<p v-if="error" role="alert" class="mb-2 text-sm text-red-600">{{ error }}</p>
|
|
47
54
|
<button
|
|
55
|
+
type="button"
|
|
56
|
+
:disabled="loading"
|
|
48
57
|
class="flex items-center gap-2 rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3.5 py-1.5 text-sm font-medium text-[var(--esc-panel-text-tertiary)] transition-all hover:text-[var(--esc-panel-text-secondary)]"
|
|
49
58
|
@click="open = !open"
|
|
50
59
|
>
|
|
@@ -66,6 +75,8 @@ async function exportAs(format) {
|
|
|
66
75
|
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] py-1 shadow-xl"
|
|
67
76
|
>
|
|
68
77
|
<button
|
|
78
|
+
type="button"
|
|
79
|
+
:disabled="loading"
|
|
69
80
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-hover)]"
|
|
70
81
|
@click="exportAs('csv')"
|
|
71
82
|
>
|
|
@@ -85,6 +96,8 @@ async function exportAs(format) {
|
|
|
85
96
|
Export as CSV
|
|
86
97
|
</button>
|
|
87
98
|
<button
|
|
99
|
+
type="button"
|
|
100
|
+
:disabled="loading"
|
|
88
101
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-hover)]"
|
|
89
102
|
@click="exportAs('json')"
|
|
90
103
|
>
|
|
@@ -68,11 +68,12 @@ export function useChat(options = {}) {
|
|
|
68
68
|
* @param {string|number} sessionId
|
|
69
69
|
* @returns {Promise<void>}
|
|
70
70
|
*/
|
|
71
|
-
|
|
71
|
+
const lastTypingSent = new Map();
|
|
72
72
|
async function sendTyping(sessionId) {
|
|
73
73
|
const now = Date.now();
|
|
74
|
-
|
|
75
|
-
lastTypingSent
|
|
74
|
+
const key = String(sessionId);
|
|
75
|
+
if (lastTypingSent.has(key) && now - lastTypingSent.get(key) < 3000) return;
|
|
76
|
+
lastTypingSent.set(key, now);
|
|
76
77
|
try {
|
|
77
78
|
await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
|
|
78
79
|
} catch {
|
|
@@ -11,6 +11,8 @@ export function useKeyboardShortcuts(shortcuts, options = {}) {
|
|
|
11
11
|
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
12
12
|
|
|
13
13
|
function handleKeydown(event) {
|
|
14
|
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
|
|
15
|
+
|
|
14
16
|
// Check if shortcuts are enabled
|
|
15
17
|
if (options.enabled !== undefined && !unref(options.enabled)) {
|
|
16
18
|
return;
|
|
@@ -20,6 +20,7 @@ export function useMentions(options = {}) {
|
|
|
20
20
|
const loading = ref(false);
|
|
21
21
|
|
|
22
22
|
let timer = null;
|
|
23
|
+
let requestVersion = 0;
|
|
23
24
|
|
|
24
25
|
function clearTimer() {
|
|
25
26
|
if (timer !== null) {
|
|
@@ -55,18 +56,23 @@ export function useMentions(options = {}) {
|
|
|
55
56
|
|
|
56
57
|
function debouncedFetch(searchQuery) {
|
|
57
58
|
clearTimer();
|
|
59
|
+
const version = ++requestVersion;
|
|
60
|
+
// Keep the current list on screen while the next search is pending,
|
|
61
|
+
// so the dropdown stays open and Enter/Tab still pick a mention.
|
|
62
|
+
// requestVersion already discards any older response.
|
|
63
|
+
loading.value = true;
|
|
58
64
|
|
|
59
65
|
if (searchQuery.length < 1) {
|
|
60
|
-
fetchSuggestions('');
|
|
66
|
+
fetchSuggestions('', version);
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
timer = window.setTimeout(() => {
|
|
65
|
-
fetchSuggestions(searchQuery);
|
|
71
|
+
fetchSuggestions(searchQuery, version);
|
|
66
72
|
}, debounce);
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
async function fetchSuggestions(searchQuery) {
|
|
75
|
+
async function fetchSuggestions(searchQuery, version) {
|
|
70
76
|
loading.value = true;
|
|
71
77
|
try {
|
|
72
78
|
const endpoint = options.searchEndpoint || '/support/admin/agents/search';
|
|
@@ -78,15 +84,12 @@ export function useMentions(options = {}) {
|
|
|
78
84
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
79
85
|
},
|
|
80
86
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} else {
|
|
84
|
-
suggestions.value = [];
|
|
85
|
-
}
|
|
87
|
+
const results = response.ok ? await response.json() : [];
|
|
88
|
+
if (version === requestVersion) suggestions.value = results;
|
|
86
89
|
} catch {
|
|
87
|
-
suggestions.value = [];
|
|
90
|
+
if (version === requestVersion) suggestions.value = [];
|
|
88
91
|
} finally {
|
|
89
|
-
loading.value = false;
|
|
92
|
+
if (version === requestVersion) loading.value = false;
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -113,6 +116,8 @@ export function useMentions(options = {}) {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function close() {
|
|
119
|
+
requestVersion++;
|
|
120
|
+
loading.value = false;
|
|
116
121
|
isActive.value = false;
|
|
117
122
|
suggestions.value = [];
|
|
118
123
|
query.value = '';
|
|
@@ -120,7 +125,7 @@ export function useMentions(options = {}) {
|
|
|
120
125
|
clearTimer();
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
onUnmounted(
|
|
128
|
+
onUnmounted(close);
|
|
124
129
|
|
|
125
130
|
return {
|
|
126
131
|
suggestions,
|
|
@@ -42,9 +42,14 @@ export function useRealtime(options = {}) {
|
|
|
42
42
|
const echo = window.Echo;
|
|
43
43
|
const channel = echo.private(channelName);
|
|
44
44
|
const release = retainChannel(echo, channelName, false);
|
|
45
|
-
|
|
45
|
+
let active = true;
|
|
46
|
+
const guarded = (...args) => {
|
|
47
|
+
if (active) callback(...args);
|
|
48
|
+
};
|
|
49
|
+
channel.listen(eventName, guarded);
|
|
46
50
|
return track(() => {
|
|
47
|
-
|
|
51
|
+
active = false;
|
|
52
|
+
channel.stopListening(eventName, guarded);
|
|
48
53
|
release();
|
|
49
54
|
});
|
|
50
55
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref, computed, inject } from 'vue';
|
|
3
3
|
import { useForm, usePage } from '@inertiajs/vue3';
|
|
4
4
|
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
5
|
+
import { readFlash } from '../../../utils/flash';
|
|
5
6
|
|
|
6
7
|
defineProps({
|
|
7
8
|
tokens: Array,
|
|
@@ -33,10 +34,8 @@ function createToken() {
|
|
|
33
34
|
form.post(`${prefix.value}/admin/api-tokens`, {
|
|
34
35
|
preserveScroll: true,
|
|
35
36
|
onSuccess: (page) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
copiedToken.value = flash.plain_text_token;
|
|
39
|
-
}
|
|
37
|
+
const token = readFlash(page).plain_text_token;
|
|
38
|
+
copiedToken.value = typeof token === 'string' ? token : null;
|
|
40
39
|
form.reset();
|
|
41
40
|
showCreate.value = false;
|
|
42
41
|
},
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
3
|
import TwoFactorSetup from '../../../components/TwoFactorSetup.vue';
|
|
4
4
|
import { router, usePage } from '@inertiajs/vue3';
|
|
5
|
-
import { ref, computed } from 'vue';
|
|
5
|
+
import { ref, computed, watch } from 'vue';
|
|
6
|
+
import { readFlash } from '../../../utils/flash';
|
|
6
7
|
|
|
7
8
|
defineProps({
|
|
8
9
|
enabled: { type: Boolean, default: false },
|
|
@@ -11,17 +12,28 @@ defineProps({
|
|
|
11
12
|
|
|
12
13
|
const page = usePage();
|
|
13
14
|
const showSetup = ref(false);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
15
|
+
const startingSetup = ref(false);
|
|
16
|
+
const flash = computed(() => readFlash(page));
|
|
17
|
+
const setupData = computed(() => flash.value.two_factor_setup);
|
|
18
|
+
const confirmedData = computed(() => flash.value.two_factor_confirmed);
|
|
19
|
+
const copyError = ref('');
|
|
20
|
+
|
|
21
|
+
watch(setupData, (data) => (showSetup.value = Boolean(data)), { immediate: true });
|
|
22
|
+
watch(confirmedData, () => (copyError.value = ''));
|
|
16
23
|
|
|
17
24
|
function startSetup() {
|
|
25
|
+
if (startingSetup.value) return;
|
|
26
|
+
startingSetup.value = true;
|
|
18
27
|
router.post(
|
|
19
28
|
route('escalated.admin.two-factor.setup'),
|
|
20
29
|
{},
|
|
21
30
|
{
|
|
22
31
|
preserveScroll: true,
|
|
23
32
|
onSuccess: () => {
|
|
24
|
-
showSetup.value =
|
|
33
|
+
showSetup.value = Boolean(setupData.value);
|
|
34
|
+
},
|
|
35
|
+
onFinish: () => {
|
|
36
|
+
startingSetup.value = false;
|
|
25
37
|
},
|
|
26
38
|
},
|
|
27
39
|
);
|
|
@@ -48,6 +60,18 @@ function disable() {
|
|
|
48
60
|
);
|
|
49
61
|
}
|
|
50
62
|
}
|
|
63
|
+
|
|
64
|
+
async function copyRecoveryCodes() {
|
|
65
|
+
const codes = confirmedData.value?.recovery_codes;
|
|
66
|
+
if (!codes?.length) return;
|
|
67
|
+
copyError.value = '';
|
|
68
|
+
try {
|
|
69
|
+
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
|
|
70
|
+
await navigator.clipboard.writeText(codes.join('\n'));
|
|
71
|
+
} catch {
|
|
72
|
+
copyError.value = 'Could not copy recovery codes. Select and copy the codes below.';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
51
75
|
</script>
|
|
52
76
|
|
|
53
77
|
<template>
|
|
@@ -75,7 +99,7 @@ function disable() {
|
|
|
75
99
|
Disable 2FA
|
|
76
100
|
</button>
|
|
77
101
|
</div>
|
|
78
|
-
<div v-else-if="!showSetup
|
|
102
|
+
<div v-else-if="!showSetup">
|
|
79
103
|
<span
|
|
80
104
|
class="mb-3 inline-flex items-center rounded-full bg-neutral-700/50 px-3 py-1 text-xs font-medium text-[var(--esc-panel-text-tertiary)]"
|
|
81
105
|
>
|
|
@@ -84,6 +108,8 @@ function disable() {
|
|
|
84
108
|
<div class="mt-3">
|
|
85
109
|
<button
|
|
86
110
|
type="button"
|
|
111
|
+
:disabled="startingSetup"
|
|
112
|
+
:aria-busy="startingSetup"
|
|
87
113
|
class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-5 py-2 text-sm font-medium text-white shadow-lg shadow-[var(--esc-panel-bg)]/20 transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)]"
|
|
88
114
|
@click="startSetup"
|
|
89
115
|
>
|
|
@@ -96,7 +122,7 @@ function disable() {
|
|
|
96
122
|
|
|
97
123
|
<!-- Setup Wizard -->
|
|
98
124
|
<div
|
|
99
|
-
v-if="setupData && !enabled"
|
|
125
|
+
v-if="showSetup && setupData && !enabled"
|
|
100
126
|
class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6"
|
|
101
127
|
>
|
|
102
128
|
<TwoFactorSetup :qr-uri="setupData.qr_uri" @confirm="confirmCode" @cancel="showSetup = false" />
|
|
@@ -116,11 +142,12 @@ function disable() {
|
|
|
116
142
|
<button
|
|
117
143
|
type="button"
|
|
118
144
|
class="rounded-lg border border-[var(--esc-panel-border)] px-4 py-2 text-sm text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-surface-alt)]"
|
|
119
|
-
@click="
|
|
145
|
+
@click="copyRecoveryCodes"
|
|
120
146
|
>
|
|
121
147
|
Copy Codes
|
|
122
148
|
</button>
|
|
123
149
|
</div>
|
|
150
|
+
<p v-if="copyError" role="alert" class="mt-3 text-sm text-rose-400">{{ copyError }}</p>
|
|
124
151
|
<div class="mt-4 grid gap-2 md:grid-cols-2">
|
|
125
152
|
<code
|
|
126
153
|
v-for="code in confirmedData.recovery_codes"
|
|
@@ -15,6 +15,8 @@ const supported = computed(() => (props.supported_settings === null ? null : new
|
|
|
15
15
|
const supports = (key) => supported.value === null || supported.value.has(key);
|
|
16
16
|
const supportsAny = (keys) => keys.some(supports);
|
|
17
17
|
const page = usePage();
|
|
18
|
+
// Absolute, so the snippet also works when pasted into another site.
|
|
19
|
+
const siteOrigin = typeof window === 'undefined' ? '' : window.location.origin;
|
|
18
20
|
|
|
19
21
|
const settingsData = () => ({
|
|
20
22
|
guest_tickets_enabled: props.settings.guest_tickets_enabled,
|
|
@@ -42,6 +44,7 @@ const settingsData = () => ({
|
|
|
42
44
|
widget_position: props.settings.widget_position ?? 'bottom-right',
|
|
43
45
|
widget_greeting: props.settings.widget_greeting ?? 'Hi there! How can we help?',
|
|
44
46
|
widget_departments: props.settings.widget_departments ?? '',
|
|
47
|
+
widget_allowed_origins: props.settings.widget_allowed_origins ?? '',
|
|
45
48
|
knowledge_base_enabled: props.settings.knowledge_base_enabled ?? true,
|
|
46
49
|
knowledge_base_public: props.settings.knowledge_base_public ?? true,
|
|
47
50
|
knowledge_base_feedback_enabled: props.settings.knowledge_base_feedback_enabled ?? true,
|
|
@@ -685,6 +688,7 @@ function submit() {
|
|
|
685
688
|
'widget_greeting',
|
|
686
689
|
'widget_position',
|
|
687
690
|
'widget_departments',
|
|
691
|
+
'widget_allowed_origins',
|
|
688
692
|
])
|
|
689
693
|
"
|
|
690
694
|
class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6"
|
|
@@ -719,7 +723,13 @@ function submit() {
|
|
|
719
723
|
<template
|
|
720
724
|
v-if="
|
|
721
725
|
form.widget_enabled &&
|
|
722
|
-
supportsAny([
|
|
726
|
+
supportsAny([
|
|
727
|
+
'widget_color',
|
|
728
|
+
'widget_greeting',
|
|
729
|
+
'widget_position',
|
|
730
|
+
'widget_departments',
|
|
731
|
+
'widget_allowed_origins',
|
|
732
|
+
])
|
|
723
733
|
"
|
|
724
734
|
>
|
|
725
735
|
<div v-if="supports('widget_color')">
|
|
@@ -799,6 +809,26 @@ function submit() {
|
|
|
799
809
|
</label>
|
|
800
810
|
</div>
|
|
801
811
|
</div>
|
|
812
|
+
<div v-if="supports('widget_allowed_origins')">
|
|
813
|
+
<label
|
|
814
|
+
for="widget-allowed-origins"
|
|
815
|
+
class="block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
|
|
816
|
+
>Sites that can embed the widget</label
|
|
817
|
+
>
|
|
818
|
+
<p class="mt-0.5 text-xs text-[var(--esc-panel-text-muted)]">
|
|
819
|
+
One site per line, such as https://shop.example.com. Use https://*.example.com for every
|
|
820
|
+
subdomain. Leave empty to allow this site only.
|
|
821
|
+
</p>
|
|
822
|
+
<textarea
|
|
823
|
+
id="widget-allowed-origins"
|
|
824
|
+
v-model="form.widget_allowed_origins"
|
|
825
|
+
rows="3"
|
|
826
|
+
maxlength="5000"
|
|
827
|
+
spellcheck="false"
|
|
828
|
+
placeholder="https://shop.example.com"
|
|
829
|
+
class="mt-2 w-full max-w-lg rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-3 py-2 font-mono text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none focus:ring-1 focus:ring-[var(--esc-panel-border-input)]"
|
|
830
|
+
></textarea>
|
|
831
|
+
</div>
|
|
802
832
|
<div
|
|
803
833
|
class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4"
|
|
804
834
|
>
|
|
@@ -810,8 +840,9 @@ function submit() {
|
|
|
810
840
|
</p>
|
|
811
841
|
<code
|
|
812
842
|
class="mt-2 block whitespace-pre-wrap break-all rounded-lg bg-[var(--esc-panel-bg)] p-3 text-xs text-[var(--esc-panel-text-tertiary)] select-all"
|
|
813
|
-
><script src="
|
|
814
|
-
|
|
843
|
+
><script src="{{ siteOrigin }}/{{
|
|
844
|
+
page.props.escalated?.prefix || 'support'
|
|
845
|
+
}}/widget/embed.js" data-color="{{ form.widget_color }}" data-position="{{
|
|
815
846
|
form.widget_position
|
|
816
847
|
}}"></script></code
|
|
817
848
|
>
|