@visns-studio/visns-components 6.17.2 → 6.20.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 (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +142 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. package/src/utils/fetchUtil.js +38 -14
@@ -0,0 +1,454 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import { Check, Copy, Link2, Loader2, ShieldAlert, Trash2 } from 'lucide-react';
4
+
5
+ import CustomFetch from '../Fetch';
6
+ import StandardModal from '../generic/StandardModal';
7
+ import styles from '../styles/Vault.module.scss';
8
+ import { copyText } from './vaultClipboard';
9
+ import { resolveVaultEndpoints } from './vaultEndpoints';
10
+ import {
11
+ DEFAULT_EXPIRY_HOURS,
12
+ EXPIRY_OPTIONS,
13
+ VIEW_LIMIT_OPTIONS,
14
+ availableShareFields,
15
+ initialShareFields,
16
+ isShareRevocable,
17
+ shareFieldsLabel,
18
+ shareRequestBody,
19
+ shareStatus,
20
+ shareStatusLabel,
21
+ shareViewsLabel,
22
+ toggleShareField,
23
+ } from './vaultShares';
24
+
25
+ /** The pill colour per state. Anything unknown falls through to the neutral. */
26
+ const STATUS_CLASS = {
27
+ active: 'shareStatusActive',
28
+ expired: 'shareStatusClosed',
29
+ spent: 'shareStatusClosed',
30
+ revoked: 'shareStatusRevoked',
31
+ };
32
+
33
+ /**
34
+ * Send one credential to somebody who does not have a CRM account.
35
+ *
36
+ * THE LINK IS THE CREDENTIAL. There is no password on the far side; anyone
37
+ * holding the URL inside its expiry and its view budget can read what it
38
+ * carries. Everything in this modal is shaped by that one fact:
39
+ *
40
+ * - Expiry is not optional and there is no "never" in the list, because the
41
+ * server has no such option either.
42
+ * - The default field selection is username and password, not everything.
43
+ * - The URL is shown exactly once. The server stores only its SHA-256, so
44
+ * "you will not see this link again" is a statement of fact rather than a
45
+ * warning — nothing, here or on the server, can reproduce it.
46
+ * - The existing links are listed under the form rather than behind a tab,
47
+ * because "have we already sent this out" is the question somebody opening
48
+ * this dialog most often actually has.
49
+ *
50
+ * The 2FA option sends the CURRENT CODE, computed by the server at the moment
51
+ * the recipient reveals — never the seed. That is said on the checkbox, not
52
+ * just in this comment, because it is the one thing about this dialog a person
53
+ * could reasonably get wrong.
54
+ */
55
+ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
56
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
57
+
58
+ const fieldOptions = useMemo(() => availableShareFields(entry), [entry]);
59
+
60
+ const [fields, setFields] = useState(() => initialShareFields(entry));
61
+ const [expiresInHours, setExpiresInHours] = useState(DEFAULT_EXPIRY_HOURS);
62
+ const [maxViews, setMaxViews] = useState(null);
63
+
64
+ const [creating, setCreating] = useState(false);
65
+ // The one and only copy of a freshly minted URL. Held in component state
66
+ // and nowhere else — closing the modal is what destroys it.
67
+ const [created, setCreated] = useState(null);
68
+ const [copied, setCopied] = useState(false);
69
+
70
+ const [shares, setShares] = useState([]);
71
+ const [loading, setLoading] = useState(false);
72
+ const [revokingId, setRevokingId] = useState(null);
73
+
74
+ const mountedRef = useRef(true);
75
+ const copiedTimer = useRef(null);
76
+ const urlRef = useRef(null);
77
+
78
+ useEffect(() => {
79
+ mountedRef.current = true;
80
+
81
+ return () => {
82
+ mountedRef.current = false;
83
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
84
+ };
85
+ }, []);
86
+
87
+ const load = useCallback(() => {
88
+ if (!entry?.id) return;
89
+
90
+ setLoading(true);
91
+
92
+ CustomFetch(
93
+ routes.shares(entry.id),
94
+ 'GET',
95
+ {},
96
+ (result) => {
97
+ if (!mountedRef.current) return;
98
+
99
+ setShares(Array.isArray(result?.data) ? result.data : []);
100
+ setLoading(false);
101
+ },
102
+ null
103
+ ).catch(() => {
104
+ if (mountedRef.current) setLoading(false);
105
+ });
106
+ }, [routes, entry]);
107
+
108
+ useEffect(() => {
109
+ load();
110
+ }, [load]);
111
+
112
+ // A brand new link is worth selecting for the reader, so the keyboard path
113
+ // (focus, ⌘A, ⌘C) works for anyone whose browser refuses the clipboard API.
114
+ useEffect(() => {
115
+ if (created && urlRef.current) {
116
+ urlRef.current.focus();
117
+ urlRef.current.select();
118
+ }
119
+ }, [created]);
120
+
121
+ const toggle = (key) => setFields((current) => toggleShareField(current, key));
122
+
123
+ const create = useCallback(() => {
124
+ if (!entry?.id || fields.length === 0) return;
125
+
126
+ setCreating(true);
127
+
128
+ CustomFetch(
129
+ routes.createShare(entry.id),
130
+ 'POST',
131
+ shareRequestBody({ fields, expiresInHours, maxViews }),
132
+ (result) => {
133
+ if (!mountedRef.current) return;
134
+
135
+ setCreating(false);
136
+
137
+ if (!result?.url) {
138
+ toast.error('The link could not be created.');
139
+
140
+ return;
141
+ }
142
+
143
+ setCreated(result);
144
+ setCopied(false);
145
+ load();
146
+ },
147
+ null
148
+ ).catch(() => {
149
+ // CustomFetch has already said what went wrong — including the
150
+ // server's "this entry has no password to share".
151
+ if (mountedRef.current) setCreating(false);
152
+ });
153
+ }, [routes, entry, fields, expiresInHours, maxViews, load]);
154
+
155
+ const copyUrl = useCallback(async () => {
156
+ if (!created?.url) return;
157
+
158
+ const ok = await copyText(created.url);
159
+
160
+ if (!mountedRef.current) return;
161
+
162
+ if (!ok) {
163
+ // The input below is already on screen and already selected, so
164
+ // there is somewhere to fall back TO — say so rather than failing.
165
+ toast.info('Copy it from the box above — your browser blocked the clipboard.');
166
+
167
+ return;
168
+ }
169
+
170
+ toast.success('Link copied');
171
+ setCopied(true);
172
+
173
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
174
+
175
+ copiedTimer.current = setTimeout(() => {
176
+ copiedTimer.current = null;
177
+ if (mountedRef.current) setCopied(false);
178
+ }, 1500);
179
+ }, [created]);
180
+
181
+ const revoke = useCallback(
182
+ (share) => {
183
+ setRevokingId(share.id);
184
+
185
+ CustomFetch(
186
+ routes.revokeShare(entry.id, share.id),
187
+ 'DELETE',
188
+ {},
189
+ () => {
190
+ if (!mountedRef.current) return;
191
+
192
+ setRevokingId(null);
193
+ toast.success('Link revoked');
194
+ load();
195
+ },
196
+ null
197
+ ).catch(() => {
198
+ if (mountedRef.current) setRevokingId(null);
199
+ });
200
+ },
201
+ [routes, entry, load]
202
+ );
203
+
204
+ return (
205
+ <StandardModal
206
+ isOpen={isOpen}
207
+ onClose={onClose}
208
+ size="medium"
209
+ showHeader
210
+ title={`Share ${entry?.title || 'credential'}`}
211
+ >
212
+ <div className={styles.form}>
213
+ {created ? (
214
+ <div className={styles.shareResult}>
215
+ <p className={styles.shareResultHead}>
216
+ <Link2 size={15} strokeWidth={2.2} aria-hidden="true" />
217
+ Your link is ready
218
+ </p>
219
+
220
+ <div className={styles.inputRow}>
221
+ <input
222
+ ref={urlRef}
223
+ className={styles.input}
224
+ readOnly
225
+ value={created.url}
226
+ onFocus={(e) => e.target.select()}
227
+ aria-label="The share link"
228
+ />
229
+ <button
230
+ type="button"
231
+ className={styles.primaryButton}
232
+ onClick={copyUrl}
233
+ >
234
+ {copied ? (
235
+ <Check size={15} strokeWidth={2.6} aria-hidden="true" />
236
+ ) : (
237
+ <Copy size={15} strokeWidth={2} aria-hidden="true" />
238
+ )}
239
+ {copied ? 'Copied' : 'Copy'}
240
+ </button>
241
+ </div>
242
+
243
+ <p className={styles.shareWarning}>
244
+ <ShieldAlert size={14} strokeWidth={2.2} aria-hidden="true" />
245
+ <span>
246
+ <strong>You will not see this link again.</strong>{' '}
247
+ Only its fingerprint is stored, so it cannot be
248
+ looked up later. Treat the link itself as the
249
+ password — anyone who has it can read{' '}
250
+ {shareFieldsLabel(created).toLowerCase()} until it
251
+ expires or you revoke it.
252
+ </span>
253
+ </p>
254
+
255
+ <div className={styles.formActions}>
256
+ <button
257
+ type="button"
258
+ className={styles.ghostButton}
259
+ onClick={() => {
260
+ setCreated(null);
261
+ setCopied(false);
262
+ }}
263
+ >
264
+ Create another
265
+ </button>
266
+ <button
267
+ type="button"
268
+ className={styles.linkButton}
269
+ onClick={onClose}
270
+ >
271
+ Done
272
+ </button>
273
+ </div>
274
+ </div>
275
+ ) : (
276
+ <>
277
+ <div className={styles.field}>
278
+ <span className={styles.label}>What to send</span>
279
+ <div className={styles.shareFields}>
280
+ {fieldOptions.map((field) => (
281
+ <label
282
+ key={field.key}
283
+ className={`${styles.inlineCheck} ${
284
+ field.disabled ? styles.radioDisabled : ''
285
+ }`}
286
+ title={field.reason || undefined}
287
+ >
288
+ <input
289
+ type="checkbox"
290
+ className={styles.checkControl}
291
+ checked={fields.includes(field.key)}
292
+ disabled={field.disabled}
293
+ onChange={() => toggle(field.key)}
294
+ />
295
+ <span>
296
+ {field.label}
297
+ {field.hint && (
298
+ <em className={styles.shareHint}>
299
+ {field.hint}
300
+ </em>
301
+ )}
302
+ {field.disabled && field.reason && (
303
+ <em className={styles.shareHint}>
304
+ {field.reason}
305
+ </em>
306
+ )}
307
+ </span>
308
+ </label>
309
+ ))}
310
+ </div>
311
+ </div>
312
+
313
+ <div className={styles.field}>
314
+ <label className={styles.label} htmlFor="vault-share-expiry">
315
+ Expires after
316
+ </label>
317
+ <select
318
+ id="vault-share-expiry"
319
+ className={styles.select}
320
+ value={expiresInHours}
321
+ onChange={(e) =>
322
+ setExpiresInHours(Number(e.target.value))
323
+ }
324
+ >
325
+ {EXPIRY_OPTIONS.map((option) => (
326
+ <option key={option.value} value={option.value}>
327
+ {option.label}
328
+ </option>
329
+ ))}
330
+ </select>
331
+ </div>
332
+
333
+ <div className={styles.field}>
334
+ <label className={styles.label} htmlFor="vault-share-views">
335
+ View limit
336
+ </label>
337
+ <select
338
+ id="vault-share-views"
339
+ className={styles.select}
340
+ value={maxViews === null ? '' : maxViews}
341
+ onChange={(e) =>
342
+ setMaxViews(
343
+ e.target.value === ''
344
+ ? null
345
+ : Number(e.target.value)
346
+ )
347
+ }
348
+ >
349
+ {VIEW_LIMIT_OPTIONS.map((option) => (
350
+ <option
351
+ key={String(option.value)}
352
+ value={option.value === null ? '' : option.value}
353
+ >
354
+ {option.label}
355
+ </option>
356
+ ))}
357
+ </select>
358
+ <p className={styles.help}>
359
+ A one-view link closes itself the moment it is
360
+ opened — the safest choice for a single recipient.
361
+ </p>
362
+ </div>
363
+
364
+ <div className={styles.formActions}>
365
+ <button
366
+ type="button"
367
+ className={styles.primaryButton}
368
+ onClick={create}
369
+ disabled={creating || fields.length === 0}
370
+ >
371
+ {creating && (
372
+ <Loader2 size={15} className={styles.spin} />
373
+ )}
374
+ {creating ? 'Creating…' : 'Create link'}
375
+ </button>
376
+ <button
377
+ type="button"
378
+ className={styles.ghostButton}
379
+ onClick={onClose}
380
+ disabled={creating}
381
+ >
382
+ Cancel
383
+ </button>
384
+ </div>
385
+ </>
386
+ )}
387
+
388
+ <div className={styles.shareList}>
389
+ <p className={styles.label}>
390
+ Links for this entry
391
+ {loading && (
392
+ <Loader2 size={13} className={styles.spin} aria-hidden="true" />
393
+ )}
394
+ </p>
395
+
396
+ {shares.length === 0 && !loading && (
397
+ <p className={styles.muted}>
398
+ No links have been created for this entry.
399
+ </p>
400
+ )}
401
+
402
+ {shares.map((share) => (
403
+ <div key={share.id} className={styles.shareRow}>
404
+ <div className={styles.shareRowMain}>
405
+ <span
406
+ className={`${styles.pill} ${
407
+ styles[
408
+ STATUS_CLASS[shareStatus(share)] ||
409
+ 'shareStatusClosed'
410
+ ] || ''
411
+ }`}
412
+ >
413
+ {shareStatusLabel(share)}
414
+ </span>
415
+ <span className={styles.shareRowFields}>
416
+ {shareFieldsLabel(share)}
417
+ </span>
418
+ </div>
419
+ <div className={styles.shareRowMeta}>
420
+ <span>{shareViewsLabel(share)}</span>
421
+ {share.created_by && <span>by {share.created_by}</span>}
422
+ {share.expires_at && (
423
+ <span>
424
+ {shareStatus(share) === 'expired'
425
+ ? 'expired'
426
+ : 'expires'}{' '}
427
+ {new Date(share.expires_at).toLocaleString()}
428
+ </span>
429
+ )}
430
+ </div>
431
+ {isShareRevocable(share) && (
432
+ <button
433
+ type="button"
434
+ className={styles.linkButton}
435
+ onClick={() => revoke(share)}
436
+ disabled={revokingId === share.id}
437
+ >
438
+ {revokingId === share.id ? (
439
+ <Loader2 size={13} className={styles.spin} />
440
+ ) : (
441
+ <Trash2 size={13} strokeWidth={2} aria-hidden="true" />
442
+ )}
443
+ Revoke
444
+ </button>
445
+ )}
446
+ </div>
447
+ ))}
448
+ </div>
449
+ </div>
450
+ </StandardModal>
451
+ );
452
+ };
453
+
454
+ export default VaultShareModal;
@@ -2,7 +2,7 @@
2
2
  * The URLs the vault talks to.
