@visns-studio/visns-components 6.6.4 → 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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. 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
+ };