@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.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
220
|
-
|
|
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;
|