@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.
Files changed (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +134 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. 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
- <div className={styles.managerHead}>
561
- <div>
562
- <h1 className={styles.pageTitle}>{pageTitle}</h1>
563
- <p className={styles.pageIntro}>
564
- Shared logins for the team, and your own private ones.
565
- Passwords and 2FA secrets are only ever revealed one at
566
- a time, and every reveal is logged.
567
- </p>
568
- </div>
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
- <button
571
- type="button"
572
- className={styles.primaryButton}
573
- onClick={() => {
574
- setPrefill(null);
575
- setEditing({});
576
- }}
577
- >
578
- <Plus size={16} strokeWidth={2.2} aria-hidden="true" />
579
- New entry
580
- </button>
581
- </div>
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
- {(entry.can_edit || canManage) && (
854
- <span
855
- className={styles.actionDivider}
856
- aria-hidden="true"
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={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
  };