@escalated-dev/escalated 0.11.9 → 0.12.0

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 CHANGED
@@ -359,6 +359,42 @@ npm install @escalated-dev/plugin-jira
359
359
 
360
360
  ## For Package Maintainers
361
361
 
362
+ Verified guest access is enabled by backend capabilities. `Escalated/Guest/Create`
363
+ accepts optional `verification_url` and `lookup_url` props. With these URLs, the
364
+ form requests an email code before posting a ticket; a separate form verifies
365
+ email to find existing tickets by tracking/ticket reference and renew private
366
+ links. `Guest/Show` displays `ticket.guest_access_expires_at` when supplied.
367
+
368
+ The built-in widget enables the same flow only when `/config` returns
369
+ `guest_verification_required: true`. The backend must provide POST `/verification`
370
+ with email and purpose (`ticket`, `chat`, `lookup`), returning `verification_id`;
371
+ creation accepts `verification_id` and the eight-digit `verification_code`.
372
+ POST `/lookup` returns `{ data: [{ reference, subject, guest_access_token,
373
+ expires_at }] }`; subsequent widget status reads send the token as a Bearer
374
+ header. Chat creation returns an opaque `id` and polling uses
375
+ `/chat/{id}/messages`. Proofs and private results are cleared on destination or
376
+ identity changes. Tokens remain in component memory. CSRF headers are sent only
377
+ to the page's own origin.
378
+
379
+ Backends that omit these capabilities retain their existing form contract. Hosts
380
+ must ship these forms before requiring verification in their backend. This does
381
+ not implement verification in the other backend packages, release the separate
382
+ web-widget plugin, or configure cross-origin CORS/CSRF policy.
383
+
384
+ Backends with account isolation can share `escalated.broadcasting.channel_prefix`
385
+ through Inertia, for example `escalated.tenants.<opaque-account-namespace>`.
386
+ Ticket, agent, chat and presence subscriptions use this prefix and rejoin when it
387
+ changes. Omitting it preserves the existing `escalated` channels. Standalone
388
+ `PresenceIndicator` consumers can pass its `channelPrefix` prop; `useRealtime`
389
+ and `useChat` accept `channelPrefix` as a string, ref or getter. Raw
390
+ `useRealtime().listen(channel, event, callback)` still treats the channel literally.
391
+
392
+ The backend must authorize the current account and each private/presence join.
393
+ Channel names alone are not authorization. Ticket presence should expose only
394
+ agent identity/display names, with requester access limited to private ticket
395
+ updates. Deploy a frontend version containing this support before enabling a
396
+ backend's account-scoped channels.
397
+
362
398
  If you're building a new backend integration, this package is available on npm:
363
399
 
