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