@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
@@ -1,7 +1,14 @@
1
1
  // src/components/auth/Profile.jsx
2
2
  import '../styles/global.css';
3
3
 
4
- import React, { useState, useEffect, useRef, useCallback, memo } from '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
- const Profile = ({ userProfile, setUserProfile }) => {
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
- const [filters, setFilters] = useState([
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
- // Check URL query parameters for tab selection
1106
- const queryParams = new URLSearchParams(location.search);
1107
- const tabParam = queryParams.get('tab');
1108
-
1109
- if (tabParam === '2fa') {
1110
- // Update filters to show 2FA tab
1111
- setFilters((prevFilters) =>
1112
- prevFilters.map((filter) => ({
1113
- ...filter,
1114
- show: filter.id === '2fa',
1115
- class: filter.id === '2fa' ? 'subactive' : '',
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
- userProfile.name,
1121
- userProfile.email,
1122
- userProfile.signature,
1123
- location.search,
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
- const cleanNumber = phoneNumber.replace(/\D/g, '');
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
- <div
231
- className={styles['icon-search']}
232
- style={{ left: 25, color: '#000' }}
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}