@visns-studio/visns-components 6.17.2 → 6.20.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 +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +39 -23
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +134 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
|
|
|
3
3
|
import {
|
|
4
4
|
ArrowDown,
|
|
5
5
|
ArrowUp,
|
|
6
|
+
Building2,
|
|
6
7
|
Check,
|
|
7
8
|
ExternalLink,
|
|
8
9
|
History,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
Plus,
|
|
14
15
|
RotateCcw,
|
|
15
16
|
Search,
|
|
17
|
+
Share2,
|
|
16
18
|
ShieldCheck,
|
|
17
19
|
Trash2,
|
|
18
20
|
User,
|
|
@@ -25,6 +27,7 @@ import OtpChip from './OtpChip';
|
|
|
25
27
|
import VaultAccessLog from './VaultAccessLog';
|
|
26
28
|
import VaultConfirmPanel from './VaultConfirmPanel';
|
|
27
29
|
import VaultEntryForm from './VaultEntryForm';
|
|
30
|
+
import VaultShareModal from './VaultShareModal';
|
|
28
31
|
import useDebouncedValue from './useDebouncedValue';
|
|
29
32
|
import useVaultReveal from './useVaultReveal';
|
|
30
33
|
import withVaultNavigation from './vaultNavigation';
|
|
@@ -144,16 +147,48 @@ const VaultManagerInner = ({
|
|
|
144
147
|
// what this user may see, so nothing here reads it.
|
|
145
148
|
userProfile: _userProfile,
|
|
146
149
|
canManage = false,
|
|
150
|
+
// `null` drops the title and its intro, for a host that already printed a
|
|
151
|
+
// heading above this one — a settings panel, a customer's tab.
|
|
147
152
|
pageTitle = 'Password manager',
|
|
148
153
|
locationSearch = '',
|
|
149
154
|
// A fixed page size, for a consumer that wants one. Passing it turns off
|
|
150
155
|
// the measurement below entirely — the two must not fight over `per_page`.
|
|
151
156
|
perPage: perPageProp = null,
|
|
157
|
+
/**
|
|
158
|
+
* Scope every entry on this page to one client.
|
|
159
|
+
*
|
|
160
|
+
* Used by a customer's Credentials tab, which is the whole vault filtered
|
|
161
|
+
* to that customer. It narrows the LIST and prefills a new entry, so an
|
|
162
|
+
* entry created on a customer's page belongs to that customer without
|
|
163
|
+
* anyone having to remember to pick them.
|
|
164
|
+
*
|
|
165
|
+
* Filtering happens on the SERVER (`client_id` on the list endpoint) and is
|
|
166
|
+
* applied after the visibility scope, never instead of it — narrowing to a
|
|
167
|
+
* client must not widen what this user is allowed to see.
|
|
168
|
+
*/
|
|
169
|
+
clientId = null,
|
|
170
|
+
clientLabel = null,
|
|
152
171
|
}) => {
|
|
153
172
|
const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
|
|
154
173
|
|
|
155
174
|
const [query, setQuery] = useState('');
|
|
156
175
|
const [visibility, setVisibility] = useState('');
|
|
176
|
+
/**
|
|
177
|
+
* The client the user has narrowed the list to, as `{ id, label }`.
|
|
178
|
+
*
|
|
179
|
+
* Held as the pair rather than as an id so the control can name the client
|
|
180
|
+
* the moment it is picked — including from a row's chip, which happens
|
|
181
|
+
* before (or instead of) the options ever being read.
|
|
182
|
+
*
|
|
183
|
+
* Only ever set when this page is NOT already locked to a client: with
|
|
184
|
+
* `clientId` passed in, every row is that client's by definition and a
|
|
185
|
+
* filter over one value is furniture.
|
|
186
|
+
*/
|
|
187
|
+
const [clientFilter, setClientFilter] = useState(null);
|
|
188
|
+
// The clients that have entries, from the server. NOT derived from `rows`:
|
|
189
|
+
// the list is paginated, so those are the clients on this page, which in
|
|
190
|
+
// production is one page in eight.
|
|
191
|
+
const [clientOptions, setClientOptions] = useState([]);
|
|
157
192
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
|
158
193
|
const [sort, setSort] = useState('updated_at');
|
|
159
194
|
const [direction, setDirection] = useState('desc');
|
|
@@ -169,6 +204,8 @@ const VaultManagerInner = ({
|
|
|
169
204
|
|
|
170
205
|
const [editing, setEditing] = useState(null); // entry | {} for a new one
|
|
171
206
|
const [logFor, setLogFor] = useState(null); // entry | 'global'
|
|
207
|
+
// The entry whose external share links are open, if any.
|
|
208
|
+
const [sharingFor, setSharingFor] = useState(null);
|
|
172
209
|
const [fallback, setFallback] = useState(null);
|
|
173
210
|
// `${entryId}:${what}` for the button currently showing a tick.
|
|
174
211
|
const [copied, setCopied] = useState(null);
|
|
@@ -200,6 +237,28 @@ const VaultManagerInner = ({
|
|
|
200
237
|
};
|
|
201
238
|
}, []);
|
|
202
239
|
|
|
240
|
+
/* ------------------------------------------------------ client filter */
|
|
241
|
+
|
|
242
|
+
// A page locked to one client (a customer's Credentials tab) owns the
|
|
243
|
+
// scope; the in-page filter only exists on the vault's own page.
|
|
244
|
+
const canFilterByClient = !clientId;
|
|
245
|
+
|
|
246
|
+
// What actually goes on the request. The prop wins: it is the host saying
|
|
247
|
+
// "this page is one client's", which the user cannot widen from here.
|
|
248
|
+
const effectiveClientId = clientId || clientFilter?.id || null;
|
|
249
|
+
|
|
250
|
+
const filterByClient = useCallback(
|
|
251
|
+
(entry) => {
|
|
252
|
+
if (!canFilterByClient || !entry?.client_id) return;
|
|
253
|
+
|
|
254
|
+
setClientFilter({
|
|
255
|
+
id: entry.client_id,
|
|
256
|
+
label: entry.client_label || `Client #${entry.client_id}`,
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
[canFilterByClient]
|
|
260
|
+
);
|
|
261
|
+
|
|
203
262
|
/* ----------------------------------------------------------- loading */
|
|
204
263
|
|
|
205
264
|
const load = useCallback(() => {
|
|
@@ -217,6 +276,11 @@ const VaultManagerInner = ({
|
|
|
217
276
|
};
|
|
218
277
|
|
|
219
278
|
if (includeDeleted) params.include_deleted = 1;
|
|
279
|
+
// Filtered on the SERVER, after the visibility scope and never instead
|
|
280
|
+
// of it — narrowing to a client must not widen what this user may see,
|
|
281
|
+
// and the pages have to be counted against the filtered set or the
|
|
282
|
+
// pager lies.
|
|
283
|
+
if (effectiveClientId) params.client_id = effectiveClientId;
|
|
220
284
|
|
|
221
285
|
CustomFetch(
|
|
222
286
|
routes.list,
|
|
@@ -255,17 +319,76 @@ const VaultManagerInner = ({
|
|
|
255
319
|
direction,
|
|
256
320
|
includeDeleted,
|
|
257
321
|
visibility,
|
|
322
|
+
// Without this, navigating from one customer's Credentials tab to
|
|
323
|
+
// another's keeps showing the first customer's entries: the callback
|
|
324
|
+
// closes over the old id and `useEffect([load])` never re-fires. The
|
|
325
|
+
// same applies to the in-page filter.
|
|
326
|
+
effectiveClientId,
|
|
258
327
|
]);
|
|
259
328
|
|
|
260
329
|
useEffect(() => {
|
|
261
330
|
load();
|
|
262
331
|
}, [load]);
|
|
263
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The clients worth offering in the filter.
|
|
335
|
+
*
|
|
336
|
+
* Follows "Show deleted": with it on, a client whose entries have all been
|
|
337
|
+
* deleted is still worth being able to reach.
|
|
338
|
+
*/
|
|
339
|
+
const loadClients = useCallback(() => {
|
|
340
|
+
if (!canFilterByClient) return;
|
|
341
|
+
|
|
342
|
+
CustomFetch(
|
|
343
|
+
routes.entryClients,
|
|
344
|
+
'GET',
|
|
345
|
+
includeDeleted ? { include_deleted: 1 } : {},
|
|
346
|
+
(result) => {
|
|
347
|
+
if (!mountedRef.current) return;
|
|
348
|
+
|
|
349
|
+
setClientOptions(Array.isArray(result?.data) ? result.data : []);
|
|
350
|
+
},
|
|
351
|
+
null
|
|
352
|
+
).catch(() => {
|
|
353
|
+
// A filter that cannot reach the server offers nothing rather than
|
|
354
|
+
// a toast — the list itself has already said if the vault is down,
|
|
355
|
+
// and an active filter survives this (see `clientChoices`).
|
|
356
|
+
if (mountedRef.current) setClientOptions([]);
|
|
357
|
+
});
|
|
358
|
+
}, [routes, canFilterByClient, includeDeleted]);
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
loadClients();
|
|
362
|
+
}, [loadClients]);
|
|
363
|
+
|
|
264
364
|
// A new search starts at the first page; staying on page 4 of the previous
|
|
265
|
-
// result set is how a search appears to return nothing.
|
|
365
|
+
// result set is how a search appears to return nothing. A change of client
|
|
366
|
+
// is the same thing.
|
|
266
367
|
useEffect(() => {
|
|
267
368
|
setPage(1);
|
|
268
|
-
}, [debouncedQuery, visibility, includeDeleted, sort, direction]);
|
|
369
|
+
}, [debouncedQuery, visibility, includeDeleted, sort, direction, effectiveClientId]);
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The options the select draws, with the active client folded in.
|
|
373
|
+
*
|
|
374
|
+
* The merge is what makes the chip on a row safe to click: that client is
|
|
375
|
+
* always in the list the server sent, but the two arrive independently,
|
|
376
|
+
* and an option list that has not landed yet — or that a failed request
|
|
377
|
+
* emptied — must not leave the user holding a filter with no way to
|
|
378
|
+
* clear it.
|
|
379
|
+
*/
|
|
380
|
+
const clientChoices = useMemo(() => {
|
|
381
|
+
const options = clientOptions.filter((option) => option?.id != null);
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
clientFilter &&
|
|
385
|
+
!options.some((option) => Number(option.id) === Number(clientFilter.id))
|
|
386
|
+
) {
|
|
387
|
+
return [...options, { id: clientFilter.id, label: clientFilter.label }];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return options;
|
|
391
|
+
}, [clientOptions, clientFilter]);
|
|
269
392
|
|
|
270
393
|
/* ------------------------------------------------ fitting the window */
|
|
271
394
|
|
|
@@ -527,11 +650,14 @@ const VaultManagerInner = ({
|
|
|
527
650
|
CustomFetch(routes.destroy(entry.id), 'DELETE', {}, () => {
|
|
528
651
|
toast.success('Deleted');
|
|
529
652
|
load();
|
|
653
|
+
// The last entry a client had can go with it, and an
|
|
654
|
+
// option that returns nothing is worse than no option.
|
|
655
|
+
loadClients();
|
|
530
656
|
}).catch(() => {});
|
|
531
657
|
},
|
|
532
658
|
});
|
|
533
659
|
},
|
|
534
|
-
[routes, load]
|
|
660
|
+
[routes, load, loadClients]
|
|
535
661
|
);
|
|
536
662
|
|
|
537
663
|
const restore = useCallback(
|
|
@@ -539,9 +665,10 @@ const VaultManagerInner = ({
|
|
|
539
665
|
CustomFetch(routes.restore(entry.id), 'POST', {}, () => {
|
|
540
666
|
toast.success('Restored');
|
|
541
667
|
load();
|
|
668
|
+
loadClients();
|
|
542
669
|
}).catch(() => {});
|
|
543
670
|
},
|
|
544
|
-
[routes, load]
|
|
671
|
+
[routes, load, loadClients]
|
|
545
672
|
);
|
|
546
673
|
|
|
547
674
|
const toggleSort = (key) => {
|
|
@@ -557,28 +684,33 @@ const VaultManagerInner = ({
|
|
|
557
684
|
|
|
558
685
|
return (
|
|
559
686
|
<div className={styles.manager}>
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
687
|
+
{/* Embedded (`pageTitle={null}`): no head row at all — the New
|
|
688
|
+
entry button joins the toolbar so the panel starts at the
|
|
689
|
+
search row instead of a mostly-empty band. */}
|
|
690
|
+
{pageTitle !== null && (
|
|
691
|
+
<div className={styles.managerHead}>
|
|
692
|
+
<div>
|
|
693
|
+
<h1 className={styles.pageTitle}>{pageTitle}</h1>
|
|
694
|
+
<p className={styles.pageIntro}>
|
|
695
|
+
Shared logins for the team, and your own private
|
|
696
|
+
ones. Passwords and 2FA secrets are only ever
|
|
697
|
+
revealed one at a time, and every reveal is logged.
|
|
698
|
+
</p>
|
|
699
|
+
</div>
|
|
569
700
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
className={styles.primaryButton}
|
|
704
|
+
onClick={() => {
|
|
705
|
+
setPrefill(null);
|
|
706
|
+
setEditing({});
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
|
710
|
+
New entry
|
|
711
|
+
</button>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
582
714
|
|
|
583
715
|
<div className={styles.toolbar}>
|
|
584
716
|
<div className={styles.searchBar}>
|
|
@@ -604,6 +736,50 @@ const VaultManagerInner = ({
|
|
|
604
736
|
<option value="private">Private only</option>
|
|
605
737
|
</select>
|
|
606
738
|
|
|
739
|
+
{/* Drawn only when there is something to choose between: an
|
|
740
|
+
application with no client model configured, or a vault
|
|
741
|
+
where nothing has been assigned yet, gets no control at
|
|
742
|
+
all rather than a select with one option in it. */}
|
|
743
|
+
{canFilterByClient && clientChoices.length > 0 && (
|
|
744
|
+
<select
|
|
745
|
+
className={`${styles.select} ${
|
|
746
|
+
clientFilter ? styles.selectActive : ''
|
|
747
|
+
}`}
|
|
748
|
+
value={clientFilter ? String(clientFilter.id) : ''}
|
|
749
|
+
aria-label="Filter by client"
|
|
750
|
+
onChange={(e) => {
|
|
751
|
+
const value = e.target.value;
|
|
752
|
+
|
|
753
|
+
if (value === '') {
|
|
754
|
+
setClientFilter(null);
|
|
755
|
+
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const chosen = clientChoices.find(
|
|
760
|
+
(option) => String(option.id) === value
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
setClientFilter({
|
|
764
|
+
id: chosen?.id ?? value,
|
|
765
|
+
label: chosen?.label || `Client #${value}`,
|
|
766
|
+
});
|
|
767
|
+
}}
|
|
768
|
+
>
|
|
769
|
+
<option value="">All clients</option>
|
|
770
|
+
{clientChoices.map((option) => (
|
|
771
|
+
<option key={option.id} value={String(option.id)}>
|
|
772
|
+
{/* The count is what makes the list worth
|
|
773
|
+
reading — a name alone does not say which
|
|
774
|
+
of these is worth filtering to. */}
|
|
775
|
+
{option.entries > 0
|
|
776
|
+
? `${option.label} (${option.entries})`
|
|
777
|
+
: option.label}
|
|
778
|
+
</option>
|
|
779
|
+
))}
|
|
780
|
+
</select>
|
|
781
|
+
)}
|
|
782
|
+
|
|
607
783
|
{canManage && (
|
|
608
784
|
<label className={styles.inlineCheck}>
|
|
609
785
|
<input
|
|
@@ -626,6 +802,20 @@ const VaultManagerInner = ({
|
|
|
626
802
|
Access log
|
|
627
803
|
</button>
|
|
628
804
|
)}
|
|
805
|
+
|
|
806
|
+
{pageTitle === null && (
|
|
807
|
+
<button
|
|
808
|
+
type="button"
|
|
809
|
+
className={styles.primaryButton}
|
|
810
|
+
onClick={() => {
|
|
811
|
+
setPrefill(null);
|
|
812
|
+
setEditing({});
|
|
813
|
+
}}
|
|
814
|
+
>
|
|
815
|
+
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
|
816
|
+
New entry
|
|
817
|
+
</button>
|
|
818
|
+
)}
|
|
629
819
|
</div>
|
|
630
820
|
|
|
631
821
|
{confirmRequired && (
|
|
@@ -732,6 +922,103 @@ const VaultManagerInner = ({
|
|
|
732
922
|
{host}
|
|
733
923
|
</span>
|
|
734
924
|
)}
|
|
925
|
+
{/* Which client this credential
|
|
926
|
+
belongs to. Linked when the host
|
|
927
|
+
app supplied a URL for the record;
|
|
928
|
+
plain text when it did not.
|
|
929
|
+
|
|
930
|
+
A chip, and once the importer's
|
|
931
|
+
duplicated "Client — Title" prefix
|
|
932
|
+
has been stripped it is the only
|
|
933
|
+
place the client is named on the
|
|
934
|
+
row — hence the title attribute,
|
|
935
|
+
because a long name ellipsises. */}
|
|
936
|
+
{entry.client_label && (
|
|
937
|
+
<span
|
|
938
|
+
className={`${styles.clientUnderTitle} ${
|
|
939
|
+
Number(effectiveClientId) ===
|
|
940
|
+
Number(entry.client_id)
|
|
941
|
+
? styles.clientUnderTitleActive
|
|
942
|
+
: ''
|
|
943
|
+
}`}
|
|
944
|
+
title={entry.client_label}
|
|
945
|
+
>
|
|
946
|
+
<Building2
|
|
947
|
+
size={11}
|
|
948
|
+
strokeWidth={2.2}
|
|
949
|
+
aria-hidden="true"
|
|
950
|
+
/>
|
|
951
|
+
{/* On the vault's own page the
|
|
952
|
+
name filters the list to
|
|
953
|
+
that client — the question
|
|
954
|
+
a chip on a row actually
|
|
955
|
+
provokes ("what else does
|
|
956
|
+
this client have?").
|
|
957
|
+
|
|
958
|
+
The link to the client
|
|
959
|
+
record is kept, moved onto
|
|
960
|
+
its own small anchor beside
|
|
961
|
+
the name: a button wrapping
|
|
962
|
+
an anchor is neither valid
|
|
963
|
+
markup nor operable, and
|
|
964
|
+
both destinations are worth
|
|
965
|
+
reaching.
|
|
966
|
+
|
|
967
|
+
Where the page is already
|
|
968
|
+
scoped to one client the
|
|
969
|
+
filter would be a no-op, so
|
|
970
|
+
the chip stays exactly what
|
|
971
|
+
it was. */}
|
|
972
|
+
{canFilterByClient && entry.client_id ? (
|
|
973
|
+
<>
|
|
974
|
+
<button
|
|
975
|
+
type="button"
|
|
976
|
+
className={styles.clientChipButton}
|
|
977
|
+
onClick={() => filterByClient(entry)}
|
|
978
|
+
aria-pressed={
|
|
979
|
+
Number(effectiveClientId) ===
|
|
980
|
+
Number(entry.client_id)
|
|
981
|
+
}
|
|
982
|
+
aria-label={`Show only entries for ${entry.client_label}`}
|
|
983
|
+
data-tooltip-id="system-tooltip"
|
|
984
|
+
data-tooltip-content={`Filter to ${entry.client_label}`}
|
|
985
|
+
>
|
|
986
|
+
<span
|
|
987
|
+
className={styles.clientChipLabel}
|
|
988
|
+
>
|
|
989
|
+
{entry.client_label}
|
|
990
|
+
</span>
|
|
991
|
+
</button>
|
|
992
|
+
|
|
993
|
+
{entry.client_url && (
|
|
994
|
+
<a
|
|
995
|
+
href={entry.client_url}
|
|
996
|
+
className={styles.clientChipLink}
|
|
997
|
+
aria-label={`Open ${entry.client_label}`}
|
|
998
|
+
data-tooltip-id="system-tooltip"
|
|
999
|
+
data-tooltip-content="Open client"
|
|
1000
|
+
>
|
|
1001
|
+
<ExternalLink
|
|
1002
|
+
size={10}
|
|
1003
|
+
strokeWidth={2.4}
|
|
1004
|
+
aria-hidden="true"
|
|
1005
|
+
/>
|
|
1006
|
+
</a>
|
|
1007
|
+
)}
|
|
1008
|
+
</>
|
|
1009
|
+
) : (
|
|
1010
|
+
<span className={styles.clientChipLabel}>
|
|
1011
|
+
{entry.client_url ? (
|
|
1012
|
+
<a href={entry.client_url}>
|
|
1013
|
+
{entry.client_label}
|
|
1014
|
+
</a>
|
|
1015
|
+
) : (
|
|
1016
|
+
entry.client_label
|
|
1017
|
+
)}
|
|
1018
|
+
</span>
|
|
1019
|
+
)}
|
|
1020
|
+
</span>
|
|
1021
|
+
)}
|
|
735
1022
|
{Array.isArray(entry.tags) &&
|
|
736
1023
|
entry.tags.length > 0 && (
|
|
737
1024
|
<span className={styles.tagRow}>
|
|
@@ -850,16 +1137,35 @@ const VaultManagerInner = ({
|
|
|
850
1137
|
label={`Copy the 2FA code for ${entry.title}`}
|
|
851
1138
|
/>
|
|
852
1139
|
|
|
853
|
-
{
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1140
|
+
{/* Always drawn now: Share sits
|
|
1141
|
+
past the hairline and is
|
|
1142
|
+
open to anyone who can see
|
|
1143
|
+
the entry, so there is
|
|
1144
|
+
always something on the
|
|
1145
|
+
far side of it. */}
|
|
1146
|
+
<span
|
|
1147
|
+
className={styles.actionDivider}
|
|
1148
|
+
aria-hidden="true"
|
|
1149
|
+
/>
|
|
859
1150
|
|
|
860
1151
|
{/* Icon-only past the hairline,
|
|
861
1152
|
each with a tooltip: one row
|
|
862
|
-
of actions is the point.
|
|
1153
|
+
of actions is the point.
|
|
1154
|
+
|
|
1155
|
+
Share sends a credential OUT
|
|
1156
|
+
of the CRM, and is gated on
|
|
1157
|
+
being able to see the entry
|
|
1158
|
+
and nothing more — the same
|
|
1159
|
+
rule the reveal button
|
|
1160
|
+
follows, since a share is a
|
|
1161
|
+
reveal with a delay on it. */}
|
|
1162
|
+
<RowAction
|
|
1163
|
+
icon={Share2}
|
|
1164
|
+
label={`Share ${entry.title} externally`}
|
|
1165
|
+
tooltip="Share by link"
|
|
1166
|
+
onClick={() => setSharingFor(entry)}
|
|
1167
|
+
/>
|
|
1168
|
+
|
|
863
1169
|
{entry.can_edit && (
|
|
864
1170
|
<RowAction
|
|
865
1171
|
icon={Pencil}
|
|
@@ -900,7 +1206,22 @@ const VaultManagerInner = ({
|
|
|
900
1206
|
{rows.length === 0 && !loading && (
|
|
901
1207
|
<tr>
|
|
902
1208
|
<td colSpan={6} className={styles.tableEmpty}>
|
|
903
|
-
{query.trim() === '' ? (
|
|
1209
|
+
{query.trim() === '' && clientFilter ? (
|
|
1210
|
+
// An empty vault and an empty filter
|
|
1211
|
+
// look identical, and "add your first
|
|
1212
|
+
// password" is the wrong advice for
|
|
1213
|
+
// the second one.
|
|
1214
|
+
<>
|
|
1215
|
+
<p>No entries for {clientFilter.label}.</p>
|
|
1216
|
+
<button
|
|
1217
|
+
type="button"
|
|
1218
|
+
className={styles.ghostButton}
|
|
1219
|
+
onClick={() => setClientFilter(null)}
|
|
1220
|
+
>
|
|
1221
|
+
Show all clients
|
|
1222
|
+
</button>
|
|
1223
|
+
</>
|
|
1224
|
+
) : query.trim() === '' ? (
|
|
904
1225
|
<>
|
|
905
1226
|
<p>No passwords saved yet.</p>
|
|
906
1227
|
<button
|
|
@@ -941,6 +1262,10 @@ const VaultManagerInner = ({
|
|
|
941
1262
|
<div ref={pagerRef} className={styles.pager}>
|
|
942
1263
|
<span className={styles.muted}>
|
|
943
1264
|
{total} {total === 1 ? 'entry' : 'entries'}
|
|
1265
|
+
{/* The client filter is counted by the server, so this
|
|
1266
|
+
total IS the filtered total — unlike the visibility
|
|
1267
|
+
filter below it, which narrows the page only. */}
|
|
1268
|
+
{clientFilter && ` for ${clientFilter.label}`}
|
|
944
1269
|
{visibility && ' (filtered on this page)'}
|
|
945
1270
|
</span>
|
|
946
1271
|
<div className={styles.pagerButtons}>
|
|
@@ -971,7 +1296,16 @@ const VaultManagerInner = ({
|
|
|
971
1296
|
entry={editing.id ? editing : null}
|
|
972
1297
|
endpoints={routes}
|
|
973
1298
|
canManage={canManage}
|
|
974
|
-
prefill={
|
|
1299
|
+
prefill={{
|
|
1300
|
+
...(prefill || {}),
|
|
1301
|
+
// On a customer's Credentials tab a new entry belongs
|
|
1302
|
+
// to that customer by definition. Prefilled rather
|
|
1303
|
+
// than forced, so it is still visible and still
|
|
1304
|
+
// changeable in the form.
|
|
1305
|
+
...(clientId
|
|
1306
|
+
? { client_id: clientId, client_label: clientLabel }
|
|
1307
|
+
: {}),
|
|
1308
|
+
}}
|
|
975
1309
|
onClose={() => {
|
|
976
1310
|
setEditing(null);
|
|
977
1311
|
setPrefill(null);
|
|
@@ -980,6 +1314,9 @@ const VaultManagerInner = ({
|
|
|
980
1314
|
setEditing(null);
|
|
981
1315
|
setPrefill(null);
|
|
982
1316
|
load();
|
|
1317
|
+
// A save can be the first entry a client has ever had,
|
|
1318
|
+
// which is the moment they become filterable.
|
|
1319
|
+
loadClients();
|
|
983
1320
|
}}
|
|
984
1321
|
/>
|
|
985
1322
|
)}
|
|
@@ -991,6 +1328,17 @@ const VaultManagerInner = ({
|
|
|
991
1328
|
onClose={() => setLogFor(null)}
|
|
992
1329
|
/>
|
|
993
1330
|
)}
|
|
1331
|
+
|
|
1332
|
+
{/* Unmounted on close rather than hidden: the one copy of a freshly
|
|
1333
|
+
created link lives in this component's state, and closing the
|
|
1334
|
+
dialog is what is supposed to destroy it. */}
|
|
1335
|
+
{sharingFor && (
|
|
1336
|
+
<VaultShareModal
|
|
1337
|
+
entry={sharingFor}
|
|
1338
|
+
endpoints={routes}
|
|
1339
|
+
onClose={() => setSharingFor(null)}
|
|
1340
|
+
/>
|
|
1341
|
+
)}
|
|
994
1342
|
</div>
|
|
995
1343
|
);
|
|
996
1344
|
};
|