@visns-studio/visns-components 6.21.0 → 6.23.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.
@@ -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
 
package/src/index.js CHANGED
@@ -197,6 +197,30 @@ import {
197
197
  toE164,
198
198
  } from './components/sms/smsHelpers';
199
199
 
200
+ // Zoom Phone presence — the header phone chip. ZoomPhoneBadge mounts through
201
+ // Navigation's `renderers` slot and is the whole feature; the helpers are
202
+ // exported for a consumer that wants the same roster somewhere else (a wall
203
+ // board, say) without re-deriving what "available" means.
204
+ import ZoomPhoneBadge from './components/phone/ZoomPhoneBadge';
205
+ import useZoomPhoneLive from './components/phone/useZoomPhoneLive';
206
+ import {
207
+ DEFAULT_ENDPOINTS as DEFAULT_PHONE_PRESENCE_ENDPOINTS,
208
+ makePhonePresenceEndpoints,
209
+ resolvePhonePresenceEndpoints,
210
+ } from './components/phone/phonePresenceEndpoints';
211
+ import {
212
+ STATUS as PHONE_PRESENCE_STATUS,
213
+ applyPresenceEvent,
214
+ callDuration,
215
+ callSummary,
216
+ freshnessLabel,
217
+ onCallCount,
218
+ orderRoster,
219
+ presenceNote,
220
+ statusLabel as phoneStatusLabel,
221
+ statusTone as phoneStatusTone,
222
+ } from './components/phone/phonePresenceHelpers';
223
+
200
224
  // Desktop alerts — the browser Notification API behind three gates (support,
201
225
  // permission, the user's own switch). Used by the bell and the SMS badge; the
202
226
  // module is exported so a consumer can drive it from anywhere else too.
@@ -281,7 +305,6 @@ import OAuthConnectionStatus from './components/oauth/OAuthConnectionStatus';
281
305
  import OAuthIntegrationsPage from './components/oauth/OAuthIntegrationsPage';
282
306
  import DataSyncWizard from './components/oauth/DataSyncWizard';
283
307
 
284
-
285
308
  export {
286
309
  applyUsernameDomain,
287
310
  AsyncSelect,
@@ -371,6 +394,22 @@ export {
371
394
  displayNumber,
372
395
  smsHref,
373
396
  telHref,
397
+ // Zoom Phone presence — the header roster.
398
+ ZoomPhoneBadge,
399
+ useZoomPhoneLive,
400
+ DEFAULT_PHONE_PRESENCE_ENDPOINTS,
401
+ makePhonePresenceEndpoints,
402
+ resolvePhonePresenceEndpoints,
403
+ PHONE_PRESENCE_STATUS,
404
+ applyPresenceEvent,
405
+ callDuration,
406
+ callSummary,
407
+ freshnessLabel,
408
+ onCallCount,
409
+ orderRoster,
410
+ presenceNote,
411
+ phoneStatusLabel,
412
+ phoneStatusTone,
374
413
  // Page primitives.
375
414
  AnchoredMenu,
376
415
  Band,
@@ -505,4 +544,4 @@ export {
505
544
  VariableInserter,
506
545
  verifyContactFor,
507
546
  Verify,
508
- };
547
+ };