@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.
@@ -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
- <textarea
698
- id="vault-notes"
699
- className={styles.textarea}
700
- rows={3}
701
- value={values.notes || ''}
702
- onChange={(e) => set('notes', e.target.value)}
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('updated_at');
194
- const [direction, setDirection] = useState('desc');
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 { Check, Copy, Link2, Loader2, ShieldAlert, Trash2 } from 'lucide-react';
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({ fields, expiresInHours, maxViews }),
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
- }, [routes, entry, fields, expiresInHours, maxViews, load]);
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={creating || fields.length === 0}
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 ? 'Creating…' : 'Create link'}
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 = ({ fields, expiresInHours, maxViews } = {}) => {
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