@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
|
@@ -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;
|