@visns-studio/visns-components 6.6.3 → 6.15.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.
Files changed (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  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/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -170,6 +170,224 @@ export const useDensity = () => {
170
170
  return useMemo(() => parseDensitySnapshot(snapshot), [snapshot]);
171
171
  };
172
172
 
173
+ /**
174
+ * The arithmetic behind `useAvailableHeight`, split out so it can be tested
175
+ * without a DOM: how much room is left under an element whose top edge sits at
176
+ * `top`, in a window `windowHeight` tall, once `bottomGutter` px are reserved.
177
+ *
178
+ * Returns `null` rather than a number for anything unusable — a non-finite
179
+ * input, or a result at or below zero (the element is `display:none`, or has
180
+ * been scrolled past the bottom of the window) — so callers keep their own
181
+ * fallback instead of flashing a zero-height pane.
182
+ *
183
+ * @param {number} top
184
+ * @param {number} windowHeight
185
+ * @param {number} bottomGutter
186
+ * @returns {number|null}
187
+ */
188
+ export const availableHeightFrom = (top, windowHeight, bottomGutter) => {
189
+ const next = windowHeight - top - bottomGutter;
190
+
191
+ return Number.isFinite(next) && next > 0 ? next : null;
192
+ };
193
+
194
+ /**
195
+ * Sub-pixel noise is not a layout change.
196
+ *
197
+ * `getBoundingClientRect()` returns fractions, and a `ResizeObserver` fires for
198
+ * changes far below the threshold anyone can see — a font swapping in, a
199
+ * scrollbar appearing, a zoom level that lands on a half pixel. Re-measuring on
200
+ * every one of those would set state (and so re-render the grid) constantly.
201
+ * Only a real move of the element's top edge counts.
202
+ *
203
+ * `null` for `previousTop` means nothing has been measured yet, which always
204
+ * counts.
205
+ *
206
+ * @param {number|null} previousTop
207
+ * @param {number} nextTop
208
+ * @param {number} [epsilon=2] px of movement to ignore.
209
+ * @returns {boolean}
210
+ */
211
+ export const hasTopMoved = (previousTop, nextTop, epsilon = 2) => {
212
+ if (previousTop === null || !Number.isFinite(previousTop)) {
213
+ return true;
214
+ }
215
+
216
+ if (!Number.isFinite(nextTop)) {
217
+ return true;
218
+ }
219
+
220
+ return Math.abs(nextTop - previousTop) > epsilon;
221
+ };
222
+
223
+ /**
224
+ * Height for a grid that is *not* the page's main list: a sub-table inside a
225
+ * form, an attachments list inside a modal.
226
+ *
227
+ * Those cannot use the fill-the-viewport measurement. A modal is centred on the
228
+ * window, so measuring from the grid's top edge to the bottom of the window
229
+ * asks for roughly half the screen *below a box that is already centred* — the
230
+ * modal grows past the fold, the overlay starts scrolling, and the grid's own
231
+ * pager ends up below it. A fixed height keeps the whole panel, pager included,
232
+ * inside the dialog on every screen this application supports (the shortest
233
+ * being a 768px laptop: 60px modal header + 32px padding + this = 512px).
234
+ */
235
+ export const SECONDARY_GRID_HEIGHT = 420;
236
+
237
+ /**
238
+ * Whether an element is taken out of normal flow, and so contributes nothing to
239
+ * the space below a pane. `.polActions` — the button strip GenericIndex renders
240
+ * after the grid — is `position: fixed`, and counting its height would shrink
241
+ * every index grid by a row for no reason.
242
+ *
243
+ * @param {{position?: string, display?: string}} style computed style
244
+ * @returns {boolean}
245
+ */
246
+ export const isOutOfFlow = (style) => {
247
+ if (!style) {
248
+ return true;
249
+ }
250
+
251
+ return (
252
+ style.display === 'none' ||
253
+ style.position === 'fixed' ||
254
+ style.position === 'absolute'
255
+ );
256
+ };
257
+
258
+ const px = (value) => {
259
+ const parsed = parseFloat(value);
260
+
261
+ return Number.isFinite(parsed) ? parsed : 0;
262
+ };
263
+
264
+ /**
265
+ * How much room everything below `node` needs, all the way up to the document.
266
+ *
267
+ * A pane that fills the window has to know what is underneath it, and "28px,
268
+ * probably" is a guess that goes wrong the moment a page nests its grid one
269
+ * card deeper or renders a second section after it. This walks the ancestor
270
+ * chain and adds up the three things that actually take room below the pane:
271
+ *
272
+ * - the element's own bottom margin,
273
+ * - anything laid out after it inside the same parent (the Family Tasks
274
+ * table under the task list, a relocated auto-refresh control),
275
+ * - each parent's bottom padding and bottom border (the card the grid sits
276
+ * in, the page's `.padding` gutter).
277
+ *
278
+ * Deliberately computed from styles and sibling rects rather than from
279
+ * `parent.bottom - node.bottom`. Ancestors here are stretched by
280
+ * `min-height: 100vh` and by flex, so when the page is *shorter* than the
281
+ * window that difference reads as slack the pane could grow into — and the
282
+ * pane growing removes the slack, which is an oscillation, not a measurement.
283
+ * Padding, borders and siblings do not move when the pane resizes, so this
284
+ * converges in one pass.
285
+ *
286
+ * @param {HTMLElement} node
287
+ * @param {object} [readers] injectable DOM access, for tests.
288
+ * @param {(el: any) => any} [readers.getStyle]
289
+ * @param {(el: any) => {height: number}} [readers.getRect]
290
+ * @param {any} [readers.stopAt] ancestor to stop at, exclusive.
291
+ * @returns {number}
292
+ */
293
+ export const bottomChromeBelow = (node, readers = {}) => {
294
+ const getStyle =
295
+ readers.getStyle ||
296
+ ((el) =>
297
+ typeof window !== 'undefined' && window.getComputedStyle
298
+ ? window.getComputedStyle(el)
299
+ : null);
300
+ const getRect =
301
+ readers.getRect ||
302
+ ((el) =>
303
+ typeof el.getBoundingClientRect === 'function'
304
+ ? el.getBoundingClientRect()
305
+ : { height: 0 });
306
+ const stopAt =
307
+ readers.stopAt !== undefined
308
+ ? readers.stopAt
309
+ : typeof document !== 'undefined'
310
+ ? document.documentElement
311
+ : null;
312
+
313
+ let total = 0;
314
+ let current = node;
315
+ // The chain is a dozen elements deep at worst; the cap is only here so a
316
+ // detached or circular tree cannot hang the page.
317
+ let guard = 0;
318
+
319
+ while (current && current !== stopAt && guard < 64) {
320
+ guard += 1;
321
+
322
+ const style = getStyle(current);
323
+
324
+ total += px(style?.marginBottom);
325
+
326
+ let sibling = current.nextElementSibling;
327
+
328
+ while (sibling) {
329
+ const siblingStyle = getStyle(sibling);
330
+
331
+ if (!isOutOfFlow(siblingStyle)) {
332
+ total +=
333
+ px(getRect(sibling)?.height) +
334
+ px(siblingStyle?.marginTop) +
335
+ px(siblingStyle?.marginBottom);
336
+ }
337
+
338
+ sibling = sibling.nextElementSibling;
339
+ }
340
+
341
+ const parent = current.parentElement;
342
+
343
+ if (!parent) {
344
+ break;
345
+ }
346
+
347
+ const parentStyle = getStyle(parent);
348
+
349
+ total +=
350
+ px(parentStyle?.paddingBottom) + px(parentStyle?.borderBottomWidth);
351
+
352
+ current = parent;
353
+ }
354
+
355
+ return total > 0 ? total : 0;
356
+ };
357
+
358
+ /**
359
+ * How many rows to ask the server for, given the room the grid has.
360
+ *
361
+ * Exported for the tests; the arithmetic is the whole reason index pages
362
+ * either fill their card or leave a band of empty grid under the last row.
363
+ *
364
+ * `rowUnit` is the density's row height, not a measured one. Rows in this grid
365
+ * can be any height — a knowledge-base entry with a four-line description is
366
+ * 250px where the next one is 40 — and sizing a page off the first row would
367
+ * ask for three rows on those grids. The unit is deliberately the *smallest* a
368
+ * row can be, so this is an upper bound: a page of uniform rows lands exactly,
369
+ * and a page of tall ones over-fetches and scrolls inside the grid, which is
370
+ * the right way round.
371
+ *
372
+ * @param {number} height the grid element's total height, pager included.
373
+ * @param {number} chrome everything in that height which is not a data row:
374
+ * column header, filter row, footer, pager toolbar.
375
+ * @param {number} rowUnit
376
+ * @returns {number} at least 1.
377
+ */
378
+ export const rowsForHeight = (height, chrome, rowUnit) => {
379
+ if (!(rowUnit > 0)) {
380
+ return 1;
381
+ }
382
+
383
+ const usable = height - chrome;
384
+
385
+ return Math.max(1, Math.floor(usable / rowUnit));
386
+ };
387
+
388
+ /** How long to wait after a layout change before re-measuring, in ms. */
389
+ export const REMEASURE_DEBOUNCE_MS = 100;
390
+
173
391
  /**
174
392
  * Height left on screen below `ref`'s element, for panes that should fill the
175
393
  * window instead of taking a fraction of it.
@@ -179,17 +397,60 @@ export const useDensity = () => {
179
397
  * scrolled out of view) — so callers can keep their own fallback rather than
180
398
  * flashing a zero-height pane.
181
399
  *
400
+ * WHY IT WATCHES MORE THAN THE WINDOW
401
+ *
402
+ * The window resizing is not the only thing that changes how much room is left
403
+ * below an element. Everything stacked above it moves its top edge without the
404
+ * window changing size at all, and in this library that happens constantly
405
+ * *after* the first paint:
406
+ *
407
+ * - `GenericIndex` renders its tab strip only once `subnav` arrives, so the
408
+ * grid is measured before the tabs exist and then pushed down by them.
409
+ * - A page header that fills in a record count, a client summary or an alert
410
+ * from a second request grows a line taller.
411
+ * - A filter panel above the grid expands or collapses.
412
+ * - A grid mounted in a hidden tab measures as nothing, and nothing about the
413
+ * window changes when its tab is finally shown.
414
+ *
415
+ * None of those touch `window.innerHeight`, and the ref object is stable across
416
+ * a remount, so the effect below does not re-run for any of them. A
417
+ * `ResizeObserver` on the document body — and on the element's own offset
418
+ * parent, for a pane that scrolls inside a fixed-height region where the body
419
+ * never changes size — catches all of them, debounced so a run of mutations
420
+ * costs one measurement.
421
+ *
422
+ * The measurement only becomes new state when the top edge actually moved (see
423
+ * `hasTopMoved`), which is also what stops the obvious feedback loop: the pane
424
+ * resizing itself from this value changes the body's height, which fires the
425
+ * observer again — but never moves the pane's own top edge, so it settles after
426
+ * one pass.
427
+ *
182
428
  * @param {{current: HTMLElement|null}} ref wrapper to measure from.
183
429
  * @param {object} [options]
184
430
  * @param {boolean} [options.enabled=true] false parks the hook at null.
185
- * @param {number} [options.bottomGutter=16] px left below the element.
431
+ * @param {number} [options.bottomGutter=16] px left below the element, on
432
+ * top of any measured chrome.
433
+ * @param {boolean} [options.measureChrome=false] also subtract what the page
434
+ * itself needs below the element
435
+ * — card padding, the page
436
+ * gutter, a section rendered
437
+ * after it. See
438
+ * `bottomChromeBelow`. With this
439
+ * on, `bottomGutter` is a safety
440
+ * margin rather than the whole
441
+ * allowance, so pass it small.
186
442
  * @param {*} [options.recomputeKey] change it to force a re-measure
187
443
  * (e.g. the app's window height).
188
444
  * @returns {number|null}
189
445
  */
190
446
  export const useAvailableHeight = (
191
447
  ref,
192
- { enabled = true, bottomGutter = 16, recomputeKey } = {}
448
+ {
449
+ enabled = true,
450
+ bottomGutter = 16,
451
+ measureChrome = false,
452
+ recomputeKey,
453
+ } = {}
193
454
  ) => {
194
455
  const [height, setHeight] = useState(null);
195
456
 
@@ -199,25 +460,100 @@ export const useAvailableHeight = (
199
460
  return undefined;
200
461
  }
201
462
 
202
- const measure = () => {
463
+ // What this effect last measured, so a `ResizeObserver` burst that
464
+ // leaves the page looking the same costs nothing.
465
+ let previousTop = null;
466
+ let previousGutter = null;
467
+ let timer = null;
468
+
469
+ const measure = (force) => {
203
470
  const node = ref?.current;
204
471
 
205
472
  if (!node || typeof node.getBoundingClientRect !== 'function') {
473
+ previousTop = null;
474
+ previousGutter = null;
206
475
  setHeight(null);
207
476
  return;
208
477
  }
209
478
 
210
479
  const { top } = node.getBoundingClientRect();
211
- const next = window.innerHeight - top - bottomGutter;
212
480
 
213
- setHeight(Number.isFinite(next) && next > 0 ? next : null);
481
+ // The gutter alone is a guess at what sits under the pane. When the
482
+ // caller asks, measure it instead, so `top + height + everything
483
+ // below === window.innerHeight` actually closes.
484
+ const gutter = measureChrome
485
+ ? bottomGutter + bottomChromeBelow(node)
486
+ : bottomGutter;
487
+
488
+ // Both ends matter, and they move independently. A tab strip
489
+ // arriving above the grid moves the top; a second section rendering
490
+ // *below* it (My Tasks grows a Family Tasks table once a
491
+ // relationship filter is set) changes what is underneath while the
492
+ // top edge stays exactly where it was.
493
+ if (
494
+ !force &&
495
+ !hasTopMoved(previousTop, top) &&
496
+ !hasTopMoved(previousGutter, gutter)
497
+ ) {
498
+ return;
499
+ }
500
+
501
+ previousTop = top;
502
+ previousGutter = gutter;
503
+
504
+ setHeight(availableHeightFrom(top, window.innerHeight, gutter));
505
+ };
506
+
507
+ // A resize changes the height even when the top edge has not moved, so
508
+ // it always re-measures; a layout shift only counts if the edge moved.
509
+ const remeasure = () => measure(true);
510
+ const remeasureIfMoved = () => measure(false);
511
+
512
+ const scheduleRemeasure = () => {
513
+ if (timer) {
514
+ clearTimeout(timer);
515
+ }
516
+
517
+ timer = setTimeout(() => {
518
+ timer = null;
519
+ remeasureIfMoved();
520
+ }, REMEASURE_DEBOUNCE_MS);
214
521
  };
215
522
 
216
- measure();
217
- window.addEventListener('resize', measure);
523
+ measure(true);
524
+ window.addEventListener('resize', remeasure);
525
+
526
+ const observers = [];
527
+
528
+ if (typeof ResizeObserver !== 'undefined') {
529
+ const observed = new Set();
530
+
531
+ // The body covers content flowing above the pane. The offset parent
532
+ // covers the case where it does not: a pane inside a fixed-height,
533
+ // internally scrolling region, whose siblings can grow without the
534
+ // document getting any taller.
535
+ [document.body, ref?.current?.offsetParent].forEach((target) => {
536
+ if (!target || observed.has(target)) {
537
+ return;
538
+ }
218
539
 
219
- return () => window.removeEventListener('resize', measure);
220
- }, [ref, enabled, bottomGutter, recomputeKey]);
540
+ observed.add(target);
541
+
542
+ const observer = new ResizeObserver(scheduleRemeasure);
543
+ observer.observe(target);
544
+ observers.push(observer);
545
+ });
546
+ }
547
+
548
+ return () => {
549
+ window.removeEventListener('resize', remeasure);
550
+ observers.forEach((observer) => observer.disconnect());
551
+
552
+ if (timer) {
553
+ clearTimeout(timer);
554
+ }
555
+ };
556
+ }, [ref, enabled, bottomGutter, measureChrome, recomputeKey]);
221
557
 
