@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
|
@@ -49,7 +49,9 @@ function applyQuickFilter(filter) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// Real-time updates - refresh the ticket list when new tickets arrive or statuses change
|
|
52
|
-
const { echoAvailable, subscribeToTickets, subscribeToAgent } = useRealtime(
|
|
52
|
+
const { echoAvailable, subscribeToTickets, subscribeToAgent } = useRealtime({
|
|
53
|
+
channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
|
|
54
|
+
});
|
|
53
55
|
|
|
54
56
|
onMounted(() => {
|
|
55
57
|
if (!echoAvailable.value) return;
|
|
@@ -92,7 +92,10 @@ const chatTypingUser = ref(null);
|
|
|
92
92
|
// Subscribe to chat channel for real-time messages. Pass widgetPath so
|
|
93
93
|
// useChat's API calls resolve against whatever prefix the host framework
|
|
94
94
|
// serves Escalated under (config on NestJS, default /support elsewhere).
|
|
95
|
-
const { subscribeToChat } = useChat({
|
|
95
|
+
const { subscribeToChat } = useChat({
|
|
96
|
+
channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
|
|
97
|
+
widgetPath: widgetPath.value,
|
|
98
|
+
});
|
|
96
99
|
let chatTypingTimer = null;
|
|
97
100
|
let chatUnsubscribe = null;
|
|
98
101
|
|
|
@@ -100,9 +103,11 @@ let chatUnsubscribe = null;
|
|
|
100
103
|
// reuses this page component across ticket navigation (setup() does not re-run),
|
|
101
104
|
// so without this the previous ticket's chat would persist.
|
|
102
105
|
watch(
|
|
103
|
-
() => props.ticket.id,
|
|
106
|
+
() => [props.ticket.id, page.props.escalated?.broadcasting?.channel_prefix],
|
|
104
107
|
() => {
|
|
105
108
|
chatMessages.value = props.ticket.chat_messages || [];
|
|
109
|
+
chatTypingUser.value = null;
|
|
110
|
+
clearTimeout(chatTypingTimer);
|
|
106
111
|
},
|
|
107
112
|
);
|
|
108
113
|
|
|
@@ -187,7 +192,9 @@ useKeyboardShortcuts({
|
|
|
187
192
|
});
|
|
188
193
|
|
|
189
194
|
// Real-time updates via WebSocket (gracefully degrades to no-op when Echo is absent)
|
|
190
|
-
const { echoAvailable, subscribeToTicket } = useRealtime(
|
|
195
|
+
const { echoAvailable, subscribeToTicket } = useRealtime({
|
|
196
|
+
channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
|
|
197
|
+
});
|
|
191
198
|
let ticketUnsubscribe = null;
|
|
192
199
|
|
|
193
200
|
// (Re)subscribe whenever the ticket id changes. Because Inertia reuses this
|
|
@@ -243,6 +250,7 @@ onUnmounted(() => {
|
|
|
243
250
|
<PresenceIndicator
|
|
244
251
|
:ticket-reference="ticket.reference"
|
|
245
252
|
:ticket-id="ticket.id"
|
|
253
|
+
:channel-prefix="page.props.escalated?.broadcasting?.channel_prefix"
|
|
246
254
|
route-prefix="escalated.agent"
|
|
247
255
|
/>
|
|
248
256
|
<div class="ml-auto flex items-center gap-2">
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
3
|
import FileDropzone from '../../components/FileDropzone.vue';
|
|
4
|
-
import
|
|
4
|
+
import GuestEmailCode from '../../components/GuestEmailCode.vue';
|
|
5
|
+
import { useGuestVerification } from '../../composables/useGuestVerification';
|
|
6
|
+
import { guestRequest } from '../../utils/guestRequests';
|
|
7
|
+
import { useForm, usePage, Link } from '@inertiajs/vue3';
|
|
8
|
+
import { reactive, ref, watch } from 'vue';
|
|
5
9
|
|
|
6
|
-
defineProps({
|
|
10
|
+
const props = defineProps({
|
|
7
11
|
departments: Array,
|
|
8
12
|
priorities: Array,
|
|
13
|
+
verification_url: { type: String, default: null },
|
|
14
|
+
lookup_url: { type: String, default: null },
|
|
9
15
|
});
|
|
10
16
|
|
|
11
17
|
const form = useForm({
|
|
@@ -16,10 +22,62 @@ const form = useForm({
|
|
|
16
22
|
priority: 'medium',
|
|
17
23
|
department_id: '',
|
|
18
24
|
attachments: [],
|
|
25
|
+
verification_id: '',
|
|
26
|
+
verification_code: '',
|
|
19
27
|
});
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
const page = usePage();
|
|
30
|
+
const context = () =>
|
|
31
|
+
`${props.verification_url}|${props.lookup_url}|${page.props.escalated?.broadcasting?.channel_prefix || ''}`;
|
|
32
|
+
const requestCode = (email, purpose) => guestRequest(props.verification_url, { email, purpose });
|
|
33
|
+
const verification = useGuestVerification(() => form.guest_email, 'ticket', requestCode, context);
|
|
34
|
+
const lookup = reactive({ email: '', reference: '', pending: false, error: '', searched: false });
|
|
35
|
+
const matches = ref([]);
|
|
36
|
+
watch(
|
|
37
|
+
() => `${lookup.email}|${lookup.reference}`,
|
|
38
|
+
() => {
|
|
39
|
+
matches.value = [];
|
|
40
|
+
lookup.searched = false;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
const lookupVerification = useGuestVerification(() => lookup.email, 'lookup', requestCode, context);
|
|
44
|
+
watch(context, () => {
|
|
45
|
+
form.cancel();
|
|
46
|
+
form.reset();
|
|
47
|
+
Object.assign(lookup, { email: '', reference: '', pending: false, error: '', searched: false });
|
|
48
|
+
matches.value = [];
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function submit() {
|
|
52
|
+
if (props.verification_url) {
|
|
53
|
+
if (!(await verification.ready())) return;
|
|
54
|
+
Object.assign(form, verification.fields());
|
|
55
|
+
}
|
|
56
|
+
form.post(route('escalated.guest.tickets.store'), { onSuccess: () => verification.reset() });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function findTickets() {
|
|
60
|
+
lookup.error = '';
|
|
61
|
+
if (!(await lookupVerification.ready())) return;
|
|
62
|
+
lookup.pending = true;
|
|
63
|
+
const current = context();
|
|
64
|
+
const email = lookup.email;
|
|
65
|
+
const reference = lookup.reference;
|
|
66
|
+
try {
|
|
67
|
+
const data = await guestRequest(props.lookup_url, {
|
|
68
|
+
email,
|
|
69
|
+
reference: lookup.reference,
|
|
70
|
+
...lookupVerification.fields(),
|
|
71
|
+
});
|
|
72
|
+
if (current !== context() || email !== lookup.email || reference !== lookup.reference) return;
|
|
73
|
+
matches.value = data.data || [];
|
|
74
|
+
lookup.searched = true;
|
|
75
|
+
lookupVerification.reset();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (current === context() && email === lookup.email) lookup.error = error.message;
|
|
78
|
+
} finally {
|
|
79
|
+
if (current === context()) lookup.pending = false;
|
|
80
|
+
}
|
|
23
81
|
}
|
|
24
82
|
</script>
|
|
25
83
|
|
|
@@ -28,7 +86,13 @@ function submit() {
|
|
|
28
86
|
<div class="mx-auto max-w-2xl">
|
|
29
87
|
<div class="mb-6 text-center">
|
|
30
88
|
<h1 class="text-xl font-semibold text-gray-900">Submit a Support Ticket</h1>
|
|
31
|
-
<p class="mt-1 text-sm text-gray-500">
|
|
89
|
+
<p class="mt-1 text-sm text-gray-500">
|
|
90
|
+
{{
|
|
91
|
+
verification_url
|
|
92
|
+
? 'No account needed. Verify your email to submit a ticket and receive a private link.'
|
|
93
|
+
: "No account needed. We'll give you a link to track your ticket."
|
|
94
|
+
}}
|
|
95
|
+
</p>
|
|
32
96
|
<Link :href="route('login')" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-700">
|
|
33
97
|
Already have an account? Sign in
|
|
34
98
|
</Link>
|
|
@@ -107,16 +171,82 @@ function submit() {
|
|
|
107
171
|
<FileDropzone v-model="form.attachments" />
|
|
108
172
|
</div>
|
|
109
173
|
|
|
174
|
+
<GuestEmailCode
|
|
175
|
+
v-if="verification_url"
|
|
176
|
+
:verification="verification"
|
|
177
|
+
@code="verification.state.code = $event"
|
|
178
|
+
/>
|
|
179
|
+
<p
|
|
180
|
+
v-if="form.errors.verification_code || form.errors.verification_id"
|
|
181
|
+
class="text-sm text-red-600"
|
|
182
|
+
role="alert"
|
|
183
|
+
>
|
|
184
|
+
{{ form.errors.verification_code || form.errors.verification_id }}
|
|
185
|
+
</p>
|
|
110
186
|
<div class="flex justify-end">
|
|
111
187
|
<button
|
|
112
188
|
type="submit"
|
|
113
|
-
:disabled="form.processing"
|
|
189
|
+
:disabled="form.processing || verification.state.pending"
|
|
114
190
|
class="rounded-lg bg-indigo-600 px-6 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
|
115
191
|
>
|
|
116
|
-
{{
|
|
192
|
+
{{
|
|
193
|
+
form.processing
|
|
194
|
+
? 'Submitting...'
|
|
195
|
+
: verification_url && !verification.state.id
|
|
196
|
+
? 'Send verification code'
|
|
197
|
+
: 'Submit Ticket'
|
|
198
|
+
}}
|
|
117
199
|
</button>
|
|
118
200
|
</div>
|
|
119
201
|
</form>
|
|
202
|
+
|
|
203
|
+
<form
|
|
204
|
+
v-if="lookup_url"
|
|
205
|
+
class="mt-8 space-y-4 rounded-lg border border-gray-200 bg-white p-6"
|
|
206
|
+
@submit.prevent="findTickets"
|
|
207
|
+
>
|
|
208
|
+
<h2 class="text-lg font-semibold">Find a ticket or renew your link</h2>
|
|
209
|
+
<label class="block text-sm"
|
|
210
|
+
>Email address
|
|
211
|
+
<input
|
|
212
|
+
v-model="lookup.email"
|
|
213
|
+
type="email"
|
|
214
|
+
required
|
|
215
|
+
class="mt-1 block w-full rounded-lg border-gray-300"
|
|
216
|
+
/>
|
|
217
|
+
</label>
|
|
218
|
+
<label class="block text-sm"
|
|
219
|
+
>Tracking or ticket reference
|
|
220
|
+
<input
|
|
221
|
+
v-model="lookup.reference"
|
|
222
|
+
type="text"
|
|
223
|
+
maxlength="255"
|
|
224
|
+
required
|
|
225
|
+
class="mt-1 block w-full rounded-lg border-gray-300"
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
<GuestEmailCode :verification="lookupVerification" @code="lookupVerification.state.code = $event" />
|
|
229
|
+
<p v-if="lookup.error" class="text-sm text-red-600" role="alert">{{ lookup.error }}</p>
|
|
230
|
+
<button
|
|
231
|
+
type="submit"
|
|
232
|
+
:disabled="lookup.pending || lookupVerification.state.pending"
|
|
233
|
+
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
234
|
+
>
|
|
235
|
+
{{ lookupVerification.state.id ? 'Find tickets' : 'Send verification code' }}
|
|
236
|
+
</button>
|
|
237
|
+
<p v-if="lookup.searched && !matches.length" class="text-sm">
|
|
238
|
+
No tickets matched that reference and email.
|
|
239
|
+
</p>
|
|
240
|
+
<ul v-if="matches.length" class="space-y-2">
|
|
241
|
+
<li v-for="match in matches" :key="match.reference">
|
|
242
|
+
<a
|
|
243
|
+
:href="route('escalated.guest.tickets.show', match.guest_access_token)"
|
|
244
|
+
class="text-indigo-600 underline"
|
|
245
|
+
>{{ match.reference }}: {{ match.subject }}</a
|
|
246
|
+
>
|
|
247
|
+
</li>
|
|
248
|
+
</ul>
|
|
249
|
+
</form>
|
|
120
250
|
</div>
|
|
121
251
|
</EscalatedLayout>
|
|
122
252
|
</template>
|
package/src/pages/Guest/Show.vue
CHANGED
|
@@ -52,6 +52,12 @@ function copyLink() {
|
|
|
52
52
|
<p class="mt-0.5 text-xs text-amber-600">
|
|
53
53
|
This is your private link to view and reply to this ticket.
|
|
54
54
|
</p>
|
|
55
|
+
<p v-if="ticket.guest_access_expires_at" class="mt-1 text-xs text-amber-600">
|
|
56
|
+
Expires {{ new Date(ticket.guest_access_expires_at).toLocaleString() }}.
|
|
57
|
+
<a :href="route('escalated.guest.tickets.create')" class="underline"
|
|
58
|
+
>Verify your email to renew it.</a
|
|
59
|
+
>
|
|
60
|
+
</p>
|
|
55
61
|
</div>
|
|
56
62
|
<button
|
|
57
63
|
class="ml-auto shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Never forward the embedding site's CSRF token to another origin. */
|
|
2
|
+
export function guestCsrfHeaders(url) {
|
|
3
|
+
if (typeof window === 'undefined' || new URL(url, window.location.href).origin !== window.location.origin)
|
|
4
|
+
return {};
|
|
5
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
6
|
+
if (token) return { 'X-CSRF-TOKEN': token };
|
|
7
|
+
const cookie = document.cookie.split('; ').find((value) => value.startsWith('XSRF-TOKEN='));
|
|
8
|
+
if (!cookie) return {};
|
|
9
|
+
try {
|
|
10
|
+
return { 'X-XSRF-TOKEN': decodeURIComponent(cookie.slice('XSRF-TOKEN='.length)) };
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function guestRequest(url, body) {
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
credentials: 'same-origin',
|
|
20
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...guestCsrfHeaders(url) },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
const data = await response.json();
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new Error(data.errors ? Object.values(data.errors).flat().join(' ') : data.message || 'Request failed.');
|
|
26
|
+
return data;
|
|
27
|
+
}
|