@visns-studio/visns-components 6.21.0 → 6.23.1
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/package.json +1 -1
- package/src/components/DataGrid.jsx +15 -2
- package/src/components/JsonView.jsx +299 -0
- package/src/components/Navigation.jsx +10 -1
- package/src/components/columns/SelectFilterEditor.jsx +372 -0
- package/src/components/controls/DataGridSearch.jsx +6 -1
- package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
- package/src/components/phone/phonePresenceEndpoints.js +34 -0
- package/src/components/phone/phonePresenceHelpers.js +298 -0
- package/src/components/phone/useZoomPhoneLive.js +165 -0
- package/src/components/sms/SmsClientConversations.jsx +8 -0
- package/src/components/sms/SmsInbox.jsx +11 -0
- package/src/components/sms/SmsThreadPanel.jsx +258 -29
- package/src/components/sms/smsHelpers.js +37 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +12 -2
- package/src/components/styles/JsonView.module.scss +233 -0
- package/src/components/styles/Sms.module.scss +131 -0
- package/src/components/styles/Vault.module.scss +114 -0
- package/src/components/styles/ZoomPhone.module.scss +550 -0
- package/src/components/styles/global-datagrid.css +23 -0
- package/src/components/vault/VaultAccessLog.jsx +24 -0
- package/src/components/vault/VaultEntryForm.jsx +239 -8
- package/src/components/vault/VaultManager.jsx +14 -2
- package/src/components/vault/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +49 -2
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
|
|
15
15
|
import CustomFetch from '../Fetch';
|
|
16
|
+
import JsonView, { parseJsonDocument } from '../JsonView';
|
|
16
17
|
import StandardModal from '../generic/StandardModal';
|
|
17
18
|
import styles from '../styles/Vault.module.scss';
|
|
18
19
|
import PasswordGenerator from './PasswordGenerator';
|
|
@@ -41,6 +42,26 @@ const EMPTY = {
|
|
|
41
42
|
* it. So the form tracks intent rather than value, and only puts a key in the
|
|
42
43
|
* body when the user actually expressed one.
|
|
43
44
|
*
|
|
45
|
+
* **Notes are the same rule for a different reason.** They are not secret, but
|
|
46
|
+
* they are encrypted and so are deliberately absent from the LIST row — and the
|
|
47
|
+
* list row is what opens this form. Seeding `notes` from it and then sending
|
|
48
|
+
* `notes: ""` back means every edit made from the list silently wipes whatever
|
|
49
|
+
* the entry held, which is how a 32KB machine-written backup can vanish behind
|
|
50
|
+
* a one-word title change. So an existing entry fetches its own detail payload
|
|
51
|
+
* when the form opens, and the `notes` key goes in the body only when there is
|
|
52
|
+
* an answer to send: always for a new entry, and for an existing one only once
|
|
53
|
+
* that fetch has succeeded. A fetch that failed omits the key entirely, which
|
|
54
|
+
* is the API's "leave it alone" — the rest of the form still saves.
|
|
55
|
+
*
|
|
56
|
+
* Once those notes are on screen they are often not prose at all:
|
|
57
|
+
* `zoho:credentials-backup` leaves a pretty-printed
|
|
58
|
+
* `{source, account_id, …, credentials: [{label, content}…]}` blob in the
|
|
59
|
+
* field, and reading 32KB of it through a three-row textarea is miserable. So
|
|
60
|
+
* notes that parse as a JSON object or array get a Formatted/Raw toggle and
|
|
61
|
+
* default to a collapsible tree (`JsonView`). It changes nothing about what is
|
|
62
|
+
* saved: the notes VALUE is the raw text either way, the tree is read-only, and
|
|
63
|
+
* every edit still happens in the textarea behind the Raw chip.
|
|
64
|
+
*
|
|
44
65
|
* The 2FA field takes either a bare base32 secret or the whole `otpauth://`
|
|
45
66
|
* link, and says what it understood — digits, period, algorithm — before the
|
|
46
67
|
* entry is saved, because the failure mode otherwise is a code that is simply
|
|
@@ -104,12 +125,24 @@ const VaultEntryForm = ({
|
|
|
104
125
|
const [dropping, setDropping] = useState(false);
|
|
105
126
|
const qrFileRef = useRef(null);
|
|
106
127
|
|
|
128
|
+
// 'ready' (nothing to load — a new entry) | 'loading' | 'loaded' | 'failed'.
|
|
129
|
+
// Only 'ready' and 'loaded' are safe to assert intent from; see submit().
|
|
130
|
+
const [notesState, setNotesState] = useState(() =>
|
|
131
|
+
entry?.id ? 'loading' : 'ready'
|
|
132
|
+
);
|
|
133
|
+
|
|
107
134
|
const [errors, setErrors] = useState({});
|
|
108
135
|
const [saving, setSaving] = useState(false);
|
|
109
136
|
|
|
110
137
|
const mountedRef = useRef(true);
|
|
111
138
|
const titleRef = useRef(null);
|
|
112
139
|
|
|
140
|
+
// The entry id whose notes have already been asked for. A ref rather than a
|
|
141
|
+
// dependency because the fetch must happen once per open, not once per
|
|
142
|
+
// render — `routes` is rebuilt whenever the consumer passes a fresh
|
|
143
|
+
// `endpoints` object, and a keystroke must never cost a round trip.
|
|
144
|
+
const notesRequestedFor = useRef(null);
|
|
145
|
+
|
|
113
146
|
useEffect(() => {
|
|
114
147
|
mountedRef.current = true;
|
|
115
148
|
|
|
@@ -122,6 +155,115 @@ const VaultEntryForm = ({
|
|
|
122
155
|
if (isOpen) setTimeout(() => titleRef.current?.focus(), 0);
|
|
123
156
|
}, [isOpen]);
|
|
124
157
|
|
|
158
|
+
/* -------------------------------------------------------------- notes */
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetch the entry's notes from the detail endpoint.
|
|
162
|
+
*
|
|
163
|
+
* The list row this form is opened from has no `notes` — the column is
|
|
164
|
+
* encrypted and loaded one entry at a time — so the only honest way to fill
|
|
165
|
+
* the field is to ask for the entry itself. The fetch lives here rather
|
|
166
|
+
* than in the list because this is the component that knows it is editing,
|
|
167
|
+
* and because the notes are of no use to a row that only draws a title.
|
|
168
|
+
*
|
|
169
|
+
* A failure is not toasted: it is not a failed action, it is a field that
|
|
170
|
+
* cannot be edited this time round. The submit path reads `notesState` and
|
|
171
|
+
* omits the key, so the stored notes survive the save either way.
|
|
172
|
+
*
|
|
173
|
+
* Note that the detail endpoint writes a `view` to the access log, so
|
|
174
|
+
* opening this form on an existing entry now leaves a row. That is correct
|
|
175
|
+
* rather than incidental — the notes are decrypted and put on screen, which
|
|
176
|
+
* is exactly what a `view` records.
|
|
177
|
+
*/
|
|
178
|
+
const loadNotes = useCallback(() => {
|
|
179
|
+
if (!entry?.id) return;
|
|
180
|
+
|
|
181
|
+
setNotesState('loading');
|
|
182
|
+
|
|
183
|
+
CustomFetch(
|
|
184
|
+
routes.show(entry.id),
|
|
185
|
+
'GET',
|
|
186
|
+
null,
|
|
187
|
+
(result) => {
|
|
188
|
+
if (!mountedRef.current) return;
|
|
189
|
+
|
|
190
|
+
setValues((prev) => ({ ...prev, notes: result?.notes ?? '' }));
|
|
191
|
+
setNotesState('loaded');
|
|
192
|
+
},
|
|
193
|
+
// An errorCallback, empty on purpose: given one, CustomFetch stops
|
|
194
|
+
// toasting, which is the whole point.
|
|
195
|
+
() => {
|
|
196
|
+
if (!mountedRef.current) return;
|
|
197
|
+
|
|
198
|
+
setNotesState('failed');
|
|
199
|
+
}
|
|
200
|
+
).catch(() => {
|
|
201
|
+
// Already handled above; swallowed so the rejection is not
|
|
202
|
+
// unhandled.
|
|
203
|
+
});
|
|
204
|
+
}, [entry?.id, routes]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!isOpen || !entry?.id) return;
|
|
208
|
+
|
|
209
|
+
// Once per open. Re-running on every render would refetch while the
|
|
210
|
+
// user types, and worse, would overwrite what they had typed.
|
|
211
|
+
if (notesRequestedFor.current === entry.id) return;
|
|
212
|
+
|
|
213
|
+
notesRequestedFor.current = entry.id;
|
|
214
|
+
loadNotes();
|
|
215
|
+
}, [isOpen, entry?.id, loadNotes]);
|
|
216
|
+
|
|
217
|
+
// Disabled while there is nothing trustworthy to edit: blank-and-editable
|
|
218
|
+
// reads as "this entry has no notes", which is the misunderstanding that
|
|
219
|
+
// wiped them in the first place.
|
|
220
|
+
const notesLocked = notesState === 'loading' || notesState === 'failed';
|
|
221
|
+
|
|
222
|
+
/* --------------------------------------------------- notes as a document */
|
|
223
|
+
|
|
224
|
+
// 'formatted' (the tree) | 'raw' (the textarea, and the only place editing
|
|
225
|
+
// happens). Presentation only — see submit(): the notes VALUE is the raw
|
|
226
|
+
// string in `values.notes` whichever of the two is on screen.
|
|
227
|
+
const [notesView, setNotesView] = useState('formatted');
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The notes as a JSON document, or null if they are not one.
|
|
231
|
+
*
|
|
232
|
+
* `zoho:credentials-backup` writes up to 32KB of pretty-printed
|
|
233
|
+
* `{source, account_id, …, credentials: [{label, content}…]}` into this
|
|
234
|
+
* field, and a textarea three rows tall is a genuinely bad way to read it.
|
|
235
|
+
*
|
|
236
|
+
* Gated on `notesState` for the same reason submit() is: until the detail
|
|
237
|
+
* fetch has answered, `values.notes` is not the entry's notes — it is the
|
|
238
|
+
* empty string the form started with. Parsing that would be answering a
|
|
239
|
+
* question about a value we do not have yet. Because the gate reads the
|
|
240
|
+
* state, this re-runs by itself the moment the fetch lands, which is what
|
|
241
|
+
* makes the toggle appear on an entry opened from the list.
|
|
242
|
+
*/
|
|
243
|
+
const notesDocument = useMemo(
|
|
244
|
+
() =>
|
|
245
|
+
notesState === 'ready' || notesState === 'loaded'
|
|
246
|
+
? parseJsonDocument(values.notes)
|
|
247
|
+
: null,
|
|
248
|
+
[notesState, values.notes]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Shown while the notes ARE a document — and also while the user is in the
|
|
252
|
+
// raw editor with something that is still trying to be one. That second
|
|
253
|
+
// case is the whole reason this is not just `Boolean(notesDocument)`: break
|
|
254
|
+
// a brace while editing and the toggle would otherwise vanish out from
|
|
255
|
+
// under the cursor, taking the way back with it. Instead it stays, with
|
|
256
|
+
// Formatted disabled and saying why. It cannot appear on ordinary prose,
|
|
257
|
+
// because reaching the raw editor at all requires the chips, which require
|
|
258
|
+
// a document.
|
|
259
|
+
const notesToggle =
|
|
260
|
+
Boolean(notesDocument) ||
|
|
261
|
+
(notesView === 'raw' && /^\s*[{[]/.test(values.notes || ''));
|
|
262
|
+
|
|
263
|
+
// The tree is only ever drawn for a document that parsed THIS render, so
|
|
264
|
+
// broken JSON falls back to the textarea rather than to a stale tree.
|
|
265
|
+
const notesFormatted = Boolean(notesDocument) && notesView === 'formatted';
|
|
266
|
+
|
|
125
267
|
const set = (key, value) => {
|
|
126
268
|
setValues((prev) => ({ ...prev, [key]: value }));
|
|
127
269
|
setErrors((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
|
|
@@ -266,7 +408,6 @@ const VaultEntryForm = ({
|
|
|
266
408
|
title: values.title.trim(),
|
|
267
409
|
url: (values.url || '').trim(),
|
|
268
410
|
username: (values.username || '').trim(),
|
|
269
|
-
notes: values.notes || '',
|
|
270
411
|
tags,
|
|
271
412
|
visibility: values.visibility,
|
|
272
413
|
// Always sent, including as null — that is how an entry gets
|
|
@@ -275,6 +416,15 @@ const VaultEntryForm = ({
|
|
|
275
416
|
};
|
|
276
417
|
|
|
277
418
|
// Key presence is the contract — see the note at the top of the file.
|
|
419
|
+
|
|
420
|
+
// Notes: sent whenever what is in the box is genuinely what the entry
|
|
421
|
+
// holds. For a new entry that is trivially true; for an existing one it
|
|
422
|
+
// is true only after the detail fetch answered. Anything else omits the
|
|
423
|
+
// key, and the API leaves the stored notes exactly where they were.
|
|
424
|
+
if (notesState === 'ready' || notesState === 'loaded') {
|
|
425
|
+
body.notes = values.notes || '';
|
|
426
|
+
}
|
|
427
|
+
|
|
278
428
|
if (clearPassword) body.password = '';
|
|
279
429
|
else if (password !== '') body.password = password;
|
|
280
430
|
|
|
@@ -694,13 +844,94 @@ const VaultEntryForm = ({
|
|
|
694
844
|
{field(
|
|
695
845
|
'notes',
|
|
696
846
|
'Notes',
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
847
|
+
<>
|
|
848
|
+
{notesToggle && (
|
|
849
|
+
<div
|
|
850
|
+
className={styles.viewToggle}
|
|
851
|
+
role="group"
|
|
852
|
+
aria-label="Notes view"
|
|
853
|
+
>
|
|
854
|
+
<button
|
|
855
|
+
type="button"
|
|
856
|
+
className={`${styles.viewChip} ${
|
|
857
|
+
notesFormatted ? styles.viewChipOn : ''
|
|
858
|
+
}`}
|
|
859
|
+
aria-pressed={notesFormatted}
|
|
860
|
+
disabled={!notesDocument}
|
|
861
|
+
title={
|
|
862
|
+
notesDocument
|
|
863
|
+
? undefined
|
|
864
|
+
: 'These notes are no longer valid JSON, so there is nothing to lay out — fix the syntax here and this comes back.'
|
|
865
|
+
}
|
|
866
|
+
onClick={() => setNotesView('formatted')}
|
|
867
|
+
>
|
|
868
|
+
Formatted
|
|
869
|
+
</button>
|
|
870
|
+
<button
|
|
871
|
+
type="button"
|
|
872
|
+
className={`${styles.viewChip} ${
|
|
873
|
+
notesFormatted ? '' : styles.viewChipOn
|
|
874
|
+
}`}
|
|
875
|
+
aria-pressed={!notesFormatted}
|
|
876
|
+
onClick={() => setNotesView('raw')}
|
|
877
|
+
>
|
|
878
|
+
Raw
|
|
879
|
+
</button>
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
|
|
883
|
+
{notesFormatted ? (
|
|
884
|
+
// Read-only, and capped so a 32KB backup scrolls
|
|
885
|
+
// inside the field instead of pushing Save off the
|
|
886
|
+
// bottom of the dialog.
|
|
887
|
+
<div
|
|
888
|
+
// The label above says "Notes"; keeping the id
|
|
889
|
+
// here means it still points at what it names.
|
|
890
|
+
// Focusable because a scroll region a mouse can
|
|
891
|
+
// reach and a keyboard cannot is only half a
|
|
892
|
+
// control.
|
|
893
|
+
id="vault-notes"
|
|
894
|
+
className={styles.notesJson}
|
|
895
|
+
role="region"
|
|
896
|
+
aria-label="Notes, laid out as JSON"
|
|
897
|
+
tabIndex={0}
|
|
898
|
+
>
|
|
899
|
+
<JsonView value={notesDocument} />
|
|
900
|
+
</div>
|
|
901
|
+
) : (
|
|
902
|
+
<textarea
|
|
903
|
+
id="vault-notes"
|
|
904
|
+
className={styles.textarea}
|
|
905
|
+
rows={3}
|
|
906
|
+
value={values.notes || ''}
|
|
907
|
+
disabled={notesLocked}
|
|
908
|
+
placeholder={
|
|
909
|
+
notesState === 'loading' ? 'Loading notes…' : ''
|
|
910
|
+
}
|
|
911
|
+
onChange={(e) => set('notes', e.target.value)}
|
|
912
|
+
/>
|
|
913
|
+
)}
|
|
914
|
+
|
|
915
|
+
{notesFormatted && (
|
|
916
|
+
<p className={styles.help}>
|
|
917
|
+
These notes are JSON. Switch to Raw to edit them.
|
|
918
|
+
</p>
|
|
919
|
+
)}
|
|
920
|
+
|
|
921
|
+
{notesState === 'failed' && (
|
|
922
|
+
<p className={styles.fieldError} role="alert">
|
|
923
|
+
The notes could not be loaded, so they cannot be
|
|
924
|
+
edited here — saving will leave them as they are.{' '}
|
|
925
|
+
<button
|
|
926
|
+
type="button"
|
|
927
|
+
className={styles.linkButton}
|
|
928
|
+
onClick={loadNotes}
|
|
929
|
+
>
|
|
930
|
+
Try again
|
|
931
|
+
</button>
|
|
932
|
+
</p>
|
|
933
|
+
)}
|
|
934
|
+
</>
|
|
704
935
|
)}
|
|
705
936
|
|
|
706
937
|
{field(
|
|
@@ -168,6 +168,18 @@ const VaultManagerInner = ({
|
|
|
168
168
|
*/
|
|
169
169
|
clientId = null,
|
|
170
170
|
clientLabel = null,
|
|
171
|
+
/**
|
|
172
|
+
* The order the list opens in, before anyone clicks a column header.
|
|
173
|
+
*
|
|
174
|
+
* The library's default stays most-recently-touched first — on a vault
|
|
175
|
+
* that is worked in, the entry someone just rotated is the one they are
|
|
176
|
+
* about to verify. A host whose vault is browsed rather than worked
|
|
177
|
+
* (looked through by name, like a phone book) passes 'title' / 'asc'.
|
|
178
|
+
* Clicking a header still overrides either way; this is only the opening
|
|
179
|
+
* state.
|
|
180
|
+
*/
|
|
181
|
+
defaultSort = 'updated_at',
|
|
182
|
+
defaultDirection = 'desc',
|
|
171
183
|
}) => {
|
|
172
184
|
const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
|
|
173
185
|
|
|
@@ -190,8 +202,8 @@ const VaultManagerInner = ({
|
|
|
190
202
|
// production is one page in eight.
|
|
191
203
|
const [clientOptions, setClientOptions] = useState([]);
|
|
192
204
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
|
193
|
-
const [sort, setSort] = useState(
|
|
194
|
-
const [direction, setDirection] = useState(
|
|
205
|
+
const [sort, setSort] = useState(defaultSort);
|
|
206
|
+
const [direction, setDirection] = useState(defaultDirection);
|
|
195
207
|
const [page, setPage] = useState(1);
|
|
196
208
|
const [perPage, setPerPage] = useState(
|
|
197
209
|
Number(perPageProp) > 0 ? Number(perPageProp) : PER_PAGE
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { toast } from 'react-toastify';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Check,
|
|
6
|
+
Copy,
|
|
7
|
+
Link2,
|
|
8
|
+
Loader2,
|
|
9
|
+
Mail,
|
|
10
|
+
ShieldAlert,
|
|
11
|
+
Trash2,
|
|
12
|
+
} from 'lucide-react';
|
|
4
13
|
|
|
5
14
|
import CustomFetch from '../Fetch';
|
|
6
15
|
import StandardModal from '../generic/StandardModal';
|
|
@@ -14,6 +23,8 @@ import {
|
|
|
14
23
|
availableShareFields,
|
|
15
24
|
initialShareFields,
|
|
16
25
|
isShareRevocable,
|
|
26
|
+
shareEmailEnabled,
|
|
27
|
+
shareEmailIssue,
|
|
17
28
|
shareFieldsLabel,
|
|
18
29
|
shareRequestBody,
|
|
19
30
|
shareStatus,
|
|
@@ -51,6 +62,19 @@ const STATUS_CLASS = {
|
|
|
51
62
|
* the recipient reveals — never the seed. That is said on the checkbox, not
|
|
52
63
|
* just in this comment, because it is the one thing about this dialog a person
|
|
53
64
|
* could reasonably get wrong.
|
|
65
|
+
*
|
|
66
|
+
* EMAILING THE LINK is optional and changes none of the above. The server sends
|
|
67
|
+
* it at creation — the only moment the raw URL exists on that side — and the
|
|
68
|
+
* mail carries the entry's title, the link and its limits, never a value off
|
|
69
|
+
* the credential. Two consequences show up in this file:
|
|
70
|
+
*
|
|
71
|
+
* - The URL is still shown here, always, exactly as before. An email is a
|
|
72
|
+
* delivery, not a substitute for the one copy of the link, and a send that
|
|
73
|
+
* quietly failed must not take the credential with it — which is why the
|
|
74
|
+
* server answers 201 with `email_error` rather than an error status, and why
|
|
75
|
+
* the success panel below reports either outcome next to a copyable URL.
|
|
76
|
+
* - Whether the field appears at all is the server's call, read from
|
|
77
|
+
* `meta.email_enabled` on the share list this modal already fetches.
|
|
54
78
|
*/
|
|
55
79
|
const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
56
80
|
const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
|
|
@@ -60,6 +84,12 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
60
84
|
const [fields, setFields] = useState(() => initialShareFields(entry));
|
|
61
85
|
const [expiresInHours, setExpiresInHours] = useState(DEFAULT_EXPIRY_HOURS);
|
|
62
86
|
const [maxViews, setMaxViews] = useState(null);
|
|
87
|
+
const [recipient, setRecipient] = useState('');
|
|
88
|
+
|
|
89
|
+
// Whether the server will email at all. Optimistic until the list answers:
|
|
90
|
+
// a field that flickers away is better than one that never appears because
|
|
91
|
+
// a request was slow, and the server refuses a recipient it did not offer.
|
|
92
|
+
const [emailEnabled, setEmailEnabled] = useState(true);
|
|
63
93
|
|
|
64
94
|
const [creating, setCreating] = useState(false);
|
|
65
95
|
// The one and only copy of a freshly minted URL. Held in component state
|
|
@@ -97,6 +127,9 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
97
127
|
if (!mountedRef.current) return;
|
|
98
128
|
|
|
99
129
|
setShares(Array.isArray(result?.data) ? result.data : []);
|
|
130
|
+
// The one place this modal can learn its own configuration:
|
|
131
|
+
// the list request it was already making.
|
|
132
|
+
setEmailEnabled(shareEmailEnabled(result));
|
|
100
133
|
setLoading(false);
|
|
101
134
|
},
|
|
102
135
|
null
|
|
@@ -120,6 +153,11 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
120
153
|
|
|
121
154
|
const toggle = (key) => setFields((current) => toggleShareField(current, key));
|
|
122
155
|
|
|
156
|
+
// A typo caught here is a sentence under the field; the same typo caught by
|
|
157
|
+
// the server is a 422 — and the server validates before it mints, so no
|
|
158
|
+
// link is lost either way. This is politeness, not a control.
|
|
159
|
+
const emailIssue = emailEnabled ? shareEmailIssue(recipient) : null;
|
|
160
|
+
|
|
123
161
|
const create = useCallback(() => {
|
|
124
162
|
if (!entry?.id || fields.length === 0) return;
|
|
125
163
|
|
|
@@ -128,7 +166,15 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
128
166
|
CustomFetch(
|
|
129
167
|
routes.createShare(entry.id),
|
|
130
168
|
'POST',
|
|
131
|
-
shareRequestBody({
|
|
169
|
+
shareRequestBody({
|
|
170
|
+
fields,
|
|
171
|
+
expiresInHours,
|
|
172
|
+
maxViews,
|
|
173
|
+
// Never sent when the server said it does not email, so a
|
|
174
|
+
// stale tab cannot post a recipient into an installation that
|
|
175
|
+
// has switched it off.
|
|
176
|
+
recipientEmail: emailEnabled ? recipient : '',
|
|
177
|
+
}),
|
|
132
178
|
(result) => {
|
|
133
179
|
if (!mountedRef.current) return;
|
|
134
180
|
|
|
@@ -150,7 +196,16 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
150
196
|
// server's "this entry has no password to share".
|
|
151
197
|
if (mountedRef.current) setCreating(false);
|
|
152
198
|
});
|
|
153
|
-
}, [
|
|
199
|
+
}, [
|
|
200
|
+
routes,
|
|
201
|
+
entry,
|
|
202
|
+
fields,
|
|
203
|
+
expiresInHours,
|
|
204
|
+
maxViews,
|
|
205
|
+
recipient,
|
|
206
|
+
emailEnabled,
|
|
207
|
+
load,
|
|
208
|
+
]);
|
|
154
209
|
|
|
155
210
|
const copyUrl = useCallback(async () => {
|
|
156
211
|
if (!created?.url) return;
|
|
@@ -240,6 +295,29 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
240
295
|
</button>
|
|
241
296
|
</div>
|
|
242
297
|
|
|
298
|
+
{created.emailed_to && (
|
|
299
|
+
<p className={styles.shareEmailed}>
|
|
300
|
+
<Mail size={14} strokeWidth={2.2} aria-hidden="true" />
|
|
301
|
+
<span>
|
|
302
|
+
Also emailed to{' '}
|
|
303
|
+
<strong>{created.emailed_to}</strong>. The
|
|
304
|
+
email carries this link and the entry's
|
|
305
|
+
title — none of the details themselves.
|
|
306
|
+
</span>
|
|
307
|
+
</p>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{created.email_error && (
|
|
311
|
+
<p className={styles.shareEmailFailed} role="alert">
|
|
312
|
+
<AlertTriangle
|
|
313
|
+
size={14}
|
|
314
|
+
strokeWidth={2.2}
|
|
315
|
+
aria-hidden="true"
|
|
316
|
+
/>
|
|
317
|
+
<span>{created.email_error}</span>
|
|
318
|
+
</p>
|
|
319
|
+
)}
|
|
320
|
+
|
|
243
321
|
<p className={styles.shareWarning}>
|
|
244
322
|
<ShieldAlert size={14} strokeWidth={2.2} aria-hidden="true" />
|
|
245
323
|
<span>
|
|
@@ -259,6 +337,10 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
259
337
|
onClick={() => {
|
|
260
338
|
setCreated(null);
|
|
261
339
|
setCopied(false);
|
|
340
|
+
// A second link almost never goes to the
|
|
341
|
+
// same person, and a left-over address is
|
|
342
|
+
// how one quietly does.
|
|
343
|
+
setRecipient('');
|
|
262
344
|
}}
|
|
263
345
|
>
|
|
264
346
|
Create another
|
|
@@ -361,17 +443,56 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
|
|
|
361
443
|
</p>
|
|
362
444
|
</div>
|
|
363
445
|
|
|
446
|
+
{emailEnabled && (
|
|
447
|
+
<div className={styles.field}>
|
|
448
|
+
<label
|
|
449
|
+
className={styles.label}
|
|
450
|
+
htmlFor="vault-share-recipient"
|
|
451
|
+
>
|
|
452
|
+
Email the link to (optional)
|
|
453
|
+
</label>
|
|
454
|
+
<input
|
|
455
|
+
id="vault-share-recipient"
|
|
456
|
+
type="email"
|
|
457
|
+
className={styles.input}
|
|
458
|
+
value={recipient}
|
|
459
|
+
placeholder="name@example.com"
|
|
460
|
+
autoComplete="off"
|
|
461
|
+
spellCheck="false"
|
|
462
|
+
onChange={(e) => setRecipient(e.target.value)}
|
|
463
|
+
/>
|
|
464
|
+
{emailIssue ? (
|
|
465
|
+
<p className={styles.fieldError}>{emailIssue}</p>
|
|
466
|
+
) : (
|
|
467
|
+
<p className={styles.help}>
|
|
468
|
+
Sent when you create the link. The email
|
|
469
|
+
carries the link and this entry's title —
|
|
470
|
+
never the username, password or code. You
|
|
471
|
+
still get the link here to copy.
|
|
472
|
+
</p>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
|
|
364
477
|
<div className={styles.formActions}>
|
|
365
478
|
<button
|
|
366
479
|
type="button"
|
|
367
480
|
className={styles.primaryButton}
|
|
368
481
|
onClick={create}
|
|
369
|
-
disabled={
|
|
482
|
+
disabled={
|
|
483
|
+
creating ||
|
|
484
|
+
fields.length === 0 ||
|
|
485
|
+
Boolean(emailIssue)
|
|
486
|
+
}
|
|
370
487
|
>
|
|
371
488
|
{creating && (
|
|
372
489
|
<Loader2 size={15} className={styles.spin} />
|
|
373
490
|
)}
|
|
374
|
-
{creating
|
|
491
|
+
{creating
|
|
492
|
+
? 'Creating…'
|
|
493
|
+
: recipient.trim() !== '' && emailEnabled
|
|
494
|
+
? 'Create and email link'
|
|
495
|
+
: 'Create link'}
|
|
375
496
|
</button>
|
|
376
497
|
<button
|
|
377
498
|
type="button"
|
|
@@ -131,17 +131,69 @@ export const toggleShareField = (fields, key) => {
|
|
|
131
131
|
return known.filter((field) => next.includes(field));
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Whether this installation will email a link for the sender.
|
|
136
|
+
*
|
|
137
|
+
* Read off the share LIST response, which the modal already fetches when it
|
|
138
|
+
* opens — a second request to learn one boolean would be a round trip for
|
|
139
|
+
* nothing. Defaults to true when the key is absent, so a build talking to a
|
|
140
|
+
* server that predates the flag offers the field and lets the server refuse,
|
|
141
|
+
* rather than hiding a feature that is switched on.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} listResponse the payload from `endpoints.shares(id)`
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
export const shareEmailEnabled = (listResponse) => {
|
|
147
|
+
const flag = listResponse?.meta?.email_enabled;
|
|
148
|
+
|
|
149
|
+
return flag === undefined || flag === null ? true : Boolean(flag);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What is wrong with the typed recipient address, or null.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately the loosest check that still catches a typo — something, an @,
|
|
156
|
+
* something with a dot in it. The server validates properly and is the only
|
|
157
|
+
* opinion that counts; this exists so that a mistyped address is a sentence
|
|
158
|
+
* under the field rather than a 422 toast after the link has already been
|
|
159
|
+
* minted.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} value
|
|
162
|
+
* @returns {(string|null)}
|
|
163
|
+
*/
|
|
164
|
+
export const shareEmailIssue = (value) => {
|
|
165
|
+
const address = String(value ?? '').trim();
|
|
166
|
+
|
|
167
|
+
if (address === '') return null;
|
|
168
|
+
|
|
169
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
|
|
170
|
+
? null
|
|
171
|
+
: 'That does not look like an email address.';
|
|
172
|
+
};
|
|
173
|
+
|
|
134
174
|
/**
|
|
135
175
|
* The body the create endpoint wants.
|
|
136
176
|
*
|
|
137
177
|
* `max_views` is omitted rather than sent as null when there is no limit: the
|
|
138
178
|
* server treats an absent key and a null the same way, and a body that carries
|
|
139
|
-
* only what was chosen is easier to read in a network panel.
|
|
179
|
+
* only what was chosen is easier to read in a network panel. `recipient_email`
|
|
180
|
+
* is omitted the same way when nobody is being mailed — a create request with
|
|
181
|
+
* no recipient key in it is a create request that cannot possibly send mail,
|
|
182
|
+
* which is worth being able to read off a network panel at a glance.
|
|
183
|
+
*
|
|
184
|
+
* A recipient name is only sent alongside an address: it is the greeting on the
|
|
185
|
+
* email and means nothing without one.
|
|
140
186
|
*
|
|
141
|
-
* @param {{fields: Array<string>, expiresInHours: number, maxViews: (number|null)}} choice
|
|
187
|
+
* @param {{fields: Array<string>, expiresInHours: number, maxViews: (number|null), recipientEmail?: string, recipientName?: string}} choice
|
|
142
188
|
* @returns {object}
|
|
143
189
|
*/
|
|
144
|
-
export const shareRequestBody = ({
|
|
190
|
+
export const shareRequestBody = ({
|
|
191
|
+
fields,
|
|
192
|
+
expiresInHours,
|
|
193
|
+
maxViews,
|
|
194
|
+
recipientEmail,
|
|
195
|
+
recipientName,
|
|
196
|
+
} = {}) => {
|
|
145
197
|
const known = SHARE_FIELDS.map((field) => field.key);
|
|
146
198
|
const clean = known.filter((field) => (fields || []).includes(field));
|
|
147
199
|
|
|
@@ -160,6 +212,18 @@ export const shareRequestBody = ({ fields, expiresInHours, maxViews } = {}) => {
|
|
|
160
212
|
body.max_views = Math.round(views);
|
|
161
213
|
}
|
|
162
214
|
|
|
215
|
+
const address = String(recipientEmail ?? '').trim();
|
|
216
|
+
|
|
217
|
+
if (address !== '') {
|
|
218
|
+
body.recipient_email = address;
|
|
219
|
+
|
|
220
|
+
const name = String(recipientName ?? '').trim();
|
|
221
|
+
|
|
222
|
+
if (name !== '') {
|
|
223
|
+
body.recipient_name = name;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
163
227
|
return body;
|
|
164
228
|
};
|
|
165
229
|
|