@visns-studio/visns-components 6.6.4 → 6.15.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.
Files changed (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. package/src/index.js +233 -1
@@ -0,0 +1,468 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Loader2, MessageSquare, Unplug, TriangleAlert } from 'lucide-react';
3
+
4
+ import CustomFetch from '../Fetch';
5
+ import { notify as desktopNotify } from '../notify/desktopNotifications';
6
+ import styles from '../styles/Sms.module.scss';
7
+ // The HOC is named for the vault because that is where it was written, but
8
+ // nothing in it is vault-specific: it hands a component a `navigate` that
9
+ // works inside a Router and outside one. Imported rather than copied so there
10
+ // is one implementation of that decision in the library.
11
+ import withVaultNavigation from '../vault/vaultNavigation';
12
+ import { resolveSmsEndpoints } from './smsEndpoints';
13
+ import useSmsLive, { defaultChannelFor } from './useSmsLive';
14
+ import {
15
+ badgeCount,
16
+ initialsFor,
17
+ relativeTime,
18
+ threadDisplayName,
19
+ upsertThread,
20
+ } from './smsHelpers';
21
+
22
+ /** The one-line preview under a thread's name. */
23
+ const previewOf = (thread) => {
24
+ const message = thread?.last_message;
25
+
26
+ if (!message || !message.body) return 'No messages yet';
27
+
28
+ const body = String(message.body).replace(/\s+/g, ' ').trim();
29
+
30
+ return message.direction === 'out' ? `You: ${body}` : body;
31
+ };
32
+
33
+ /**
34
+ * Messages, from the header.
35
+ *
36
+ * Mounted as an account action through Navigation's `renderers` slot, so it
37
+ * sits in the chip row beside the notification bell and the vault and wears
38
+ * the same trigger. It answers one question — "has anyone texted us?" — and
39
+ * gives one path onward, into the inbox.
40
+ *
41
+ * The two states this was built around are the ones that do not exist yet:
42
+ * Zoom is not connected, and the dev transport that stands in for it. Both say
43
+ * so on the face of the popover rather than in a console somewhere, because a
44
+ * message that is queued and a message that is sent look identical from here
45
+ * otherwise.
46
+ */
47
+ const SmsInboxBadgeInner = ({
48
+ setting,
49
+ // Accepted so the component matches the shape Navigation's renderers hand
50
+ // over, even though the popover does not read them.
51
+ userProfile: _userProfile,
52
+ setSystemAuth: _setSystemAuth,
53
+ endpoints,
54
+ echo = null,
55
+ channelFor = defaultChannelFor,
56
+ inboxUrl = '/sms',
57
+ limit = 8,
58
+ navigate,
59
+ }) => {
60
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
61
+
62
+ const [open, setOpen] = useState(false);
63
+ const [status, setStatus] = useState(null);
64
+ const [lines, setLines] = useState([]);
65
+ const [threads, setThreads] = useState([]);
66
+ const [unread, setUnread] = useState(0);
67
+ const [loading, setLoading] = useState(false);
68
+ const [loaded, setLoaded] = useState(false);
69
+ const [highlight, setHighlight] = useState(0);
70
+
71
+ const wrapperRef = useRef(null);
72
+ const triggerRef = useRef(null);
73
+ const listRef = useRef(null);
74
+ const mountedRef = useRef(true);
75
+ // Guards against an early response overwriting a later one.
76
+ const requestRef = useRef(0);
77
+
78
+ useEffect(
79
+ () => () => {
80
+ mountedRef.current = false;
81
+ },
82
+ []
83
+ );
84
+
85
+ /* -------------------------------------------------------------- loading */
86
+
87
+ /** Status carries the unread total, so this is also the badge's source. */
88
+ const loadStatus = useCallback(() => {
89
+ CustomFetch(
90
+ routes.status,
91
+ 'GET',
92
+ null,
93
+ (result) => {
94
+ if (!mountedRef.current || !result) return;
95
+
96
+ setStatus(result);
97
+
98
+ if (typeof result.unread_total === 'number') {
99
+ setUnread(result.unread_total);
100
+ }
101
+ },
102
+ // Silent: a header chip must not toast at someone because a
103
+ // background poll failed.
104
+ () => {}
105
+ ).catch(() => {});
106
+ }, [routes]);
107
+
108
+ const loadLines = useCallback(() => {
109
+ CustomFetch(
110
+ routes.lines,
111
+ 'GET',
112
+ null,
113
+ (result) => {
114
+ if (!mountedRef.current) return;
115
+
116
+ setLines(Array.isArray(result?.data) ? result.data : []);
117
+ },
118
+ () => {}
119
+ ).catch(() => {});
120
+ }, [routes]);
121
+
122
+ const loadThreads = useCallback(() => {
123
+ const ticket = requestRef.current + 1;
124
+
125
+ requestRef.current = ticket;
126
+ setLoading(true);
127
+
128
+ CustomFetch(
129
+ routes.threads,
130
+ 'GET',
131
+ { per_page: limit, page: 1 },
132
+ (result) => {
133
+ if (!mountedRef.current || requestRef.current !== ticket) return;
134
+
135
+ setThreads(Array.isArray(result?.data) ? result.data : []);
136
+ setLoading(false);
137
+ setLoaded(true);
138
+ setHighlight(0);
139
+ },
140
+ () => {
141
+ if (!mountedRef.current || requestRef.current !== ticket) return;
142
+
143
+ setLoading(false);
144
+ setLoaded(true);
145
+ }
146
+ ).catch(() => {
147
+ if (!mountedRef.current || requestRef.current !== ticket) return;
148
+
149
+ setLoading(false);
150
+ setLoaded(true);
151
+ });
152
+ }, [routes, limit]);
153
+
154
+ useEffect(() => {
155
+ loadStatus();
156
+ loadLines();
157
+ }, [loadStatus, loadLines]);
158
+
159
+ // The list is deliberately not cached between openings: a preview from
160
+ // twenty minutes ago is worse than a spinner.
161
+ useEffect(() => {
162
+ if (!open) return;
163
+
164
+ loadThreads();
165
+ }, [open, loadThreads]);
166
+
167
+ /* ----------------------------------------------------------------- live */
168
+
169
+ const handleReceived = useCallback(
170
+ (event) => {
171
+ if (!mountedRef.current) return;
172
+
173
+ if (event?.thread) {
174
+ setThreads((prev) => upsertThread(prev, event.thread).slice(0, 50));
175
+ }
176
+
177
+ // An inbound message is one more unread, unless the open conversation
178
+ // just marked it read — which the inbox page, not this popover, owns.
179
+ setUnread((prev) => prev + 1);
180
+
181
+ // ...and a desktop alert, if the user has asked for them and is not
182
+ // already looking at this tab. Only `sms.received`: an updated
183
+ // delivery status is not news anybody needs an OS toast for.
184
+ const thread = event?.thread;
185
+ const body = event?.message?.body;
186
+
187
+ if (!thread) return;
188
+
189
+ desktopNotify({
190
+ title: `New text from ${threadDisplayName(thread)}`,
191
+ body: String(body || '')
192
+ .replace(/\s+/g, ' ')
193
+ .trim()
194
+ .slice(0, 120),
195
+ // One toast per conversation: a burst of five replaces itself
196
+ // rather than burying the desktop.
197
+ tag: `sms-${thread.id}`,
198
+ url: `${inboxUrl}?thread=${thread.id}`,
199
+ navigate,
200
+ });
201
+ },
202
+ [inboxUrl, navigate]
203
+ );
204
+
205
+ const handleUpdated = useCallback((event) => {
206
+ if (!mountedRef.current || !event?.thread) return;
207
+
208
+ setThreads((prev) => upsertThread(prev, event.thread).slice(0, 50));
209
+ }, []);
210
+
211
+ const handleUnread = useCallback((payload) => {
212
+ if (!mountedRef.current) return;
213
+
214
+ if (typeof payload?.total === 'number') setUnread(payload.total);
215
+ }, []);
216
+
217
+ useSmsLive({
218
+ echo,
219
+ lines,
220
+ channelFor,
221
+ endpoints,
222
+ onReceived: handleReceived,
223
+ onUpdated: handleUpdated,
224
+ onUnread: handleUnread,
225
+ });
226
+
227
+ /* -------------------------------------------------------------- opening */
228
+
229
+ const close = useCallback(() => {
230
+ setOpen(false);
231
+ setHighlight(0);
232
+ }, []);
233
+
234
+ useEffect(() => {
235
+ if (!open) return undefined;
236
+
237
+ const onMouseDown = (event) => {
238
+ if (wrapperRef.current?.contains(event.target)) return;
239
+ if (triggerRef.current?.contains(event.target)) return;
240
+
241
+ close();
242
+ };
243
+
244
+ document.addEventListener('mousedown', onMouseDown);
245
+
246
+ return () => document.removeEventListener('mousedown', onMouseDown);
247
+ }, [open, close]);
248
+
249
+ useEffect(() => {
250
+ if (!open) return undefined;
251
+
252
+ // Focus after paint, or the list is not in the document yet. The
253
+ // popover has no search box, so the list itself takes the keyboard.
254
+ const handle = setTimeout(() => listRef.current?.focus(), 0);
255
+
256
+ return () => clearTimeout(handle);
257
+ }, [open]);
258
+
259
+ const openThread = useCallback(
260
+ (thread) => {
261
+ close();
262
+ navigate(thread ? `${inboxUrl}?thread=${thread.id}` : inboxUrl);
263
+ },
264
+ [close, navigate, inboxUrl]
265
+ );
266
+
267
+ const onKeyDown = useCallback(
268
+ (event) => {
269
+ if (event.key === 'Escape') {
270
+ event.preventDefault();
271
+ close();
272
+ triggerRef.current?.focus();
273
+
274
+ return;
275
+ }
276
+
277
+ if (threads.length === 0) return;
278
+
279
+ if (event.key === 'ArrowDown') {
280
+ event.preventDefault();
281
+ setHighlight((prev) => (prev + 1) % threads.length);
282
+ } else if (event.key === 'ArrowUp') {
283
+ event.preventDefault();
284
+ setHighlight((prev) => (prev - 1 + threads.length) % threads.length);
285
+ } else if (event.key === 'Enter') {
286
+ event.preventDefault();
287
+ openThread(threads[highlight]);
288
+ }
289
+ },
290
+ [threads, highlight, close, openThread]
291
+ );
292
+
293
+ /* --------------------------------------------------------------- render */
294
+
295
+ const label = setting?.label || 'Messages';
296
+ const count = badgeCount(unread);
297
+
298
+ // Two different sentences, because they mean two different things: one is
299
+ // "this will send later", the other is "this is pretending to send".
300
+ const note = (() => {
301
+ if (!status) return null;
302
+ if (status.connected === false && status.transport === 'null') {
303
+ return 'Not connected to Zoom yet — messages you send are held';
304
+ }
305
+ if (status.transport === 'log') return 'Dev transport — replies are simulated';
306
+
307
+ return null;
308
+ })();
309
+
310
+ const renderRow = (thread, index) => {
311
+ const unreadCount = Number(thread.unread_count) || 0;
312
+ const name = threadDisplayName(thread);
313
+ const preview = previewOf(thread);
314
+ const stamp = thread.last_message?.at ?? thread.updated_at;
315
+
316
+ return (
317
+ <li
318
+ key={thread.id}
319
+ className={[
320
+ styles.row,
321
+ index === highlight ? styles.rowActive : '',
322
+ unreadCount > 0 ? styles.rowUnread : '',
323
+ ]
324
+ .filter(Boolean)
325
+ .join(' ')}
326
+ role="option"
327
+ aria-selected={index === highlight}
328
+ onMouseEnter={() => setHighlight(index)}
329
+ onClick={() => openThread(thread)}
330
+ title={`${name} — ${preview}`}
331
+ >
332
+ {/* The unread marker, in its own fixed track. It used to be a
333
+ border-left, which is layout: unread rows sat three pixels
334
+ to the right of read ones and the avatars zig-zagged. */}
335
+ <span className={styles.rowAccent} aria-hidden="true" />
336
+
337
+ <span className={styles.avatar} aria-hidden="true">
338
+ {initialsFor(name)}
339
+ </span>
340
+
341
+ <span className={styles.rowMain}>
342
+ <strong className={styles.rowName}>{name}</strong>
343
+ <span className={styles.rowSep} aria-hidden="true">
344
+ ·
345
+ </span>
346
+ <span className={styles.rowPreview}>{preview}</span>
347
+ </span>
348
+
349
+ <span className={styles.rowTime}>
350
+ {unreadCount > 0 && (
351
+ <span
352
+ className={styles.unreadDot}
353
+ aria-label={`${unreadCount} unread`}
354
+ />
355
+ )}
356
+ {relativeTime(stamp)}
357
+ </span>
358
+ </li>
359
+ );
360
+ };
361
+
362
+ return (
363
+ <div className={styles.quickSms}>
364
+ <button
365
+ ref={triggerRef}
366
+ type="button"
367
+ className={styles.trigger}
368
+ onClick={() => (open ? close() : setOpen(true))}
369
+ data-tooltip-id="system-tooltip"
370
+ data-tooltip-place="bottom"
371
+ data-tooltip-content={label}
372
+ aria-label={unread > 0 ? `${label} (${unread} unread)` : label}
373
+ aria-expanded={open}
374
+ aria-haspopup="dialog"
375
+ >
376
+ <MessageSquare size={20} strokeWidth={2} />
377
+ {count !== '' && (
378
+ <span className={styles.badge} aria-hidden="true">
379
+ {count}
380
+ </span>
381
+ )}
382
+ </button>
383
+
384
+ {open && (
385
+ <div
386
+ className={styles.popoverWrapper}
387
+ ref={wrapperRef}
388
+ /* Tells the navigation's action pill to stop clipping while
389
+ this is open. See Navigation.module.scss `.hactions > ul`,
390
+ and Notification and the vault, which do the same. */
391
+ data-nav-overlay="open"
392
+ role="dialog"
393
+ aria-label={label}
394
+ >
395
+ <div className={styles.popover}>
396
+ <div className={styles.popoverHead}>
397
+ <span className={styles.popoverTitle}>
398
+ <MessageSquare size={15} strokeWidth={2.2} aria-hidden="true" />
399
+ {label}
400
+ </span>
401
+ <button
402
+ type="button"
403
+ className={styles.primaryButton}
404
+ onClick={() => openThread(null)}
405
+ >
406
+ Open messages
407
+ </button>
408
+ </div>
409
+
410
+ {threads.length > 0 && (
411
+ <ul
412
+ className={styles.rows}
413
+ ref={listRef}
414
+ role="listbox"
415
+ aria-label="Recent conversations"
416
+ tabIndex={-1}
417
+ onKeyDown={onKeyDown}
418
+ >
419
+ {threads.map(renderRow)}
420
+ </ul>
421
+ )}
422
+
423
+ {threads.length === 0 && (
424
+ <div className={styles.empty}>
425
+ {loading || !loaded ? (
426
+ <p>
427
+ <Loader2
428
+ size={16}
429
+ className={styles.spin}
430
+ aria-label="Loading conversations"
431
+ />
432
+ </p>
433
+ ) : (
434
+ <p>
435
+ {status && status.lines_count === 0
436
+ ? 'No messaging lines are set up yet.'
437
+ : 'No conversations yet.'}
438
+ </p>
439
+ )}
440
+ </div>
441
+ )}
442
+
443
+ {note && (
444
+ <p className={styles.statusNote}>
445
+ {status?.transport === 'log' ? (
446
+ <TriangleAlert size={14} strokeWidth={2} aria-hidden="true" />
447
+ ) : (
448
+ <Unplug size={14} strokeWidth={2} aria-hidden="true" />
449
+ )}
450
+ {note}
451
+ </p>
452
+ )}
453
+
454
+ <div className={styles.footer}>
455
+ <span className={styles.footerHint}>
456
+ ↑↓ move · ⏎ open · Esc close
457
+ </span>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ };
465
+
466
+ const SmsInboxBadge = withVaultNavigation(SmsInboxBadgeInner);
467
+
468
+ export default SmsInboxBadge;