@visns-studio/visns-components 6.32.2 → 6.34.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 (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -15,7 +15,25 @@ import { toast } from 'react-toastify';
15
15
  import SignaturePad from 'react-signature-pad-wrapper';
16
16
  import Swal from 'sweetalert2';
17
17
  import CustomFetch from '../Fetch';
18
- import TableFilter from '../TableFilter';
18
+ import TableFilter, { isFlatFilterList } from '../TableFilter';
19
+ // Which shell the app is wearing, read off `<html data-layout>` — GenericAuth
20
+ // writes it. See utils/useShellLayout.js.
21
+ import { useShellLayout } from '../utils/useShellLayout';
22
+ // Whether this app offers passkeys, read off `<html data-passkeys>` — the same
23
+ // arrangement, for the same reason: this screen is mounted by the consuming
24
+ // app's route config and is never handed GenericAuth's props.
25
+ import { usePasskeysEnabled } from '../utils/usePasskeysEnabled';
26
+ import { resolveProfileTabLayout } from './profileLayout';
27
+ // The three passkey requests, shared with the enrolment prompt so the two
28
+ // cannot drift — the ceremony in particular is ordered steps that fail at the
29
+ // authenticator rather than at a lint.
30
+ import {
31
+ deletePasskey,
32
+ enrolPasskey,
33
+ listPasskeys,
34
+ } from './passkeyClient';
35
+ import { defaultPasskeyName, resolvePasskeyOptions } from './passkeyPrompt';
36
+ import { isPasskeyCancellation, isPasskeySupported } from './webauthn';
19
37
  import styles from '../styles/Profile.module.scss';
20
38
 
21
39
  /**
@@ -63,27 +81,63 @@ const FormField = memo(
63
81
  }
64
82
  );
65
83
 
66
- /** The tabs this screen has always had. */
84
+ /**
85
+ * The tabs this screen owns.
86
+ *
87
+ * All three ids are reserved — `normaliseExtraTabs` refuses a contributed tab
88
+ * that collides with any of them — but only the first two are always drawn.
89
+ * Passkeys appears when the application has the feature on; see
90
+ * `visibleBuiltInTabs` below.
91
+ */
67
92
  const BUILT_IN_TABS = [
68
93
  { id: 'overview', label: 'Overview' },
69
94
  { id: '2fa', label: 'Two-Factor Authentication' },
95
+ { id: 'passkeys', label: 'Passkeys' },
70
96
  ];
71
97
 
98
+ /** The id of the tab that only exists when passkeys are switched on. */
99
+ const PASSKEYS_TAB = 'passkeys';
100
+
101
+ /** A credential's own label, or the date it was created, or a last resort. */
102
+ const passkeyLabel = (passkey) => {
103
+ if (typeof passkey?.alias === 'string' && passkey.alias.trim() !== '') {
104
+ return passkey.alias.trim();
105
+ }
106
+
107
+ if (passkey?.created_at) {
108
+ const created = new Date(passkey.created_at);
109
+
110
+ if (!Number.isNaN(created.getTime())) {
111
+ return `Added ${created.toLocaleDateString()}`;
112
+ }
113
+ }
114
+
115
+ return 'Passkey';
116
+ };
117
+
72
118
  /**
73
119
  * Normalise a host's `extraTabs` into the shape `filters` holds.
74
120
  *
75
121
  * Anything without an `id` and a `render` is dropped rather than rendered as
76
- * an empty tab, and an id that collides with a built-in is dropped too — a
77
- * host must not be able to shadow Overview or 2FA out of existence.
122
+ * an empty tab, and an id that collides with a tab this screen is DRAWING is
123
+ * dropped too — a host must not be able to shadow Overview or 2FA out of
124
+ * existence.
125
+ *
126
+ * `reserved` is the drawn list, not `BUILT_IN_TABS`, for one specific reason:
127
+ * `passkeys` is a built-in only when the application has passkeys switched on.
128
+ * An app that contributed its own tab with that id before 6.33.0 — which is
129
+ * exactly what `extraTabs` was added for — keeps it while the feature is off,
130
+ * and loses it to the built-in when the feature is turned on, which is the
131
+ * right way round.
78
132
  */
79
- const normaliseExtraTabs = (extraTabs) =>
133
+ const normaliseExtraTabs = (extraTabs, reserved) =>
80
134
  (Array.isArray(extraTabs) ? extraTabs : []).filter(
81
135
  (tab) =>
82
136
  tab &&
83
137
  typeof tab.id === 'string' &&
84
138
  tab.id !== '' &&
85
139
  typeof tab.render === 'function' &&
86
- !BUILT_IN_TABS.some((builtIn) => builtIn.id === tab.id)
140
+ !reserved.includes(tab.id)
87
141
  );
88
142
 
89
143
  const Profile = ({
@@ -104,8 +158,48 @@ const Profile = ({
104
158
  * fetches on mount does not fetch until it is looked at.
105
159
  */
106
160
  extraTabs,
161
+ /**
162
+ * Where the tabs go: `'rail'` down the left of the panel, or
163
+ * `'underline'` as a horizontal strip above it — the same two names, and
164
+ * the same TableFilter variant, GenericIndex's `tabLayout` prop takes.
165
+ *
166
+ * PASSING NOTHING IS THE NORMAL CASE. The default is resolved from the
167
+ * app's shell layout: a top-bar app (`layout="full"`) keeps the rail it
168
+ * has always had, and a sidebar app (`layout="cms"` / `"simple"`) gets
169
+ * the strip, because on those the page already has a vertical rail down
170
+ * its left edge and a second one beside it is two columns of navigation
171
+ * to reach two tabs. See profileLayout.js, where the rule is written out
172
+ * and tested. Naming either value here overrides all of that.
173
+ */
174
+ tabLayout,
175
+ /**
176
+ * Whether this screen offers the Passkeys tab.
177
+ *
178
+ * PASSING NOTHING IS THE NORMAL CASE, and it is not "off": it reads the
179
+ * application-wide switch that GenericAuth's `passkeys` prop writes onto
180
+ * `<html data-passkeys>`. That is what makes "let them add a passkey" a
181
+ * one-prop feature for a consuming app — this screen is mounted by the
182
+ * app's own route config and could not otherwise be told.
183
+ *
184
+ * A boolean, or the same object shape GenericAuth takes, overrides it —
185
+ * for a host that wants the tab on one profile screen and not another.
186
+ */
187
+ passkeys,
188
+ /**
189
+ * Endpoint overrides for the passkey requests, merged over
190
+ * DEFAULT_AUTH_ENDPOINTS exactly as in the auth screens. Nothing else on
191
+ * this page is configurable yet, which is why it is only used there.
192
+ */
193
+ endpoints,
107
194
  }) => {
108
195
  const location = useLocation();
196
+ const shellLayout = useShellLayout();
197
+ // The app-wide switch, unless this screen was told otherwise.
198
+ const appOffersPasskeys = usePasskeysEnabled();
199
+ const passkeysEnabled =
200
+ passkeys === undefined || passkeys === null
201
+ ? appOffersPasskeys
202
+ : resolvePasskeyOptions(passkeys).enabled;
109
203
  const signatureRef = useRef(null);
110
204
  const [isLoading, setIsLoading] = useState(false);
111
205
  const [profile, setProfile] = useState({
@@ -132,11 +226,32 @@ const Profile = ({
132
226
  const [verificationCode, setVerificationCode] = useState('');
133
227
  const [showTwoFactorSetup, setShowTwoFactorSetup] = useState(false);
134
228
 
229
+ // Passkeys. `null` means "not fetched yet", which the tab draws as a
230
+ // loading line rather than as "you have none" — the two look identical in
231
+ // an empty array and mean opposite things.
232
+ const [passkeyList, setPasskeyList] = useState(null);
233
+ const [passkeyBusy, setPasskeyBusy] = useState(false);
234
+
235
+ /** The tabs actually drawn: the always-there two, plus passkeys if on. */
236
+ const builtInTabs = useMemo(
237
+ () =>
238
+ BUILT_IN_TABS.filter(
239
+ (tab) => tab.id !== PASSKEYS_TAB || passkeysEnabled
240
+ ),
241
+ [passkeysEnabled]
242
+ );
243
+
244
+ /** Their ids, which are the ids a contributed tab may not take. */
245
+ const builtInIds = useMemo(
246
+ () => builtInTabs.map((tab) => tab.id),
247
+ [builtInTabs]
248
+ );
249
+
135
250
  // Keyed by id so a host can look one up while rendering, and so the
136
251
  // reconcile effect below can tell a contributed tab from a built-in.
137
252
  const contributedTabs = useMemo(
138
- () => normaliseExtraTabs(extraTabs),
139
- [extraTabs]
253
+ () => normaliseExtraTabs(extraTabs, builtInIds),
254
+ [extraTabs, builtInIds]
140
255
  );
141
256
 
142
257
  // A stable identity for "which tabs exist", so the reconcile effect fires
@@ -145,18 +260,12 @@ const Profile = ({
145
260
  const contributedIds = contributedTabs.map((tab) => tab.id).join('|');
146
261
 
147
262
  const [filters, setFilters] = useState(() => [
148
- {
149
- id: 'overview',
150
- show: true,
151
- label: 'Overview',
152
- class: 'subactive',
153
- },
154
- {
155
- id: '2fa',
156
- show: false,
157
- label: 'Two-Factor Authentication',
158
- class: '',
159
- },
263
+ ...builtInTabs.map((tab, index) => ({
264
+ id: tab.id,
265
+ show: index === 0,
266
+ label: tab.label,
267
+ class: index === 0 ? 'subactive' : '',
268
+ })),
160
269
  ...contributedTabs.map((tab) => ({
161
270
  id: tab.id,
162
271
  show: false,
@@ -181,7 +290,7 @@ const Profile = ({
181
290
  useEffect(() => {
182
291
  setFilters((previous) => {
183
292
  const wanted = [
184
- ...BUILT_IN_TABS.map((tab) => tab.id),
293
+ ...builtInTabs.map((tab) => tab.id),
185
294
  ...contributedTabs.map((tab) => tab.id),
186
295
  ];
187
296
 
@@ -209,7 +318,7 @@ const Profile = ({
209
318
  show: false,
210
319
  label:
211
320
  contributed?.label ||
212
- BUILT_IN_TABS.find((tab) => tab.id === id)?.label ||
321
+ builtInTabs.find((tab) => tab.id === id)?.label ||
213
322
  id,
214
323
  class: '',
215
324
  };
@@ -222,7 +331,11 @@ const Profile = ({
222
331
 
223
332
  return next;
224
333
  });
225
- }, [contributedIds]); // eslint-disable-line react-hooks/exhaustive-deps
334
+ // `passkeysEnabled` belongs here beside the contributed ids: the
335
+ // attribute it reads is written by GenericAuth's insertion effect,
336
+ // which can land after this screen's first render (a refresh straight
337
+ // onto /profile), and the tab has to appear when it does.
338
+ }, [contributedIds, passkeysEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
226
339
 
227
340
  const handleClearSignature = useCallback((e) => {
228
341
  if (e) {
@@ -563,6 +676,101 @@ const Profile = ({
563
676
  }, []);
564
677
 
565
678
  // Memoized content renderer to prevent unnecessary re-renders
679
+ /* ---------------------------------------------------------------------
680
+ | Passkeys
681
+ |
682
+ | The same three requests the enrolment prompt makes, through the same
683
+ | module (passkeyClient.js) — deliberately not a second copy of the
684
+ | ceremony here, because the two would drift the first time one of them
685
+ | was fixed and the failure would be an authenticator refusing rather
686
+ | than anything a test or a lint would catch.
687
+ * ------------------------------------------------------------------ */
688
+
689
+ /** Whether the passkeys tab is the one currently on screen. */
690
+ const passkeysTabShown = filters.some(
691
+ (filter) => filter.id === PASSKEYS_TAB && filter.show
692
+ );
693
+
694
+ const refreshPasskeys = useCallback(async () => {
695
+ try {
696
+ const { passkeys: credentials } = await listPasskeys(endpoints);
697
+ setPasskeyList(credentials);
698
+ } catch (error) {
699
+ // An empty list rather than a permanent "loading": the tab has to
700
+ // settle on something, and the toast says why it is empty.
701
+ setPasskeyList([]);
702
+ toast.error(
703
+ error?.message || 'Your passkeys could not be loaded.'
704
+ );
705
+ }
706
+ }, [endpoints]);
707
+
708
+ // Fetched when the tab is first looked at, not on mount: a user who never
709
+ // opens it never makes the request, which is the same rule `extraTabs`
710
+ // renderers follow.
711
+ useEffect(() => {
712
+ if (passkeysTabShown && passkeyList === null) {
713
+ refreshPasskeys();
714
+ }
715
+ }, [passkeysTabShown, passkeyList, refreshPasskeys]);
716
+
717
+ const handleAddPasskey = useCallback(async () => {
718
+ setPasskeyBusy(true);
719
+
720
+ try {
721
+ await enrolPasskey(endpoints, { name: defaultPasskeyName() });
722
+ toast.success('Passkey added.');
723
+ await refreshPasskeys();
724
+ } catch (error) {
725
+ // Closing the browser's own sheet is not a failure; see
726
+ // isPasskeyCancellation.
727
+ if (!isPasskeyCancellation(error)) {
728
+ toast.error(
729
+ error?.message ||
730
+ 'That passkey could not be added. Please try again.'
731
+ );
732
+ }
733
+ } finally {
734
+ setPasskeyBusy(false);
735
+ }
736
+ }, [endpoints, refreshPasskeys]);
737
+
738
+ const handleRemovePasskey = useCallback(
739
+ async (passkey) => {
740
+ const result = await Swal.fire({
741
+ title: 'Remove this passkey',
742
+ text: `${passkeyLabel(
743
+ passkey
744
+ )} will no longer sign you in. You can add it again at any time.`,
745
+ icon: 'warning',
746
+ showCancelButton: true,
747
+ confirmButtonColor: 'var(--primary-color)',
748
+ cancelButtonColor: 'var(--secondary-color)',
749
+ confirmButtonText: 'Yes, remove it',
750
+ cancelButtonText: 'No, keep it',
751
+ });
752
+
753
+ if (!result.isConfirmed) {
754
+ return;
755
+ }
756
+
757
+ setPasskeyBusy(true);
758
+
759
+ try {
760
+ await deletePasskey(endpoints, passkey.id);
761
+ toast.success('Passkey removed.');
762
+ await refreshPasskeys();
763
+ } catch (error) {
764
+ toast.error(
765
+ error?.message || 'That passkey could not be removed.'
766
+ );
767
+ } finally {
768
+ setPasskeyBusy(false);
769
+ }
770
+ },
771
+ [endpoints, refreshPasskeys]
772
+ );
773
+
566
774
  const renderContent = useCallback(
567
775
  (filter) => {
568
776
  if (filter.show && filter.id === 'overview') {
@@ -1171,6 +1379,146 @@ const Profile = ({
1171
1379
  </div>
1172
1380
  </div>
1173
1381
  );
1382
+ } else if (filter.show && filter.id === PASSKEYS_TAB) {
1383
+ const supported = isPasskeySupported();
1384
+
1385
+ return (
1386
+ <div className={styles.gridtxt} key={filter.id}>
1387
+ <div className={styles.gridtxt__header}>
1388
+ <span>Passkeys</span>
1389
+ </div>
1390
+ {/* Same two-column sectioning as the other two tabs:
1391
+ what a passkey is on the left, what this account
1392
+ holds and the control that changes it on the
1393
+ right. */}
1394
+ <div className={styles.form}>
1395
+ <section className={styles.section}>
1396
+ <div className={styles.sectionAside}>
1397
+ <h3 className={styles.sectionTitle}>
1398
+ This account&apos;s passkeys
1399
+ </h3>
1400
+ <p className={styles.sectionDescription}>
1401
+ A passkey signs you in with the
1402
+ fingerprint, face or screen PIN of a
1403
+ device you already carry — no password
1404
+ to type and nothing to remember. The
1405
+ key itself never leaves the device,
1406
+ and each device you use needs its own.
1407
+ </p>
1408
+ </div>
1409
+ <div className={styles.sectionFields}>
1410
+ <div className={styles.twoFactorStatus}>
1411
+ {passkeyList === null ? (
1412
+ <p
1413
+ className={
1414
+ styles.sectionDescription
1415
+ }
1416
+ >
1417
+ Loading your passkeys…
1418
+ </p>
1419
+ ) : passkeyList.length === 0 ? (
1420
+ <p
1421
+ className={
1422
+ styles.sectionDescription
1423
+ }
1424
+ >
1425
+ You have no passkeys yet.
1426
+ </p>
1427
+ ) : (
1428
+ <ul
1429
+ className={
1430
+ styles.passkeyList
1431
+ }
1432
+ >
1433
+ {passkeyList.map((passkey) => (
1434
+ <li
1435
+ key={passkey.id}
1436
+ className={
1437
+ styles.passkeyItem
1438
+ }
1439
+ >
1440
+ <span
1441
+ className={
1442
+ styles.passkeyName
1443
+ }
1444
+ >
1445
+ {passkeyLabel(
1446
+ passkey
1447
+ )}
1448
+ </span>
1449
+ {passkey.last_used_at ? (
1450
+ <span
1451
+ className={
1452
+ styles.passkeyMeta
1453
+ }
1454
+ >
1455
+ Last used{' '}
1456
+ {new Date(
1457
+ passkey.last_used_at
1458
+ ).toLocaleDateString()}
1459
+ </span>
1460
+ ) : (
1461
+ <span
1462
+ className={
1463
+ styles.passkeyMeta
1464
+ }
1465
+ >
1466
+ Not used yet
1467
+ </span>
1468
+ )}
1469
+ <button
1470
+ className={`${styles.btn} ${styles.btnDanger}`}
1471
+ onClick={() =>
1472
+ handleRemovePasskey(
1473
+ passkey
1474
+ )
1475
+ }
1476
+ disabled={
1477
+ passkeyBusy
1478
+ }
1479
+ type="button"
1480
+ >
1481
+ Remove
1482
+ </button>
1483
+ </li>
1484
+ ))}
1485
+ </ul>
1486
+ )}
1487
+
1488
+ {/* The browser is the other half of
1489
+ the question: a host can offer
1490
+ passkeys and this browser still
1491
+ not be able to make one. Saying so
1492
+ beats a button that fails. */}
1493
+ {supported ? (
1494
+ <button
1495
+ className={`${styles.btn} ${styles.btnPrimary}`}
1496
+ onClick={handleAddPasskey}
1497
+ disabled={passkeyBusy}
1498
+ aria-busy={passkeyBusy}
1499
+ type="button"
1500
+ >
1501
+ {passkeyBusy
1502
+ ? 'Waiting for your device…'
1503
+ : 'Add a passkey'}
1504
+ </button>
1505
+ ) : (
1506
+ <p
1507
+ className={
1508
+ styles.sectionDescription
1509
+ }
1510
+ >
1511
+ This browser cannot create
1512
+ passkeys. Try the device you
1513
+ sign in on most.
1514
+ </p>
1515
+ )}
1516
+ </div>
1517
+ </div>
1518
+ </section>
1519
+ </div>
1520
+ </div>
1521
+ );
1174
1522
  }
1175
1523
 
1176
1524
  // A tab the consuming application contributed. Wrapped in the
@@ -1220,6 +1568,10 @@ const Profile = ({
1220
1568
  copyToClipboard,
1221
1569
  handleVerificationCodeChange,
1222
1570
  contributedTabs,
1571
+ passkeyList,
1572
+ passkeyBusy,
1573
+ handleAddPasskey,
1574
+ handleRemovePasskey,
1223
1575
  ]
1224
1576
  );
1225
1577
 
@@ -1310,21 +1662,65 @@ const Profile = ({
1310
1662
  // eslint-disable-next-line react-hooks/exhaustive-deps
1311
1663
  }, [location.search, location.pathname, filterIds]);
1312
1664
 
1665
+ /*
1666
+ * Rail or strip.
1667
+ *
1668
+ * `isFlatFilterList` is TableFilter's own gate, checked here for the same
1669
+ * reason GenericIndex and GenericDetail check it: the container built
1670
+ * below must never disagree with the markup TableFilter puts inside it.
1671
+ * Profile's tabs are flat today — the two built-ins plus whatever
1672
+ * `extraTabs` adds, none of which take children — so this is a guard
1673
+ * against a future shape rather than a live branch.
1674
+ */
1675
+ const tabsUnderlined =
1676
+ resolveProfileTabLayout({ tabLayout, shellLayout }) === 'underline' &&
1677
+ isFlatFilterList(filters);
1678
+
1313
1679
  return (
1314
1680
  <div>
1315
1681
  <div className={styles.grid}>
1316
1682
  <div className={styles.grid__row}>
1317
- <div className={`${styles.grid__full} ${styles.crmtitle}`}>
1683
+ {/* In strip mode the title is the page's own title row —
1684
+ type on the page ground, the treatment GenericIndex
1685
+ and GenericDetail use — rather than a white card
1686
+ holding two words above a second white card holding
1687
+ the rail. */}
1688
+ <div
1689
+ className={[
1690
+ styles.grid__full,
1691
+ styles.crmtitle,
1692
+ tabsUnderlined ? styles['crmtitle--flat'] : '',
1693
+ ]
1694
+ .filter(Boolean)
1695
+ .join(' ')}
1696
+ >
1318
1697
  <h1>My Profile</h1>
1319
1698
  </div>
1320
1699
  </div>
1321
1700
  </div>
1322
1701
  <div className={styles.grid}>
1323
- <div className={styles.grid__subrow}>
1324
- <div className={styles.grid__subnav}>
1702
+ <div
1703
+ className={[
1704
+ styles.grid__subrow,
1705
+ tabsUnderlined ? styles['grid__subrow--stacked'] : '',
1706
+ ]
1707
+ .filter(Boolean)
1708
+ .join(' ')}
1709
+ >
1710
+ <div
1711
+ className={[
1712
+ styles.grid__subnav,
1713
+ tabsUnderlined
1714
+ ? styles['grid__subnav--underline']
1715
+ : '',
1716
+ ]
1717
+ .filter(Boolean)
1718
+ .join(' ')}
1719
+ >
1325
1720
  <TableFilter
1326
1721
  filters={filters}
1327
1722
  setFilters={setFilters}
1723
+ variant={tabsUnderlined ? 'underline' : undefined}
1328
1724
  />
1329
1725
  </div>
1330
1726
  <div className={styles.grid__subcontent}>
@@ -27,6 +27,22 @@ export const DEFAULT_AUTH_ENDPOINTS = {
27
27
  passkeyLoginOptions: '/login/passkey/options',
28
28
  /** Login.jsx — POST the signed assertion; answers like `authenticate`. */
29
29
  passkeyLogin: '/login/passkey',
30
+ /*
31
+ * The authenticated half of the same feature: managing the credentials a
32
+ * signed-in user holds. These four are the defaults shipped by
33
+ * visnsstudio/visns-packages — `config('visns-packages.passkeys.uris')` —
34
+ * with the leading slash the login pair above already carries. An app that
35
+ * mounts the controller somewhere else overrides them through the same
36
+ * `endpoints` prop, one key at a time.
37
+ */
38
+ /** PasskeyEnrolPrompt / Profile — GET `{passkeys: [...]}` for this user. */
39
+ passkeyIndex: '/ajax/passkeys',
40
+ /** POST {} for a credential-creation challenge. */
41
+ passkeyRegisterOptions: '/ajax/passkeys/options',
42
+ /** POST the created credential plus `{alias}`. */
43
+ passkeyRegister: '/ajax/passkeys/register',
44
+ /** DELETE one credential. `{id}` is replaced with its base64url id. */
45
+ passkeyDestroy: '/ajax/passkeys/{id}',
30
46
  /** GenericAuth.jsx — GET, polled to decide whether a session exists. */
31
47
  profile: '/ajax/user/profile',
32
48
  /** Reset.jsx — POST {email}. */
@@ -63,6 +79,33 @@ export const resolveAuthEndpoints = (overrides) => {
63
79
  return resolved;
64
80
  };
65
81
 
82
+ /**
83
+ * The `destroy` URL for one credential.
84
+ *
85
+ * The Laravel route carries the credential id as a path parameter, and the id
86
+ * is base64url — path-safe by construction, but encoded anyway: the template
87
+ * is a consumer-supplied string and nothing here may assume what it substitutes
88
+ * into. A template with no `{id}` placeholder gets the id appended, so
89
+ * `'/ajax/passkeys'` works as well as `'/ajax/passkeys/{id}'`.
90
+ *
91
+ * @param {string} template Usually `endpoints.passkeyDestroy`.
92
+ * @param {string} id The credential id, exactly as the server gave it.
93
+ * @returns {string}
94
+ */
95
+ export const resolvePasskeyDestroyUrl = (template, id) => {
96
+ const path =
97
+ typeof template === 'string' && template !== ''
98
+ ? template
99
+ : DEFAULT_AUTH_ENDPOINTS.passkeyDestroy;
100
+ const encoded = encodeURIComponent(String(id ?? ''));
101
+
102
+ if (path.includes('{id}')) {
103
+ return path.replace('{id}', encoded);
104
+ }
105
+
106
+ return `${path.replace(/\/+$/, '')}/${encoded}`;
107
+ };
108
+
66
109
  /** The client-portal route paths, as they were hardcoded before 6.7.0. */
67
110
  export const DEFAULT_CLIENT_PATHS = {
68
111
  /** Route segment the client routes are nested under in GenericAuth. */