@visns-studio/visns-components 6.17.2 → 6.20.1
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 +142 -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
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// src/components/auth/Profile.jsx
|
|
2
2
|
import '../styles/global.css';
|
|
3
3
|
|
|
4
|
-
import React, {
|
|
4
|
+
import React, {
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useCallback,
|
|
10
|
+
memo,
|
|
11
|
+
} from 'react';
|
|
5
12
|
import { useLocation } from 'react-router-dom';
|
|
6
13
|
import PasswordStrengthBar from 'react-password-strength-bar';
|
|
7
14
|
import { toast } from 'react-toastify';
|
|
@@ -56,7 +63,48 @@ const FormField = memo(
|
|
|
56
63
|
}
|
|
57
64
|
);
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
/** The tabs this screen has always had. */
|
|
67
|
+
const BUILT_IN_TABS = [
|
|
68
|
+
{ id: 'overview', label: 'Overview' },
|
|
69
|
+
{ id: '2fa', label: 'Two-Factor Authentication' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalise a host's `extraTabs` into the shape `filters` holds.
|
|
74
|
+
*
|
|
75
|
+
* 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.
|
|
78
|
+
*/
|
|
79
|
+
const normaliseExtraTabs = (extraTabs) =>
|
|
80
|
+
(Array.isArray(extraTabs) ? extraTabs : []).filter(
|
|
81
|
+
(tab) =>
|
|
82
|
+
tab &&
|
|
83
|
+
typeof tab.id === 'string' &&
|
|
84
|
+
tab.id !== '' &&
|
|
85
|
+
typeof tab.render === 'function' &&
|
|
86
|
+
!BUILT_IN_TABS.some((builtIn) => builtIn.id === tab.id)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const Profile = ({
|
|
90
|
+
userProfile,
|
|
91
|
+
setUserProfile,
|
|
92
|
+
/**
|
|
93
|
+
* Tabs contributed by the consuming application:
|
|
94
|
+
* `[{ id, label, render }]`, appended after the built-in two and drawn
|
|
95
|
+
* through the same rail and the same section chrome, so a contributed tab
|
|
96
|
+
* is indistinguishable from a native one.
|
|
97
|
+
*
|
|
98
|
+
* This exists because a CRM's own account settings talk to that CRM's own
|
|
99
|
+
* endpoints — the Omnia passkey screen is the first — and this library is
|
|
100
|
+
* shared, so those cannot live in here. Passing nothing leaves the screen
|
|
101
|
+
* exactly as it was.
|
|
102
|
+
*
|
|
103
|
+
* `render` is called only while its tab is selected, so a section that
|
|
104
|
+
* fetches on mount does not fetch until it is looked at.
|
|
105
|
+
*/
|
|
106
|
+
extraTabs,
|
|
107
|
+
}) => {
|
|
60
108
|
const location = useLocation();
|
|
61
109
|
const signatureRef = useRef(null);
|
|
62
110
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -84,7 +132,19 @@ const Profile = ({ userProfile, setUserProfile }) => {
|
|
|
84
132
|
const [verificationCode, setVerificationCode] = useState('');
|
|
85
133
|
const [showTwoFactorSetup, setShowTwoFactorSetup] = useState(false);
|
|
86
134
|
|
|
87
|
-
|
|
135
|
+
// Keyed by id so a host can look one up while rendering, and so the
|
|
136
|
+
// reconcile effect below can tell a contributed tab from a built-in.
|
|
137
|
+
const contributedTabs = useMemo(
|
|
138
|
+
() => normaliseExtraTabs(extraTabs),
|
|
139
|
+
[extraTabs]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// A stable identity for "which tabs exist", so the reconcile effect fires
|
|
143
|
+
// when the set changes and not merely because the host rebuilt the array
|
|
144
|
+
// literal on a re-render — which it does on every one.
|
|
145
|
+
const contributedIds = contributedTabs.map((tab) => tab.id).join('|');
|
|
146
|
+
|
|
147
|
+
const [filters, setFilters] = useState(() => [
|
|
88
148
|
{
|
|
89
149
|
id: 'overview',
|
|
90
150
|
show: true,
|
|
@@ -97,8 +157,73 @@ const Profile = ({ userProfile, setUserProfile }) => {
|
|
|
97
157
|
label: 'Two-Factor Authentication',
|
|
98
158
|
class: '',
|
|
99
159
|
},
|
|
160
|
+
...contributedTabs.map((tab) => ({
|
|
161
|
+
id: tab.id,
|
|
162
|
+
show: false,
|
|
163
|
+
label: tab.label || tab.id,
|
|
164
|
+
class: '',
|
|
165
|
+
})),
|
|
100
166
|
]);
|
|
101
167
|
|
|
168
|
+
/** Which tabs the rail currently holds — see the URL sync effect. */
|
|
169
|
+
const filterIds = filters.map((filter) => filter.id).join('|');
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Keep the rail in step with a host that decides its tabs late.
|
|
173
|
+
*
|
|
174
|
+
* The initial state above is enough for a host passing a constant, which
|
|
175
|
+
* is the common case; this covers the one that only knows whether a tab
|
|
176
|
+
* applies once something has loaded. Existing entries are carried across
|
|
177
|
+
* untouched so the selection survives, and a tab that disappears while
|
|
178
|
+
* selected hands the selection back to Overview rather than leaving the
|
|
179
|
+
* screen blank.
|
|
180
|
+
*/
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
setFilters((previous) => {
|
|
183
|
+
const wanted = [
|
|
184
|
+
...BUILT_IN_TABS.map((tab) => tab.id),
|
|
185
|
+
...contributedTabs.map((tab) => tab.id),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const unchanged =
|
|
189
|
+
previous.length === wanted.length &&
|
|
190
|
+
previous.every((filter, index) => filter.id === wanted[index]);
|
|
191
|
+
|
|
192
|
+
if (unchanged) {
|
|
193
|
+
return previous;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const next = wanted.map((id) => {
|
|
197
|
+
const existing = previous.find((filter) => filter.id === id);
|
|
198
|
+
|
|
199
|
+
if (existing) {
|
|
200
|
+
return existing;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const contributed = contributedTabs.find(
|
|
204
|
+
(tab) => tab.id === id
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
id,
|
|
209
|
+
show: false,
|
|
210
|
+
label:
|
|
211
|
+
contributed?.label ||
|
|
212
|
+
BUILT_IN_TABS.find((tab) => tab.id === id)?.label ||
|
|
213
|
+
id,
|
|
214
|
+
class: '',
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Nothing selected any more — the tab that was showing has gone.
|
|
219
|
+
if (!next.some((filter) => filter.show)) {
|
|
220
|
+
next[0] = { ...next[0], show: true, class: 'subactive' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
}, [contributedIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
226
|
+
|
|
102
227
|
const handleClearSignature = useCallback((e) => {
|
|
103
228
|
if (e) {
|
|
104
229
|
e.preventDefault();
|
|
@@ -1047,6 +1172,35 @@ const Profile = ({ userProfile, setUserProfile }) => {
|
|
|
1047
1172
|
</div>
|
|
1048
1173
|
);
|
|
1049
1174
|
}
|
|
1175
|
+
|
|
1176
|
+
// A tab the consuming application contributed. Wrapped in the
|
|
1177
|
+
// same `gridtxt` shell and header the two above use, so what the
|
|
1178
|
+
// host supplies is only the body — it cannot get the chrome
|
|
1179
|
+
// subtly wrong, and a change to the chrome reaches every tab.
|
|
1180
|
+
if (filter.show) {
|
|
1181
|
+
const contributed = contributedTabs.find(
|
|
1182
|
+
(tab) => tab.id === filter.id
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
if (contributed) {
|
|
1186
|
+
return (
|
|
1187
|
+
<div className={styles.gridtxt} key={filter.id}>
|
|
1188
|
+
<div className={styles.gridtxt__header}>
|
|
1189
|
+
<span>
|
|
1190
|
+
{contributed.label || contributed.id}
|
|
1191
|
+
</span>
|
|
1192
|
+
</div>
|
|
1193
|
+
{/* Called here rather than held as an element, so
|
|
1194
|
+
nothing inside it mounts — or fetches — until
|
|
1195
|
+
the tab is actually being looked at. */}
|
|
1196
|
+
<div className={styles.form}>
|
|
1197
|
+
{contributed.render()}
|
|
1198
|
+
</div>
|
|
1199
|
+
</div>
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1050
1204
|
return null;
|
|
1051
1205
|
},
|
|
1052
1206
|
[
|
|
@@ -1065,6 +1219,7 @@ const Profile = ({ userProfile, setUserProfile }) => {
|
|
|
1065
1219
|
disableTwoFactor,
|
|
1066
1220
|
copyToClipboard,
|
|
1067
1221
|
handleVerificationCodeChange,
|
|
1222
|
+
contributedTabs,
|
|
1068
1223
|
]
|
|
1069
1224
|
);
|
|
1070
1225
|
|
|
@@ -1102,26 +1257,58 @@ const Profile = ({ userProfile, setUserProfile }) => {
|
|
|
1102
1257
|
setTwoFactorEnabled(false);
|
|
1103
1258
|
}
|
|
1104
1259
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1260
|
+
}, [userProfile.name, userProfile.email, userProfile.signature]);
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Select a tab from the URL.
|
|
1264
|
+
*
|
|
1265
|
+
* Two spellings, because two already existed in the wild. `?tab=<id>` is
|
|
1266
|
+
* what this screen has always read — it only ever recognised `2fa`, and
|
|
1267
|
+
* now recognises any tab that exists, contributed ones included. The
|
|
1268
|
+
* trailing path segment is the other: apps route `/profile/:type` here,
|
|
1269
|
+
* so `/profile/passkeys` and `/profile/2fa` now land on their tab instead
|
|
1270
|
+
* of silently showing Overview.
|
|
1271
|
+
*
|
|
1272
|
+
* An unrecognised value selects nothing rather than clearing the rail, so
|
|
1273
|
+
* a stray URL leaves the screen on Overview rather than blank.
|
|
1274
|
+
*
|
|
1275
|
+
* Split out of the form-reset effect above deliberately: that one wipes
|
|
1276
|
+
* the name/email fields, and it must not run again because somebody
|
|
1277
|
+
* switched tab.
|
|
1278
|
+
*/
|
|
1279
|
+
useEffect(() => {
|
|
1280
|
+
const fromQuery = new URLSearchParams(location.search).get('tab');
|
|
1281
|
+
const fromPath = location.pathname
|
|
1282
|
+
.replace(/\/+$/, '')
|
|
1283
|
+
.split('/')
|
|
1284
|
+
.pop();
|
|
1285
|
+
|
|
1286
|
+
const requested = [fromQuery, fromPath].find((candidate) =>
|
|
1287
|
+
filters.some((filter) => filter.id === candidate)
|
|
1288
|
+
);
|
|
1289
|
+
|
|
1290
|
+
if (!requested) {
|
|
1291
|
+
return;
|
|
1118
1292
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1293
|
+
|
|
1294
|
+
setFilters((prevFilters) => {
|
|
1295
|
+
if (prevFilters.find((filter) => filter.id === requested)?.show) {
|
|
1296
|
+
return prevFilters;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return prevFilters.map((filter) => ({
|
|
1300
|
+
...filter,
|
|
1301
|
+
show: filter.id === requested,
|
|
1302
|
+
class: filter.id === requested ? 'subactive' : '',
|
|
1303
|
+
}));
|
|
1304
|
+
});
|
|
1305
|
+
// Keyed on which tabs EXIST, not on `filters` itself: the id list
|
|
1306
|
+
// changes only when the rail gains or loses a tab, so a late-arriving
|
|
1307
|
+
// contributed tab still gets its deep link — while clicking between
|
|
1308
|
+
// tabs (which changes `show`, not the ids) does not re-run this and
|
|
1309
|
+
// pin the rail back to the URL.
|
|
1310
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1311
|
+
}, [location.search, location.pathname, filterIds]);
|
|
1125
1312
|
|
|
1126
1313
|
return (
|
|
1127
1314
|
<div>
|
|
@@ -23,6 +23,10 @@ export const DEFAULT_AUTH_ENDPOINTS = {
|
|
|
23
23
|
twoFactorResend: '/login/two-factor-resend',
|
|
24
24
|
/** Login.jsx — the azure SSO button navigates here. */
|
|
25
25
|
azureSso: '/auth/azure',
|
|
26
|
+
/** Login.jsx — POST {} for a passkey assertion challenge. */
|
|
27
|
+
passkeyLoginOptions: '/login/passkey/options',
|
|
28
|
+
/** Login.jsx — POST the signed assertion; answers like `authenticate`. */
|
|
29
|
+
passkeyLogin: '/login/passkey',
|
|
26
30
|
/** GenericAuth.jsx — GET, polled to decide whether a session exists. */
|
|
27
31
|
profile: '/ajax/user/profile',
|
|
28
32
|
/** Reset.jsx — POST {email}. */
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey (WebAuthn) marshalling for the browser.
|
|
3
|
+
*
|
|
4
|
+
* `navigator.credentials` speaks ArrayBuffers and JSON speaks strings, so both
|
|
5
|
+
* ceremonies need the same translation in both directions. That is roughly
|
|
6
|
+
* forty lines of base64url, which is not worth an npm dependency — especially
|
|
7
|
+
* one that would ship in every consumer of this library.
|
|
8
|
+
*
|
|
9
|
+
* The wire format is the Laravel side's (laragear/webauthn): base64url in
|
|
10
|
+
* the options, base64url back in the credential. Padding is omitted on the
|
|
11
|
+
* way out; the server's decoder accepts either.
|
|
12
|
+
*
|
|
13
|
+
* Pure module (no React, no imports) so it can be unit-tested with
|
|
14
|
+
* `node --test`, the same way authEndpoints.js is.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* base64url string -> Uint8Array.
|
|
19
|
+
*
|
|
20
|
+
* Tolerates the padding a server may or may not have included.
|
|
21
|
+
*/
|
|
22
|
+
export const base64UrlToBytes = (value) => {
|
|
23
|
+
const base64 = String(value).replace(/-/g, '+').replace(/_/g, '/');
|
|
24
|
+
const padded = base64.padEnd(
|
|
25
|
+
base64.length + ((4 - (base64.length % 4)) % 4),
|
|
26
|
+
'='
|
|
27
|
+
);
|
|
28
|
+
const binary = atob(padded);
|
|
29
|
+
const bytes = new Uint8Array(binary.length);
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
32
|
+
bytes[i] = binary.charCodeAt(i);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return bytes;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ArrayBuffer -> unpadded base64url.
|
|
40
|
+
*
|
|
41
|
+
* Built in chunks rather than `String.fromCharCode(...bytes)`: an attestation
|
|
42
|
+
* object runs to a couple of kilobytes, and spreading that many arguments is
|
|
43
|
+
* how this kind of helper blows the call stack on the one authenticator that
|
|
44
|
+
* returns a large one.
|
|
45
|
+
*/
|
|
46
|
+
export const bytesToBase64Url = (buffer) => {
|
|
47
|
+
const bytes = new Uint8Array(buffer);
|
|
48
|
+
let binary = '';
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
51
|
+
binary += String.fromCharCode.apply(
|
|
52
|
+
null,
|
|
53
|
+
bytes.subarray(i, i + 0x8000)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether this browser can do WebAuthn at all.
|
|
62
|
+
*
|
|
63
|
+
* The screen still needs the server to say passkeys are switched on; this is
|
|
64
|
+
* the other half of that question.
|
|
65
|
+
*/
|
|
66
|
+
export const isPasskeySupported = () =>
|
|
67
|
+
typeof window !== 'undefined' &&
|
|
68
|
+
typeof window.PublicKeyCredential === 'function' &&
|
|
69
|
+
typeof navigator !== 'undefined' &&
|
|
70
|
+
!!navigator.credentials;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether the person simply dismissed the passkey prompt.
|
|
74
|
+
*
|
|
75
|
+
* The credential API reports "you closed the sheet", "you let it time out"
|
|
76
|
+
* and "no key matched" as the same NotAllowedError, and none of the three is
|
|
77
|
+
* a failure worth a red toast — the person knows what they just did. Callers
|
|
78
|
+
* use this to fall silent instead.
|
|
79
|
+
*/
|
|
80
|
+
export const isPasskeyCancellation = (error) =>
|
|
81
|
+
!!error &&
|
|
82
|
+
(error.name === 'NotAllowedError' || error.name === 'AbortError');
|
|
83
|
+
|
|
84
|
+
/** Turn the server's creation options into what the browser wants. */
|
|
85
|
+
const parseCreationOptions = (options) => ({
|
|
86
|
+
...options,
|
|
87
|
+
challenge: base64UrlToBytes(options.challenge),
|
|
88
|
+
user: {
|
|
89
|
+
...options.user,
|
|
90
|
+
id: base64UrlToBytes(options.user.id),
|
|
91
|
+
},
|
|
92
|
+
excludeCredentials: (options.excludeCredentials || []).map(
|
|
93
|
+
(credential) => ({
|
|
94
|
+
...credential,
|
|
95
|
+
id: base64UrlToBytes(credential.id),
|
|
96
|
+
})
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Turn the server's request options into what the browser wants. */
|
|
101
|
+
const parseRequestOptions = (options) => {
|
|
102
|
+
const parsed = {
|
|
103
|
+
...options,
|
|
104
|
+
challenge: base64UrlToBytes(options.challenge),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Absent for a username-less sign-in, and it must STAY absent: an empty
|
|
108
|
+
// array means "no credential is acceptable", not "any".
|
|
109
|
+
if (Array.isArray(options.allowCredentials)) {
|
|
110
|
+
parsed.allowCredentials = options.allowCredentials.map(
|
|
111
|
+
(credential) => ({
|
|
112
|
+
...credential,
|
|
113
|
+
id: base64UrlToBytes(credential.id),
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parsed;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Turn what the browser produced back into JSON for the server. */
|
|
122
|
+
const serialiseCredential = (credential) => {
|
|
123
|
+
const response = {};
|
|
124
|
+
|
|
125
|
+
[
|
|
126
|
+
'clientDataJSON',
|
|
127
|
+
'attestationObject',
|
|
128
|
+
'authenticatorData',
|
|
129
|
+
'signature',
|
|
130
|
+
'userHandle',
|
|
131
|
+
].forEach((key) => {
|
|
132
|
+
const value = credential.response[key];
|
|
133
|
+
|
|
134
|
+
if (value) {
|
|
135
|
+
response[key] = bytesToBase64Url(value);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Which ways this authenticator can be reached — USB, NFC, the platform
|
|
140
|
+
// itself. Sent so a later sign-in can hint the browser where to look;
|
|
141
|
+
// not every authenticator reports it.
|
|
142
|
+
if (typeof credential.response.getTransports === 'function') {
|
|
143
|
+
const transports = credential.response.getTransports();
|
|
144
|
+
|
|
145
|
+
if (transports && transports.length) {
|
|
146
|
+
response.transports = transports;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
// Already base64url per the spec, and the server matches its stored
|
|
152
|
+
// primary key against exactly this string — do not re-encode it.
|
|
153
|
+
id: credential.id,
|
|
154
|
+
rawId: bytesToBase64Url(credential.rawId),
|
|
155
|
+
type: credential.type,
|
|
156
|
+
response,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Enrol: ask the authenticator for a new key pair.
|
|
162
|
+
*
|
|
163
|
+
* @param options the server's PublicKeyCredentialCreationOptions, as JSON
|
|
164
|
+
* @returns the credential, ready to POST
|
|
165
|
+
*/
|
|
166
|
+
export const createPasskeyCredential = async (options) => {
|
|
167
|
+
const credential = await navigator.credentials.create({
|
|
168
|
+
publicKey: parseCreationOptions(options),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!credential) {
|
|
172
|
+
throw new Error('No passkey was created.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return serialiseCredential(credential);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sign in: ask the authenticator to sign the server's challenge.
|
|
180
|
+
*
|
|
181
|
+
* @param options the server's PublicKeyCredentialRequestOptions, as JSON
|
|
182
|
+
* @returns the assertion, ready to POST
|
|
183
|
+
*/
|
|
184
|
+
export const getPasskeyCredential = async (options) => {
|
|
185
|
+
const credential = await navigator.credentials.get({
|
|
186
|
+
publicKey: parseRequestOptions(options),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!credential) {
|
|
190
|
+
throw new Error('No passkey was offered.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return serialiseCredential(credential);
|
|
194
|
+
};
|
|
@@ -3159,7 +3159,12 @@ const formatPhoneNumber = (phoneNumber) => {
|
|
|
3159
3159
|
if (!phoneNumber) return '';
|
|
3160
3160
|
|
|
3161
3161
|
// Remove all non-digit characters
|
|
3162
|
-
|
|
3162
|
+
let cleanNumber = phoneNumber.replace(/\D/g, '');
|
|
3163
|
+
|
|
3164
|
+
// +61 spellings collapse to the local form so every branch below applies.
|
|
3165
|
+
if (cleanNumber.match(/^61[2-9]\d{8}$/)) {
|
|
3166
|
+
cleanNumber = '0' + cleanNumber.slice(2);
|
|
3167
|
+
}
|
|
3163
3168
|
|
|
3164
3169
|
// Check if it's an Australian mobile number (starts with 04 and has 10 digits)
|
|
3165
3170
|
if (cleanNumber.match(/^04\d{8}$/)) {
|
|
@@ -227,10 +227,17 @@ const DataGridSearch = ({
|
|
|
227
227
|
return (
|
|
228
228
|
<div className={styles.searchContainer}>
|
|
229
229
|
<div className={styles.searchInputWrapper}>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
{/* No inline styles here, deliberately.
|
|
231
|
+
|
|
232
|
+
These carried `left: 25` / `color: '#000'` on the icon and
|
|
233
|
+
`borderRadius: 0, paddingLeft: 55` on the input. An inline
|
|
234
|
+
style beats every stylesheet, so the square corners and the
|
|
235
|
+
55px indent survived the 2026 pass untouched — the CSS was
|
|
236
|
+
correct the whole time and simply never applied. The icon
|
|
237
|
+
offset and the input's indent are a matched pair, so they
|
|
238
|
+
belong in one place: `.icon-search` and `.searchInput` in
|
|
239
|
+
DataGrid.module.scss. */}
|
|
240
|
+
<div className={styles['icon-search']}>
|
|
234
241
|
<Search strokeWidth={2} size={14} />
|
|
235
242
|
</div>
|
|
236
243
|
<input
|
|
@@ -238,7 +245,6 @@ const DataGridSearch = ({
|
|
|
238
245
|
placeholder="Search"
|
|
239
246
|
value={search}
|
|
240
247
|
onChange={handleChangeSearch}
|
|
241
|
-
style={{ borderRadius: 0, paddingLeft: 55 }}
|
|
242
248
|
className={styles.searchInput}
|
|
243
249
|
/>
|
|
244
250
|
</div>
|
|
@@ -90,6 +90,9 @@ const GenericAuth = ({
|
|
|
90
90
|
logo,
|
|
91
91
|
headerLogo,
|
|
92
92
|
providers,
|
|
93
|
+
// Whether this host offers passkeys. Threaded to the login screen the
|
|
94
|
+
// same way `providers` is; false leaves the button undrawn.
|
|
95
|
+
passkeys = false,
|
|
93
96
|
navigation,
|
|
94
97
|
routeConfig,
|
|
95
98
|
themeType = 'primary',
|
|
@@ -259,6 +262,7 @@ const GenericAuth = ({
|
|
|
259
262
|
loginBg={loginBg}
|
|
260
263
|
logo={logo}
|
|
261
264
|
providers={providers}
|
|
265
|
+
passkeys={passkeys}
|
|
262
266
|
setIsAuthenticated={setIsAuthenticated}
|
|
263
267
|
setUserProfile={setUserProfile}
|
|
264
268
|
config={config}
|
|
@@ -451,6 +455,7 @@ const LoginComponent = React.memo(
|
|
|
451
455
|
loginBg,
|
|
452
456
|
logo,
|
|
453
457
|
providers,
|
|
458
|
+
passkeys,
|
|
454
459
|
setIsAuthenticated,
|
|
455
460
|
setUserProfile,
|
|
456
461
|
config,
|
|
@@ -460,6 +465,7 @@ const LoginComponent = React.memo(
|
|
|
460
465
|
loginBg={loginBg}
|
|
461
466
|
logo={logo}
|
|
462
467
|
providers={providers}
|
|
468
|
+
passkeys={passkeys}
|
|
463
469
|
setSystemAuth={setIsAuthenticated}
|
|
464
470
|
setUserProfile={setUserProfile}
|
|
465
471
|
config={config}
|