@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,247 @@
1
+ /**
2
+ * The share modal's pure logic.
3
+ *
4
+ * Kept React-free so `node --test` can import it: the parts of this feature
5
+ * worth testing are the field whitelist, the request body and the words put
6
+ * next to a link's state, none of which need a DOM.
7
+ *
8
+ * ONE THING TO KEEP IN MIND THROUGHOUT. A share link is the credential — there
9
+ * is no password on the far side of it — so every default in here leans
10
+ * conservative: the shortest expiry list that is still useful, the narrowest
11
+ * default field set, and a status vocabulary that never says "active" about a
12
+ * link that is not.
13
+ */
14
+
15
+ /**
16
+ * The fields a link can carry, in the order the modal lists them and the order
17
+ * the server stores them.
18
+ *
19
+ * `totp` is the CURRENT SIX-DIGIT CODE, computed server-side when the
20
+ * recipient reveals — never the seed. There is no option to send the seed and
21
+ * that is deliberate: a seed is a permanent second factor, and one sitting in
22
+ * the same message as the password is not a second factor at all.
23
+ */
24
+ export const SHARE_FIELDS = [
25
+ { key: 'username', label: 'Username' },
26
+ { key: 'password', label: 'Password' },
27
+ { key: 'totp', label: '2FA code', hint: 'The current code, not the secret' },
28
+ { key: 'url', label: 'Website' },
29
+ { key: 'notes', label: 'Notes' },
30
+ ];
31
+
32
+ /** What a new share starts with: the pair that is useful on its own. */
33
+ export const DEFAULT_SHARE_FIELDS = ['username', 'password'];
34
+
35
+ /**
36
+ * The expiry choices, in hours.
37
+ *
38
+ * No "never" option anywhere, at any layer: the server requires an expiry and
39
+ * caps it, and offering one here that it would refuse is a worse experience
40
+ * than not offering it.
41
+ */
42
+ export const EXPIRY_OPTIONS = [
43
+ { value: 1, label: '1 hour' },
44
+ { value: 24, label: '24 hours' },
45
+ { value: 168, label: '7 days' },
46
+ { value: 720, label: '30 days' },
47
+ ];
48
+
49
+ export const DEFAULT_EXPIRY_HOURS = 24;
50
+
51
+ /**
52
+ * The view budget. `null` is "no limit", which is still bounded by the expiry —
53
+ * unlimited views is not the same as an unlimited link.
54
+ */
55
+ export const VIEW_LIMIT_OPTIONS = [
56
+ { value: 1, label: '1 view' },
57
+ { value: 5, label: '5 views' },
58
+ { value: null, label: 'No limit' },
59
+ ];
60
+
61
+ /**
62
+ * Which fields this entry can actually offer.
63
+ *
64
+ * The LIST payload deliberately carries no password and no notes — that is the
65
+ * vault's whole design — so those two cannot be known here and are always
66
+ * offered; the server refuses a link to a field the entry does not hold, with a
67
+ * message saying which. The three that ARE knowable from a row are disabled up
68
+ * front, because a checkbox that turns into a 422 is a worse answer than one
69
+ * that was never tickable.
70
+ *
71
+ * @param {object} entry a row from the list endpoint
72
+ * @returns {Array<{key: string, label: string, hint?: string, disabled: boolean, reason?: string}>}
73
+ */
74
+ export const availableShareFields = (entry) => {
75
+ const row = entry || {};
76
+
77
+ return SHARE_FIELDS.map((field) => {
78
+ let disabled = false;
79
+ let reason;
80
+
81
+ if (field.key === 'username' && !row.username) {
82
+ disabled = true;
83
+ reason = 'This entry has no username';
84
+ }
85
+
86
+ if (field.key === 'url' && !row.url) {
87
+ disabled = true;
88
+ reason = 'This entry has no address';
89
+ }
90
+
91
+ if (field.key === 'totp' && !row.has_totp) {
92
+ disabled = true;
93
+ reason = 'This entry has no 2FA';
94
+ }
95
+
96
+ return { ...field, disabled, ...(reason ? { reason } : {}) };
97
+ });
98
+ };
99
+
100
+ /**
101
+ * The starting selection for an entry: the defaults, minus anything it cannot
102
+ * offer. An entry with no username shares just the password.
103
+ *
104
+ * @returns {Array<string>}
105
+ */
106
+ export const initialShareFields = (entry) =>
107
+ availableShareFields(entry)
108
+ .filter((field) => DEFAULT_SHARE_FIELDS.includes(field.key) && !field.disabled)
109
+ .map((field) => field.key);
110
+
111
+ /**
112
+ * Add or remove one field, keeping the module's own order.
113
+ *
114
+ * Order matters more than it looks: two shares of the same fields should store
115
+ * the same array, so a list of shares does not read as though they differ.
116
+ *
117
+ * @param {Array<string>} fields
118
+ * @param {string} key
119
+ * @returns {Array<string>}
120
+ */
121
+ export const toggleShareField = (fields, key) => {
122
+ const current = Array.isArray(fields) ? fields : [];
123
+ const known = SHARE_FIELDS.map((field) => field.key);
124
+
125
+ if (!known.includes(key)) return current.slice();
126
+
127
+ const next = current.includes(key)
128
+ ? current.filter((field) => field !== key)
129
+ : current.concat([key]);
130
+
131
+ return known.filter((field) => next.includes(field));
132
+ };
133
+
134
+ /**
135
+ * The body the create endpoint wants.
136
+ *
137
+ * `max_views` is omitted rather than sent as null when there is no limit: the
138
+ * server treats an absent key and a null the same way, and a body that carries
139
+ * only what was chosen is easier to read in a network panel.
140
+ *
141
+ * @param {{fields: Array<string>, expiresInHours: number, maxViews: (number|null)}} choice
142
+ * @returns {object}
143
+ */
144
+ export const shareRequestBody = ({ fields, expiresInHours, maxViews } = {}) => {
145
+ const known = SHARE_FIELDS.map((field) => field.key);
146
+ const clean = known.filter((field) => (fields || []).includes(field));
147
+
148
+ const hours = Number(expiresInHours);
149
+ const views = Number(maxViews);
150
+
151
+ const body = {
152
+ fields: clean,
153
+ expires_in_hours:
154
+ Number.isFinite(hours) && hours > 0
155
+ ? Math.round(hours)
156
+ : DEFAULT_EXPIRY_HOURS,
157
+ };
158
+
159
+ if (Number.isFinite(views) && views > 0) {
160
+ body.max_views = Math.round(views);
161
+ }
162
+
163
+ return body;
164
+ };
165
+
166
+ /**
167
+ * One word for a link's state, matching the server's own vocabulary.
168
+ *
169
+ * Falls back to computing it from the row when the server did not say, so a
170
+ * list rendered from a stale response still labels an expired link as expired
171
+ * rather than as active.
172
+ */
173
+ export const shareStatus = (share) => {
174
+ const row = share || {};
175
+
176
+ if (typeof row.status === 'string' && row.status !== '') return row.status;
177
+
178
+ if (row.revoked_at) return 'revoked';
179
+
180
+ if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
181
+ return 'expired';
182
+ }
183
+
184
+ if (
185
+ row.max_views !== null &&
186
+ row.max_views !== undefined &&
187
+ Number(row.views || 0) >= Number(row.max_views)
188
+ ) {
189
+ return 'spent';
190
+ }
191
+
192
+ return 'active';
193
+ };
194
+
195
+ const STATUS_LABELS = {
196
+ active: 'Active',
197
+ expired: 'Expired',
198
+ spent: 'Views used up',
199
+ revoked: 'Revoked',
200
+ };
201
+
202
+ export const shareStatusLabel = (share) =>
203
+ STATUS_LABELS[shareStatus(share)] || 'Unknown';
204
+
205
+ /** Only an active link can be closed; the rest are already closed. */
206
+ export const isShareRevocable = (share) => shareStatus(share) === 'active';
207
+
208
+ /**
209
+ * How many times this link has been opened, said in English.
210
+ *
211
+ * "0 of 1 view" rather than "0 views" when there is a budget: the number that
212
+ * matters to whoever is looking is how many are left, and stating the ceiling
213
+ * is how that is read off in one glance.
214
+ */
215
+ export const shareViewsLabel = (share) => {
216
+ const row = share || {};
217
+ const used = Number(row.views || 0);
218
+
219
+ if (row.max_views === null || row.max_views === undefined) {
220
+ return `${used} ${used === 1 ? 'view' : 'views'}`;
221
+ }
222
+
223
+ const max = Number(row.max_views);
224
+
225
+ return `${used} of ${max} ${max === 1 ? 'view' : 'views'}`;
226
+ };
227
+
228
+ /**
229
+ * The field list as a sentence, for the row in the shares list.
230
+ *
231
+ * Unknown keys are printed rather than dropped: the server is free to grow a
232
+ * shareable field this build has not heard of, and a list with a silent gap in
233
+ * it is worse than one with an ugly word.
234
+ */
235
+ export const shareFieldsLabel = (share) => {
236
+ const fields = Array.isArray(share?.fields) ? share.fields : [];
237
+
238
+ if (fields.length === 0) return 'Nothing';
239
+
240
+ return fields
241
+ .map((key) => {
242
+ const known = SHARE_FIELDS.find((field) => field.key === key);
243
+
244
+ return known ? known.label : String(key);
245
+ })
246
+ .join(', ');
247
+ };
package/src/index.js CHANGED
@@ -88,6 +88,14 @@ import {
88
88
  syncCsrfToken,
89
89
  twoFactorSuccessTarget,
90
90
  } from './components/auth/authHelpers';