3
3
  *
4
4
  * Every vault component takes an `endpoints` object rather than a base string,
5
- * so a consumer can move one route without reproducing the other eleven. The
5
+ * so a consumer can move one route without reproducing the rest. The
6
6
  * defaults are the CRM's routes under `/ajax/vault`; `makeVaultEndpoints` is
7
7
  * there for the common case of moving all of them at once.
8
8
  *
@@ -22,6 +22,20 @@ export const makeVaultEndpoints = (base = '/ajax/vault') => ({
22
22
  logCopy: (id) => `${base}/${id}/log`,
23
23
  entryLog: (id) => `${base}/${id}/log`,
24
24
  globalLog: `${base}/log`,
25
+ // The client typeahead behind the "Client" field on an entry: every client
26
+ // in the host application, whether or not it has a credential.
27
+ clients: `${base}/clients`,
28
+ // The clients that actually HAVE entries, for the list's client filter.
29
+ // A different question from `clients` above, and it has to come from the
30
+ // server: the list is paginated, so the clients on the visible page are
31
+ // not the clients in the vault.
32
+ entryClients: `${base}/entry-clients`,
33
+ // External share links. There is no endpoint that reads a link back:
34
+ // `createShare` returns the URL once and the server keeps only its hash,
35
+ // so there is nothing to read.
36
+ shares: (id) => `${base}/${id}/shares`,
37
+ createShare: (id) => `${base}/${id}/shares`,
38
+ revokeShare: (id, shareId) => `${base}/${id}/shares/${shareId}`,
25
39
  });
26
40
 
27
41
  export const DEFAULT_ENDPOINTS = makeVaultEndpoints();
@@ -29,7 +43,7 @@ export const DEFAULT_ENDPOINTS = makeVaultEndpoints();
29
43
  /**
30
44
  * Merge a partial `endpoints` prop over the defaults.
31
45
  *
32
- * A consumer overriding one route must not lose the other eleven, and passing
46
+ * A consumer overriding one route must not lose the rest, and passing
33
47
  * nothing at all must land exactly on DEFAULT_ENDPOINTS.
34
48
  */
35
49
  export const resolveVaultEndpoints = (endpoints) => ({