@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.
- package/README.md +504 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +353 -43
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +9 -2
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2487 -0
- package/src/components/sms/SmsInbox.jsx +18 -0
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +354 -15
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +451 -0
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/global.css +24 -5
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +103 -1
- 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
|
-
/**
|
|
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
|
|
77
|
-
* host must not be able to shadow Overview or 2FA out of
|
|
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
|
-
!
|
|
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:
|
|
150
|
-
show:
|
|
151
|
-
label:
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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'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
|
-
|
|
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
|
|
1324
|
-
|
|
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. */
|