@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.
@@ -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
- * Composable for real-time WebSocket event listening via Laravel Echo.
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 = window.Echo.private(channelName);
28
- channel.listen(eventName, callback);
29
-
30
- const unsub = () => {
31
- channel.stopListening(eventName, callback);
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
- subscriptions.push(unsub);
35
- return unsub;
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
- * Subscribe to a specific ticket's channel for real-time updates.
40
- *
41
- * @param {number|string} ticketId - The ticket ID
42
- * @param {Object} handlers - Event handler callbacks
43
- * @param {Function} [handlers.onReplyCreated] - Called when a new reply is added
44
- * @param {Function} [handlers.onTicketUpdated] - Called when ticket fields are updated
45
- * @param {Function} [handlers.onStatusChanged] - Called when ticket status changes
46
- * @param {Function} [handlers.onTicketAssigned] - Called when ticket is assigned
47
- * @param {Function} [handlers.onTicketEscalated] - Called when ticket is escalated
48
- * @returns {Function} Unsubscribe function
49
- */
50
- function subscribeToTicket(ticketId, handlers = {}) {
51
- if (!echoAvailable.value) return () => {};
52
-
53
- const channelName = `escalated.tickets.${ticketId}`;
54
- const channel = window.Echo.private(channelName);
55
- const listeners = [];
56
-
57
- if (handlers.onReplyCreated) {
58
- channel.listen('.reply.created', handlers.onReplyCreated);
59
- listeners.push(['.reply.created', handlers.onReplyCreated]);
60
- }
61
- if (handlers.onTicketUpdated) {
62
- channel.listen('.ticket.updated', handlers.onTicketUpdated);
63
- listeners.push(['.ticket.updated', handlers.onTicketUpdated]);
64
- }
65
- if (handlers.onStatusChanged) {
66
- channel.listen('.ticket.status_changed', handlers.onStatusChanged);
67
- listeners.push(['.ticket.status_changed', handlers.onStatusChanged]);
68
- }
69
- if (handlers.onTicketAssigned) {
70
- channel.listen('.ticket.assigned', handlers.onTicketAssigned);
71
- listeners.push(['.ticket.assigned', handlers.onTicketAssigned]);
72
- }
73
- if (handlers.onTicketEscalated) {
74
- channel.listen('.ticket.escalated', handlers.onTicketEscalated);
75
- listeners.push(['.ticket.escalated', handlers.onTicketEscalated]);
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
- * Subscribe to the global tickets channel for new ticket notifications.
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
- const channelName = 'escalated.tickets';
97
- const channel = window.Echo.private(channelName);
98
- const listeners = [];
103
+ function identitySuffix(kind, id) {
104
+ return () => (toValue(id) == null ? null : `${kind}.${toValue(id)}`);
105
+ }
99
106
 
100
- if (handlers.onTicketCreated) {
101
- channel.listen('.ticket.created', handlers.onTicketCreated);
102
- listeners.push(['.ticket.created', handlers.onTicketCreated]);
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
- const unsub = () => {
106
- listeners.forEach(([event, cb]) => channel.stopListening(event, cb));
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
- if (!echoAvailable.value) return () => {};
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((unsub) => {
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 flash = page.props.flash || {};
37
- if (flash.plain_text_token) {
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 setupData = computed(() => page.props.flash?.two_factor_setup);
15
- const confirmedData = computed(() => page.props.flash?.two_factor_confirmed);
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 = true;
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 && !setupData">
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="navigator.clipboard?.writeText(confirmedData.recovery_codes.join('\n'))"
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"