@escalated-dev/escalated 0.11.10 → 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 +103 -1
- package/dist/widget/escalated-widget.js +215 -0
- package/package.json +24 -5
- package/pages.json +7 -3
- package/src/components/ActiveChatsPanel.vue +4 -1
- package/src/components/ChatQueue.vue +4 -1
- package/src/components/GuestEmailCode.vue +58 -0
- package/src/components/PresenceIndicator.vue +25 -4
- package/src/components/ReportExportButton.vue +13 -0
- package/src/components/TotpInput.vue +1 -1
- package/src/composables/useChat.js +16 -33
- package/src/composables/useGuestVerification.js +48 -0
- package/src/composables/useKeyboardShortcuts.js +2 -0
- package/src/composables/useMentions.js +16 -11
- package/src/composables/useRealtime.js +108 -147
- package/src/pages/Admin/ApiTokens/Index.vue +3 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +34 -7
- package/src/pages/Admin/Settings.vue +273 -72
- package/src/pages/Agent/TicketIndex.vue +3 -1
- package/src/pages/Agent/TicketShow.vue +11 -3
- package/src/pages/Guest/Create.vue +137 -7
- package/src/pages/Guest/Show.vue +6 -0
- package/src/utils/flash.js +4 -0
- package/src/utils/guestRequests.js +27 -0
- package/src/widget/EscalatedWidget.vue +323 -86
- package/src/widget/embed.js +120 -0
- package/src/widget/index.js +93 -33
|
@@ -1,179 +1,140 @@
|
|
|
1
|
-
import { ref, onUnmounted, readonly } from 'vue';
|
|
1
|
+
import { ref, onUnmounted, readonly, toValue, watch } from 'vue';
|
|
2
|
+
|
|
3
|
+
// Multiple components can listen to the same channel. A component leaving must
|
|
4
|
+
// not disconnect the ticket updates or presence owned by another component.
|
|
5
|
+
const leases = new WeakMap();
|
|
6
|
+
function retainChannel(echo, name, presence) {
|
|
7
|
+
const key = `${presence ? 'presence' : 'private'}-${name}`;
|
|
8
|
+
let counts = leases.get(echo);
|
|
9
|
+
if (!counts) leases.set(echo, (counts = new Map()));
|
|
10
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
11
|
+
return () => {
|
|
12
|
+
const count = counts.get(key) - 1;
|
|
13
|
+
if (count > 0) counts.set(key, count);
|
|
14
|
+
else {
|
|
15
|
+
counts.delete(key);
|
|
16
|
+
if (echo.leaveChannel) echo.leaveChannel(key);
|
|
17
|
+
else echo.leave(presence ? `presence-${name}` : name);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
2
21
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* If `window.Echo` is available (provided by the host app), this composable
|
|
7
|
-
* subscribes to private channels and listens for broadcast events. When Echo
|
|
8
|
-
* is not present, it silently falls back - the caller can check `echoAvailable`
|
|
9
|
-
* and implement polling as needed.
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* const { echoAvailable, subscribeToTicket, subscribeToTickets, subscribeToAgent } = useRealtime();
|
|
13
|
-
*
|
|
14
|
-
* All subscriptions are automatically cleaned up on component unmount.
|
|
15
|
-
*/
|
|
16
|
-
export function useRealtime() {
|
|
22
|
+
/** Optional channelPrefix accepts a value, ref or getter. Older backends use escalated. */
|
|
23
|
+
export function useRealtime(options = {}) {
|
|
17
24
|
const echoAvailable = ref(typeof window !== 'undefined' && !!window.Echo);
|
|
18
|
-
const subscriptions =
|
|
25
|
+
const subscriptions = new Set();
|
|
26
|
+
|
|
27
|
+
function track(cleanup) {
|
|
28
|
+
let active = true;
|
|
29
|
+
const stop = () => {
|
|
30
|
+
if (!active) return;
|
|
31
|
+
active = false;
|
|
32
|
+
subscriptions.delete(stop);
|
|
33
|
+
cleanup();
|
|
34
|
+
};
|
|
35
|
+
subscriptions.add(stop);
|
|
36
|
+
return stop;
|
|
37
|
+
}
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
* Subscribe to a private channel and listen for an event.
|
|
22
|
-
* Returns an unsubscribe function.
|
|
23
|
-
*/
|
|
39
|
+
// Raw channel names remain literal for existing integrations.
|
|
24
40
|
function listen(channelName, eventName, callback) {
|
|
25
41
|
if (!echoAvailable.value) return () => {};
|
|
26
|
-
|
|
27
|
-
const channel =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
window.Echo.leave(channelName);
|
|
42
|
+
const echo = window.Echo;
|
|
43
|
+
const channel = echo.private(channelName);
|
|
44
|
+
const release = retainChannel(echo, channelName, false);
|
|
45
|
+
let active = true;
|
|
46
|
+
const guarded = (...args) => {
|
|
47
|
+
if (active) callback(...args);
|
|
33
48
|
};
|
|
34
|
-
|
|
35
|
-
return
|
|
49
|
+
channel.listen(eventName, guarded);
|
|
50
|
+
return track(() => {
|
|
51
|
+
active = false;
|
|
52
|
+
channel.stopListening(eventName, guarded);
|
|
53
|
+
release();
|
|
54
|
+
});
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const unsub = () => {
|
|
79
|
-
listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
|
|
80
|
-
window.Echo.leave(channelName);
|
|
57
|
+
function scopedSubscription(suffix, handlers, presence = false) {
|
|
58
|
+
let currentChannel = null;
|
|
59
|
+
const stopWatch = watch(
|
|
60
|
+
() => {
|
|
61
|
+
const tail = toValue(suffix);
|
|
62
|
+
return tail == null ? null : `${toValue(options.channelPrefix) ?? 'escalated'}.${tail}`;
|
|
63
|
+
},
|
|
64
|
+
(name, previous, onCleanup) => {
|
|
65
|
+
if (!name || !echoAvailable.value) return;
|
|
66
|
+
const echo = window.Echo;
|
|
67
|
+
const channel = presence ? echo.join(name) : echo.private(name);
|
|
68
|
+
currentChannel = channel;
|
|
69
|
+
const release = retainChannel(echo, name, presence);
|
|
70
|
+
let active = true;
|
|
71
|
+
const listeners = Object.entries(handlers)
|
|
72
|
+
.filter(([, callback]) => typeof callback === 'function')
|
|
73
|
+
.map(([event, callback]) => {
|
|
74
|
+
// Ignore an old socket callback already queued during account navigation.
|
|
75
|
+
const guarded = (...args) => {
|
|
76
|
+
if (active) callback(...args);
|
|
77
|
+
};
|
|
78
|
+
if (presence) channel[event](guarded);
|
|
79
|
+
else channel.listen(event, guarded);
|
|
80
|
+
return [event, guarded];
|
|
81
|
+
});
|
|
82
|
+
onCleanup(() => {
|
|
83
|
+
active = false;
|
|
84
|
+
currentChannel = null;
|
|
85
|
+
if (!presence) listeners.forEach(([event, callback]) => channel.stopListening(event, callback));
|
|
86
|
+
release();
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
{ immediate: true, flush: 'sync' },
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
get channel() {
|
|
93
|
+
return currentChannel;
|
|
94
|
+
},
|
|
95
|
+
unsubscribe: track(stopWatch),
|
|
81
96
|
};
|
|
82
|
-
subscriptions.push(unsub);
|
|
83
|
-
return unsub;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* @param {Object} handlers - Event handler callbacks
|
|
90
|
-
* @param {Function} [handlers.onTicketCreated] - Called when a new ticket is created
|
|
91
|
-
* @returns {Function} Unsubscribe function
|
|
92
|
-
*/
|
|
93
|
-
function subscribeToTickets(handlers = {}) {
|
|
94
|
-
if (!echoAvailable.value) return () => {};
|
|
99
|
+
function listenScoped(suffix, handlers) {
|
|
100
|
+
return scopedSubscription(suffix, handlers).unsubscribe;
|
|
101
|
+
}
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
function identitySuffix(kind, id) {
|
|
104
|
+
return () => (toValue(id) == null ? null : `${kind}.${toValue(id)}`);
|
|
105
|
+
}
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
function subscribeToTicket(ticketId, handlers = {}) {
|
|
108
|
+
return listenScoped(identitySuffix('tickets', ticketId), {
|
|
109
|
+
'.reply.created': handlers.onReplyCreated,
|
|
110
|
+
'.ticket.updated': handlers.onTicketUpdated,
|
|
111
|
+
'.ticket.status_changed': handlers.onStatusChanged,
|
|
112
|
+
'.ticket.assigned': handlers.onTicketAssigned,
|
|
113
|
+
'.ticket.escalated': handlers.onTicketEscalated,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
window.Echo.leave(channelName);
|
|
108
|
-
};
|
|
109
|
-
subscriptions.push(unsub);
|
|
110
|
-
return unsub;
|
|
117
|
+
function subscribeToTickets(handlers = {}) {
|
|
118
|
+
return listenScoped('tickets', { '.ticket.created': handlers.onTicketCreated });
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
/**
|
|
114
|
-
* Subscribe to an agent's personal channel for assignment notifications.
|
|
115
|
-
*
|
|
116
|
-
* @param {number|string} agentId - The agent's user ID
|
|
117
|
-
* @param {Object} handlers - Event handler callbacks
|
|
118
|
-
* @param {Function} [handlers.onTicketAssigned] - Called when a ticket is assigned to this agent
|
|
119
|
-
* @returns {Function} Unsubscribe function
|
|
120
|
-
*/
|
|
121
121
|
function subscribeToAgent(agentId, handlers = {}) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const channelName = `escalated.agents.${agentId}`;
|
|
125
|
-
const channel = window.Echo.private(channelName);
|
|
126
|
-
const listeners = [];
|
|
127
|
-
|
|
128
|
-
if (handlers.onTicketAssigned) {
|
|
129
|
-
channel.listen('.ticket.assigned', handlers.onTicketAssigned);
|
|
130
|
-
listeners.push(['.ticket.assigned', handlers.onTicketAssigned]);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const unsub = () => {
|
|
134
|
-
listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
|
|
135
|
-
window.Echo.leave(channelName);
|
|
136
|
-
};
|
|
137
|
-
subscriptions.push(unsub);
|
|
138
|
-
return unsub;
|
|
122
|
+
return listenScoped(identitySuffix('agents', agentId), { '.ticket.assigned': handlers.onTicketAssigned });
|
|
139
123
|
}
|
|
140
124
|
|
|
141
|
-
/**
|
|
142
|
-
* Get a presence channel for a ticket (if Echo supports it).
|
|
143
|
-
* Returns the channel instance or null.
|
|
144
|
-
*/
|
|
145
125
|
function joinTicketPresence(ticketId, callbacks = {}) {
|
|
146
126
|
if (!echoAvailable.value) return null;
|
|
147
|
-
|
|
148
|
-
const channelName = `escalated.tickets.${ticketId}`;
|
|
149
|
-
const channel = window.Echo.join(channelName);
|
|
150
|
-
|
|
151
|
-
if (callbacks.here) channel.here(callbacks.here);
|
|
152
|
-
if (callbacks.joining) channel.joining(callbacks.joining);
|
|
153
|
-
if (callbacks.leaving) channel.leaving(callbacks.leaving);
|
|
154
|
-
|
|
155
|
-
const unsub = () => {
|
|
156
|
-
window.Echo.leave(`presence-${channelName}`);
|
|
157
|
-
};
|
|
158
|
-
subscriptions.push(unsub);
|
|
159
|
-
return { channel, unsubscribe: unsub };
|
|
127
|
+
return scopedSubscription(identitySuffix('tickets', ticketId), callbacks, true);
|
|
160
128
|
}
|
|
161
129
|
|
|
162
|
-
// Cleanup all subscriptions on component unmount
|
|
163
130
|
onUnmounted(() => {
|
|
164
|
-
subscriptions.forEach((
|
|
165
|
-
try {
|
|
166
|
-
unsub();
|
|
167
|
-
} catch {
|
|
168
|
-
/* ignore */
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
subscriptions.length = 0;
|
|
131
|
+
[...subscriptions].forEach((unsubscribe) => unsubscribe());
|
|
172
132
|
});
|
|
173
133
|
|
|
174
134
|
return {
|
|
175
135
|
echoAvailable: readonly(echoAvailable),
|
|
176
136
|
listen,
|
|
137
|
+
listenScoped,
|
|
177
138
|
subscribeToTicket,
|
|
178
139
|
subscribeToTickets,
|
|
179
140
|
subscribeToAgent,
|
|
@@ -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"
|