91
+ import {
92
+ base64UrlToBytes,
93
+ bytesToBase64Url,
94
+ createPasskeyCredential,
95
+ getPasskeyCredential,
96
+ isPasskeyCancellation,
97
+ isPasskeySupported,
98
+ } from './components/auth/webauthn';
91
99
  import {
92
100
  CLIENT_AUTH_PROTOCOLS,
93
101
  DEFAULT_CLIENT_PROTOCOL,
@@ -104,9 +112,16 @@ import CallQueueSettings from './components/callQueue/CallQueueSettings';
104
112
  import OtpChip from './components/vault/OtpChip';
105
113
  import VaultAccessLog from './components/vault/VaultAccessLog';
106
114
  import VaultConfirmPanel from './components/vault/VaultConfirmPanel';
115
+ import GenericSettings from './components/generic/GenericSettings';
116
+ import IntegrationsManager from './components/settings/IntegrationsManager';
117
+ import TicketConversation from './components/tickets/TicketConversation';
118
+ import VaultClientPicker from './components/vault/VaultClientPicker';
107
119
  import VaultEntryForm from './components/vault/VaultEntryForm';
108
120
  import VaultManager from './components/vault/VaultManager';
109
121
  import VaultQuickSearch from './components/vault/VaultQuickSearch';
122
+ // External share links. VaultManager already mounts this from the Share action
123
+ // on a row; it is exported for a consumer that wants the dialog somewhere else.
124
+ import VaultShareModal from './components/vault/VaultShareModal';
110
125
  import PasswordGenerator from './components/vault/PasswordGenerator';
111
126
  import QrScanner from './components/vault/QrScanner';
112
127
  import useVaultReveal from './components/vault/useVaultReveal';
@@ -206,6 +221,10 @@ import GenericClientPortal from './components/generic/GenericClientPortal';
206
221
  import GenericDashboard from './components/generic/GenericDashboard';
207
222
  import GenericDetail from './components/generic/GenericDetail';
208
223
  import GenericDynamic from './components/generic/GenericDynamic';
224
+ import UniversalSearch, {
225
+ DEFAULT_UNIVERSAL_SEARCH_ENDPOINT,
226
+ } from './components/UniversalSearch';
227
+ import ExcelStyleGrid from './components/generic/ExcelStyleGrid';
209
228
  import GenericFormBuilder from './components/generic/GenericFormBuilder';
210
229
  import GenericIndex from './components/generic/GenericIndex';
211
230
  import GenericGrid from './components/generic/GenericGrid';
@@ -280,9 +299,14 @@ export {
280
299
  useVaultReveal,
281
300
  VaultAccessLog,
282
301
  VaultConfirmPanel,
302
+ GenericSettings,
303
+ IntegrationsManager,
304
+ TicketConversation,
305
+ VaultClientPicker,
283
306
  VaultEntryForm,
284
307
  VaultManager,
285
308
  VaultQuickSearch,
309
+ VaultShareModal,
286
310
  clientRecipient,
287
311
  conversationCountLabel,
288
312
  DEFAULT_SMS_ENDPOINTS,
@@ -313,6 +337,7 @@ export {
313
337
  threadDisplayName,
314
338
  toE164,
315
339
  useSmsLive,
340
+ UniversalSearch,
316
341
  useSmsThreads,
317
342
  CameraPlacement,
318
343
  CellWithTooltip,
@@ -352,6 +377,8 @@ export {
352
377
  GenericDashboard,
353
378
  GenericDetail,
354
379
  GenericDynamic,
380
+ DEFAULT_UNIVERSAL_SEARCH_ENDPOINT,
381
+ ExcelStyleGrid,
355
382
  GenericFormBuilder,
356
383
  GenericIndex,
357
384
  GenericGrid,
@@ -402,6 +429,14 @@ export {
402
429
  resolveAuthEndpoints,
403
430
  resolveClientPaths,
404
431
  resolveClientProtocol,
432
+ // Passkeys (WebAuthn). Exported so a consuming app can build its own
433
+ // enrolment screen against the same marshalling the login screen uses.
434
+ base64UrlToBytes,
435
+ bytesToBase64Url,
436
+ createPasskeyCredential,
437
+ getPasskeyCredential,
438
+ isPasskeyCancellation,
439
+ isPasskeySupported,
405
440
  SectionEditor,
406
441
  SectionGroupedReport,
407
442
  SectionTypeSelector,
@@ -92,23 +92,47 @@ const fetchUtil = async (config) => {
92
92
  requestHeaders.set('Content-Type', 'application/json');
93
93
  }
94
94
 
95
- // Add CSRF token for Laravel if available
96
- const csrfToken = document
97
- .querySelector('meta[name="csrf-token"]')
98
- ?.getAttribute('content');
99
- if (csrfToken) {
100
- // Laravel accepts the CSRF token in different headers
101
- if (!requestHeaders.has('X-CSRF-TOKEN')) {
102
- requestHeaders.set('X-CSRF-TOKEN', csrfToken);
95
+ /*
96
+ * Laravel's session headers are for Laravel — this app's own origin.
97
+ *
98
+ * Attached to every request they also went to third parties: the address
99
+ * autocomplete handed the user's CSRF token to api.mapbox.com on every
100
+ * keystroke. A token another host has no use for and no business seeing,
101
+ * and because they are not CORS-simple headers each request also paid for
102
+ * a preflight it did not need.
103
+ */
104
+ const isSameOrigin = (() => {
105
+ try {
106
+ return (
107
+ new URL(requestUrl, window.location.href).origin ===
108
+ window.location.origin
109
+ );
110
+ } catch {
111
+ // A url this fetch could not parse is one the browser will reject
112
+ // anyway; treat it as ours rather than silently dropping headers.
113
+ return true;
103
114
  }
104
- if (!requestHeaders.has('X-XSRF-TOKEN')) {
105
- requestHeaders.set('X-XSRF-TOKEN', csrfToken);
115
+ })();
116
+
117
+ if (isSameOrigin) {
118
+ // Add CSRF token for Laravel if available
119
+ const csrfToken = document
120
+ .querySelector('meta[name="csrf-token"]')
121
+ ?.getAttribute('content');
122
+ if (csrfToken) {
123
+ // Laravel accepts the CSRF token in different headers
124
+ if (!requestHeaders.has('X-CSRF-TOKEN')) {
125
+ requestHeaders.set('X-CSRF-TOKEN', csrfToken);
126
+ }
127
+ if (!requestHeaders.has('X-XSRF-TOKEN')) {
128
+ requestHeaders.set('X-XSRF-TOKEN', csrfToken);
129
+ }
106
130
  }
107
- }
108
131
 
109
- // Add X-Requested-With header for Laravel to recognize AJAX requests
110
- if (!requestHeaders.has('X-Requested-With')) {
111
- requestHeaders.set('X-Requested-With', 'XMLHttpRequest');
132
+ // Add X-Requested-With header for Laravel to recognize AJAX requests
133
+ if (!requestHeaders.has('X-Requested-With')) {
134
+ requestHeaders.set('X-Requested-With', 'XMLHttpRequest');
135
+ }
112
136
  }
113
137
 
114
138
  // Handle credentials