@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,740 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { toast } from 'react-toastify';
|
|
3
|
+
import {
|
|
4
|
+
Camera,
|
|
5
|
+
Check,
|
|
6
|
+
Eye,
|
|
7
|
+
EyeOff,
|
|
8
|
+
ImageUp,
|
|
9
|
+
Loader2,
|
|
10
|
+
ShieldCheck,
|
|
11
|
+
Wand2,
|
|
12
|
+
X,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
import CustomFetch from '../Fetch';
|
|
16
|
+
import StandardModal from '../generic/StandardModal';
|
|
17
|
+
import styles from '../styles/Vault.module.scss';
|
|
18
|
+
import PasswordGenerator from './PasswordGenerator';
|
|
19
|
+
import QrScanner from './QrScanner';
|
|
20
|
+
import { decodeFromBlob, imageFromTransfer } from './qrDecode';
|
|
21
|
+
import { resolveVaultEndpoints } from './vaultEndpoints';
|
|
22
|
+
import { describeOtpConfig, parseOtpAuthUri, parseTags, scorePassword } from './vaultHelpers';
|
|
23
|
+
|
|
24
|
+
const EMPTY = {
|
|
25
|
+
title: '',
|
|
26
|
+
url: '',
|
|
27
|
+
username: '',
|
|
28
|
+
notes: '',
|
|
29
|
+
visibility: 'shared',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create or edit one vault entry.
|
|
34
|
+
*
|
|
35
|
+
* The interesting part is what is *not* sent. The list and show payloads never
|
|
36
|
+
* carry a password or a 2FA secret, so this form starts with both fields blank
|
|
37
|
+
* even when editing an entry that has them — there is nothing to prefill with.
|
|
38
|
+
* That makes "blank" ambiguous, and the API resolves it by key presence: omit
|
|
39
|
+
* `password` to leave it alone, send `""` to clear it, send a value to replace
|
|
40
|
+
* it. So the form tracks intent rather than value, and only puts a key in the
|
|
41
|
+
* body when the user actually expressed one.
|
|
42
|
+
*
|
|
43
|
+
* The 2FA field takes either a bare base32 secret or the whole `otpauth://`
|
|
44
|
+
* link, and says what it understood — digits, period, algorithm — before the
|
|
45
|
+
* entry is saved, because the failure mode otherwise is a code that is simply
|
|
46
|
+
* always wrong with nothing on screen to explain why.
|
|
47
|
+
*
|
|
48
|
+
* It also takes the QR code itself, which is the only artefact a provider is
|
|
49
|
+
* guaranteed to show: scanned with the device camera, uploaded as a file,
|
|
50
|
+
* pasted from the clipboard, or dropped on the field. All four decode to the
|
|
51
|
+
* same `otpauth://` text and land in the same input, where the same feedback
|
|
52
|
+
* line vouches for it — so nothing downstream, here or in the API, knows or
|
|
53
|
+
* cares which way it arrived.
|
|
54
|
+
*/
|
|
55
|
+
const VaultEntryForm = ({
|
|
56
|
+
entry = null,
|
|
57
|
+
endpoints,
|
|
58
|
+
canManage = false,
|
|
59
|
+
isOpen = true,
|
|
60
|
+
onClose,
|
|
61
|
+
onSaved,
|
|
62
|
+
prefill = {},
|
|
63
|
+
}) => {
|
|
64
|
+
const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
|
|
65
|
+
const editing = Boolean(entry?.id);
|
|
66
|
+
|
|
67
|
+
const [values, setValues] = useState(() => ({
|
|
68
|
+
...EMPTY,
|
|
69
|
+
...(entry || {}),
|
|
70
|
+
// A shared entry is the default, but only for someone allowed to make
|
|
71
|
+
// one; everyone else can only keep their own.
|
|
72
|
+
visibility: entry?.visibility || (canManage ? 'shared' : 'private'),
|
|
73
|
+
title: entry?.title || prefill.title || '',
|
|
74
|
+
url: entry?.url || prefill.url || '',
|
|
75
|
+
}));
|
|
76
|
+
const [tags, setTags] = useState(() => parseTags(entry?.tags || prefill.tags || []));
|
|
77
|
+
const [tagDraft, setTagDraft] = useState('');
|
|
78
|
+
|
|
79
|
+
const [password, setPassword] = useState('');
|
|
80
|
+
const [clearPassword, setClearPassword] = useState(false);
|
|
81
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
82
|
+
const [showGenerator, setShowGenerator] = useState(false);
|
|
83
|
+
|
|
84
|
+
// 'keep' (an existing secret, untouched) | 'edit' | 'remove'
|
|
85
|
+
const [totpMode, setTotpMode] = useState(
|
|
86
|
+
editing && entry?.has_totp ? 'keep' : 'edit'
|
|
87
|
+
);
|
|
88
|
+
const [totpInput, setTotpInput] = useState('');
|
|
89
|
+
|
|
90
|
+
// The QR paths. All four of them end in the same place — decoded text in
|
|
91
|
+
// `totpInput` — so they share one busy flag and one error line.
|
|
92
|
+
const [scanning, setScanning] = useState(false);
|
|
93
|
+
const [qrBusy, setQrBusy] = useState(false);
|
|
94
|
+
const [qrError, setQrError] = useState('');
|
|
95
|
+
const [dropping, setDropping] = useState(false);
|
|
96
|
+
const qrFileRef = useRef(null);
|
|
97
|
+
|
|
98
|
+
const [errors, setErrors] = useState({});
|
|
99
|
+
const [saving, setSaving] = useState(false);
|
|
100
|
+
|
|
101
|
+
const mountedRef = useRef(true);
|
|
102
|
+
const titleRef = useRef(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
mountedRef.current = true;
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
mountedRef.current = false;
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (isOpen) setTimeout(() => titleRef.current?.focus(), 0);
|
|
114
|
+
}, [isOpen]);
|
|
115
|
+
|
|
116
|
+
const set = (key, value) => {
|
|
117
|
+
setValues((prev) => ({ ...prev, [key]: value }));
|
|
118
|
+
setErrors((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/* --------------------------------------------------------------- tags */
|
|
122
|
+
|
|
123
|
+
const commitTag = useCallback(
|
|
124
|
+
(raw) => {
|
|
125
|
+
const next = parseTags([...tags, raw]);
|
|
126
|
+
|
|
127
|
+
setTags(next);
|
|
128
|
+
setTagDraft('');
|
|
129
|
+
},
|
|
130
|
+
[tags]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const onTagKeyDown = (event) => {
|
|
134
|
+
if (event.key === ',' || event.key === 'Enter') {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
if (tagDraft.trim() !== '') commitTag(tagDraft);
|
|
137
|
+
} else if (event.key === 'Backspace' && tagDraft === '' && tags.length) {
|
|
138
|
+
// Backspace on an empty field takes the last pill, which is what
|
|
139
|
+
// every other tag input does.
|
|
140
|
+
setTags(tags.slice(0, -1));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/* --------------------------------------------------------------- totp */
|
|
145
|
+
|
|
146
|
+
const totpFeedback = useMemo(() => {
|
|
147
|
+
const raw = totpInput.trim();
|
|
148
|
+
|
|
149
|
+
if (totpMode !== 'edit' || raw === '') return null;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
return { ok: true, text: describeOtpConfig(parseOtpAuthUri(raw)) };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { ok: false, text: error.message };
|
|
155
|
+
}
|
|
156
|
+
}, [totpInput, totpMode]);
|
|
157
|
+
|
|
158
|
+
/* ---------------------------------------------------------------- qr */
|
|
159
|
+
|
|
160
|
+
// Hidden rather than disabled where there is no camera at all: a button
|
|
161
|
+
// that can never work is worse than no button, and the upload sitting
|
|
162
|
+
// next to it does the same job.
|
|
163
|
+
const cameraAvailable = useMemo(
|
|
164
|
+
() =>
|
|
165
|
+
typeof navigator !== 'undefined' &&
|
|
166
|
+
Boolean(navigator.mediaDevices?.getUserMedia),
|
|
167
|
+
[]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Put decoded QR text in the field, or say why it could not go there.
|
|
172
|
+
*
|
|
173
|
+
* The raw text is stored, not the parsed pieces: the API takes the link
|
|
174
|
+
* whole, and the feedback line under the field re-parses it — so what the
|
|
175
|
+
* user sees is the same sentence a pasted link would have produced, and
|
|
176
|
+
* an edit by hand afterwards still behaves.
|
|
177
|
+
*/
|
|
178
|
+
const applyScanned = useCallback((text) => {
|
|
179
|
+
const raw = String(text ?? '').trim();
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
parseOtpAuthUri(raw);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
setQrError(
|
|
185
|
+
/^otpauth:/i.test(raw)
|
|
186
|
+
? error.message
|
|
187
|
+
: `That QR isn't an authenticator setup code — it reads “${
|
|
188
|
+
raw.length > 42 ? `${raw.slice(0, 42)}…` : raw
|
|
189
|
+
}”.`
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setTotpInput(raw);
|
|
196
|
+
setQrError('');
|
|
197
|
+
setErrors((prev) =>
|
|
198
|
+
prev.totp_secret ? { ...prev, totp_secret: undefined } : prev
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return true;
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
/** The upload, paste and drop paths, which are one path after the file. */
|
|
205
|
+
const readImage = useCallback(
|
|
206
|
+
async (blob) => {
|
|
207
|
+
if (!blob) return;
|
|
208
|
+
|
|
209
|
+
setQrBusy(true);
|
|
210
|
+
setQrError('');
|
|
211
|
+
|
|
212
|
+
const text = await decodeFromBlob(blob);
|
|
213
|
+
|
|
214
|
+
if (!mountedRef.current) return;
|
|
215
|
+
|
|
216
|
+
setQrBusy(false);
|
|
217
|
+
|
|
218
|
+
if (!text) {
|
|
219
|
+
setQrError(
|
|
220
|
+
'No QR code found in that image — try a sharper screenshot, or paste the setup link instead.'
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
applyScanned(text);
|
|
227
|
+
},
|
|
228
|
+
[applyScanned]
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const onTotpPaste = (event) => {
|
|
232
|
+
const file = imageFromTransfer(event.clipboardData);
|
|
233
|
+
|
|
234
|
+
// No image on the clipboard means an ordinary text paste, which must
|
|
235
|
+
// reach the input untouched.
|
|
236
|
+
if (!file) return;
|
|
237
|
+
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
readImage(file);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const onTotpDrop = (event) => {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
setDropping(false);
|
|
245
|
+
|
|
246
|
+
const file = imageFromTransfer(event.dataTransfer);
|
|
247
|
+
|
|
248
|
+
if (file) readImage(file);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/* -------------------------------------------------------------- save */
|
|
252
|
+
|
|
253
|
+
const submit = () => {
|
|
254
|
+
if (saving) return;
|
|
255
|
+
|
|
256
|
+
const body = {
|
|
257
|
+
title: values.title.trim(),
|
|
258
|
+
url: (values.url || '').trim(),
|
|
259
|
+
username: (values.username || '').trim(),
|
|
260
|
+
notes: values.notes || '',
|
|
261
|
+
tags,
|
|
262
|
+
visibility: values.visibility,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Key presence is the contract — see the note at the top of the file.
|
|
266
|
+
if (clearPassword) body.password = '';
|
|
267
|
+
else if (password !== '') body.password = password;
|
|
268
|
+
|
|
269
|
+
if (totpMode === 'remove') body.totp_secret = '';
|
|
270
|
+
else if (totpMode === 'edit' && totpInput.trim() !== '') {
|
|
271
|
+
// Sent raw: the API accepts a bare secret or a full otpauth link,
|
|
272
|
+
// and the link carries the digits and period that parsing here
|
|
273
|
+
// would only have to send separately.
|
|
274
|
+
body.totp_secret = totpInput.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A secret the user typed but that does not parse would be rejected by
|
|
278
|
+
// the API anyway; saying so here costs no round trip.
|
|
279
|
+
if (totpFeedback && !totpFeedback.ok) {
|
|
280
|
+
setErrors({ totp_secret: totpFeedback.text });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
setSaving(true);
|
|
285
|
+
setErrors({});
|
|
286
|
+
|
|
287
|
+
CustomFetch(
|
|
288
|
+
editing ? routes.update(entry.id) : routes.create,
|
|
289
|
+
editing ? 'PUT' : 'POST',
|
|
290
|
+
body,
|
|
291
|
+
(result) => {
|
|
292
|
+
if (!mountedRef.current) return;
|
|
293
|
+
|
|
294
|
+
setSaving(false);
|
|
295
|
+
toast.success('Saved');
|
|
296
|
+
onSaved?.(result);
|
|
297
|
+
},
|
|
298
|
+
null,
|
|
299
|
+
// Read the field errors off the bag rather than taking the
|
|
300
|
+
// flattened string CustomFetch would otherwise toast.
|
|
301
|
+
{ silentStatuses: [422] }
|
|
302
|
+
).catch((error) => {
|
|
303
|
+
if (!mountedRef.current) return;
|
|
304
|
+
|
|
305
|
+
setSaving(false);
|
|
306
|
+
|
|
307
|
+
if (error?.status === 422) {
|
|
308
|
+
const bag = error.data?.errors || {};
|
|
309
|
+
|
|
310
|
+
setErrors(
|
|
311
|
+
Object.fromEntries(
|
|
312
|
+
Object.entries(bag).map(([field, messages]) => [
|
|
313
|
+
field,
|
|
314
|
+
Array.isArray(messages) ? messages[0] : String(messages),
|
|
315
|
+
])
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const strength = password === '' ? null : scorePassword(password);
|
|
323
|
+
|
|
324
|
+
const field = (key, labelText, input, help) => (
|
|
325
|
+
<div className={styles.field}>
|
|
326
|
+
<label className={styles.label} htmlFor={`vault-${key}`}>
|
|
327
|
+
{labelText}
|
|
328
|
+
</label>
|
|
329
|
+
{input}
|
|
330
|
+
{help && !errors[key] && <p className={styles.help}>{help}</p>}
|
|
331
|
+
{errors[key] && (
|
|
332
|
+
<p className={styles.fieldError} role="alert">
|
|
333
|
+
{errors[key]}
|
|
334
|
+
</p>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<StandardModal
|
|
341
|
+
isOpen={isOpen}
|
|
342
|
+
onClose={onClose}
|
|
343
|
+
size="medium"
|
|
344
|
+
showHeader
|
|
345
|
+
title={editing ? `Edit ${entry.title}` : 'New vault entry'}
|
|
346
|
+
>
|
|
347
|
+
<div className={styles.form}>
|
|
348
|
+
{field(
|
|
349
|
+
'title',
|
|
350
|
+
'Title *',
|
|
351
|
+
<input
|
|
352
|
+
id="vault-title"
|
|
353
|
+
ref={titleRef}
|
|
354
|
+
className={styles.input}
|
|
355
|
+
value={values.title}
|
|
356
|
+
onChange={(e) => set('title', e.target.value)}
|
|
357
|
+
placeholder="What is this login for?"
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{field(
|
|
362
|
+
'url',
|
|
363
|
+
'URL',
|
|
364
|
+
<input
|
|
365
|
+
id="vault-url"
|
|
366
|
+
className={styles.input}
|
|
367
|
+
value={values.url || ''}
|
|
368
|
+
onChange={(e) => set('url', e.target.value)}
|
|
369
|
+
placeholder="mybank.com.au"
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{field(
|
|
374
|
+
'username',
|
|
375
|
+
'Username',
|
|
376
|
+
<input
|
|
377
|
+
id="vault-username"
|
|
378
|
+
className={styles.input}
|
|
379
|
+
value={values.username || ''}
|
|
380
|
+
onChange={(e) => set('username', e.target.value)}
|
|
381
|
+
autoComplete="off"
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
{field(
|
|
386
|
+
'password',
|
|
387
|
+
'Password',
|
|
388
|
+
<div className={styles.passwordField}>
|
|
389
|
+
<div className={styles.inputRow}>
|
|
390
|
+
<input
|
|
391
|
+
id="vault-password"
|
|
392
|
+
className={styles.input}
|
|
393
|
+
type={showPassword ? 'text' : 'password'}
|
|
394
|
+
value={password}
|
|
395
|
+
autoComplete="new-password"
|
|
396
|
+
disabled={clearPassword}
|
|
397
|
+
placeholder={
|
|
398
|
+
editing
|
|
399
|
+
? 'Leave blank to keep the current password'
|
|
400
|
+
: ''
|
|
401
|
+
}
|
|
402
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
403
|
+
/>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
className={styles.iconButton}
|
|
407
|
+
onClick={() => setShowPassword((v) => !v)}
|
|
408
|
+
aria-label={
|
|
409
|
+
showPassword ? 'Hide password' : 'Show password'
|
|
410
|
+
}
|
|
411
|
+
>
|
|
412
|
+
{showPassword ? (
|
|
413
|
+
<EyeOff size={16} strokeWidth={2} />
|
|
414
|
+
) : (
|
|
415
|
+
<Eye size={16} strokeWidth={2} />
|
|
416
|
+
)}
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
className={styles.ghostButton}
|
|
421
|
+
onClick={() => setShowGenerator((v) => !v)}
|
|
422
|
+
aria-expanded={showGenerator}
|
|
423
|
+
>
|
|
424
|
+
<Wand2 size={15} strokeWidth={2} />
|
|
425
|
+
Generate
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{strength && (
|
|
430
|
+
<div className={styles.strength} data-score={strength.score}>
|
|
431
|
+
<div className={styles.strengthTrack}>
|
|
432
|
+
<div
|
|
433
|
+
className={styles.strengthFill}
|
|
434
|
+
style={{
|
|
435
|
+
width: `${((strength.score + 1) / 5) * 100}%`,
|
|
436
|
+
}}
|
|
437
|
+
/>
|
|
438
|
+
</div>
|
|
439
|
+
<span className={styles.strengthLabel}>
|
|
440
|
+
{strength.label}
|
|
441
|
+
</span>
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{showGenerator && (
|
|
446
|
+
<PasswordGenerator
|
|
447
|
+
onUse={(generated) => {
|
|
448
|
+
setPassword(generated);
|
|
449
|
+
setClearPassword(false);
|
|
450
|
+
// Shown once so it can be copied into
|
|
451
|
+
// whatever system it belongs to; the user
|
|
452
|
+
// will not get a second look at it here.
|
|
453
|
+
setShowPassword(true);
|
|
454
|
+
setShowGenerator(false);
|
|
455
|
+
}}
|
|
456
|
+
onClose={() => setShowGenerator(false)}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
{editing && (
|
|
461
|
+
<label className={styles.inlineCheck}>
|
|
462
|
+
<input
|
|
463
|
+
type="checkbox"
|
|
464
|
+
className={styles.checkControl}
|
|
465
|
+
checked={clearPassword}
|
|
466
|
+
onChange={(e) => {
|
|
467
|
+
setClearPassword(e.target.checked);
|
|
468
|
+
if (e.target.checked) setPassword('');
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
<span>Clear the stored password</span>
|
|
472
|
+
</label>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{field(
|
|
478
|
+
'totp_secret',
|
|
479
|
+
'2FA secret',
|
|
480
|
+
totpMode === 'keep' ? (
|
|
481
|
+
<div className={styles.totpConfigured}>
|
|
482
|
+
<ShieldCheck size={16} strokeWidth={2} aria-hidden="true" />
|
|
483
|
+
<span>2FA configured</span>
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
className={styles.linkButton}
|
|
487
|
+
onClick={() => setTotpMode('edit')}
|
|
488
|
+
>
|
|
489
|
+
Replace
|
|
490
|
+
</button>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
className={styles.dangerLink}
|
|
494
|
+
onClick={() => setTotpMode('remove')}
|
|
495
|
+
>
|
|
496
|
+
Remove
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
) : totpMode === 'remove' ? (
|
|
500
|
+
<div className={styles.totpConfigured}>
|
|
501
|
+
<span className={styles.muted}>
|
|
502
|
+
2FA will be removed when you save.
|
|
503
|
+
</span>
|
|
504
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
className={styles.linkButton}
|
|
507
|
+
onClick={() => setTotpMode('keep')}
|
|
508
|
+
>
|
|
509
|
+
Undo
|
|
510
|
+
</button>
|
|
511
|
+
</div>
|
|
512
|
+
) : (
|
|
513
|
+
<div
|
|
514
|
+
className={`${styles.totpField} ${
|
|
515
|
+
dropping ? styles.totpFieldDropping : ''
|
|
516
|
+
}`}
|
|
517
|
+
onPaste={onTotpPaste}
|
|
518
|
+
onDragOver={(e) => {
|
|
519
|
+
// Without preventDefault on dragover the drop
|
|
520
|
+
// never fires and the browser navigates to the
|
|
521
|
+
// image instead.
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
if (!dropping) setDropping(true);
|
|
524
|
+
}}
|
|
525
|
+
onDragLeave={(e) => {
|
|
526
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
527
|
+
setDropping(false);
|
|
528
|
+
}
|
|
529
|
+
}}
|
|
530
|
+
onDrop={onTotpDrop}
|
|
531
|
+
>
|
|
532
|
+
<input
|
|
533
|
+
id="vault-totp_secret"
|
|
534
|
+
className={styles.input}
|
|
535
|
+
value={totpInput}
|
|
536
|
+
autoComplete="off"
|
|
537
|
+
spellCheck="false"
|
|
538
|
+
placeholder="otpauth://totp/… or a base32 secret"
|
|
539
|
+
onChange={(e) => {
|
|
540
|
+
setTotpInput(e.target.value);
|
|
541
|
+
if (qrError) setQrError('');
|
|
542
|
+
}}
|
|
543
|
+
/>
|
|
544
|
+
|
|
545
|
+
<div className={styles.qrActions}>
|
|
546
|
+
{cameraAvailable && (
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
className={styles.ghostButton}
|
|
550
|
+
onClick={() => {
|
|
551
|
+
setQrError('');
|
|
552
|
+
setScanning((v) => !v);
|
|
553
|
+
}}
|
|
554
|
+
aria-expanded={scanning}
|
|
555
|
+
>
|
|
556
|
+
<Camera size={15} strokeWidth={2} />
|
|
557
|
+
{scanning ? 'Stop scanning' : 'Scan QR'}
|
|
558
|
+
</button>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
<button
|
|
562
|
+
type="button"
|
|
563
|
+
className={styles.ghostButton}
|
|
564
|
+
onClick={() => qrFileRef.current?.click()}
|
|
565
|
+
disabled={qrBusy}
|
|
566
|
+
>
|
|
567
|
+
{qrBusy ? (
|
|
568
|
+
<Loader2 size={15} className={styles.spin} />
|
|
569
|
+
) : (
|
|
570
|
+
<ImageUp size={15} strokeWidth={2} />
|
|
571
|
+
)}
|
|
572
|
+
{qrBusy ? 'Reading…' : 'Upload QR image'}
|
|
573
|
+
</button>
|
|
574
|
+
|
|
575
|
+
<input
|
|
576
|
+
ref={qrFileRef}
|
|
577
|
+
type="file"
|
|
578
|
+
accept="image/*"
|
|
579
|
+
className={styles.visuallyHidden}
|
|
580
|
+
tabIndex={-1}
|
|
581
|
+
onChange={(e) => {
|
|
582
|
+
const file = e.target.files?.[0];
|
|
583
|
+
|
|
584
|
+
// Cleared so picking the same file
|
|
585
|
+
// twice fires `change` the second time.
|
|
586
|
+
e.target.value = '';
|
|
587
|
+
readImage(file);
|
|
588
|
+
}}
|
|
589
|
+
/>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
{scanning && (
|
|
593
|
+
<QrScanner
|
|
594
|
+
onResult={(text) => {
|
|
595
|
+
setScanning(false);
|
|
596
|
+
applyScanned(text);
|
|
597
|
+
}}
|
|
598
|
+
onCancel={() => setScanning(false)}
|
|
599
|
+
/>
|
|
600
|
+
)}
|
|
601
|
+
|
|
602
|
+
{qrError && (
|
|
603
|
+
<p className={styles.fieldError} role="alert">
|
|
604
|
+
{qrError}
|
|
605
|
+
</p>
|
|
606
|
+
)}
|
|
607
|
+
|
|
608
|
+
{totpFeedback && (
|
|
609
|
+
<p
|
|
610
|
+
className={
|
|
611
|
+
totpFeedback.ok
|
|
612
|
+
? styles.totpOk
|
|
613
|
+
: styles.fieldError
|
|
614
|
+
}
|
|
615
|
+
role="status"
|
|
616
|
+
>
|
|
617
|
+
{totpFeedback.ok && (
|
|
618
|
+
<Check size={13} strokeWidth={2} />
|
|
619
|
+
)}
|
|
620
|
+
{totpFeedback.text}
|
|
621
|
+
</p>
|
|
622
|
+
)}
|
|
623
|
+
{editing && entry?.has_totp && (
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
className={styles.linkButton}
|
|
627
|
+
onClick={() => {
|
|
628
|
+
setTotpMode('keep');
|
|
629
|
+
setTotpInput('');
|
|
630
|
+
}}
|
|
631
|
+
>
|
|
632
|
+
Keep the existing secret
|
|
633
|
+
</button>
|
|
634
|
+
)}
|
|
635
|
+
</div>
|
|
636
|
+
),
|
|
637
|
+
'Paste the setup link or key from your provider, scan the QR code, or drop a screenshot of it here.'
|
|
638
|
+
)}
|
|
639
|
+
|
|
640
|
+
{field(
|
|
641
|
+
'tags',
|
|
642
|
+
'Tags',
|
|
643
|
+
<div className={styles.tagField}>
|
|
644
|
+
{tags.map((tag) => (
|
|
645
|
+
<span key={tag} className={styles.tagPill}>
|
|
646
|
+
{tag}
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
onClick={() =>
|
|
650
|
+
setTags(tags.filter((t) => t !== tag))
|
|
651
|
+
}
|
|
652
|
+
aria-label={`Remove the ${tag} tag`}
|
|
653
|
+
>
|
|
654
|
+
<X size={12} strokeWidth={2.5} />
|
|
655
|
+
</button>
|
|
656
|
+
</span>
|
|
657
|
+
))}
|
|
658
|
+
<input
|
|
659
|
+
id="vault-tags"
|
|
660
|
+
className={styles.tagInput}
|
|
661
|
+
value={tagDraft}
|
|
662
|
+
placeholder={tags.length ? '' : 'banking, admin'}
|
|
663
|
+
onChange={(e) => setTagDraft(e.target.value)}
|
|
664
|
+
onKeyDown={onTagKeyDown}
|
|
665
|
+
onBlur={() => tagDraft.trim() && commitTag(tagDraft)}
|
|
666
|
+
/>
|
|
667
|
+
</div>,
|
|
668
|
+
'Comma or Enter adds a tag.'
|
|
669
|
+
)}
|
|
670
|
+
|
|
671
|
+
{field(
|
|
672
|
+
'notes',
|
|
673
|
+
'Notes',
|
|
674
|
+
<textarea
|
|
675
|
+
id="vault-notes"
|
|
676
|
+
className={styles.textarea}
|
|
677
|
+
rows={3}
|
|
678
|
+
value={values.notes || ''}
|
|
679
|
+
onChange={(e) => set('notes', e.target.value)}
|
|
680
|
+
/>
|
|
681
|
+
)}
|
|
682
|
+
|
|
683
|
+
{field(
|
|
684
|
+
'visibility',
|
|
685
|
+
'Visibility',
|
|
686
|
+
<div className={styles.radioRow}>
|
|
687
|
+
<label className={!canManage ? styles.radioDisabled : ''}>
|
|
688
|
+
<input
|
|
689
|
+
type="radio"
|
|
690
|
+
className={styles.checkControl}
|
|
691
|
+
name="vault-visibility"
|
|
692
|
+
value="shared"
|
|
693
|
+
checked={values.visibility === 'shared'}
|
|
694
|
+
disabled={!canManage}
|
|
695
|
+
onChange={() => set('visibility', 'shared')}
|
|
696
|
+
/>
|
|
697
|
+
<span>Shared with the team</span>
|
|
698
|
+
</label>
|
|
699
|
+
<label>
|
|
700
|
+
<input
|
|
701
|
+
type="radio"
|
|
702
|
+
className={styles.checkControl}
|
|
703
|
+
name="vault-visibility"
|
|
704
|
+
value="private"
|
|
705
|
+
checked={values.visibility === 'private'}
|
|
706
|
+
onChange={() => set('visibility', 'private')}
|
|
707
|
+
/>
|
|
708
|
+
<span>Private to me</span>
|
|
709
|
+
</label>
|
|
710
|
+
</div>,
|
|
711
|
+
canManage
|
|
712
|
+
? undefined
|
|
713
|
+
: 'Only a vault manager can share an entry with the team.'
|
|
714
|
+
)}
|
|
715
|
+
|
|
716
|
+
<div className={styles.formActions}>
|
|
717
|
+
<button
|
|
718
|
+
type="button"
|
|
719
|
+
className={styles.primaryButton}
|
|
720
|
+
onClick={submit}
|
|
721
|
+
disabled={saving || values.title.trim() === ''}
|
|
722
|
+
>
|
|
723
|
+
{saving && <Loader2 size={15} className={styles.spin} />}
|
|
724
|
+
{saving ? 'Saving…' : 'Save'}
|
|
725
|
+
</button>
|
|
726
|
+
<button
|
|
727
|
+
type="button"
|
|
728
|
+
className={styles.ghostButton}
|
|
729
|
+
onClick={onClose}
|
|
730
|
+
disabled={saving}
|
|
731
|
+
>
|
|
732
|
+
Cancel
|
|
733
|
+
</button>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
</StandardModal>
|
|
737
|
+
);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
export default VaultEntryForm;
|