@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,145 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { RefreshCw } from 'lucide-react';
3
+
4
+ import styles from '../styles/Vault.module.scss';
5
+ import { generatePassword, scorePassword } from './vaultHelpers';
6
+
7
+ const MIN_LENGTH = 12;
8
+ const MAX_LENGTH = 40;
9
+
10
+ /**
11
+ * The generator popover behind the password field's "Generate" button.
12
+ *
13
+ * Shows the candidate before it is accepted, because a generated password
14
+ * that lands straight in the field gives no chance to reject one full of
15
+ * characters this particular system will not take. Every option regenerates
16
+ * immediately, so the effect of a checkbox is visible rather than described.
17
+ *
18
+ * All the actual work is in `generatePassword`; this is its dial.
19
+ */
20
+ const PasswordGenerator = ({ onUse, onClose }) => {
21
+ const [options, setOptions] = useState({
22
+ length: 20,
23
+ uppercase: true,
24
+ digits: true,
25
+ symbols: true,
26
+ // The checkbox is phrased as "avoid look-alikes", which is the
27
+ // inverse of the generator's `ambiguous` flag.
28
+ avoidAmbiguous: true,
29
+ });
30
+ const [candidate, setCandidate] = useState('');
31
+
32
+ const regenerate = useCallback((next) => {
33
+ setCandidate(
34
+ generatePassword({
35
+ length: next.length,
36
+ uppercase: next.uppercase,
37
+ digits: next.digits,
38
+ symbols: next.symbols,
39
+ ambiguous: !next.avoidAmbiguous,
40
+ })
41
+ );
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ regenerate(options);
46
+ // Deliberately once: every later regeneration is driven by an
47
+ // explicit change below, so the candidate does not churn under the
48
+ // user's cursor while they read it.
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, []);
51
+
52
+ const update = (patch) => {
53
+ const next = { ...options, ...patch };
54
+
55
+ setOptions(next);
56
+ regenerate(next);
57
+ };
58
+
59
+ const strength = scorePassword(candidate);
60
+
61
+ const toggle = (key, label) => (
62
+ <label className={styles.genOption}>
63
+ <input
64
+ type="checkbox"
65
+ className={styles.checkControl}
66
+ checked={options[key]}
67
+ onChange={(e) => update({ [key]: e.target.checked })}
68
+ />
69
+ <span>{label}</span>
70
+ </label>
71
+ );
72
+
73
+ return (
74
+ <div className={styles.generator} role="group" aria-label="Generate a password">
75
+ <div className={styles.genCandidate}>
76
+ <code>{candidate}</code>
77
+ <button
78
+ type="button"
79
+ className={styles.iconButton}
80
+ onClick={() => regenerate(options)}
81
+ aria-label="Generate another"
82
+ data-tooltip-id="system-tooltip"
83
+ data-tooltip-content="Generate another"
84
+ >
85
+ <RefreshCw size={15} strokeWidth={2} />
86
+ </button>
87
+ </div>
88
+
89
+ <div
90
+ className={styles.strength}
91
+ data-score={strength.score}
92
+ role="status"
93
+ aria-live="polite"
94
+ >
95
+ <div className={styles.strengthTrack}>
96
+ <div
97
+ className={styles.strengthFill}
98
+ style={{ width: `${((strength.score + 1) / 5) * 100}%` }}
99
+ />
100
+ </div>
101
+ <span className={styles.strengthLabel}>{strength.label}</span>
102
+ </div>
103
+
104
+ <label className={styles.genLength}>
105
+ <span>
106
+ Length <strong>{options.length}</strong>
107
+ </span>
108
+ <input
109
+ type="range"
110
+ min={MIN_LENGTH}
111
+ max={MAX_LENGTH}
112
+ value={options.length}
113
+ onChange={(e) => update({ length: Number(e.target.value) })}
114
+ aria-label="Password length"
115
+ />
116
+ </label>
117
+
118
+ <div className={styles.genOptions}>
119
+ {toggle('uppercase', 'A-Z')}
120
+ {toggle('digits', '0-9')}
121
+ {toggle('symbols', 'Symbols')}
122
+ {toggle('avoidAmbiguous', 'Avoid look-alikes')}
123
+ </div>
124
+
125
+ <div className={styles.genActions}>
126
+ <button
127
+ type="button"
128
+ className={styles.primaryButton}
129
+ onClick={() => onUse(candidate)}
130
+ >
131
+ Use this password
132
+ </button>
133
+ <button
134
+ type="button"
135
+ className={styles.linkButton}
136
+ onClick={onClose}
137
+ >
138
+ Cancel
139
+ </button>
140
+ </div>
141
+ </div>
142
+ );
143
+ };
144
+
145
+ export default PasswordGenerator;
@@ -0,0 +1,326 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Camera, Loader2, SwitchCamera, X } from 'lucide-react';
3
+
4
+ import styles from '../styles/Vault.module.scss';
5
+ import { decodeFromSource } from './qrDecode';
6
+ import { parseOtpAuthUri } from './vaultHelpers';
7
+
8
+ /** How often a frame is actually decoded, in milliseconds. */
9
+ const DECODE_INTERVAL = 250;
10
+
11
+ /** How long a "that wasn't a setup code" notice stays up before scanning quietly again. */
12
+ const NOTICE_MS = 4000;
13
+
14
+ const MESSAGES = {
15
+ insecure:
16
+ 'The camera only opens on a secure (https) page — upload a screenshot of the QR instead.',
17
+ unsupported:
18
+ "This browser won't open a camera — upload a screenshot of the QR instead.",
19
+ denied:
20
+ 'Camera access was blocked — allow it in the browser settings, or upload a screenshot instead.',
21
+ missing: 'No camera was found on this device — upload a screenshot of the QR instead.',
22
+ failed: "The camera couldn't be started — upload a screenshot of the QR instead.",
23
+ };
24
+
25
+ /**
26
+ * Point the device camera at the QR code a provider is showing.
27
+ *
28
+ * Deliberately an inline panel rather than a modal of its own: the form that
29
+ * mounts this is already inside a `StandardModal`, and that component locks
30
+ * `document.body` scroll and listens for Escape at the document — so a second
31
+ * one nested inside it closes both at once on the first Escape and unlocks
32
+ * the page's scroll when only the inner one went away. The generator popover
33
+ * beside it in the same form takes the same shape for the same reason.
34
+ *
35
+ * `onResult` is called with the raw decoded text, once, and only for a QR that
36
+ * `parseOtpAuthUri` accepts — anything else is reported in place and scanning
37
+ * continues, because the usual cause is the camera catching the wrong code on
38
+ * a page that shows several.
39
+ *
40
+ * The stream is stopped on unmount, on cancel, and on a successful read. A
41
+ * camera left running is a lit LED on the user's device and a genuine privacy
42
+ * complaint, so every exit from this component goes through `stop()`.
43
+ */
44
+ const QrScanner = ({ onResult, onCancel }) => {
45
+ const [status, setStatus] = useState('starting'); // starting | scanning | error
46
+ const [error, setError] = useState('');
47
+ const [notice, setNotice] = useState('');
48
+ const [deviceId, setDeviceId] = useState(null);
49
+ const [devices, setDevices] = useState([]);
50
+
51
+ const videoRef = useRef(null);
52
+ const streamRef = useRef(null);
53
+ const frameRef = useRef(null);
54
+ const lastDecodeRef = useRef(0);
55
+ const busyRef = useRef(false);
56
+ const doneRef = useRef(false);
57
+ const noticeTimerRef = useRef(null);
58
+
59
+ /** Tear the camera down. Safe to call twice; every exit path calls it. */
60
+ const stop = useCallback(() => {
61
+ if (frameRef.current) {
62
+ cancelAnimationFrame(frameRef.current);
63
+ frameRef.current = null;
64
+ }
65
+
66
+ const stream = streamRef.current;
67
+
68
+ if (stream) {
69
+ stream.getTracks().forEach((track) => track.stop());
70
+ streamRef.current = null;
71
+ }
72
+
73
+ if (videoRef.current) videoRef.current.srcObject = null;
74
+ }, []);
75
+
76
+ const showNotice = useCallback((text) => {
77
+ setNotice(text);
78
+
79
+ clearTimeout(noticeTimerRef.current);
80
+ noticeTimerRef.current = setTimeout(() => setNotice(''), NOTICE_MS);
81
+ }, []);
82
+
83
+ /* ------------------------------------------------------------ the loop */
84
+
85
+ const tick = useCallback(() => {
86
+ frameRef.current = requestAnimationFrame(tick);
87
+
88
+ const video = videoRef.current;
89
+
90
+ if (!video || doneRef.current || busyRef.current) return;
91
+ // readyState < 2 is a video with no frame to read yet.
92
+ if (video.readyState < 2) return;
93
+
94
+ const now = Date.now();
95
+
96
+ // rAF fires 60 times a second; decoding that often would peg a tablet
97
+ // CPU for no gain, so the work is throttled and the animation frame
98
+ // is used only as the clock.
99
+ if (now - lastDecodeRef.current < DECODE_INTERVAL) return;
100
+
101
+ lastDecodeRef.current = now;
102
+ busyRef.current = true;
103
+
104
+ decodeFromSource(video)
105
+ .then((text) => {
106
+ if (doneRef.current || !text) return;
107
+
108
+ try {
109
+ parseOtpAuthUri(text);
110
+ } catch {
111
+ showNotice(
112
+ /^otpauth:/i.test(text)
113
+ ? "That setup link isn't one we can read."
114
+ : "That QR isn't an authenticator setup code."
115
+ );
116
+
117
+ return;
118
+ }
119
+
120
+ doneRef.current = true;
121
+ stop();
122
+ onResult?.(text);
123
+ })
124
+ .finally(() => {
125
+ busyRef.current = false;
126
+ });
127
+ }, [onResult, showNotice, stop]);
128
+
129
+ /* ----------------------------------------------------------- the camera */
130
+
131
+ useEffect(() => {
132
+ let cancelled = false;
133
+
134
+ const start = async () => {
135
+ if (typeof window === 'undefined') return;
136
+
137
+ // Chrome and Safari both hide `mediaDevices` entirely on an
138
+ // insecure origin, so this check has to come first or the user is
139
+ // told the browser cannot do it at all.
140
+ if (window.isSecureContext === false) {
141
+ setStatus('error');
142
+ setError(MESSAGES.insecure);
143
+
144
+ return;
145
+ }
146
+
147
+ if (!navigator.mediaDevices?.getUserMedia) {
148
+ setStatus('error');
149
+ setError(MESSAGES.unsupported);
150
+
151
+ return;
152
+ }
153
+
154
+ setStatus('starting');
155
+ setError('');
156
+
157
+ try {
158
+ const stream = await navigator.mediaDevices.getUserMedia({
159
+ audio: false,
160
+ video: deviceId
161
+ ? { deviceId: { exact: deviceId } }
162
+ : // The rear camera on a tablet or phone; a laptop has
163
+ // only one and ignores the hint rather than failing.
164
+ { facingMode: { ideal: 'environment' } },
165
+ });
166
+
167
+ if (cancelled) {
168
+ stream.getTracks().forEach((track) => track.stop());
169
+
170
+ return;
171
+ }
172
+
173
+ streamRef.current = stream;
174
+
175
+ const video = videoRef.current;
176
+
177
+ if (video) {
178
+ video.srcObject = stream;
179
+ // Both are required on iOS Safari, which otherwise takes
180
+ // the video full-screen the moment it plays.
181
+ video.setAttribute('playsinline', 'true');
182
+ video.muted = true;
183
+
184
+ try {
185
+ await video.play();
186
+ } catch {
187
+ // An autoplay rejection still leaves a usable stream
188
+ // on most engines; the loop reads readyState anyway.
189
+ }
190
+ }
191
+
192
+ if (cancelled) return;
193
+
194
+ setStatus('scanning');
195
+
196
+ // Only worth listing once there is a stream: before
197
+ // permission is granted the labels are blank and, on Safari,
198
+ // the list is short by design.
199
+ try {
200
+ const all = await navigator.mediaDevices.enumerateDevices();
201
+
202
+ if (!cancelled) {
203
+ setDevices(all.filter((one) => one.kind === 'videoinput'));
204
+ }
205
+ } catch {
206
+ // Not fatal — it only costs the "Switch camera" button.
207
+ }
208
+
209
+ frameRef.current = requestAnimationFrame(tick);
210
+ } catch (exception) {
211
+ if (cancelled) return;
212
+
213
+ const name = exception?.name || '';
214
+
215
+ setStatus('error');
216
+ setError(
217
+ name === 'NotAllowedError' || name === 'SecurityError'
218
+ ? MESSAGES.denied
219
+ : name === 'NotFoundError' || name === 'OverconstrainedError'
220
+ ? MESSAGES.missing
221
+ : MESSAGES.failed
222
+ );
223
+ }
224
+ };
225
+
226
+ start();
227
+
228
+ return () => {
229
+ cancelled = true;
230
+ stop();
231
+ };
232
+ // `tick` is stable for the life of the panel, and re-running this on a
233
+ // new identity would restart the camera mid-scan.
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ }, [deviceId]);
236
+
237
+ useEffect(
238
+ () => () => {
239
+ doneRef.current = true;
240
+ clearTimeout(noticeTimerRef.current);
241
+ stop();
242
+ },
243
+ [stop]
244
+ );
245
+
246
+ const cancel = () => {
247
+ doneRef.current = true;
248
+ stop();
249
+ onCancel?.();
250
+ };
251
+
252
+ const switchCamera = () => {
253
+ const index = devices.findIndex((one) => one.deviceId === deviceId);
254
+ const next = devices[(index + 1) % devices.length];
255
+
256
+ if (next) {
257
+ stop();
258
+ setDeviceId(next.deviceId);
259
+ }
260
+ };
261
+
262
+ return (
263
+ <div className={styles.qrScanner} role="group" aria-label="Scan a QR code">
264
+ <div className={styles.qrScannerHead}>
265
+ <Camera size={15} strokeWidth={2} aria-hidden="true" />
266
+ <span>Scan the QR code</span>
267
+
268
+ {status === 'scanning' && devices.length > 1 && (
269
+ <button
270
+ type="button"
271
+ className={styles.linkButton}
272
+ onClick={switchCamera}
273
+ >
274
+ <SwitchCamera size={14} strokeWidth={2} />
275
+ Switch camera
276
+ </button>
277
+ )}
278
+
279
+ <button
280
+ type="button"
281
+ className={styles.iconButton}
282
+ onClick={cancel}
283
+ aria-label="Close the scanner"
284
+ >
285
+ <X size={15} strokeWidth={2} />
286
+ </button>
287
+ </div>
288
+
289
+ {status === 'error' ? (
290
+ <p className={styles.fieldError} role="alert">
291
+ {error}
292
+ </p>
293
+ ) : (
294
+ <div className={styles.qrStage}>
295
+ <video
296
+ ref={videoRef}
297
+ className={styles.qrVideo}
298
+ playsInline
299
+ muted
300
+ autoPlay
301
+ />
302
+ <div className={styles.qrViewfinder} aria-hidden="true">
303
+ <span className={styles.qrSweep} />
304
+ </div>
305
+
306
+ {status === 'starting' && (
307
+ <div className={styles.qrStarting}>
308
+ <Loader2 size={16} className={styles.spin} />
309
+ Starting the camera…
310
+ </div>
311
+ )}
312
+ </div>
313
+ )}
314
+
315
+ <p
316
+ className={notice ? styles.fieldError : styles.help}
317
+ role="status"
318
+ aria-live="polite"
319
+ >
320
+ {notice || 'Hold the code inside the frame — it reads by itself.'}
321
+ </p>
322
+ </div>
323
+ );
324
+ };
325
+
326
+ export default QrScanner;
@@ -0,0 +1,225 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import moment from 'moment';
3
+ import { Loader2 } from 'lucide-react';
4
+
5
+ import CustomFetch from '../Fetch';
6
+ import StandardModal from '../generic/StandardModal';
7
+ import styles from '../styles/Vault.module.scss';
8
+ import { resolveVaultEndpoints } from './vaultEndpoints';
9
+
10
+ /**
11
+ * The actions the log records, and how to say them in English.
12
+ *
13
+ * An unknown action is shown as it arrived rather than dropped — the backend
14
+ * is free to record something this build has not heard of, and a log with
15
+ * silent gaps in it is worse than one with an ugly line.
16
+ */
17
+ export const VAULT_ACTIONS = {
18
+ reveal: 'revealed the password',
19
+ copy_password: 'copied the password',
20
+ copy_username: 'copied the username',
21
+ otp: 'read the 2FA code',
22
+ view: 'opened the entry',
23
+ create: 'created the entry',
24
+ update: 'edited the entry',
25
+ delete: 'deleted the entry',
26
+ restore: 'restored the entry',
27
+ };
28
+
29
+ const describeAction = (action) =>
30
+ VAULT_ACTIONS[action] || String(action || '').replace(/_/g, ' ');
31
+
32
+ /**
33
+ * Who took what out of the vault, and when.
34
+ *
35
+ * Two modes on one component, because they render the same rows: `entry` logs
36
+ * one credential's history, and the global mode (no `entry`) logs everything,
37
+ * with filters. Only the global one shows which entry a line belongs to.
38
+ *
39
+ * The user filter is built from the users actually seen in the log rather than
40
+ * from a directory endpoint — the vault has no business asking for the staff
41
+ * list, and the only users who can appear here are ones who already have.
42
+ */
43
+ const VaultAccessLog = ({ entry = null, endpoints, isOpen = true, onClose }) => {
44
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
45
+ const global = !entry;
46
+
47
+ const [rows, setRows] = useState([]);
48
+ const [loading, setLoading] = useState(false);
49
+ const [page, setPage] = useState(1);
50
+ const [lastPage, setLastPage] = useState(1);
51
+ const [userId, setUserId] = useState('');
52
+ const [action, setAction] = useState('');
53
+ const [knownUsers, setKnownUsers] = useState([]);
54
+
55
+ const mountedRef = useRef(true);
56
+
57
+ useEffect(() => {
58
+ mountedRef.current = true;
59
+
60
+ return () => {
61
+ mountedRef.current = false;
62
+ };
63
+ }, []);
64
+
65
+ const load = useCallback(() => {
66
+ setLoading(true);
67
+
68
+ const params = { page };
69
+
70
+ if (global) {
71
+ if (userId) params.user_id = userId;
72
+ if (action) params.action = action;
73
+ }
74
+
75
+ CustomFetch(
76
+ global ? routes.globalLog : routes.entryLog(entry.id),
77
+ 'GET',
78
+ params,
79
+ (result) => {
80
+ if (!mountedRef.current) return;
81
+
82
+ const data = Array.isArray(result?.data) ? result.data : [];
83
+
84
+ setRows(data);
85
+ setLastPage(Number(result?.last_page) || 1);
86
+ setLoading(false);
87
+
88
+ // Accumulate, so paging back and forth does not empty the
89
+ // filter of a user who was on the previous page.
90
+ setKnownUsers((prev) => {
91
+ const seen = new Map(prev.map((u) => [String(u.id), u]));
92
+
93
+ data.forEach((row) => {
94
+ if (row.user?.id) seen.set(String(row.user.id), row.user);
95
+ });
96
+
97
+ return [...seen.values()].sort((a, b) =>
98
+ String(a.name).localeCompare(String(b.name))
99
+ );
100
+ });
101
+ },
102
+ null
103
+ ).catch(() => {
104
+ if (mountedRef.current) setLoading(false);
105
+ });
106
+ }, [routes, global, entry, page, userId, action]);
107
+
108
+ useEffect(() => {
109
+ if (isOpen) load();
110
+ }, [isOpen, load]);
111
+
112
+ return (
113
+ <StandardModal
114
+ isOpen={isOpen}
115
+ onClose={onClose}
116
+ size="medium"
117
+ showHeader
118
+ title={global ? 'Vault access log' : `Access log — ${entry.title}`}
119
+ >
120
+ <div className={styles.log}>
121
+ {global && (
122
+ <div className={styles.logFilters}>
123
+ <select
124
+ className={styles.select}
125
+ value={userId}
126
+ aria-label="Filter by user"
127
+ onChange={(e) => {
128
+ setUserId(e.target.value);
129
+ setPage(1);
130
+ }}
131
+ >
132
+ <option value="">Everyone</option>
133
+ {knownUsers.map((user) => (
134
+ <option key={user.id} value={user.id}>
135
+ {user.name}
136
+ </option>
137
+ ))}
138
+ </select>
139
+
140
+ <select
141
+ className={styles.select}
142
+ value={action}
143
+ aria-label="Filter by action"
144
+ onChange={(e) => {
145
+ setAction(e.target.value);
146
+ setPage(1);
147
+ }}
148
+ >
149
+ <option value="">Every action</option>
150
+ {Object.entries(VAULT_ACTIONS).map(([key, text]) => (
151
+ <option key={key} value={key}>
152
+ {text}
153
+ </option>
154
+ ))}
155
+ </select>
156
+ </div>
157
+ )}
158
+
159
+ {loading && rows.length === 0 && (
160
+ <p className={styles.muted}>
161
+ <Loader2 size={14} className={styles.spin} /> Loading…
162
+ </p>
163
+ )}
164
+
165
+ {!loading && rows.length === 0 && (
166
+ <p className={styles.muted}>Nothing recorded yet.</p>
167
+ )}
168
+
169
+ <ul className={styles.logRows}>
170
+ {rows.map((row) => (
171
+ <li key={row.id} className={styles.logRow}>
172
+ <div>
173
+ <strong>{row.user?.name || 'Unknown user'}</strong>{' '}
174
+ {describeAction(row.action)}
175
+ {global && row.entry?.title && (
176
+ <>
177
+ {' — '}
178
+ <em>{row.entry.title}</em>
179
+ </>
180
+ )}
181
+ </div>
182
+ <div className={styles.logMeta}>
183
+ {row.ip && <span>{row.ip}</span>}
184
+ <span
185
+ data-tooltip-id="system-tooltip"
186
+ data-tooltip-content={moment(row.created_at).format(
187
+ 'DD MMM YYYY, HH:mm:ss'
188
+ )}
189
+ >
190
+ {moment(row.created_at).fromNow()}
191
+ </span>
192
+ </div>
193
+ </li>
194
+ ))}
195
+ </ul>
196
+
197
+ {lastPage > 1 && (
198
+ <div className={styles.pager}>
199
+ <button
200
+ type="button"
201
+ className={styles.ghostButton}
202
+ disabled={page <= 1 || loading}
203
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
204
+ >
205
+ Previous
206
+ </button>
207
+ <span className={styles.muted}>
208
+ Page {page} of {lastPage}
209
+ </span>
210
+ <button
211
+ type="button"
212
+ className={styles.ghostButton}
213
+ disabled={page >= lastPage || loading}
214
+ onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
215
+ >
216
+ Next
217
+ </button>
218
+ </div>
219
+ )}
220
+ </div>
221
+ </StandardModal>
222
+ );
223
+ };
224
+
225
+ export default VaultAccessLog;