@visns-studio/visns-components 6.6.3 → 6.15.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 +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers shared by the auth screens.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deliberately free of React and of the DOM so it can be
|
|
5
|
+
* unit-tested with `node --test` (see tests/authHelpers.test.mjs) — the screens
|
|
6
|
+
* themselves are verified by hand.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* -------------------------------------------------------------------------- */
|
|
10
|
+
/* contact-type classifier */
|
|
11
|
+
/* -------------------------------------------------------------------------- */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The copy the portal's login page shows under the contact field, one line per
|
|
15
|
+
* classification. Overridable per-screen via the `contactHints` prop.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CONTACT_HINTS = {
|
|
18
|
+
email: 'Email address detected',
|
|
19
|
+
mobile: 'Mobile number detected',
|
|
20
|
+
username: 'Username detected',
|
|
21
|
+
empty: 'Enter username, email, or mobile number',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What kind of contact did the user just type?
|
|
26
|
+
*
|
|
27
|
+
* Mirrors app/portal/page.js exactly, including its order of tests: an '@'
|
|
28
|
+
* anywhere wins (so `04@x` is an email), then a leading digit means a phone
|
|
29
|
+
* number, then anything else non-empty is a username. Whitespace is trimmed
|
|
30
|
+
* first, so a field holding only spaces still reads as empty.
|
|
31
|
+
*
|
|
32
|
+
* Returns one of 'email' | 'mobile' | 'username' | 'empty'.
|
|
33
|
+
*/
|
|
34
|
+
export const classifyContact = (value) => {
|
|
35
|
+
const trimmed = String(value ?? '').trim();
|
|
36
|
+
|
|
37
|
+
if (trimmed.includes('@')) {
|
|
38
|
+
return 'email';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (/^\d/.test(trimmed)) {
|
|
42
|
+
return 'mobile';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (trimmed.length > 0) {
|
|
46
|
+
return 'username';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return 'empty';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The hint line for a typed value. `hints` is merged over the defaults, so a
|
|
54
|
+
* consumer can reword one line without restating the other three.
|
|
55
|
+
*/
|
|
56
|
+
export const contactHint = (value, hints) => {
|
|
57
|
+
const table = { ...DEFAULT_CONTACT_HINTS, ...(hints || {}) };
|
|
58
|
+
|
|
59
|
+
return table[classifyContact(value)] ?? table.empty;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Append `@domain` to a bare username, the way the portal does before it asks
|
|
64
|
+
* for an OTP. Emails and phone numbers are handed back untouched, and so is
|
|
65
|
+
* everything when no domain is configured — which is the library default, so
|
|
66
|
+
* this is a no-op unless a consumer opts in.
|
|
67
|
+
*/
|
|
68
|
+
export const applyUsernameDomain = (value, domain) => {
|
|
69
|
+
const trimmed = String(value ?? '').trim();
|
|
70
|
+
|
|
71
|
+
if (!domain || trimmed === '') {
|
|
72
|
+
return trimmed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (classifyContact(trimmed) !== 'username') {
|
|
76
|
+
return trimmed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return `${trimmed}@${String(domain).replace(/^@/, '')}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/* -------------------------------------------------------------------------- */
|
|
83
|
+
/* one-time-code input */
|
|
84
|
+
/* -------------------------------------------------------------------------- */
|
|
85
|
+
|
|
86
|
+
/** How many digits a one-time code holds unless a screen says otherwise. */
|
|
87
|
+
export const OTP_LENGTH = 6;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reduce whatever landed in a one-time-code field to digits, capped at
|
|
91
|
+
* `length`.
|
|
92
|
+
*
|
|
93
|
+
* Stripping rather than rejecting is what makes pasting work: a code copied out
|
|
94
|
+
* of a text message often arrives as `123 456`, and the previous
|
|
95
|
+
* `/^\d*$/`-or-ignore test dropped the paste on the floor entirely. For input
|
|
96
|
+
* that was already all digits the result is identical, so nothing that worked
|
|
97
|
+
* before behaves differently.
|
|
98
|
+
*/
|
|
99
|
+
export const clampOtp = (value, length = OTP_LENGTH) => {
|
|
100
|
+
const digits = String(value ?? '').replace(/\D/g, '');
|
|
101
|
+
const max = Number.isFinite(length) && length > 0 ? length : OTP_LENGTH;
|
|
102
|
+
|
|
103
|
+
return digits.slice(0, max);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Is this a complete code? Used to gate the submit handler. */
|
|
107
|
+
export const isCompleteOtp = (value, length = OTP_LENGTH) =>
|
|
108
|
+
clampOtp(value, length).length === (length > 0 ? length : OTP_LENGTH);
|
|
109
|
+
|
|
110
|
+
/* -------------------------------------------------------------------------- */
|
|
111
|
+
/* error normalisation */
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turn whatever a failed request handed back into one displayable line.
|
|
116
|
+
*
|
|
117
|
+
* The screens see three shapes in practice:
|
|
118
|
+
* - a string, from CustomFetch's `errorCallback` (already flattened)
|
|
119
|
+
* - an Error, from a rejected promise
|
|
120
|
+
* - a response body, `{error}` / `{message}` / `{errors: {...}}`
|
|
121
|
+
* …and an `{error: '…'}`-only body on a non-200 is the one CustomFetch itself
|
|
122
|
+
* cannot flatten, which is why the screens normalise rather than trusting it.
|
|
123
|
+
*
|
|
124
|
+
* `fallback` is returned when nothing readable is present, so a caller never has
|
|
125
|
+
* to render an empty toast.
|
|
126
|
+
*/
|
|
127
|
+
export const normaliseAuthError = (error, fallback = '') => {
|
|
128
|
+
const clean = (value) => {
|
|
129
|
+
if (typeof value !== 'string') {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const trimmed = value.trim();
|
|
134
|
+
|
|
135
|
+
// `String(undefined)` and friends reach here through `String(error)`
|
|
136
|
+
// call sites; none of them is worth showing a user.
|
|
137
|
+
if (
|
|
138
|
+
trimmed === '' ||
|
|
139
|
+
trimmed === 'undefined' ||
|
|
140
|
+
trimmed === 'null' ||
|
|
141
|
+
trimmed === '[object Object]'
|
|
142
|
+
) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return trimmed;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const fromBag = (bag) => {
|
|
150
|
+
if (!bag) {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Array.isArray(bag)) {
|
|
155
|
+
for (const entry of bag) {
|
|
156
|
+
const line = fromBag(entry);
|
|
157
|
+
|
|
158
|
+
if (line) {
|
|
159
|
+
return line;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof bag === 'object') {
|
|
167
|
+
if (clean(bag.message)) {
|
|
168
|
+
return clean(bag.message);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const value of Object.values(bag)) {
|
|
172
|
+
const line = fromBag(value);
|
|
173
|
+
|
|
174
|
+
if (line) {
|
|
175
|
+
return line;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return '';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return clean(bag);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const fromBody = (body) => {
|
|
186
|
+
if (!body) {
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof body === 'string') {
|
|
191
|
+
return clean(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof body !== 'object') {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `error` first: it is the field this library's own backends use, and
|
|
199
|
+
// the one CustomFetch's flattener does not look at.
|
|
200
|
+
return (
|
|
201
|
+
clean(body.error) ||
|
|
202
|
+
clean(body.message) ||
|
|
203
|
+
fromBag(body.errors) ||
|
|
204
|
+
''
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (error === null || error === undefined) {
|
|
209
|
+
return fallback;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof error === 'string') {
|
|
213
|
+
return clean(error) || fallback;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (typeof error === 'object') {
|
|
217
|
+
const body = error.response?.data ?? error.data;
|
|
218
|
+
const fromResponse = fromBody(body);
|
|
219
|
+
|
|
220
|
+
if (fromResponse) {
|
|
221
|
+
return fromResponse;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const direct = fromBody(error);
|
|
225
|
+
|
|
226
|
+
if (direct) {
|
|
227
|
+
return direct;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (clean(error.statusText)) {
|
|
231
|
+
return clean(error.statusText);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return fallback;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Did a response actually succeed?
|
|
240
|
+
*
|
|
241
|
+
* The client endpoints answer `{success: true}`; the staff endpoints answer
|
|
242
|
+
* `{error: ''}`. A body carrying neither is treated as a success only when it
|
|
243
|
+
* has no error field at all, which is what the screens assumed before.
|
|
244
|
+
*/
|
|
245
|
+
export const isSuccessBody = (body) => {
|
|
246
|
+
if (!body || typeof body !== 'object') {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (body.success === true) {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (body.success === false) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return body.error === '' || body.error === null || body.error === undefined;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/* -------------------------------------------------------------------------- */
|
|
262
|
+
/* contact masking */
|
|
263
|
+
/* -------------------------------------------------------------------------- */
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A local fallback for the server's `masked_contact`, used only when the
|
|
267
|
+
* response does not carry one: `jane@example.com` -> `j***@example.com`,
|
|
268
|
+
* `0412345678` -> `*** *** 678`.
|
|
269
|
+
*/
|
|
270
|
+
export const maskContact = (value) => {
|
|
271
|
+
const trimmed = String(value ?? '').trim();
|
|
272
|
+
|
|
273
|
+
if (trimmed === '') {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (trimmed.includes('@')) {
|
|
278
|
+
const [local, ...rest] = trimmed.split('@');
|
|
279
|
+
const domain = rest.join('@');
|
|
280
|
+
|
|
281
|
+
if (local.length <= 1) {
|
|
282
|
+
return `${local}***@${domain}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return `${local[0]}***@${domain}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const digits = trimmed.replace(/\D/g, '');
|
|
289
|
+
|
|
290
|
+
if (digits.length <= 3) {
|
|
291
|
+
return trimmed;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return `*** *** ${digits.slice(-3)}`;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/* -------------------------------------------------------------------------- */
|
|
298
|
+
/* two-factor challenge hand-over */
|
|
299
|
+
/* -------------------------------------------------------------------------- */
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Is a two-factor challenge in progress, given the state Login handed over?
|
|
303
|
+
*
|
|
304
|
+
* The marker is the STATE, never the user payload inside it. This existed as
|
|
305
|
+
* `if (!userData) navigate('/login')` inside TwoFactorAuth, and it broke every
|
|
306
|
+
* code-driver sign-in: the packages' AuthController answers a code challenge
|
|
307
|
+
* with `user: null` on purpose — there the account is identified by a code sent
|
|
308
|
+
* out of band, so echoing the record would hand an unauthenticated caller a
|
|
309
|
+
* user lookup. The SMS arrived and the screen to type it into bounced straight
|
|
310
|
+
* back to the login form.
|
|
311
|
+
*
|
|
312
|
+
* `challenge === true` is what Login sends. `userData` on its own is still
|
|
313
|
+
* accepted, so a consumer running an older Login — or driving the screen
|
|
314
|
+
* itself — keeps working. Only a completely absent state means "no challenge",
|
|
315
|
+
* which is the direct-URL-hit and reload case the guard is actually for.
|
|
316
|
+
*/
|
|
317
|
+
export const hasChallengeState = (state) =>
|
|
318
|
+
Boolean(state && (state.challenge === true || state.userData));
|
|
319
|
+
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
/* pending states and transitions */
|
|
322
|
+
/* -------------------------------------------------------------------------- */
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Which label a resend control should be showing.
|
|
326
|
+
*
|
|
327
|
+
* Three states, and the middle one used to be missing: while the request was
|
|
328
|
+
* actually in flight the link went on saying "Resend Code", so pressing it
|
|
329
|
+
* looked like it had done nothing. A cooldown counted down visibly, but the
|
|
330
|
+
* flight itself — the part the user is waiting on — said nothing at all.
|
|
331
|
+
*
|
|
332
|
+
* In-flight wins over the countdown: both can be true at once for a component
|
|
333
|
+
* configured with a cooldown, and "Sending…" is the more informative of the
|
|
334
|
+
* two at that moment.
|
|
335
|
+
*/
|
|
336
|
+
export const resendLabelFor = ({
|
|
337
|
+
isResending = false,
|
|
338
|
+
countdown = 0,
|
|
339
|
+
labels = {},
|
|
340
|
+
} = {}) => {
|
|
341
|
+
if (isResending) {
|
|
342
|
+
return labels.resending ?? 'Sending…';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (countdown > 0) {
|
|
346
|
+
return String(labels.resendIn ?? 'Resend in {seconds}s').replace(
|
|
347
|
+
'{seconds}',
|
|
348
|
+
String(countdown)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return labels.resend ?? 'Resend code';
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Where a successful two-factor verification goes, and how.
|
|
357
|
+
*
|
|
358
|
+
* Two answers, and picking the wrong one is what made the screen feel broken:
|
|
359
|
+
*
|
|
360
|
+
* - `hard` — a real navigation, because the destination is an arbitrary URL
|
|
361
|
+
* from before sign-in (or a `successPath` the consumer set deliberately)
|
|
362
|
+
* and may not be inside the SPA at all. The caller must raise a curtain
|
|
363
|
+
* and leave it up: the browser will take its time, and until it does the
|
|
364
|
+
* old page is still on screen.
|
|
365
|
+
* - `handoff` — no navigation here at all. Setting the auth state is enough;
|
|
366
|
+
* GenericAuth notices, drops its own curtain, re-reads the profile with the
|
|
367
|
+
* session the challenge just issued, and performs the one route change.
|
|
368
|
+
*
|
|
369
|
+
* `handoff` is the default case, and it is the fix: the screen used to
|
|
370
|
+
* hard-reload unconditionally, which bypassed that curtain entirely and gave
|
|
371
|
+
* the "flickers, then nothing, then the page reloads" the review reported.
|
|
372
|
+
*/
|
|
373
|
+
export const twoFactorSuccessTarget = ({
|
|
374
|
+
previousUrl = '',
|
|
375
|
+
successPath = '/',
|
|
376
|
+
defaultSuccessPath = '/',
|
|
377
|
+
} = {}) => {
|
|
378
|
+
if (previousUrl && previousUrl !== '') {
|
|
379
|
+
return { mode: 'hard', url: previousUrl };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// A consumer that set successPath to something of its own is asking to
|
|
383
|
+
// land there specifically, which this component cannot do through
|
|
384
|
+
// GenericAuth — that always goes to '/'.
|
|
385
|
+
if (successPath && successPath !== defaultSuccessPath) {
|
|
386
|
+
return { mode: 'hard', url: successPath };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { mode: 'handoff', url: '' };
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/* -------------------------------------------------------------------------- */
|
|
393
|
+
/* CSRF token resync */
|
|
394
|
+
/* -------------------------------------------------------------------------- */
|
|
395
|
+
|
|
396
|
+
/** Where Laravel-style apps park the CSRF token for the SPA to read. */
|
|
397
|
+
export const CSRF_META_SELECTOR = 'meta[name="csrf-token"]';
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Point the page's CSRF meta tag at a freshly-issued token.
|
|
401
|
+
*
|
|
402
|
+
* This exists because of what the no-reload 2FA hand-off exposed. The backend
|
|
403
|
+
* rotated the CSRF token on a successful challenge, and every sign-in before
|
|
404
|
+
* 6.8.1 ended in a full page load — which fetched a new document, with a new
|
|
405
|
+
* meta tag, and nobody had to think about it. Handing off without a reload kept
|
|
406
|
+
* the document, and with it a token the server had just invalidated: every
|
|
407
|
+
* subsequent POST came back 419 and the user got a toast storm instead of an
|
|
408
|
+
* application.
|
|
409
|
+
*
|
|
410
|
+
* The rotation is being removed on the backend, and auth responses are growing
|
|
411
|
+
* a `csrf_token` field so the SPA can resync from any of them. This is the one
|
|
412
|
+
* place that consumes it. CustomFetch re-reads the meta on every single
|
|
413
|
+
* request, so updating the attribute is the whole of the fix — nothing needs to
|
|
414
|
+
* be told the value changed.
|
|
415
|
+
*
|
|
416
|
+
* Entirely inert when the field is absent, which is what makes it safe to call
|
|
417
|
+
* unconditionally at every hand-off point: a backend that never sends one sees
|
|
418
|
+
* no change at all.
|
|
419
|
+
*
|
|
420
|
+
* Returns true only when a token was actually written, so a caller can tell
|
|
421
|
+
* "nothing to do" from "there was something to do and it did not happen".
|
|
422
|
+
*/
|
|
423
|
+
export const syncCsrfToken = (token) => {
|
|
424
|
+
if (typeof token !== 'string' || token.trim() === '') {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (typeof document === 'undefined' || !document) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const meta = document.querySelector(CSRF_META_SELECTOR);
|
|
434
|
+
|
|
435
|
+
// An app that never had the meta tag is not broken by its absence —
|
|
436
|
+
// it simply was not using CSRF tokens this way to begin with.
|
|
437
|
+
if (!meta || typeof meta.setAttribute !== 'function') {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
meta.setAttribute('content', token);
|
|
442
|
+
|
|
443
|
+
return true;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// A resync that cannot happen must never take the sign-in down with
|
|
446
|
+
// it: the worst case is the 419s this was written to prevent, which is
|
|
447
|
+
// still better than a white screen at the moment of authentication.
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Pull `csrf_token` out of an auth response and apply it.
|
|
454
|
+
*
|
|
455
|
+
* The shape differs by endpoint — the authenticate response is an envelope, the
|
|
456
|
+
* challenge response is the user record itself — but the field name is the same
|
|
457
|
+
* in both, so call sites do not need to care which they are holding.
|
|
458
|
+
*/
|
|
459
|
+
export const syncCsrfFromResponse = (response) => {
|
|
460
|
+
if (!response || typeof response !== 'object') {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return syncCsrfToken(response.csrf_token);
|
|
465
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocols for the client portal sign-in screens.
|
|
3
|
+
*
|
|
4
|
+
* There are two of them in the wild and they disagree about nearly everything:
|
|
5
|
+
* the request bodies, how success is signalled, and whether a challenge even
|
|
6
|
+
* has an id. Rather than let one of them be "the" protocol and make the other
|
|
7
|
+
* a consumer's problem, both are described here as data and the screens read
|
|
8
|
+
* whichever they were given.
|
|
9
|
+
*
|
|
10
|
+
* `uuid` is the default and is exactly what 6.6.3/6.7.0 did. `contact` is the
|
|
11
|
+
* protocol behind ThroughLife's `/api/auth/*` routes (visns-packages'
|
|
12
|
+
* OtpController) — it has no uuid at all, signals success with `error: ''`, and
|
|
13
|
+
* returns a bearer token the consumer has to store.
|
|
14
|
+
*
|
|
15
|
+
* Pure module (no React, no DOM) so it can be unit-tested with `node --test`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read a body's `error` field as a string, whatever it actually holds. A body
|
|
20
|
+
* with no `error` at all yields '' — absence is not a failure.
|
|
21
|
+
*/
|
|
22
|
+
const errorOf = (body) => {
|
|
23
|
+
if (!body || typeof body !== 'object') {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const value = body.error;
|
|
28
|
+
|
|
29
|
+
if (value === null || value === undefined) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return typeof value === 'string' ? value : String(value);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The uuid protocol — the library's original, and still the default.
|
|
38
|
+
*
|
|
39
|
+
* Step one answers `{success, uuid, message, …}` and step two is only
|
|
40
|
+
* meaningful with that uuid in hand, so a verify screen reached without one
|
|
41
|
+
* has no challenge to answer and bounces back.
|
|
42
|
+
*/
|
|
43
|
+
const UUID_PROTOCOL = {
|
|
44
|
+
name: 'uuid',
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does a challenge carry an id that step two cannot proceed without? When
|
|
48
|
+
* true, ClientOTPVerify redirects to step one if it has no `challengeId`.
|
|
49
|
+
*/
|
|
50
|
+
requiresChallengeId: true,
|
|
51
|
+
|
|
52
|
+
/** Default request-body key holding the identifier. */
|
|
53
|
+
contactField: 'email',
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which form of the identifier step two sends. `false` means the same
|
|
57
|
+
* value step one sent (domain-completed, if `usernameDomain` is set).
|
|
58
|
+
*/
|
|
59
|
+
verifyUsesRawContact: false,
|
|
60
|
+
|
|
61
|
+
/** Body for the request-a-code call. */
|
|
62
|
+
requestBody: ({ contact, contactField, previousUrl, resend }) =>
|
|
63
|
+
resend
|
|
64
|
+
? { [contactField]: contact, resend: true }
|
|
65
|
+
: { [contactField]: contact, location: previousUrl || '' },
|
|
66
|
+
|
|
67
|
+
/** Body for the verify-the-code call. */
|
|
68
|
+
verifyBody: ({ contact, contactField, code, challengeId, previousUrl }) => ({
|
|
69
|
+
[contactField]: contact,
|
|
70
|
+
uuid: challengeId,
|
|
71
|
+
otp: code,
|
|
72
|
+
previous_url: previousUrl || '',
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strictly `body.success`, which is what the screens tested before. A body
|
|
77
|
+
* that merely lacks an error is NOT a success here.
|
|
78
|
+
*/
|
|
79
|
+
isSuccess: (body) => Boolean(body && body.success),
|
|
80
|
+
|
|
81
|
+
/** Everything step two needs, read out of step one's response. */
|
|
82
|
+
readChallenge: (body) => ({
|
|
83
|
+
challengeId: body?.uuid ?? null,
|
|
84
|
+
maskedContact: body?.masked_contact ?? '',
|
|
85
|
+
contactMethod: body?.contact_method ?? '',
|
|
86
|
+
devOtp: body?.dev_otp ?? body?.otp ?? null,
|
|
87
|
+
message: body?.message ?? '',
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
/** The session, read out of step two's response. */
|
|
91
|
+
readSession: (body) => ({
|
|
92
|
+
user: body?.user ?? null,
|
|
93
|
+
// This protocol's backend sets a cookie; there is no bearer token for
|
|
94
|
+
// the consumer to store.
|
|
95
|
+
token: null,
|
|
96
|
+
redirectUrl: body?.redirect_url ?? '',
|
|
97
|
+
message: body?.message ?? '',
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The contact protocol — ThroughLife's `/api/auth/request-otp` and
|
|
103
|
+
* `/api/auth/login-otp`, served by visns-packages' OtpController.
|
|
104
|
+
*
|
|
105
|
+
* Three things make it incompatible with the uuid protocol, and all three are
|
|
106
|
+
* load-bearing:
|
|
107
|
+
*
|
|
108
|
+
* - There is no uuid. The code is stored against the contact record itself,
|
|
109
|
+
* so the identifier IS the challenge id. A verify screen must not demand
|
|
110
|
+
* one.
|
|
111
|
+
* - `login-otp` answers `{error: '', access_token, user, full_data_available}`
|
|
112
|
+
* and carries no `success` key at all, so testing `body.success` would read
|
|
113
|
+
* a correct code as a wrong one.
|
|
114
|
+
* - Failures arrive as non-200 `{error: '…'}` bodies (404/401/403/429, and a
|
|
115
|
+
* 500 for a validation failure — see the controller's own note about that
|
|
116
|
+
* wart being contract). They reach the screens through CustomFetch's
|
|
117
|
+
* rejection path, not its success path.
|
|
118
|
+
*
|
|
119
|
+
* The `verifyUsesRawContact` flag mirrors a real asymmetry in the portal page
|
|
120
|
+
* this was ported from: a bare username is completed to `name@domain` for
|
|
121
|
+
* request-otp, but sent as typed to login-otp.
|
|
122
|
+
*/
|
|
123
|
+
const CONTACT_PROTOCOL = {
|
|
124
|
+
name: 'contact',
|
|
125
|
+
|
|
126
|
+
requiresChallengeId: false,
|
|
127
|
+
|
|
128
|
+
contactField: 'contact',
|
|
129
|
+
|
|
130
|
+
verifyUsesRawContact: true,
|
|
131
|
+
|
|
132
|
+
/** A resend is simply another request — there is no resend flag. */
|
|
133
|
+
requestBody: ({ contact, contactField }) => ({ [contactField]: contact }),
|
|
134
|
+
|
|
135
|
+
verifyBody: ({ contact, contactField, code, minimalResponse }) => ({
|
|
136
|
+
[contactField]: contact,
|
|
137
|
+
otp_code: code,
|
|
138
|
+
// The whitelisted user payload, small enough to live in a cookie. The
|
|
139
|
+
// full model does not fit in one and should not travel there anyway.
|
|
140
|
+
minimal_response: minimalResponse !== false,
|
|
141
|
+
}),
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `error === ''` is the signal, for both steps. request-otp happens to
|
|
145
|
+
* also send `success: true`; login-otp does not, so the error field is the
|
|
146
|
+
* only thing both have in common.
|
|
147
|
+
*/
|
|
148
|
+
isSuccess: (body) =>
|
|
149
|
+
Boolean(body) && typeof body === 'object' && errorOf(body) === '',
|
|
150
|
+
|
|
151
|
+
readChallenge: (body) => ({
|
|
152
|
+
// No uuid in this contract. Stated explicitly rather than left to
|
|
153
|
+
// `?? null` so the absence reads as deliberate.
|
|
154
|
+
challengeId: null,
|
|
155
|
+
maskedContact: body?.masked_contact ?? '',
|
|
156
|
+
contactMethod: body?.contact_method ?? '',
|
|
157
|
+
devOtp: body?.dev_otp ?? null,
|
|
158
|
+
message: body?.message ?? '',
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
readSession: (body) => ({
|
|
162
|
+
user: body?.user ?? null,
|
|
163
|
+
// The reason this protocol needed `onAuthenticated`: the token is the
|
|
164
|
+
// session, and it has to reach the consumer to be stored.
|
|
165
|
+
token: body?.access_token ?? null,
|
|
166
|
+
redirectUrl: body?.redirect_url ?? '',
|
|
167
|
+
message: body?.message ?? '',
|
|
168
|
+
fullDataAvailable: body?.full_data_available ?? null,
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** The protocols shipped by name. */
|
|
173
|
+
export const CLIENT_AUTH_PROTOCOLS = {
|
|
174
|
+
uuid: UUID_PROTOCOL,
|
|
175
|
+
contact: CONTACT_PROTOCOL,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** What a screen falls back to when nothing is configured. */
|
|
179
|
+
export const DEFAULT_CLIENT_PROTOCOL = 'uuid';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the `protocol` prop.
|
|
183
|
+
*
|
|
184
|
+
* Accepts a name (`'uuid'` / `'contact'`), or an object of overrides. An object
|
|
185
|
+
* may name the protocol it starts from with `extends`; without one it builds on
|
|
186
|
+
* `uuid`, so a partial object is a tweak rather than a rewrite. An unknown name
|
|
187
|
+
* falls back to the default rather than throwing — a typo in a prop should not
|
|
188
|
+
* white-screen a login page.
|
|
189
|
+
*/
|
|
190
|
+
export const resolveClientProtocol = (protocol) => {
|
|
191
|
+
if (!protocol) {
|
|
192
|
+
return CLIENT_AUTH_PROTOCOLS[DEFAULT_CLIENT_PROTOCOL];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof protocol === 'string') {
|
|
196
|
+
return (
|
|
197
|
+
CLIENT_AUTH_PROTOCOLS[protocol] ||
|
|
198
|
+
CLIENT_AUTH_PROTOCOLS[DEFAULT_CLIENT_PROTOCOL]
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof protocol !== 'object') {
|
|
203
|
+
return CLIENT_AUTH_PROTOCOLS[DEFAULT_CLIENT_PROTOCOL];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const base =
|
|
207
|
+
CLIENT_AUTH_PROTOCOLS[protocol.extends] ||
|
|
208
|
+
CLIENT_AUTH_PROTOCOLS[DEFAULT_CLIENT_PROTOCOL];
|
|
209
|
+
|
|
210
|
+
const merged = { ...base };
|
|
211
|
+
|
|
212
|
+
Object.keys(protocol).forEach((key) => {
|
|
213
|
+
if (key === 'extends' || protocol[key] === undefined) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
merged[key] = protocol[key];
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return merged;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The identifier step two should send, given a challenge.
|
|
225
|
+
*
|
|
226
|
+
* `contact` is what step one actually put on the wire (domain-completed);
|
|
227
|
+
* `rawContact` is what the user typed. Which one step two wants is the
|
|
228
|
+
* protocol's business, not the screen's.
|
|
229
|
+
*/
|
|
230
|
+
export const verifyContactFor = (resolved, challenge) => {
|
|
231
|
+
if (!challenge) {
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (resolved?.verifyUsesRawContact) {
|
|
236
|
+
return challenge.rawContact ?? challenge.contact ?? '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return challenge.contact ?? challenge.rawContact ?? '';
|
|
240
|
+
};
|