@visns-studio/visns-components 6.6.4 → 6.15.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 (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  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/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. package/src/index.js +233 -1
@@ -0,0 +1,1000 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import {
4
+ ArrowDown,
5
+ ArrowUp,
6
+ Check,
7
+ ExternalLink,
8
+ History,
9
+ KeyRound,
10
+ Loader2,
11
+ Lock,
12
+ Pencil,
13
+ Plus,
14
+ RotateCcw,
15
+ Search,
16
+ ShieldCheck,
17
+ Trash2,
18
+ User,
19
+ } from 'lucide-react';
20
+
21
+ import CustomFetch from '../Fetch';
22
+ import { showConfirmDialog } from '../generic/ConfirmationDialog';
23
+ import styles from '../styles/Vault.module.scss';
24
+ import OtpChip from './OtpChip';
25
+ import VaultAccessLog from './VaultAccessLog';
26
+ import VaultConfirmPanel from './VaultConfirmPanel';
27
+ import VaultEntryForm from './VaultEntryForm';
28
+ import useDebouncedValue from './useDebouncedValue';
29
+ import useVaultReveal from './useVaultReveal';
30
+ import withVaultNavigation from './vaultNavigation';
31
+ import { copyText } from './vaultClipboard';
32
+ import { resolveVaultEndpoints } from './vaultEndpoints';
33
+ import { hostFromUrl, openableUrl } from './vaultHelpers';
34
+ import { useAvailableHeight } from '../utils/useDensity';
35
+ import {
36
+ MAX_ROWS,
37
+ MIN_ROWS,
38
+ firstIndexOfPage,
39
+ fitRowsToHeight,
40
+ pageForIndex,
41
+ } from './vaultFit';
42
+ import { fetchOtp, otpCopiedMessage } from './vaultOtp';
43
+
44
+ /** The page size before anything has been measured, and the one a consumer
45
+ * that passes `perPage` is choosing instead of the measurement. */
46
+ const PER_PAGE = 25;
47
+
48
+ /* The list sizes itself to the window, and these are the numbers it starts
49
+ from — all px, all replaced by a real measurement as soon as one row has
50
+ rendered. A first paint at roughly the right size beats one that visibly
51
+ resizes itself. */
52
+ // Fixed in CSS (.tableWrapFitted tbody tr) so the fit is arithmetic, not a
53
+ // measurement that depends on which rows happen to be on the page.
54
+ const ROW_HEIGHT = 58;
55
+ const HEAD_HEIGHT = 34;
56
+ const PAGER_HEIGHT = 40;
57
+ // The pager's own margin-top plus a little air under it; getBoundingClientRect
58
+ // reports the box, not the margin.
59
+ const PAGER_GAP = 20;
60
+ // Leaves room for the page's own bottom padding under the pager.
61
+ const BOTTOM_GUTTER = 20;
62
+ // A resize is a drag rather than an event, so refitting on every frame would
63
+ // be one request per frame.
64
+ const FIT_DEBOUNCE = 150;
65
+ // How long a copy button says "Copied".
66
+ const COPIED_FOR = 1500;
67
+
68
+ const SORTS = [
69
+ { key: 'title', label: 'Title' },
70
+ { key: 'username', label: 'Username' },
71
+ ];
72
+
73
+ /**
74
+ * One button in a row's action cluster.
75
+ *
76
+ * The tooltip sits on the wrapper rather than on the button because a disabled
77
+ * button fires no pointer events in any browser — and "why is this greyed out"
78
+ * is precisely when a tooltip earns its place. The button keeps the aria-label,
79
+ * which is what a screen reader reads either way.
80
+ *
81
+ * Three states, in the order they win: busy (the request is out), copied (the
82
+ * value reached the clipboard, said for a second and a half), idle.
83
+ */
84
+ const RowAction = ({
85
+ icon: Icon,
86
+ label,
87
+ tooltip,
88
+ onClick,
89
+ disabled = false,
90
+ busy = false,
91
+ copied = false,
92
+ primary = false,
93
+ danger = false,
94
+ }) => (
95
+ <span
96
+ className={styles.actionIconWrap}
97
+ data-tooltip-id="system-tooltip"
98
+ data-tooltip-content={tooltip}
99
+ >
100
+ <button
101
+ type="button"
102
+ className={[
103
+ styles.actionIcon,
104
+ primary ? styles.actionIconPrimary : '',
105
+ danger ? styles.actionIconDanger : '',
106
+ copied ? styles.actionIconDone : '',
107
+ ]
108
+ .filter(Boolean)
109
+ .join(' ')}
110
+ onClick={onClick}
111
+ disabled={disabled || busy}
112
+ aria-label={label}
113
+ >
114
+ {busy ? (
115
+ <Loader2 size={15} className={styles.spin} aria-hidden="true" />
116
+ ) : copied ? (
117
+ <Check size={15} strokeWidth={2.6} aria-hidden="true" />
118
+ ) : (
119
+ <Icon size={15} strokeWidth={2} aria-hidden="true" />
120
+ )}
121
+ </button>
122
+ </span>
123
+ );
124
+
125
+ /**
126
+ * The vault's own page: everything the header popover deliberately is not.
127
+ *
128
+ * The popover exists to get one password onto the clipboard in three
129
+ * keystrokes; this is where entries are created, edited, retired and audited.
130
+ * The two share every credential-touching behaviour through `useVaultReveal`
131
+ * and `OtpChip`, so a password revealed here goes through exactly the same
132
+ * confirmation as one revealed there — which is the point of extracting them.
133
+ *
134
+ * The table is hand-rolled rather than built on this library's DataGrid. That
135
+ * grid is configuration-driven over the vendored `visns-datagrid` fork, which
136
+ * has React 19 work outstanding (see README), and every column that matters
137
+ * here is a live control — a ticking OTP chip, a reveal that can open a
138
+ * confirmation panel — rather than a value to format. A plain semantic table
139
+ * costs less and does more.
140
+ */
141
+ const VaultManagerInner = ({
142
+ endpoints,
143
+ // Accepted for symmetry with the library's other pages; the server decides
144
+ // what this user may see, so nothing here reads it.
145
+ userProfile: _userProfile,
146
+ canManage = false,
147
+ pageTitle = 'Password manager',
148
+ locationSearch = '',
149
+ // A fixed page size, for a consumer that wants one. Passing it turns off
150
+ // the measurement below entirely — the two must not fight over `per_page`.
151
+ perPage: perPageProp = null,
152
+ }) => {
153
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
154
+
155
+ const [query, setQuery] = useState('');
156
+ const [visibility, setVisibility] = useState('');
157
+ const [includeDeleted, setIncludeDeleted] = useState(false);
158
+ const [sort, setSort] = useState('updated_at');
159
+ const [direction, setDirection] = useState('desc');
160
+ const [page, setPage] = useState(1);
161
+ const [perPage, setPerPage] = useState(
162
+ Number(perPageProp) > 0 ? Number(perPageProp) : PER_PAGE
163
+ );
164
+
165
+ const [rows, setRows] = useState([]);
166
+ const [total, setTotal] = useState(0);
167
+ const [lastPage, setLastPage] = useState(1);
168
+ const [loading, setLoading] = useState(false);
169
+
170
+ const [editing, setEditing] = useState(null); // entry | {} for a new one
171
+ const [logFor, setLogFor] = useState(null); // entry | 'global'
172
+ const [fallback, setFallback] = useState(null);
173
+ // `${entryId}:${what}` for the button currently showing a tick.
174
+ const [copied, setCopied] = useState(null);
175
+ const [otpBusyId, setOtpBusyId] = useState(null);
176
+
177
+ const debouncedQuery = useDebouncedValue(query, 250);
178
+
179
+ const mountedRef = useRef(true);
180
+ const requestRef = useRef(0);
181
+ const copiedTimer = useRef(null);
182
+
183
+ const {
184
+ reveal,
185
+ revealingId,
186
+ confirmRequired,
187
+ confirmMessage,
188
+ confirmError,
189
+ confirmPending,
190
+ confirm,
191
+ cancelConfirm,
192
+ } = useVaultReveal(routes);
193
+
194
+ useEffect(() => {
195
+ mountedRef.current = true;
196
+
197
+ return () => {
198
+ mountedRef.current = false;
199
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
200
+ };
201
+ }, []);
202
+
203
+ /* ----------------------------------------------------------- loading */
204
+
205
+ const load = useCallback(() => {
206
+ const ticket = requestRef.current + 1;
207
+ requestRef.current = ticket;
208
+
209
+ setLoading(true);
210
+
211
+ const params = {
212
+ search: debouncedQuery.trim(),
213
+ page,
214
+ per_page: perPage,
215
+ sort,
216
+ direction,
217
+ };
218
+
219
+ if (includeDeleted) params.include_deleted = 1;
220
+
221
+ CustomFetch(
222
+ routes.list,
223
+ 'GET',
224
+ params,
225
+ (result) => {
226
+ if (!mountedRef.current || requestRef.current !== ticket) return;
227
+
228
+ let data = Array.isArray(result?.data) ? result.data : [];
229
+
230
+ // The list endpoint has no visibility parameter, so this one
231
+ // filter is applied here. It narrows the page rather than the
232
+ // query, which the count below says out loud.
233
+ if (visibility) {
234
+ data = data.filter((row) => row.visibility === visibility);
235
+ }
236
+
237
+ setRows(data);
238
+ setTotal(Number(result?.total) || 0);
239
+ setLastPage(Number(result?.last_page) || 1);
240
+ setLoading(false);
241
+ },
242
+ null
243
+ ).catch(() => {
244
+ if (!mountedRef.current || requestRef.current !== ticket) return;
245
+
246
+ setLoading(false);
247
+ setRows([]);
248
+ });
249
+ }, [
250
+ routes,
251
+ debouncedQuery,
252
+ page,
253
+ perPage,
254
+ sort,
255
+ direction,
256
+ includeDeleted,
257
+ visibility,
258
+ ]);
259
+
260
+ useEffect(() => {
261
+ load();
262
+ }, [load]);
263
+
264
+ // A new search starts at the first page; staying on page 4 of the previous
265
+ // result set is how a search appears to return nothing.
266
+ useEffect(() => {
267
+ setPage(1);
268
+ }, [debouncedQuery, visibility, includeDeleted, sort, direction]);
269
+
270
+ /* ------------------------------------------------ fitting the window */
271
+
272
+ // A fixed page size is either a list that scrolls or a card with white
273
+ // space under it, and in both cases the pager — the control that gets you
274
+ // out of either — is the first thing to leave the screen. So the page size
275
+ // is a measurement: the room between the top of the list and the bottom of
276
+ // the window, less the pager, divided by the height of a row.
277
+ const autoFit = !(Number(perPageProp) > 0);
278
+
279
+ // A consumer that changes its mind about the fixed size mid-life.
280
+ useEffect(() => {
281
+ if (Number(perPageProp) > 0) setPerPage(Number(perPageProp));
282
+ }, [perPageProp]);
283
+
284
+ const listRef = useRef(null);
285
+ const pagerRef = useRef(null);
286
+
287
+ const [metrics, setMetrics] = useState({
288
+ rowHeight: ROW_HEIGHT,
289
+ headHeight: HEAD_HEIGHT,
290
+ pagerHeight: PAGER_HEIGHT,
291
+ });
292
+
293
+ const available = useAvailableHeight(listRef, {
294
+ enabled: autoFit,
295
+ bottomGutter: BOTTOM_GUTTER,
296
+ // The confirm panel and the manual-copy fallback both open above the
297
+ // table and push its top down; a re-measure has to follow them.
298
+ // NOT rows.length: the row count changes every time per_page does,
299
+ // and a re-measure on each of those is half of a measure/refit loop.
300
+ recomputeKey: `${confirmRequired ? 1 : 0}|${fallback ? 1 : 0}`,
301
+ });
302
+
303
+ // Measure what actually rendered rather than trusting the constants: the
304
+ // row height moves with the host's font size, its density setting and
305
+ // whether a row carries tags. Rounded and compared with a 1px tolerance so
306
+ // a sub-pixel wobble cannot start a measure/refit loop.
307
+ useEffect(() => {
308
+ if (!autoFit) return;
309
+
310
+ const list = listRef.current;
311
+
312
+ if (!list || typeof list.querySelector !== 'function') return;
313
+
314
+ // Rows are NOT measured: their height is fixed by CSS in fitted mode
315
+ // (ROW_HEIGHT). Measuring them was the flicker - a tall placeholder or
316
+ // a tag-heavy row skewed the estimate, and the page size it produced
317
+ // changed which rows were there to measure. Head and pager are stable
318
+ // and are still read from the DOM.
319
+ const head = list.querySelector('thead');
320
+
321
+ const measure = (node) => {
322
+ const height = node?.getBoundingClientRect?.().height;
323
+
324
+ return Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
325
+ };
326
+
327
+ const next = {
328
+ headHeight: measure(head),
329
+ pagerHeight: measure(pagerRef.current),
330
+ };
331
+
332
+ setMetrics((current) => {
333
+ const merged = {
334
+ rowHeight: ROW_HEIGHT,
335
+ headHeight: next.headHeight || current.headHeight,
336
+ pagerHeight: next.pagerHeight || current.pagerHeight,
337
+ };
338
+
339
+ const settled = Object.keys(merged).every(
340
+ (key) => Math.abs(merged[key] - current[key]) <= 1
341
+ );
342
+
343
+ return settled ? current : merged;
344
+ });
345
+ }, [autoFit, available]);
346
+
347
+ const fitted = autoFit
348
+ ? fitRowsToHeight({
349
+ available,
350
+ pagerHeight: metrics.pagerHeight + PAGER_GAP,
351
+ rowHeight: metrics.rowHeight,
352
+ chrome: metrics.headHeight,
353
+ min: MIN_ROWS,
354
+ max: MAX_ROWS,
355
+ })
356
+ : null;
357
+
358
+ const debouncedFit = useDebouncedValue(fitted, FIT_DEBOUNCE);
359
+
360
+ // The debounce is there for the drag, not for the first measurement: the
361
+ // list mounts at PER_PAGE, measures on the next frame, and waiting another
362
+ // 150ms to correct that is 150ms of visibly wrong page. So the first fit
363
+ // is taken as it lands and every later one waits for the drag to stop.
364
+ const fittedOnce = useRef(false);
365
+ const target = fittedOnce.current ? debouncedFit : fitted;
366
+
367
+ useEffect(() => {
368
+ if (autoFit && fitted != null) fittedOnce.current = true;
369
+ }, [autoFit, fitted]);
370
+
371
+ useEffect(() => {
372
+ if (!autoFit || target == null || target === perPage) return;
373
+
374
+ // Keep the reader where they were: whatever was at the top of the page
375
+ // stays on screen, on whichever page it now belongs to. Both updates
376
+ // land in the same render, so the list refetches once rather than
377
+ // twice.
378
+ setPage((current) =>
379
+ pageForIndex(firstIndexOfPage(current, perPage), target)
380
+ );
381
+ setPerPage(target);
382
+ }, [autoFit, target, perPage]);
383
+
384
+ // The height the rows are allowed to occupy. The pager is subtracted
385
+ // before anything is drawn, which is what keeps it on screen.
386
+ const listHeight =
387
+ autoFit && available != null
388
+ ? Math.max(0, available - metrics.pagerHeight - PAGER_GAP)
389
+ : null;
390
+
391
+ // In a window too short for the five-row floor the rows will not all fit
392
+ // whatever we do; scrolling the container is then kinder than clipping
393
+ // entries the user cannot reach, and the pager stays put either way.
394
+ const listScrolls =
395
+ listHeight != null &&
396
+ (rows.length === 0 ||
397
+ rows.length * metrics.rowHeight + metrics.headHeight > listHeight + 1);
398
+
399
+ /* ------------------------------------------------- deep link to create */
400
+
401
+ // The quick search's empty state links here as `?create=1&title=…`, so the
402
+ // "no matches — add one" path lands on a form that already knows the name.
403
+ const [prefill, setPrefill] = useState(null);
404
+
405
+ useEffect(() => {
406
+ const params = new URLSearchParams(locationSearch || '');
407
+
408
+ if (params.get('create') !== '1') return;
409
+
410
+ setPrefill({ title: params.get('title') || '' });
411
+ setEditing({});
412
+ }, [locationSearch]);
413
+
414
+ /* ------------------------------------------------------------ actions */
415
+
416
+ // The tick on the button that was just pressed. A toast is easy to miss on
417
+ // a wide screen — the confirmation belongs where the pointer already is —
418
+ // and one timer is enough because only one button can be tapped at a time.
419
+ const markCopied = useCallback((entryId, what) => {
420
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
421
+
422
+ setCopied(`${entryId}:${what}`);
423
+
424
+ copiedTimer.current = setTimeout(() => {
425
+ copiedTimer.current = null;
426
+ if (mountedRef.current) setCopied(null);
427
+ }, COPIED_FOR);
428
+ }, []);
429
+
430
+ const copyPassword = useCallback(
431
+ (entry) => {
432
+ reveal(entry.id)
433
+ .then(async (password) => {
434
+ const ok = await copyText(password);
435
+
436
+ if (!mountedRef.current) return;
437
+
438
+ if (ok) {
439
+ toast.success('Password copied — paste it now');
440
+ markCopied(entry.id, 'password');
441
+ } else
442
+ setFallback({
443
+ label: `Password for ${entry.title}`,
444
+ value: password,
445
+ });
446
+ })
447
+ .catch(() => {
448
+ // Cancelled, or already reported by CustomFetch.
449
+ });
450
+ },
451
+ [reveal, markCopied]
452
+ );
453
+
454
+ const copyUsername = useCallback(
455
+ async (entry) => {
456
+ const ok = await copyText(entry.username);
457
+
458
+ if (!mountedRef.current) return;
459
+
460
+ if (ok) {
461
+ toast.success('Username copied');
462
+ markCopied(entry.id, 'username');
463
+ } else
464
+ setFallback({
465
+ label: `Username for ${entry.title}`,
466
+ value: entry.username,
467
+ });
468
+
469
+ CustomFetch(
470
+ routes.logCopy(entry.id),
471
+ 'POST',
472
+ { action: 'copy_username' },
473
+ () => {},
474
+ () => {}
475
+ ).catch(() => {});
476
+ },
477
+ [routes, markCopied]
478
+ );
479
+
480
+ // The 2FA code, straight onto the clipboard. The chip in the 2FA column
481
+ // still shows a ticking code for anyone who wants to read it; this is for
482
+ // the far more common case of pasting it into the box that is asking.
483
+ //
484
+ // The seconds in the message are the server's own count: a code copied
485
+ // with three seconds left is about to stop working, and the user is better
486
+ // off pressing the button again than pasting it.
487
+ const copyOtp = useCallback(
488
+ (entry) => {
489
+ setOtpBusyId(entry.id);
490
+
491
+ fetchOtp(CustomFetch, routes, entry.id)
492
+ .then(async ({ code, expiresIn }) => {
493
+ if (!mountedRef.current) return;
494
+
495
+ setOtpBusyId(null);
496
+
497
+ const ok = await copyText(code);
498
+
499
+ if (!mountedRef.current) return;
500
+
501
+ if (ok) {
502
+ toast.success(otpCopiedMessage(expiresIn));
503
+ markCopied(entry.id, 'otp');
504
+ } else
505
+ setFallback({
506
+ label: `2FA code for ${entry.title}`,
507
+ value: code,
508
+ });
509
+ })
510
+ .catch(() => {
511
+ // CustomFetch has already said what went wrong.
512
+ if (mountedRef.current) setOtpBusyId(null);
513
+ });
514
+ },
515
+ [routes, markCopied]
516
+ );
517
+
518
+ const remove = useCallback(
519
+ (entry) => {
520
+ showConfirmDialog({
521
+ title: `Delete ${entry.title}?`,
522
+ message:
523
+ 'The entry is soft-deleted — a vault manager can restore it from "Show deleted".',
524
+ confirmLabel: 'Delete',
525
+ cancelLabel: 'Keep it',
526
+ onConfirm: () => {
527
+ CustomFetch(routes.destroy(entry.id), 'DELETE', {}, () => {
528
+ toast.success('Deleted');
529
+ load();
530
+ }).catch(() => {});
531
+ },
532
+ });
533
+ },
534
+ [routes, load]
535
+ );
536
+
537
+ const restore = useCallback(
538
+ (entry) => {
539
+ CustomFetch(routes.restore(entry.id), 'POST', {}, () => {
540
+ toast.success('Restored');
541
+ load();
542
+ }).catch(() => {});
543
+ },
544
+ [routes, load]
545
+ );
546
+
547
+ const toggleSort = (key) => {
548
+ if (sort === key) {
549
+ setDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
550
+ } else {
551
+ setSort(key);
552
+ setDirection(key === 'updated_at' ? 'desc' : 'asc');
553
+ }
554
+ };
555
+
556
+ /* ------------------------------------------------------------- render */
557
+
558
+ return (
559
+ <div className={styles.manager}>
560
+ <div className={styles.managerHead}>
561
+ <div>
562
+ <h1 className={styles.pageTitle}>{pageTitle}</h1>
563
+ <p className={styles.pageIntro}>
564
+ Shared logins for the team, and your own private ones.
565
+ Passwords and 2FA secrets are only ever revealed one at
566
+ a time, and every reveal is logged.
567
+ </p>
568
+ </div>
569
+
570
+ <button
571
+ type="button"
572
+ className={styles.primaryButton}
573
+ onClick={() => {
574
+ setPrefill(null);
575
+ setEditing({});
576
+ }}
577
+ >
578
+ <Plus size={16} strokeWidth={2.2} aria-hidden="true" />
579
+ New entry
580
+ </button>
581
+ </div>
582
+
583
+ <div className={styles.toolbar}>
584
+ <div className={styles.searchBar}>
585
+ <Search size={16} strokeWidth={2} aria-hidden="true" />
586
+ <input
587
+ className={styles.searchInput}
588
+ value={query}
589
+ placeholder="Search by title, username, address or tag…"
590
+ aria-label="Search the vault"
591
+ onChange={(e) => setQuery(e.target.value)}
592
+ />
593
+ {loading && <Loader2 size={15} className={styles.spin} />}
594
+ </div>
595
+
596
+ <select
597
+ className={styles.select}
598
+ value={visibility}
599
+ aria-label="Filter by visibility"
600
+ onChange={(e) => setVisibility(e.target.value)}
601
+ >
602
+ <option value="">All entries</option>
603
+ <option value="shared">Shared only</option>
604
+ <option value="private">Private only</option>
605
+ </select>
606
+
607
+ {canManage && (
608
+ <label className={styles.inlineCheck}>
609
+ <input
610
+ type="checkbox"
611
+ className={styles.checkControl}
612
+ checked={includeDeleted}
613
+ onChange={(e) => setIncludeDeleted(e.target.checked)}
614
+ />
615
+ <span>Show deleted</span>
616
+ </label>
617
+ )}
618
+
619
+ {canManage && (
620
+ <button
621
+ type="button"
622
+ className={styles.ghostButton}
623
+ onClick={() => setLogFor('global')}
624
+ >
625
+ <History size={15} strokeWidth={2} aria-hidden="true" />
626
+ Access log
627
+ </button>
628
+ )}
629
+ </div>
630
+
631
+ {confirmRequired && (
632
+ <VaultConfirmPanel
633
+ message={confirmMessage}
634
+ error={confirmError}
635
+ pending={confirmPending}
636
+ onConfirm={confirm}
637
+ onCancel={cancelConfirm}
638
+ />
639
+ )}
640
+
641
+ {fallback && (
642
+ <div className={styles.fallback}>
643
+ <label htmlFor="vault-manager-fallback">
644
+ {fallback.label} — copy it by hand
645
+ </label>
646
+ <input
647
+ id="vault-manager-fallback"
648
+ className={styles.fallbackInput}
649
+ readOnly
650
+ value={fallback.value}
651
+ onFocus={(e) => e.target.select()}
652
+ />
653
+ <button
654
+ type="button"
655
+ className={styles.linkButton}
656
+ onClick={() => setFallback(null)}
657
+ >
658
+ Done
659
+ </button>
660
+ </div>
661
+ )}
662
+
663
+ <div
664
+ ref={listRef}
665
+ className={`${styles.tableWrap} ${
666
+ listHeight != null ? styles.tableWrapFitted : ''
667
+ }`}
668
+ style={
669
+ listHeight != null
670
+ ? {
671
+ height: `${listHeight}px`,
672
+ overflowY: listScrolls ? 'auto' : 'hidden',
673
+ }
674
+ : undefined
675
+ }
676
+ >
677
+ <table className={styles.table}>
678
+ <thead>
679
+ <tr>
680
+ {SORTS.map((column) => (
681
+ <th key={column.key} scope="col">
682
+ <button
683
+ type="button"
684
+ className={styles.sortButton}
685
+ onClick={() => toggleSort(column.key)}
686
+ aria-label={`Sort by ${column.label}`}
687
+ >
688
+ {column.label}
689
+ {sort === column.key &&
690
+ (direction === 'asc' ? (
691
+ <ArrowUp size={13} strokeWidth={2} />
692
+ ) : (
693
+ <ArrowDown size={13} strokeWidth={2} />
694
+ ))}
695
+ </button>
696
+ </th>
697
+ ))}
698
+ <th scope="col" className={styles.urlColumn}>
699
+ URL
700
+ </th>
701
+ <th scope="col">Visibility</th>
702
+ <th scope="col">2FA</th>
703
+ <th scope="col" className={styles.actionsHead}>
704
+ Actions
705
+ </th>
706
+ </tr>
707
+ </thead>
708
+ <tbody>
709
+ {rows.map((entry) => {
710
+ const host = hostFromUrl(entry.url);
711
+ const url = openableUrl(entry.url);
712
+ const deleted = Boolean(entry.deleted_at);
713
+
714
+ return (
715
+ <tr
716
+ key={entry.id}
717
+ className={deleted ? styles.deletedRow : ''}
718
+ >
719
+ <td>
720
+ <strong>{entry.title}</strong>
721
+ {deleted && (
722
+ <span className={styles.deletedTag}>
723
+ Deleted
724
+ </span>
725
+ )}
726
+ {/* Shown only once the URL column
727
+ folds away below 1200px, so a
728
+ tablet still sees which site this
729
+ is for. */}
730
+ {host && (
731
+ <span className={styles.hostUnderTitle}>
732
+ {host}
733
+ </span>
734
+ )}
735
+ {Array.isArray(entry.tags) &&
736
+ entry.tags.length > 0 && (
737
+ <span className={styles.tagRow}>
738
+ {entry.tags.map((tag) => (
739
+ <span
740
+ key={tag}
741
+ className={styles.tagPill}
742
+ >
743
+ {tag}
744
+ </span>
745
+ ))}
746
+ </span>
747
+ )}
748
+ </td>
749
+ <td>
750
+ {/* Text only. Copying it is one of the
751
+ three buttons in the actions
752
+ cluster, so it exists once. */}
753
+ {entry.username || (
754
+ <span className={styles.muted}>—</span>
755
+ )}
756
+ </td>
757
+ <td className={styles.urlColumn}>
758
+ {url ? (
759
+ <a
760
+ href={url}
761
+ target="_blank"
762
+ rel="noopener noreferrer"
763
+ className={styles.hostLink}
764
+ >
765
+ {host}
766
+ <ExternalLink size={12} strokeWidth={2} />
767
+ </a>
768
+ ) : (
769
+ <span className={styles.muted}>—</span>
770
+ )}
771
+ </td>
772
+ <td>
773
+ <span
774
+ className={`${styles.pill} ${
775
+ entry.visibility === 'private'
776
+ ? styles.pillPrivate
777
+ : styles.pillShared
778
+ }`}
779
+ >
780
+ {entry.visibility === 'private' && (
781
+ <Lock size={11} strokeWidth={2.5} />
782
+ )}
783
+ {entry.visibility === 'private'
784
+ ? 'Private'
785
+ : 'Shared'}
786
+ </span>
787
+ </td>
788
+ <td>
789
+ {entry.has_totp ? (
790
+ <OtpChip
791
+ entryId={entry.id}
792
+ endpoints={routes}
793
+ />
794
+ ) : (
795
+ <span className={styles.muted}>—</span>
796
+ )}
797
+ </td>
798
+ <td className={styles.actionsCell}>
799
+ {deleted ? (
800
+ <button
801
+ type="button"
802
+ className={styles.actionPill}
803
+ onClick={() => restore(entry)}
804
+ aria-label={`Restore ${entry.title}`}
805
+ >
806
+ <RotateCcw size={14} strokeWidth={2} aria-hidden="true" />
807
+ Restore
808
+ </button>
809
+ ) : (
810
+ <div className={styles.actionCluster}>
811
+ {/* The three copies, always in
812
+ this order and always all
813
+ three, so the button under
814
+ the pointer is the same one
815
+ on every row. */}
816
+ <RowAction
817
+ icon={User}
818
+ onClick={() => copyUsername(entry)}
819
+ disabled={!entry.username}
820
+ copied={copied === `${entry.id}:username`}
821
+ tooltip={
822
+ entry.username
823
+ ? 'Copy username'
824
+ : 'No username on this entry'
825
+ }
826
+ label={`Copy the username for ${entry.title}`}
827
+ />
828
+
829
+ <RowAction
830
+ icon={KeyRound}
831
+ primary
832
+ onClick={() => copyPassword(entry)}
833
+ busy={revealingId === entry.id}
834
+ copied={copied === `${entry.id}:password`}
835
+ tooltip="Copy password"
836
+ label={`Copy the password for ${entry.title}`}
837
+ />
838
+
839
+ <RowAction
840
+ icon={ShieldCheck}
841
+ onClick={() => copyOtp(entry)}
842
+ disabled={!entry.has_totp}
843
+ busy={otpBusyId === entry.id}
844
+ copied={copied === `${entry.id}:otp`}
845
+ tooltip={
846
+ entry.has_totp
847
+ ? 'Copy 2FA code'
848
+ : 'No 2FA on this entry'
849
+ }
850
+ label={`Copy the 2FA code for ${entry.title}`}
851
+ />
852
+
853
+ {(entry.can_edit || canManage) && (
854
+ <span
855
+ className={styles.actionDivider}
856
+ aria-hidden="true"
857
+ />
858
+ )}
859
+
860
+ {/* Icon-only past the hairline,
861
+ each with a tooltip: one row
862
+ of actions is the point. */}
863
+ {entry.can_edit && (
864
+ <RowAction
865
+ icon={Pencil}
866
+ label={`Edit ${entry.title}`}
867
+ tooltip="Edit"
868
+ onClick={() => {
869
+ setPrefill(null);
870
+ setEditing(entry);
871
+ }}
872
+ />
873
+ )}
874
+
875
+ {canManage && (
876
+ <RowAction
877
+ icon={History}
878
+ label={`Access log for ${entry.title}`}
879
+ tooltip="Access log"
880
+ onClick={() => setLogFor(entry)}
881
+ />
882
+ )}
883
+
884
+ {entry.can_edit && (
885
+ <RowAction
886
+ icon={Trash2}
887
+ label={`Delete ${entry.title}`}
888
+ tooltip="Delete"
889
+ danger
890
+ onClick={() => remove(entry)}
891
+ />
892
+ )}
893
+ </div>
894
+ )}
895
+ </td>
896
+ </tr>
897
+ );
898
+ })}
899
+
900
+ {rows.length === 0 && !loading && (
901
+ <tr>
902
+ <td colSpan={6} className={styles.tableEmpty}>
903
+ {query.trim() === '' ? (
904
+ <>
905
+ <p>No passwords saved yet.</p>
906
+ <button
907
+ type="button"
908
+ className={styles.primaryButton}
909
+ onClick={() => {
910
+ setPrefill(null);
911
+ setEditing({});
912
+ }}
913
+ >
914
+ <Plus size={16} strokeWidth={2.2} aria-hidden="true" />
915
+ Add your first password
916
+ </button>
917
+ </>
918
+ ) : (
919
+ <>
920
+ <p>No matches for "{query.trim()}"</p>
921
+ <button
922
+ type="button"
923
+ className={styles.primaryButton}
924
+ onClick={() => {
925
+ setPrefill({ title: query.trim() });
926
+ setEditing({});
927
+ }}
928
+ >
929
+ <Plus size={16} strokeWidth={2.2} aria-hidden="true" />
930
+ Add "{query.trim()}"
931
+ </button>
932
+ </>
933
+ )}
934
+ </td>
935
+ </tr>
936
+ )}
937
+ </tbody>
938
+ </table>
939
+ </div>
940
+
941
+ <div ref={pagerRef} className={styles.pager}>
942
+ <span className={styles.muted}>
943
+ {total} {total === 1 ? 'entry' : 'entries'}
944
+ {visibility && ' (filtered on this page)'}
945
+ </span>
946
+ <div className={styles.pagerButtons}>
947
+ <button
948
+ type="button"
949
+ className={styles.ghostButton}
950
+ disabled={page <= 1 || loading}
951
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
952
+ >
953
+ Previous
954
+ </button>
955
+ <span className={styles.muted}>
956
+ Page {page} of {lastPage}
957
+ </span>
958
+ <button
959
+ type="button"
960
+ className={styles.ghostButton}
961
+ disabled={page >= lastPage || loading}
962
+ onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
963
+ >
964
+ Next
965
+ </button>
966
+ </div>
967
+ </div>
968
+
969
+ {editing && (
970
+ <VaultEntryForm
971
+ entry={editing.id ? editing : null}
972
+ endpoints={routes}
973
+ canManage={canManage}
974
+ prefill={prefill || {}}
975
+ onClose={() => {
976
+ setEditing(null);
977
+ setPrefill(null);
978
+ }}
979
+ onSaved={() => {
980
+ setEditing(null);
981
+ setPrefill(null);
982
+ load();
983
+ }}
984
+ />
985
+ )}
986
+
987
+ {logFor && (
988
+ <VaultAccessLog
989
+ entry={logFor === 'global' ? null : logFor}
990
+ endpoints={routes}
991
+ onClose={() => setLogFor(null)}
992
+ />
993
+ )}
994
+ </div>
995
+ );
996
+ };
997
+
998
+ const VaultManager = withVaultNavigation(VaultManagerInner);
999
+
1000
+ export default VaultManager;