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