@visns-studio/visns-components 6.6.4 → 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 (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. package/src/index.js +233 -1
@@ -0,0 +1,681 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import {
4
+ ExternalLink,
5
+ KeyRound,
6
+ Loader2,
7
+ Lock,
8
+ Plus,
9
+ Search,
10
+ User,
11
+ } from 'lucide-react';
12
+
13
+ import CustomFetch from '../Fetch';
14
+ import styles from '../styles/Vault.module.scss';
15
+ import OtpChip from './OtpChip';
16
+ import VaultConfirmPanel from './VaultConfirmPanel';
17
+ import useDebouncedValue from './useDebouncedValue';
18
+ import useVaultReveal from './useVaultReveal';
19
+ import withVaultNavigation from './vaultNavigation';
20
+ import { copyText } from './vaultClipboard';
21
+ import { resolveVaultEndpoints } from './vaultEndpoints';
22
+ import {
23
+ hostFromUrl,
24
+ isMacPlatform,
25
+ matchesShortcut,
26
+ openableUrl,
27
+ shortcutLabel,
28
+ } from './vaultHelpers';
29
+
30
+ /** What the shortcut must not interrupt. */
31
+ const MODAL_SELECTOR = [
32
+ 'dialog[open]',
33
+ '[aria-modal="true"]',
34
+ // SweetAlert, which the library uses for confirmations, and which takes
35
+ // the keyboard for itself while it is up.
36
+ '.swal2-container',
37
+ ].join(', ');
38
+
39
+ /**
40
+ * Should this keystroke be left alone?
41
+ *
42
+ * ⌘K is a cheap chord, which is the point of it and also the risk: it is the
43
+ * kind of key an editor or a spreadsheet grid binds for itself. So the vault
44
+ * declines it when something modal is already on screen, and when the caret is
45
+ * in rich text — a TinyMCE body is `contenteditable` inside an iframe, and an
46
+ * editor toolbar's own ⌘K (insert link) has a better claim on the keystroke
47
+ * than a header popover does.
48
+ */
49
+ const shouldIgnoreShortcut = (event) => {
50
+ if (typeof document === 'undefined') return false;
51
+
52
+ if (document.querySelector(MODAL_SELECTOR)) return true;
53
+
54
+ const target = event.target;
55
+
56
+ // A keydown inside an iframe never reaches this window in the first place;
57
+ // this catches the case where the iframe itself holds focus.
58
+ if (target?.tagName === 'IFRAME' || document.activeElement?.tagName === 'IFRAME') {
59
+ return true;
60
+ }
61
+
62
+ if (target?.isContentEditable) return true;
63
+
64
+ return Boolean(target?.closest?.('[contenteditable="true"], [contenteditable=""]'));
65
+ };
66
+
67
+ /**
68
+ * The vault, from the header.
69
+ *
70
+ * Mounted as an account action through Navigation's `renderers` slot, so it
71
+ * sits in the chip row beside the notification bell and wears the same
72
+ * trigger. The point of it is the shortest possible path from "I need the
73
+ * bank login" to that password being on the clipboard: a keyboard shortcut, a
74
+ * few letters, Enter.
75
+ *
76
+ * Everything here is deliberately transient. Results are not cached between
77
+ * openings, a revealed password hides itself after fifteen seconds, and
78
+ * closing the popover forgets all of it — a vault that leaves credentials
79
+ * lying around in component state is a vault that leaks them to the next
80
+ * person at the desk.
81
+ */
82
+ const VaultQuickSearchInner = ({
83
+ setting,
84
+ // Accepted so the component matches the shape Navigation's renderers hand
85
+ // over, even though the popover does not read them.
86
+ userProfile: _userProfile,
87
+ setSystemAuth: _setSystemAuth,
88
+ endpoints,
89
+ manageUrl = '/settings/vault',
90
+ // Both, because ⌘K is what a user's hands reach for and ⌘⇧K is what the
91
+ // first version of this shipped with. `matchesShortcut` takes either a
92
+ // string or a list.
93
+ shortcut = ['mod+k', 'mod+shift+k'],
94
+ limit = 8,
95
+ navigate,
96
+ }) => {
97
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
98
+
99
+ const [open, setOpen] = useState(false);
100
+ const [query, setQuery] = useState('');
101
+ const [results, setResults] = useState([]);
102
+ const [loading, setLoading] = useState(false);
103
+ const [searched, setSearched] = useState(false);
104
+ const [highlight, setHighlight] = useState(0);
105
+ // Used when the clipboard is unavailable: { label, value }.
106
+ const [fallback, setFallback] = useState(null);
107
+
108
+ const debouncedQuery = useDebouncedValue(query, 250);
109
+
110
+ const wrapperRef = useRef(null);
111
+ const triggerRef = useRef(null);
112
+ const inputRef = useRef(null);
113
+ const mountedRef = useRef(true);
114
+ // Guards against an early response overwriting a later one.
115
+ const requestRef = useRef(0);
116
+ // One button node per row's OTP chip, so Alt+O can open the highlighted
117
+ // row's code without lifting the chip's state out of it.
118
+ const otpButtonsRef = useRef({});
119
+
120
+ const {
121
+ reveal,
122
+ revealingId,
123
+ confirmRequired,
124
+ confirmMessage,
125
+ confirmError,
126
+ confirmPending,
127
+ confirm,
128
+ cancelConfirm,
129
+ } = useVaultReveal(routes);
130
+
131
+ useEffect(() => () => {
132
+ mountedRef.current = false;
133
+ }, []);
134
+
135
+ /* ------------------------------------------------------------ opening */
136
+
137
+ const close = useCallback(() => {
138
+ setOpen(false);
139
+ setQuery('');
140
+ setResults([]);
141
+ setSearched(false);
142
+ setHighlight(0);
143
+ setFallback(null);
144
+ }, []);
145
+
146
+ // Read through a ref so the listener below can be registered exactly once:
147
+ // the default is an array literal, so a dependency on it would tear the
148
+ // listener down and rebuild it on every render.
149
+ const shortcutRef = useRef(shortcut);
150
+ shortcutRef.current = shortcut;
151
+
152
+ // The shortcut is global: the whole point is not having to reach for the
153
+ // mouse, or to be on any particular page first.
154
+ //
155
+ // On `window`, in the capture phase, because the CRM is full of controls
156
+ // that stop keydown from propagating — the data grid, the rich text
157
+ // editors, react-select — and a bubbling listener behind any of them never
158
+ // hears the key at all. Capturing means the vault sees it first and, when
159
+ // it is the vault's chord, nobody else does.
160
+ useEffect(() => {
161
+ const onKeyDown = (event) => {
162
+ if (!matchesShortcut(event, shortcutRef.current)) return;
163
+ if (shouldIgnoreShortcut(event)) return;
164
+
165
+ event.preventDefault();
166
+ event.stopPropagation();
167
+ setOpen((prev) => !prev);
168
+ };
169
+
170
+ window.addEventListener('keydown', onKeyDown, true);
171
+
172
+ return () => window.removeEventListener('keydown', onKeyDown, true);
173
+ }, []);
174
+
175
+ useEffect(() => {
176
+ if (!open) return undefined;
177
+
178
+ // Focus after paint, or the input is not in the document yet.
179
+ const handle = setTimeout(() => inputRef.current?.focus(), 0);
180
+
181
+ return () => clearTimeout(handle);
182
+ }, [open]);
183
+
184
+ useEffect(() => {
185
+ if (!open) return undefined;
186
+
187
+ const onMouseDown = (event) => {
188
+ if (wrapperRef.current?.contains(event.target)) return;
189
+ if (triggerRef.current?.contains(event.target)) return;
190
+
191
+ close();
192
+ };
193
+
194
+ document.addEventListener('mousedown', onMouseDown);
195
+
196
+ return () => document.removeEventListener('mousedown', onMouseDown);
197
+ }, [open, close]);
198
+
199
+ /* ----------------------------------------------------------- searching */
200
+
201
+ useEffect(() => {
202
+ if (!open) return;
203
+
204
+ const search = debouncedQuery.trim();
205
+ const ticket = requestRef.current + 1;
206
+ requestRef.current = ticket;
207
+
208
+ setLoading(true);
209
+
210
+ CustomFetch(
211
+ routes.list,
212
+ 'GET',
213
+ { search, per_page: limit },
214
+ (result) => {
215
+ // A slower earlier request must not land on top of this one.
216
+ if (!mountedRef.current || requestRef.current !== ticket) return;
217
+
218
+ setResults(Array.isArray(result?.data) ? result.data : []);
219
+ setLoading(false);
220
+ setSearched(true);
221
+ setHighlight(0);
222
+ },
223
+ null
224
+ ).catch(() => {
225
+ if (!mountedRef.current || requestRef.current !== ticket) return;
226
+
227
+ setLoading(false);
228
+ setSearched(true);
229
+ setResults([]);
230
+ });
231
+ }, [open, debouncedQuery, limit, routes]);
232
+
233
+ /* ------------------------------------------------------------ actions */
234
+
235
+ /**
236
+ * A revealed password goes to the clipboard, and nowhere else.
237
+ *
238
+ * There is deliberately no "show it in the row" any more: this popover
239
+ * opens in the header of a screen other people walk past, and a password
240
+ * printed into a list is a password anyone behind the desk can read. The
241
+ * clipboard is what the user came for; the fallback below exists only for
242
+ * a browser that will not give us one.
243
+ */
244
+ const handleRevealed = useCallback((entry, password) => {
245
+ if (!mountedRef.current) return;
246
+
247
+ copyText(password).then((copied) => {
248
+ if (!mountedRef.current) return;
249
+
250
+ if (copied) {
251
+ toast.success('Password copied — paste it now');
252
+ } else {
253
+ // No clipboard (an insecure context, usually). Show it in the
254
+ // panel so the action is not a dead end.
255
+ setFallback({
256
+ label: `Password for ${entry.title}`,
257
+ value: password,
258
+ });
259
+ }
260
+ });
261
+ }, []);
262
+
263
+ const revealEntry = useCallback(
264
+ (entry) => {
265
+ reveal(entry.id)
266
+ .then((password) => handleRevealed(entry, password))
267
+ .catch(() => {
268
+ // A cancelled confirmation is a decision, not a failure,
269
+ // and every other outcome has already been reported by
270
+ // CustomFetch. Swallowed so it is not an unhandled
271
+ // rejection.
272
+ })
273
+ .finally(() => {
274
+ // Focus went to the confirm panel; bring it back so the
275
+ // next keystroke goes to the search box.
276
+ if (mountedRef.current && open) inputRef.current?.focus();
277
+ });
278
+ },
279
+ [reveal, handleRevealed, open]
280
+ );
281
+
282
+ const copyUsername = useCallback(
283
+ async (entry) => {
284
+ const copied = await copyText(entry.username);
285
+
286
+ if (!mountedRef.current) return;
287
+
288
+ if (copied) {
289
+ toast.success('Username copied');
290
+ } else {
291
+ setFallback({
292
+ label: `Username for ${entry.title}`,
293
+ value: entry.username,
294
+ });
295
+ }
296
+
297
+ // Logged for the same reason the password copies are: knowing who
298
+ // took a credential out of the vault, and when.
299
+ CustomFetch(
300
+ routes.logCopy(entry.id),
301
+ 'POST',
302
+ { action: 'copy_username' },
303
+ () => {},
304
+ () => {}
305
+ ).catch(() => {
306
+ // An unrecorded copy must not interrupt the user.
307
+ });
308
+ },
309
+ [routes]
310
+ );
311
+
312
+ const openSite = useCallback((entry) => {
313
+ const url = openableUrl(entry.url);
314
+
315
+ if (url === '') return;
316
+
317
+ // `noopener` matters: the opened page gets no handle on this one.
318
+ window.open(url, '_blank', 'noopener,noreferrer');
319
+ }, []);
320
+
321
+ const goToManager = useCallback(
322
+ (extra = '') => {
323
+ close();
324
+ navigate(`${manageUrl}${extra}`);
325
+ },
326
+ [close, navigate, manageUrl]
327
+ );
328
+
329
+ /* ---------------------------------------------------------- keyboard */
330
+
331
+ /**
332
+ * Every key the popover answers to while the search box has focus.
333
+ *
334
+ * The row actions are on Alt rather than on the bare letter they name:
335
+ * focus never leaves the search input — it is what ↑↓ steer from — so a
336
+ * bare `u` is a character the user is typing into their query, not a
337
+ * command. Alt+U and Alt+O are free on both platforms and do not shadow
338
+ * anything the browser or a screen reader claims.
339
+ */
340
+ const onSearchKeyDown = useCallback(
341
+ (event) => {
342
+ if (event.key === 'Escape') {
343
+ event.preventDefault();
344
+ if (confirmRequired) cancelConfirm();
345
+ else close();
346
+ return;
347
+ }
348
+
349
+ if (confirmRequired || results.length === 0) return;
350
+
351
+ const entry = results[highlight];
352
+
353
+ if (event.altKey && !event.ctrlKey && !event.metaKey) {
354
+ const key = String(event.key).toLowerCase();
355
+
356
+ // On a Mac, Alt+letter types a symbol — `event.key` is `†` for
357
+ // Alt+T — so the physical key is the only reliable read.
358
+ const code = String(event.code || '').toLowerCase();
359
+ const pressed = ['u', 'o'].find(
360
+ (one) => key === one || code === `key${one}`
361
+ );
362
+
363
+ if (!pressed || !entry) return;
364
+
365
+ event.preventDefault();
366
+
367
+ if (pressed === 'u' && entry.username) copyUsername(entry);
368
+ else if (pressed === 'o' && entry.has_totp) {
369
+ otpButtonsRef.current[entry.id]?.click();
370
+ }
371
+
372
+ return;
373
+ }
374
+
375
+ if (event.key === 'ArrowDown') {
376
+ event.preventDefault();
377
+ setHighlight((prev) => (prev + 1) % results.length);
378
+ } else if (event.key === 'ArrowUp') {
379
+ event.preventDefault();
380
+ setHighlight(
381
+ (prev) => (prev - 1 + results.length) % results.length
382
+ );
383
+ } else if (event.key === 'Enter') {
384
+ event.preventDefault();
385
+ if (entry) revealEntry(entry);
386
+ }
387
+ },
388
+ [
389
+ confirmRequired,
390
+ cancelConfirm,
391
+ close,
392
+ results,
393
+ highlight,
394
+ revealEntry,
395
+ copyUsername,
396
+ ]
397
+ );
398
+
399
+ /* ------------------------------------------------------------ render */
400
+
401
+ const label = setting?.label || 'Vault';
402
+ const hint = shortcutLabel(shortcut);
403
+ // ⌥ on a Mac keyboard, Alt+ everywhere else — the same split
404
+ // `shortcutLabel` already makes for the global shortcut.
405
+ const altLabel = isMacPlatform() ? '⌥' : 'Alt+';
406
+
407
+ /**
408
+ * One result, on one line.
409
+ *
410
+ * The row is a two-column grid: everything identifying the entry in the
411
+ * first cell, which truncates, and the actions in the second, which never
412
+ * wrap. It used to be three stacked blocks with labelled pills, and with a
413
+ * real title ("Colonial First State — Adviser portal") beside a real email
414
+ * username the pills spilled onto a second and third line and a list of
415
+ * eight entries stopped fitting on screen. So the actions are square icon
416
+ * buttons with tooltips and aria-labels instead, and the full
417
+ * `title — username — url` goes in a `title` attribute for whatever the
418
+ * ellipsis ate.
419
+ *
420
+ * Nothing makes a row taller: a revealed password goes straight to the
421
+ * clipboard rather than onto the screen, so every row is exactly one line
422
+ * whatever happens in it.
423
+ */
424
+ const renderRow = (entry, index) => {
425
+ const isHighlighted = index === highlight;
426
+ const host = hostFromUrl(entry.url);
427
+ const meta = [entry.username, host].filter(Boolean).join(' · ');
428
+ const busy = revealingId === entry.id;
429
+ const url = openableUrl(entry.url);
430
+
431
+ return (
432
+ <li
433
+ key={entry.id}
434
+ className={`${styles.row} ${isHighlighted ? styles.rowActive : ''}`}
435
+ onMouseEnter={() => setHighlight(index)}
436
+ aria-selected={isHighlighted}
437
+ role="option"
438
+ >
439
+ <div
440
+ className={styles.rowMain}
441
+ title={[entry.title, entry.username, entry.url]
442
+ .filter(Boolean)
443
+ .join(' — ')}
444
+ >
445
+ {entry.visibility === 'private' && (
446
+ <Lock
447
+ size={12}
448
+ strokeWidth={2}
449
+ className={styles.privateMark}
450
+ aria-label="Private to you"
451
+ data-tooltip-id="system-tooltip"
452
+ data-tooltip-place="bottom"
453
+ data-tooltip-content="Private to you"
454
+ />
455
+ )}
456
+ <strong className={styles.rowName}>{entry.title}</strong>
457
+ <span className={styles.rowMeta}>
458
+ {meta ? ` · ${meta}` : ' · No username'}
459
+ </span>
460
+ </div>
461
+
462
+ {/* Icons, not labelled pills: the words are what forced the
463
+ wrap. Every one carries a tooltip and an aria-label. */}
464
+ <div className={styles.rowActions}>
465
+ {entry.username && (
466
+ <button
467
+ type="button"
468
+ className={styles.rowAction}
469
+ onClick={() => copyUsername(entry)}
470
+ data-tooltip-id="system-tooltip"
471
+ data-tooltip-content="Copy username"
472
+ aria-label={`Copy the username for ${entry.title}`}
473
+ >
474
+ <User size={15} strokeWidth={2} aria-hidden="true" />
475
+ </button>
476
+ )}
477
+
478
+ <button
479
+ type="button"
480
+ className={`${styles.rowAction} ${styles.rowActionPrimary}`}
481
+ onClick={() => revealEntry(entry)}
482
+ disabled={busy}
483
+ data-tooltip-id="system-tooltip"
484
+ data-tooltip-content="Copy password"
485
+ aria-label={`Copy the password for ${entry.title}`}
486
+ >
487
+ {busy ? (
488
+ <Loader2 size={15} className={styles.spin} aria-hidden="true" />
489
+ ) : (
490
+ <KeyRound size={15} strokeWidth={2} aria-hidden="true" />
491
+ )}
492
+ </button>
493
+
494
+ {entry.has_totp && (
495
+ <OtpChip
496
+ entryId={entry.id}
497
+ endpoints={routes}
498
+ size="icon"
499
+ buttonRef={(node) => {
500
+ if (node) otpButtonsRef.current[entry.id] = node;
501
+ else delete otpButtonsRef.current[entry.id];
502
+ }}
503
+ />
504
+ )}
505
+
506
+ {url !== '' && (
507
+ <button
508
+ type="button"
509
+ className={styles.rowAction}
510
+ onClick={() => openSite(entry)}
511
+ data-tooltip-id="system-tooltip"
512
+ data-tooltip-content="Open site"
513
+ aria-label={`Open ${host} in a new tab`}
514
+ >
515
+ <ExternalLink size={15} strokeWidth={2} aria-hidden="true" />
516
+ </button>
517
+ )}
518
+ </div>
519
+ </li>
520
+ );
521
+ };
522
+
523
+ return (
524
+ <div className={styles.quickSearch}>
525
+ <button
526
+ ref={triggerRef}
527
+ type="button"
528
+ className={styles.trigger}
529
+ onClick={() => (open ? close() : setOpen(true))}
530
+ data-tooltip-id="system-tooltip"
531
+ data-tooltip-content={`${label} · ${hint}`}
532
+ aria-label={`${label} (${hint})`}
533
+ aria-keyshortcuts="Meta+K Control+K"
534
+ aria-expanded={open}
535
+ aria-haspopup="dialog"
536
+ >
537
+ <KeyRound size={20} strokeWidth={2} />
538
+ </button>
539
+
540
+ {open && (
541
+ <div
542
+ className={styles.popoverWrapper}
543
+ ref={wrapperRef}
544
+ /* Tells the navigation's action pill to stop clipping
545
+ while this is open. See Navigation.module.scss
546
+ `.hactions > ul`, and Notification, which does the
547
+ same. */
548
+ data-nav-overlay="open"
549
+ role="dialog"
550
+ aria-label={label}
551
+ >
552
+ <div className={styles.popover}>
553
+ {/* The manager is the only way to add, edit or delete,
554
+ so its link is a permanent primary control here —
555
+ not something that appears only when a search
556
+ happens to come back empty. */}
557
+ <div className={styles.popoverHead}>
558
+ <span className={styles.popoverTitle}>
559
+ <KeyRound size={15} strokeWidth={2.2} aria-hidden="true" />
560
+ {label}
561
+ </span>
562
+ <button
563
+ type="button"
564
+ className={styles.primaryButton}
565
+ onClick={() => goToManager()}
566
+ >
567
+ Open password manager
568
+ </button>
569
+ </div>
570
+
571
+ <div className={styles.searchBar}>
572
+ <Search size={16} strokeWidth={2} aria-hidden="true" />
573
+ <input
574
+ ref={inputRef}
575
+ type="text"
576
+ className={styles.searchInput}
577
+ value={query}
578
+ placeholder="Search the vault…"
579
+ aria-label="Search the vault"
580
+ autoComplete="off"
581
+ spellCheck="false"
582
+ onChange={(e) => setQuery(e.target.value)}
583
+ onKeyDown={onSearchKeyDown}
584
+ />
585
+ {loading && (
586
+ <Loader2
587
+ size={15}
588
+ className={styles.spin}
589
+ aria-label="Searching"
590
+ />
591
+ )}
592
+ </div>
593
+
594
+ {confirmRequired ? (
595
+ <VaultConfirmPanel
596
+ message={confirmMessage}
597
+ error={confirmError}
598
+ pending={confirmPending}
599
+ onConfirm={confirm}
600
+ onCancel={() => {
601
+ cancelConfirm();
602
+ inputRef.current?.focus();
603
+ }}
604
+ compact
605
+ />
606
+ ) : (
607
+ <>
608
+ {fallback && (
609
+ <div className={styles.fallback}>
610
+ <label htmlFor="vault-fallback">
611
+ {fallback.label} — copy it by hand
612
+ </label>
613
+ <input
614
+ id="vault-fallback"
615
+ className={styles.fallbackInput}
616
+ readOnly
617
+ value={fallback.value}
618
+ onFocus={(e) => e.target.select()}
619
+ />
620
+ <button
621
+ type="button"
622
+ className={styles.linkButton}
623
+ onClick={() => setFallback(null)}
624
+ >
625
+ Done
626
+ </button>
627
+ </div>
628
+ )}
629
+
630
+ {results.length > 0 && (
631
+ <ul
632
+ className={styles.rows}
633
+ role="listbox"
634
+ aria-label="Vault entries"
635
+ >
636
+ {results.map(renderRow)}
637
+ </ul>
638
+ )}
639
+
640
+ {results.length === 0 && searched && !loading && (
641
+ <div className={styles.empty}>
642
+ <p>
643
+ {query.trim() === ''
644
+ ? 'No passwords saved yet.'
645
+ : `No matches for "${query.trim()}"`}
646
+ </p>
647
+ <button
648
+ type="button"
649
+ className={styles.primaryButton}
650
+ onClick={() =>
651
+ goToManager(
652
+ `?create=1&title=${encodeURIComponent(
653
+ query.trim()
654
+ )}`
655
+ )
656
+ }
657
+ >
658
+ <Plus size={14} strokeWidth={2} />
659
+ Add a password
660
+ </button>
661
+ </div>
662
+ )}
663
+ </>
664
+ )}
665
+
666
+ <div className={styles.footer}>
667
+ <span className={styles.footerHint}>
668
+ ↑↓ move · ⏎ copy password · {altLabel}U username ·{' '}
669
+ {altLabel}O code · {hint}
670
+ </span>
671
+ </div>
672
+ </div>
673
+ </div>
674
+ )}
675
+ </div>
676
+ );
677
+ };
678
+
679
+ const VaultQuickSearch = withVaultNavigation(VaultQuickSearchInner);
680
+
681
+ export default VaultQuickSearch;