@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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. 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;