222
558
  return height;
223
559
  };
@@ -0,0 +1,208 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import { KeyRound, Loader2 } from 'lucide-react';
4
+
5
+ import CustomFetch from '../Fetch';
6
+ import styles from '../styles/Vault.module.scss';
7
+ import { copyText } from './vaultClipboard';
8
+ import { fetchOtp } from './vaultOtp';
9
+ import { formatOtp } from './vaultHelpers';
10
+ import { resolveVaultEndpoints } from './vaultEndpoints';
11
+
12
+ const RING_RADIUS = 7;
13
+ const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
14
+
15
+ /**
16
+ * The 2FA code for one vault entry.
17
+ *
18
+ * Starts as a chip rather than a live code on purpose: a screen full of
19
+ * ticking six-digit codes is a screen full of credentials that anyone walking
20
+ * past can read, and it would put one request per row on the server every 30
21
+ * seconds. A code appears only when its chip is clicked.
22
+ *
23
+ * Once open it keeps itself current — the countdown ring drains, and at the
24
+ * boundary the next code is fetched — until the chip unmounts or is closed.
25
+ * Clicking an open chip copies the code, which is what the user came for.
26
+ *
27
+ * The code is never computed here. The shared secret stays on the server and
28
+ * the endpoint returns only the current code and how long it has left, so a
29
+ * compromised bundle leaks one 30-second code rather than a permanent seed.
30
+ *
31
+ * `size` picks the shape: `sm` and `lg` are labelled pills, `icon` is a square
32
+ * button carrying only the glyph — the quick search's rows are one line each
33
+ * and cannot afford the word. An `icon` chip still expands to the code and
34
+ * ring once it is opened, because at that point the digits are the content.
35
+ *
36
+ * `buttonRef` is handed straight to the button so a parent can drive the chip
37
+ * from the keyboard; the quick search's `Alt+O` uses it.
38
+ */
39
+ const OtpChip = ({ entryId, endpoints, size = 'sm', buttonRef = null }) => {
40
+ // Memoised: an unstable routes object changes `fetchCode`'s identity every
41
+ // render, which tears down and rebuilds the countdown interval below on
42
+ // every render rather than once per open chip.
43
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
44
+
45
+ const [code, setCode] = useState('');
46
+ const [period, setPeriod] = useState(30);
47
+ const [remaining, setRemaining] = useState(0);
48
+ const [loading, setLoading] = useState(false);
49
+
50
+ // Every async path checks this before touching state: the chip lives in a
51
+ // popover that is routinely closed mid-request.
52
+ const mountedRef = useRef(true);
53
+ const tickRef = useRef(null);
54
+ const openRef = useRef(false);
55
+
56
+ useEffect(
57
+ () => () => {
58
+ mountedRef.current = false;
59
+ if (tickRef.current) clearInterval(tickRef.current);
60
+ },
61
+ []
62
+ );
63
+
64
+ const fetchCode = useCallback(() => {
65
+ setLoading(true);
66
+
67
+ fetchOtp(CustomFetch, routes, entryId)
68
+ .then(({ code: next, period: seconds, expiresIn }) => {
69
+ if (!mountedRef.current || !openRef.current) return;
70
+
71
+ setLoading(false);
72
+ setCode(next);
73
+ setPeriod(seconds);
74
+ // The server's own count, so the ring agrees with the code
75
+ // rather than with this browser's clock.
76
+ setRemaining(expiresIn);
77
+ })
78
+ .catch(() => {
79
+ if (!mountedRef.current) return;
80
+
81
+ setLoading(false);
82
+ // CustomFetch has already said what went wrong. Fall back to
83
+ // the idle chip so the user can try again.
84
+ openRef.current = false;
85
+ setCode('');
86
+ });
87
+ }, [entryId, routes]);
88
+
89
+ // One interval for the life of an open chip, and it does nothing but
90
+ // drain the ring. Fetching the next code from inside the updater would be
91
+ // a side effect in a function React is free to call twice.
92
+ useEffect(() => {
93
+ if (code === '') return undefined;
94
+
95
+ tickRef.current = setInterval(() => {
96
+ setRemaining((prev) => (prev <= 0 ? 0 : prev - 1));
97
+ }, 1000);
98
+
99
+ return () => {
100
+ if (tickRef.current) clearInterval(tickRef.current);
101
+ tickRef.current = null;
102
+ };
103
+ }, [code]);
104
+
105
+ // Reaching the boundary is what asks for the next code.
106
+ useEffect(() => {
107
+ if (code === '' || remaining > 0) return;
108
+
109
+ fetchCode();
110
+ }, [code, remaining, fetchCode]);
111
+
112
+ const handleClick = useCallback(
113
+ async (event) => {
114
+ event.preventDefault();
115
+ event.stopPropagation();
116
+
117
+ if (code === '') {
118
+ openRef.current = true;
119
+ fetchCode();
120
+ return;
121
+ }
122
+
123
+ const copied = await copyText(code);
124
+
125
+ toast[copied ? 'success' : 'info'](
126
+ copied ? 'Code copied' : `Code: ${formatOtp(code)}`
127
+ );
128
+ },
129
+ [code, fetchCode]
130
+ );
131
+
132
+ const isOpen = code !== '';
133
+ const fraction = isOpen && period > 0 ? Math.max(0, remaining) / period : 0;
134
+ // A code with under five seconds left is about to change under the user's
135
+ // fingers, which is worth flagging before they paste it somewhere.
136
+ const expiring = isOpen && remaining > 0 && remaining <= 5;
137
+
138
+ // Icon-only until there is something to show; once there is, the code
139
+ // itself is the label and the square shape gives way to it.
140
+ const iconOnly = size === 'icon' && !isOpen;
141
+
142
+ return (
143
+ <button
144
+ type="button"
145
+ ref={buttonRef}
146
+ className={[
147
+ styles.otpChip,
148
+ size === 'lg' ? styles.otpChipLg : '',
149
+ size === 'icon' ? styles.otpChipCompact : '',
150
+ iconOnly ? styles.otpChipIcon : '',
151
+ isOpen ? styles.otpChipOpen : '',
152
+ expiring ? styles.otpChipExpiring : '',
153
+ ]
154
+ .filter(Boolean)
155
+ .join(' ')}
156
+ onClick={handleClick}
157
+ disabled={loading && !isOpen}
158
+ data-tooltip-id="system-tooltip"
159
+ data-tooltip-content={
160
+ isOpen ? 'Copy this code' : '2FA code'
161
+ }
162
+ aria-label={
163
+ isOpen
164
+ ? `Copy the 2FA code, ${remaining} seconds remaining`
165
+ : 'Show the 2FA code'
166
+ }
167
+ >
168
+ {loading && !isOpen ? (
169
+ <Loader2 size={13} className={styles.spin} aria-hidden="true" />
170
+ ) : isOpen ? (
171
+ <svg
172
+ className={styles.otpRing}
173
+ width="18"
174
+ height="18"
175
+ viewBox="0 0 18 18"
176
+ aria-hidden="true"
177
+ >
178
+ <circle
179
+ className={styles.otpRingTrack}
180
+ cx="9"
181
+ cy="9"
182
+ r={RING_RADIUS}
183
+ />
184
+ <circle
185
+ className={styles.otpRingValue}
186
+ cx="9"
187
+ cy="9"
188
+ r={RING_RADIUS}
189
+ strokeDasharray={RING_CIRCUMFERENCE}
190
+ strokeDashoffset={RING_CIRCUMFERENCE * (1 - fraction)}
191
+ />
192
+ </svg>
193
+ ) : (
194
+ <KeyRound size={13} strokeWidth={2} aria-hidden="true" />
195
+ )}
196
+
197
+ {/* Tabular figures: without them the code jitters every second as
198
+ the glyph widths change. */}
199
+ {!iconOnly && (
200
+ <span className={isOpen ? styles.otpCode : styles.otpLabel}>
201
+ {isOpen ? formatOtp(code) : 'OTP'}
202
+ </span>
203
+ )}
204
+ </button>
205
+ );
206
+ };
207
+
208
+ export default OtpChip;