@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +15 -2
- 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/Sms.module.scss +131 -0
- package/src/components/styles/Vault.module.scss +46 -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/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +41 -2
|
@@ -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
|
|
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
|
+
};
|