364
400
  ```bash
@@ -378,6 +414,21 @@ import CustomerIndex from '@escalated-dev/escalated/pages/Customer/Index.vue'
378
414
 
379
415
  Peer dependencies: `vue` ^3.3.0, `@inertiajs/vue3` ^1.0.0 || ^2.0.0
380
416
 
417
+ ## Backend settings capabilities
418
+
419
+ The `Escalated/Admin/Settings` page accepts optional `supported_settings` and
420
+ `update_url` props. Pass an array of editable setting names to show and submit only
421
+ those fields, and a URL accepting a flat POST payload. An empty array exposes no
422
+ editable settings. Omitting these props preserves the full settings form and its
423
+ Laravel named route. Backends must validate their own setting allowlist; hiding a
424
+ control is not authorization.
425
+
426
+ For example, a backend implementing only knowledge-base and branding preferences
427
+ can expose `knowledge_base_enabled`, `knowledge_base_public`,
428
+ `knowledge_base_feedback_enabled`, and `show_powered_by`. Supply typed values in
429
+ the `settings` prop. Changing the settings/account props clears stale form values
430
+ and pending requests, including previously entered credentials.
431
+
381
432
  ## Ecosystem
382
433
 
383
434
  This is the shared frontend for the Escalated support ticket system. Host framework packages and client SDKs available across the ecosystem:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.11.9",
3
+ "version": "0.12.0",
4
4
  "description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
5
5
  "author": "Escalated Dev <hello@escalated.dev>",
6
6
  "license": "MIT",
@@ -59,6 +59,11 @@
59
59
  "prettier --write"
60
60
  ]
61
61
  },
62
+ "overrides": {
63
+ "concurrently": {
64
+ "shell-quote": "^1.12.0"
65
+ }
66
+ },
62
67
  "devDependencies": {
63
68
  "@eslint/js": "^10.0.1",
64
69
  "@inertiajs/vue3": "^3.7.0",
package/pages.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "Generated by scripts/generate-page-manifest.js from src/pages. Every name here resolves to a component; a name a backend renders that is not here is a blank screen. `props` is what each component reads -- a backend sending something else renders the screen with no content in it.",
3
- "version": "0.11.9",
3
+ "version": "0.12.0",
4
4
  "pages": [
5
5
  "Escalated/Admin/ApiTokens/Index",
6
6
  "Escalated/Admin/AuditLog/Index",
@@ -564,7 +564,9 @@
564
564
  "Escalated/Admin/Settings": {
565
565
  "props": [
566
566
  "departments",
567
- "settings"
567
+ "settings",
568
+ "supported_settings",
569
+ "update_url"
568
570
  ],
569
571
  "required": []
570
572
  },
@@ -837,7 +839,9 @@
837
839
  "Escalated/Guest/Create": {
838
840
  "props": [
839
841
  "departments",
840
- "priorities"
842
+ "lookup_url",
843
+ "priorities",
844
+ "verification_url"
841
845
  ],
842
846
  "required": []
843
847
  },
@@ -95,7 +95,10 @@ function chatInitials(chat) {
95
95
  // that use '/escalated/widget' instead of '/support/widget'.
96
96
  const page = usePage();
97
97
  const routePrefix = page.props.escalated?.prefix || 'support';
98
- const { subscribeToChatQueue } = useChat({ widgetPath: `/${routePrefix}/widget` });
98
+ const { subscribeToChatQueue } = useChat({
99
+ channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
100
+ widgetPath: `/${routePrefix}/widget`,
101
+ });
99
102
 
100
103
  onMounted(() => {
101
104
  subscribeToChatQueue({
@@ -73,7 +73,10 @@ async function acceptChat(session) {
73
73
  // other framework ('/support/widget').
74
74
  const page = usePage();
75
75
  const routePrefix = page.props.escalated?.prefix || 'support';
76
- const { subscribeToChatQueue } = useChat({ widgetPath: `/${routePrefix}/widget` });
76
+ const { subscribeToChatQueue } = useChat({
77
+ channelPrefix: () => page.props.escalated?.broadcasting?.channel_prefix,
78
+ widgetPath: `/${routePrefix}/widget`,
79
+ });
77
80
 
78
81
  onMounted(() => {
79
82
  subscribeToChatQueue({
@@ -0,0 +1,58 @@
1
+ <script setup>
2
+ defineProps({ verification: { type: Object, required: true } });
3
+ defineEmits(['code']);
4
+ </script>
5
+
6
+ <template>
7
+ <div class="guest-email-code space-y-2" aria-live="polite">
8
+ <p v-if="verification.state.message" class="text-sm">{{ verification.state.message }}</p>
9
+ <p v-if="verification.state.error" class="text-sm text-red-600" role="alert">{{ verification.state.error }}</p>
10
+ <label v-if="verification.state.id" class="block text-sm">
11
+ Email verification code
12
+ <input
13
+ :value="verification.state.code"
14
+ type="text"
15
+ inputmode="numeric"
16
+ autocomplete="one-time-code"
17
+ maxlength="8"
18
+ class="mt-1 block w-full rounded-lg border border-gray-300 p-2 text-gray-900"
19
+ @input="$emit('code', $event.target.value)"
20
+ />
21
+ </label>
22
+ <button
23
+ v-if="verification.state.id"
24
+ type="button"
25
+ class="text-sm underline"
26
+ :disabled="verification.state.pending"
27
+ @click="verification.send()"
28
+ >
29
+ Send a new code
30
+ </button>
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .guest-email-code {
36
+ margin: 12px 0;
37
+ font-size: 14px;
38
+ }
39
+ .guest-email-code label,
40
+ .guest-email-code input {
41
+ display: block;
42
+ }
43
+ .guest-email-code input {
44
+ box-sizing: border-box;
45
+ width: 100%;
46
+ margin-top: 6px;
47
+ padding: 8px;
48
+ border: 1px solid #d1d5db;
49
+ border-radius: 8px;
50
+ color: #111827;
51
+ background: #fff;
52
+ }
53
+ .guest-email-code button {
54
+ margin-top: 8px;
55
+ cursor: pointer;
56
+ text-decoration: underline;
57
+ }
58
+ </style>
@@ -4,6 +4,7 @@ import { ref, computed, inject, onUnmounted, watch } from 'vue';
4
4
  const props = defineProps({
5
5
  ticketReference: { type: String, required: true },
6
6
  ticketId: { type: [Number, String], default: null },
7
+ channelPrefix: { type: String, default: 'escalated' },
7
8
  routePrefix: { type: String, required: true },
8
9
  pollInterval: { type: Number, default: 30000 },
9
10
  showLabel: { type: Boolean, default: false },
@@ -23,11 +24,18 @@ let presenceChannel = null;
23
24
  // Track the exact channel name we joined so we leave the RIGHT channel when the
24
25
  // ticket changes — props.ticketId would already hold the new id by then.
25
26
  let joinedChannelName = null;
27
+ let generation = 0;
28
+ let pendingRequest = null;
26
29
 
27
30
  async function fetchPresence() {
31
+ const currentGeneration = generation;
32
+ pendingRequest?.abort();
33
+ const controller = new window.AbortController();
34
+ pendingRequest = controller;
28
35
  try {
29
36
  const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
30
37
  method: 'POST',
38
+ signal: controller.signal,
31
39
  headers: {
32
40
  Accept: 'application/json',
33
41
  'Content-Type': 'application/json',
@@ -37,7 +45,9 @@ async function fetchPresence() {
37
45
  });
38
46
  if (response.ok) {
39
47
  const data = await response.json();
40
- viewers.value = data.viewers || data || [];
48
+ if (currentGeneration === generation && !controller.signal.aborted) {
49
+ viewers.value = data.viewers || data || [];
50
+ }
41
51
  }
42
52
  } catch {
43
53
  // silently ignore network errors
@@ -52,19 +62,23 @@ function initPresenceChannel() {
52
62
  if (!echoAvailable || !props.ticketId) return false;
53
63
 
54
64
  try {
55
- joinedChannelName = `escalated.tickets.${props.ticketId}`;
65
+ const currentGeneration = generation;
66
+ joinedChannelName = `${props.channelPrefix}.tickets.${props.ticketId}`;
56
67
  presenceChannel = window.Echo.join(joinedChannelName);
57
68
 
58
69
  presenceChannel
59
70
  .here((users) => {
71
+ if (currentGeneration !== generation) return;
60
72
  viewers.value = users;
61
73
  })
62
74
  .joining((user) => {
75
+ if (currentGeneration !== generation) return;
63
76
  if (!viewers.value.find((v) => v.id === user.id)) {
64
77
  viewers.value = [...viewers.value, user];
65
78
  }
66
79
  })
67
80
  .leaving((user) => {
81
+ if (currentGeneration !== generation) return;
68
82
  viewers.value = viewers.value.filter((v) => v.id !== user.id);
69
83
  });
70
84
 
@@ -104,13 +118,17 @@ function getColor(index) {
104
118
  }
105
119
 
106
120
  function teardown() {
121
+ generation++;
122
+ pendingRequest?.abort();
123
+ pendingRequest = null;
107
124
  if (intervalId) {
108
125
  clearInterval(intervalId);
109
126
  intervalId = null;
110
127
  }
111
128
  if (presenceChannel) {
112
129
  try {
113
- window.Echo.leave(joinedChannelName);
130
+ if (window.Echo.leaveChannel) window.Echo.leaveChannel(`presence-${joinedChannelName}`);
131
+ else window.Echo.leave(`presence-${joinedChannelName}`);
114
132
  } catch {
115
133
  // ignore
116
134
  }
@@ -134,7 +152,10 @@ function start() {
134
152
  }
135
153
 
136
154
  // (Re)initialise whenever the ticket identity changes; `immediate` covers mount.
137
- watch(() => [props.ticketId, props.ticketReference], start, { immediate: true });
155
+ watch(() => [props.ticketId, props.ticketReference, props.routePrefix, props.channelPrefix], start, {
156
+ immediate: true,
157
+ flush: 'sync',
158
+ });
138
159
 
139
160
  onUnmounted(teardown);
140
161
  </script>
@@ -51,17 +51,20 @@ function selectTab(tab) {
51
51
  activeTab === tab.key
52
52
  ? escDark
53
53
  ? 'text-cyan-400 focus-visible:ring-cyan-500/40'
54
- : 'text-blue-600 focus-visible:ring-blue-500/40'
54
+ : 'text-[var(--esc-panel-accent)] focus-visible:ring-[var(--esc-panel-accent)]'
55
55
  : escDark
56
56
  ? 'text-neutral-500 hover:text-neutral-300 focus-visible:ring-cyan-500/40'
57
- : 'text-gray-500 hover:text-gray-700 focus-visible:ring-blue-500/40',
57
+ : 'text-gray-500 hover:text-gray-700 focus-visible:ring-[var(--esc-panel-accent)]',
58
58
  ]"
59
59
  @click="selectTab(tab)"
60
60
  >
61
61
  {{ tab.label }}
62
62
  <span
63
63
  v-if="activeTab === tab.key"
64
- :class="['absolute bottom-0 left-0 right-0 h-0.5 rounded-t', escDark ? 'bg-cyan-400' : 'bg-blue-600']"
64
+ :class="[
65
+ 'absolute bottom-0 left-0 right-0 h-0.5 rounded-t',
66
+ escDark ? 'bg-cyan-400' : 'bg-[var(--esc-panel-accent)]',
67
+ ]"
65
68
  ></span>
66
69
  </button>
67
70
  </div>
@@ -439,7 +439,7 @@ const colCount = computed(() => {
439
439
  type="checkbox"
440
440
  :checked="allSelected"
441
441
  aria-label="Select all tickets"
442
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
442
+ class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
443
443
  @change="toggleAll"
444
444
  />
445
445
  </th>
@@ -490,7 +490,7 @@ const colCount = computed(() => {
490
490
  <input
491
491
  type="checkbox"
492
492
  :checked="isColumnActive(col.key)"
493
- class="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
493
+ class="h-3.5 w-3.5 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
494
494
  @change="toggleColumn(col.key)"
495
495
  />
496
496
  {{ col.label }}
@@ -504,14 +504,17 @@ const colCount = computed(() => {
504
504
  <tr
505
505
  v-for="(ticket, index) in tickets.data"
506
506
  :key="ticket.id"
507
- :class="['group', navigable && focusedIndex === index ? 'bg-indigo-50' : 'hover:bg-gray-50']"
507
+ :class="[
508
+ 'group',
509
+ navigable && focusedIndex === index ? 'bg-[var(--esc-panel-active)]' : 'hover:bg-gray-50',
510
+ ]"
508
511
  >
509
512
  <td v-if="selectable" class="w-10 px-3 py-3">
510
513
  <input
511
514
  type="checkbox"
512
515
  :checked="selectedIds.includes(ticket.id)"
513
516
  aria-label="Select ticket"
514
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
517
+ class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
515
518
  @change="toggleOne(ticket.id)"
516
519
  />
517
520
  </td>
@@ -527,7 +530,7 @@ const colCount = computed(() => {
527
530
  ></span>
528
531
  <Link
529
532
  :href="route(`${routePrefix}.show`, ticket.reference)"
530
- class="text-indigo-600 hover:text-indigo-900"
533
+ class="text-[var(--esc-panel-accent)] hover:text-[var(--esc-panel-accent-hover)]"
531
534
  >
532
535
  {{ ticket.reference }}
533
536
  </Link>
@@ -20,7 +20,7 @@ export function useChat(options = {}) {
20
20
  const connectionState = ref('disconnected'); // disconnected | connecting | connected
21
21
  const messageBuffer = ref([]);
22
22
 
23
- const { echoAvailable, listen } = useRealtime();
23
+ const { echoAvailable, listenScoped } = useRealtime(options);
24
24
 
25
25
  function csrfToken() {
26
26
  return typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.content || '' : '';
@@ -123,26 +123,17 @@ export function useChat(options = {}) {
123
123
  function subscribeToChat(sessionId, callbacks = {}) {
124
124
  if (!echoAvailable.value) return () => {};
125
125
 
126
- const channelName = `escalated.chat.${sessionId}`;
127
- const unsubs = [];
128
-
129
- if (callbacks.onMessage) {
130
- unsubs.push(listen(channelName, '.chat.message', callbacks.onMessage));
131
- }
132
- if (callbacks.onTyping) {
133
- unsubs.push(listen(channelName, '.chat.typing', callbacks.onTyping));
134
- }
135
- if (callbacks.onEnded) {
136
- unsubs.push(listen(channelName, '.chat.ended', callbacks.onEnded));
137
- }
138
- if (callbacks.onAssigned) {
139
- unsubs.push(listen(channelName, '.chat.assigned', callbacks.onAssigned));
140
- }
126
+ const unsubscribe = listenScoped(`chat.${sessionId}`, {
127
+ '.chat.message': callbacks.onMessage,
128
+ '.chat.typing': callbacks.onTyping,
129
+ '.chat.ended': callbacks.onEnded,
130
+ '.chat.assigned': callbacks.onAssigned,
131
+ });
141
132
 
142
133
  connectionState.value = 'connected';
143
134
 
144
135
  return () => {
145
- unsubs.forEach((fn) => fn());
136
+ unsubscribe();
146
137
  connectionState.value = 'disconnected';
147
138
  };
148
139
  }
@@ -157,19 +148,10 @@ export function useChat(options = {}) {
157
148
  function subscribeToChatQueue(callbacks = {}) {
158
149
  if (!echoAvailable.value) return () => {};
159
150
 
160
- const channelName = 'escalated.chat.queue';
161
- const unsubs = [];
162
-
163
- if (callbacks.onNewChat) {
164
- unsubs.push(listen(channelName, '.chat.new', callbacks.onNewChat));
165
- }
166
- if (callbacks.onChatAccepted) {
167
- unsubs.push(listen(channelName, '.chat.accepted', callbacks.onChatAccepted));
168
- }
169
-
170
- return () => {
171
- unsubs.forEach((fn) => fn());
172
- };
151
+ return listenScoped('chat.queue', {
152
+ '.chat.new': callbacks.onNewChat,
153
+ '.chat.accepted': callbacks.onChatAccepted,
154
+ });
173
155
  }
174
156
 
175
157
  return {
@@ -0,0 +1,48 @@
1
+ import { onScopeDispose, reactive, watch } from 'vue';
2
+
3
+ /** Keep each mailbox proof tied to the form and destination that requested it. */
4
+ export function useGuestVerification(email, purpose, requestCode, context = () => '') {
5
+ const state = reactive({ id: '', code: '', message: '', error: '', pending: false });
6
+ let generation = 0;
7
+ function reset() {
8
+ generation++;
9
+ Object.assign(state, { id: '', code: '', message: '', error: '', pending: false });
10
+ }
11
+ watch([email, context], reset, { flush: 'sync' });
12
+ onScopeDispose(reset);
13
+
14
+ async function send() {
15
+ if (state.pending) return;
16
+ reset();
17
+ const current = generation;
18
+ state.pending = true;
19
+ try {
20
+ const result = await requestCode(email(), purpose);
21
+ if (current !== generation) return;
22
+ if (typeof result.verification_id !== 'string' || !result.verification_id) {
23
+ throw new Error('Could not request a verification code.');
24
+ }
25
+ state.id = result.verification_id;
26
+ state.message = 'Check your email. Enter the 8-digit code to continue; it expires in 10 minutes.';
27
+ } catch (error) {
28
+ if (current === generation) state.error = error.message || 'Could not request a verification code.';
29
+ } finally {
30
+ if (current === generation) state.pending = false;
31
+ }
32
+ }
33
+
34
+ async function ready() {
35
+ if (!state.id) {
36
+ await send();
37
+ return false;
38
+ }
39
+ state.error = '';
40
+ if (!/^[0-9]{8}$/.test(state.code)) {
41
+ state.error = 'Enter the 8-digit code from your email.';
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ return { state, ready, send, reset, fields: () => ({ verification_id: state.id, verification_code: state.code }) };
48
+ }