@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.
Files changed (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  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/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -1,50 +1,327 @@
1
1
  import '../styles/global.css';
2
2
 
3
- import React, { useState, useEffect } from 'react';
4
- import { useNavigate, useLocation } from 'react-router-dom';
3
+ import React, { useEffect, useMemo, useState } from 'react';
5
4
  import { toast } from 'react-toastify';
6
5
  import { Lock, ArrowLeft } from 'lucide-react';
7
6
 
8
7
  import CustomFetch from '../Fetch';
8
+ import useOptionalRouter from './useOptionalRouter';
9
+ import ClientAuthFrame from './ClientAuthFrame';
10
+ import { resolveClientPaths } from './authEndpoints';
11
+ import {
12
+ OTP_LENGTH,
13
+ clampOtp,
14
+ isCompleteOtp,
15
+ maskContact,
16
+ normaliseAuthError,
17
+ resendLabelFor,
18
+ syncCsrfFromResponse,
19
+ } from './authHelpers';
20
+ import { resolveClientProtocol, verifyContactFor } from './clientAuthProtocols';
9
21
 
10
22
  import styles from '../styles/ClientAuth.module.scss';
11
23
 
24
+ /**
25
+ * Client portal sign-in, step two: enter the code that was sent.
26
+ *
27
+ * Four things here are configurable rather than assumed, each because it
28
+ * blocked the Next.js portal's adoption in 6.7.0:
29
+ *
30
+ * - the WIRE PROTOCOL (`protocol`). The verify body used to be a hardcoded
31
+ * `{email, uuid, otp, previous_url}` and success used to mean
32
+ * `body.success`. ThroughLife's login-otp wants
33
+ * `{contact, otp_code, minimal_response}` and signals success with
34
+ * `error: ''` — under the old code a correct code read as a wrong one.
35
+ * - the CHALLENGE (`challenge`). This screen used to demand a `uuid` from
36
+ * router state and bounce back to step one without one. The contact
37
+ * protocol has no uuid at all, so what a challenge must contain is the
38
+ * protocol's business (`requiresChallengeId`), and where it comes from is
39
+ * the consumer's.
40
+ * - the SESSION (`onAuthenticated`). The bearer token that login-otp returns
41
+ * never left the component, so a consumer had nothing to put in its
42
+ * cookie. It is handed over now, alongside the user and the raw response.
43
+ * - NAVIGATION (`onNavigate`, `onBack`), because react-router is not a given.
44
+ *
45
+ * With none of those passed, this renders and behaves exactly as 6.6.3 did.
46
+ */
47
+
48
+ const DEFAULT_COPY = {
49
+ title: 'Verification Code',
50
+ subtitle: 'Please enter the 6-digit code sent to your email address:',
51
+ fieldLabel: 'Verification Code',
52
+ placeholder: 'Enter 6-digit code',
53
+ /** `showContactField` only. `{contact}` is the masked destination. */
54
+ contactFieldLabel: 'Contact',
55
+ sentTo: 'Code sent to: {contact}',
56
+ resendPrompt: "Didn't receive the code?",
57
+ resend: 'Resend Code',
58
+ /** The link's own label while a resend request is in flight. */
59
+ resendingLabel: 'Sending…',
60
+ /** `{seconds}` is substituted while a cooldown is running. */
61
+ resendIn: 'Resend in {seconds}s',
62
+ back: 'Back to Login',
63
+ devOtpLabel: 'DEV MODE - OTP',
64
+ devOtpAction: 'Auto-fill OTP',
65
+ /** Client-side validation. */
66
+ invalidLength: 'Please enter a valid 6-digit code',
67
+ /** Fallbacks when the server declines without saying why. */
68
+ invalidCode: 'Invalid verification code',
69
+ resendFailed:
70
+ 'Could not resend verification code. Please try again later.',
71
+ verifying: 'Verifying code...',
72
+ resending: 'Resending verification code...',
73
+ resent: 'Verification code resent to your email',
74
+ success: 'Login successful!',
75
+ };
76
+
77
+ /**
78
+ * Button labels — the 6.6.3 strings. An app wanting the portal's wording asks
79
+ * for it: labels={{ submit: 'Login', submitting: 'Please wait...' }}.
80
+ */
81
+ const DEFAULT_LABELS = {
82
+ submit: 'Verify',
83
+ submitting: 'Verifying...',
84
+ };
85
+
86
+ /** Simple `{token}` substitution, so copy stays one readable string. */
87
+ const fill = (template, values) =>
88
+ String(template ?? '').replace(/\{(\w+)\}/g, (match, key) =>
89
+ Object.prototype.hasOwnProperty.call(values, key)
90
+ ? String(values[key])
91
+ : match
92
+ );
93
+
12
94
  const ClientOTPVerify = ({
13
95
  logo,
14
96
  setSystemAuth,
15
97
  setUserProfile,
98
+ /** Endpoint map — `urls.verify` and `urls.login` (for the resend). */
16
99
  urls = {},
100
+ /** Route paths; `/client/*` unless overridden. See authEndpoints.js. */
101
+ paths,
102
+ /**
103
+ * Wire protocol: `'uuid'` (default, unchanged) or `'contact'` for
104
+ * ThroughLife's `/api/auth/*`. An object of overrides is accepted too.
105
+ */
106
+ protocol,
107
+ /**
108
+ * The challenge from step one, as ClientLogin's `onChallengeIssued` hands
109
+ * it over. Supplying it is what lets this screen render outside a router —
110
+ * without it, router state is read instead (the 6.6.3 hand-off).
111
+ */
112
+ challenge: challengeProp,
113
+ /** Merged over DEFAULT_COPY. */
114
+ copy: copyOverrides,
115
+ /** Merged over DEFAULT_LABELS. */
116
+ labels: labelOverrides,
117
+ /**
118
+ * Seconds to keep Resend disabled after a successful send. 60 is what the
119
+ * component has always done; the portal's own page has no cooldown at all,
120
+ * so pass 0 for parity with it.
121
+ */
122
+ resendCooldown = 60,
123
+ /** Show the server's `masked_contact` instead of the raw identifier. */
124
+ showMaskedContact = false,
125
+ /**
126
+ * Render a disabled contact field above the code field, echoing who is
127
+ * signing in, with the masked destination captioned beneath it — the
128
+ * layout the portal's own OTP step used before it adopted this component.
129
+ * The destination moves out of the sub-line when this is on, so it is
130
+ * never said twice.
131
+ */
132
+ showContactField = false,
133
+ /** Development-only OTP panel with an auto-fill button. */
134
+ showDevOtp = false,
135
+ /** Return to step one with the contact field still filled in. */
136
+ backKeepsContact = false,
137
+ /** Overrides the protocol's request-body key for the identifier. */
138
+ contactField,
139
+ /**
140
+ * `contact` protocol only: ask for the whitelisted user payload, which is
141
+ * small enough to live in a cookie. Pass false for the full model.
142
+ */
143
+ minimalResponse = true,
144
+ /**
145
+ * Called on a successful verification with `(user, token, rawResponse)`.
146
+ *
147
+ * WHEN THIS IS PRESENT THE COMPONENT DOES NOTHING ELSE: no setUserProfile,
148
+ * no setSystemAuth, no navigation. The consumer owns the session and where
149
+ * the browser goes, which is the only way an app can write its own cookie
150
+ * from the bearer token this screen receives. An app that wants the
151
+ * library's own behaviour simply does not pass it.
152
+ *
153
+ * `token` is null under the `uuid` protocol, whose backend sets a cookie
154
+ * server-side and returns no bearer token.
155
+ */
156
+ onAuthenticated,
157
+ /**
158
+ * The back link's action. When present it replaces the navigation to step
159
+ * one entirely — a single-page consumer clears its challenge state here.
160
+ * Receives the challenge, so it can restore what was typed.
161
+ */
162
+ onBack,
163
+ /**
164
+ * Navigation, when this screen still owns it. `(path, options) => void`.
165
+ * Falls back to react-router's navigate, then to a plain page load.
166
+ */
167
+ onNavigate,
168
+ /** Notified with the normalised message when a request is declined. */
169
+ onError,
170
+ /**
171
+ * 'card' (default, unchanged) or 'split' — the two-pane brand layout. See
172
+ * ClientAuthFrame; every field, hint and panel below is identical either
173
+ * way, including the contact field and the dev-OTP panel.
174
+ */
175
+ layout = 'card',
176
+ /** Split layout: the brand panel's photograph. */
177
+ loginBg,
178
+ /** Split layout: {eyebrow, name, tagline, footer}. */
179
+ branding,
17
180
  }) => {
18
- const navigate = useNavigate();
19
- const location = useLocation();
181
+ const { navigate, location } = useOptionalRouter();
182
+
183
+ const routes = resolveClientPaths(paths);
184
+ const wire = resolveClientProtocol(protocol);
185
+ const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
186
+ const labels = { ...DEFAULT_LABELS, ...(labelOverrides || {}) };
187
+ const field = contactField || wire.contactField;
188
+
189
+ /**
190
+ * The challenge, from the prop when there is one and from router state
191
+ * otherwise. The router-state branch reads the individual keys 6.6.3 wrote
192
+ * as well as the whole `challenge` object 6.8.0 writes, so a half-upgraded
193
+ * app — new verify screen, old login screen still in the bundle — still
194
+ * finds what it needs.
195
+ */
196
+ const challenge = useMemo(() => {
197
+ if (challengeProp) {
198
+ return challengeProp;
199
+ }
200
+
201
+ const state = location?.state;
202
+
203
+ if (!state) {
204
+ return null;
205
+ }
206
+
207
+ if (state.challenge) {
208
+ return state.challenge;
209
+ }
210
+
211
+ const rawContact = state.email ?? null;
212
+
213
+ if (!rawContact) {
214
+ return null;
215
+ }
216
+
217
+ return {
218
+ contact: state.contact ?? rawContact,
219
+ rawContact,
220
+ challengeId: state.uuid ?? null,
221
+ maskedContact: state.maskedContact ?? state.masked_contact ?? '',
222
+ contactMethod: state.contactMethod ?? state.contact_method ?? '',
223
+ devOtp: state.otp ?? null,
224
+ message: '',
225
+ previousUrl: state.previousUrl ?? '',
226
+ };
227
+ }, [challengeProp, location?.state]);
228
+
20
229
  const [isLoading, setIsLoading] = useState(false);
230
+ // Raised on a successful verification and never lowered: everything after
231
+ // that point leads somewhere else.
232
+ const [isLeaving, setIsLeaving] = useState(false);
233
+ const [verificationCode, setVerificationCode] = useState('');
234
+ const [codeClass, setCodeClass] = useState('');
235
+ const [resendDisabled, setResendDisabled] = useState(false);
236
+ const [isResending, setIsResending] = useState(false);
237
+ const [countdown, setCountdown] = useState(0);
238
+ // A resend under the contact protocol replaces the challenge (new masked
239
+ // contact, new dev OTP) without any navigation to carry it.
240
+ const [refreshed, setRefreshed] = useState(null);
241
+
242
+ // Anything in flight, or the screen on its way out. Every control keys off
243
+ // this, so no action here can be started twice.
244
+ const isBusy = isLoading || isLeaving;
245
+
246
+ const live = refreshed || challenge;
247
+ const rawContact = live?.rawContact ?? live?.contact ?? null;
248
+ const previousUrl = live?.previousUrl ?? '';
249
+ const devOtp = live?.devOtp ?? null;
250
+
251
+ /**
252
+ * Is there a challenge to answer at all?
253
+ *
254
+ * Under `uuid` a challenge without its id is useless, which is why the old
255
+ * code demanded one. Under `contact` there is no id in the contract, so
256
+ * demanding one bounced every user straight back to step one — the portal's
257
+ * first blocker. The protocol decides.
258
+ */
259
+ const hasChallenge = Boolean(
260
+ rawContact && (!wire.requiresChallengeId || live?.challengeId)
261
+ );
21
262
 
22
- // Get data from location state
23
- const email = location.state?.email || null;
24
- const uuid = location.state?.uuid || null;
25
- const previousUrl = location.state?.previousUrl || '';
26
- const devOtp = location.state?.otp || null; // For development testing
263
+ /** Move, by whichever mechanism this app actually has. */
264
+ const go = (path, options) => {
265
+ if (typeof onNavigate === 'function') {
266
+ onNavigate(path, options);
27
267
 
28
- // If no email or uuid, redirect to client login
268
+ return;
269
+ }
270
+
271
+ if (navigate) {
272
+ navigate(path, options);
273
+
274
+ return;
275
+ }
276
+
277
+ if (typeof window !== 'undefined') {
278
+ window.location.href = path;
279
+ }
280
+ };
281
+
282
+ /**
283
+ * Back to step one. With `onBack` the consumer owns it; otherwise the
284
+ * navigation happens here, carrying the identifier when
285
+ * `backKeepsContact` is set so retyping a mobile number to correct a
286
+ * mistyped code is not a second chore.
287
+ */
288
+ const handleBack = () => {
289
+ if (isBusy) {
290
+ return;
291
+ }
292
+
293
+ if (typeof onBack === 'function') {
294
+ onBack(live);
295
+
296
+ return;
297
+ }
298
+
299
+ go(
300
+ routes.login,
301
+ backKeepsContact ? { state: { email: rawContact } } : undefined
302
+ );
303
+ };
304
+
305
+ // Arriving without a usable challenge means there is nothing to answer.
306
+ // Only ever navigates when this screen owns navigation: a consumer holding
307
+ // the challenge in its own state renders step one instead of this, and
308
+ // must not be bounced by a screen it did not ask to mount.
29
309
  useEffect(() => {
30
- if (!email || !uuid) {
31
- const loginUrl = '/client/login';
32
- navigate(loginUrl);
310
+ if (hasChallenge || challengeProp || typeof onBack === 'function') {
311
+ return;
33
312
  }
34
- }, [email, uuid, navigate]);
313
+
314
+ go(routes.login);
315
+ // eslint-disable-next-line react-hooks/exhaustive-deps
316
+ }, [hasChallenge, challengeProp, routes.login]);
35
317
 
36
318
  // Auto-fill OTP in development mode
37
319
  useEffect(() => {
38
320
  if (process.env.NODE_ENV === 'development' && devOtp) {
39
- setVerificationCode(devOtp);
321
+ setVerificationCode(clampOtp(devOtp, OTP_LENGTH));
40
322
  }
41
323
  }, [devOtp]);
42
324
 
43
- const [verificationCode, setVerificationCode] = useState('');
44
- const [codeClass, setCodeClass] = useState('');
45
- const [resendDisabled, setResendDisabled] = useState(false);
46
- const [countdown, setCountdown] = useState(0);
47
-
48
325
  useEffect(() => {
49
326
  let timer;
50
327
  if (countdown > 0) {
@@ -55,62 +332,130 @@ const ClientOTPVerify = ({
55
332
  return () => clearTimeout(timer);
56
333
  }, [countdown]);
57
334
 
335
+ /** What the code was sent to, as the screen should say it. */
336
+ const destination = showMaskedContact
337
+ ? live?.maskedContact || maskContact(rawContact)
338
+ : rawContact;
339
+
340
+ /**
341
+ * The masked form, for the "Code sent to: …" line under the contact field.
342
+ * Unlike `destination` this does not consult `showMaskedContact`: that prop
343
+ * chooses what the sub-line says, and in this layout the sub-line has been
344
+ * replaced by the field, whose caption is masked either way.
345
+ */
346
+ const maskedDestination = live?.maskedContact || maskContact(rawContact);
347
+
348
+ /** The dev panel only ever appears outside production. */
349
+ const canShowDevOtp =
350
+ showDevOtp && devOtp && process.env.NODE_ENV !== 'production';
351
+
58
352
  const handleChange = (e) => {
59
- if (e) {
60
- const value = e.target.value;
61
- // Only allow digits and limit to 6 characters
62
- if (/^\d*$/.test(value) && value.length <= 6) {
63
- setVerificationCode(value);
64
- setCodeClass('');
65
- }
353
+ if (!e) {
354
+ return;
66
355
  }
356
+
357
+ setVerificationCode(clampOtp(e.target.value, OTP_LENGTH));
358
+ setCodeClass('');
67
359
  };
68
360
 
69
- const handleResendCode = async () => {
70
- if (resendDisabled) return;
361
+ const report = (error, fallback) => {
362
+ const message = normaliseAuthError(error, fallback);
71
363
 
72
- setResendDisabled(true);
73
- setCountdown(60); // 60 seconds cooldown
364
+ toast.error(message);
74
365
 
75
- toast.info('Resending verification code...', { autoClose: 2000 });
366
+ if (typeof onError === 'function') {
367
+ onError(message);
368
+ }
369
+
370
+ return message;
371
+ };
372
+
373
+ const handleResendCode = async () => {
374
+ if (isBusy || resendDisabled || isResending) return;
375
+
376
+ // Two separate reasons the link can be inert: the request is in flight
377
+ // (always), and the cooldown is running (only when one is configured).
378
+ // Keeping them apart is what lets `resendCooldown={0}` work — the old
379
+ // code parked `resendDisabled` on a countdown that, set to 0 while
380
+ // already 0, never re-rendered to clear it.
381
+ setIsResending(true);
382
+
383
+ if (resendCooldown > 0) {
384
+ setResendDisabled(true);
385
+ setCountdown(resendCooldown);
386
+ }
387
+
388
+ toast.info(copy.resending, { autoClose: 2000 });
76
389
 
77
390
  try {
78
- const loginUrl = urls.login;
79
- const result = await CustomFetch(loginUrl, 'POST', {
80
- email: email,
81
- resend: true,
82
- });
83
-
84
- if (result.data && result.data.success) {
85
- toast.success(
86
- result.data.message ||
87
- 'Verification code resent to your email'
88
- );
89
-
90
- // Update UUID with the new one
91
- if (result.data.uuid) {
92
- navigate('/client/verify', {
93
- state: {
94
- email: email,
95
- uuid: result.data.uuid,
96
- previousUrl: previousUrl,
97
- ...(process.env.NODE_ENV === 'development' && {
98
- otp: result.data.otp,
99
- }),
100
- },
101
- replace: true, // Replace current entry in history
102
- });
103
- }
104
- } else {
105
- toast.error(
106
- (result.data && result.data.message) ||
107
- 'Could not resend verification code. Please try again later.'
108
- );
391
+ const result = await CustomFetch(
392
+ urls.login,
393
+ 'POST',
394
+ wire.requestBody({
395
+ contact: live?.contact ?? rawContact,
396
+ contactField: field,
397
+ previousUrl,
398
+ resend: true,
399
+ })
400
+ );
401
+
402
+ setIsResending(false);
403
+
404
+ const body = result.data;
405
+
406
+ if (!wire.isSuccess(body)) {
407
+ report(body, copy.resendFailed);
109
408
  setResendDisabled(false);
110
409
  setCountdown(0);
410
+
411
+ return;
412
+ }
413
+
414
+ const read = wire.readChallenge(body);
415
+
416
+ toast.success(read.message || copy.resent);
417
+
418
+ // A fresh challenge, held locally. The 6.6.3 code re-navigated to
419
+ // carry it, which only worked because a uuid was the only thing
420
+ // that changed and a router was always present.
421
+ const next = {
422
+ ...live,
423
+ challengeId: read.challengeId ?? live?.challengeId ?? null,
424
+ maskedContact: read.maskedContact || live?.maskedContact || '',
425
+ contactMethod: read.contactMethod || live?.contactMethod || '',
426
+ devOtp:
427
+ process.env.NODE_ENV !== 'production'
428
+ ? read.devOtp ?? live?.devOtp ?? null
429
+ : null,
430
+ };
431
+
432
+ setRefreshed(next);
433
+ setVerificationCode('');
434
+ setCodeClass('');
435
+
436
+ // Router state, when that is where this screen's challenge came
437
+ // from. Local state alone is enough to keep working, but the 6.6.3
438
+ // screen replaced the history entry so a reload mid-challenge
439
+ // found the NEW id rather than the spent one — and that is still
440
+ // worth doing wherever it is possible.
441
+ if (!challengeProp && navigate && read.challengeId) {
442
+ navigate(routes.verify, {
443
+ replace: true,
444
+ state: {
445
+ email: rawContact,
446
+ contact: next.contact,
447
+ uuid: next.challengeId,
448
+ challenge: next,
449
+ maskedContact: next.maskedContact,
450
+ contactMethod: next.contactMethod,
451
+ previousUrl,
452
+ ...(next.devOtp ? { otp: next.devOtp } : {}),
453
+ },
454
+ });
111
455
  }
112
456
  } catch (error) {
113
- toast.error(String(error));
457
+ setIsResending(false);
458
+ report(error, copy.resendFailed);
114
459
  setResendDisabled(false);
115
460
  setCountdown(0);
116
461
  }
@@ -119,74 +464,165 @@ const ClientOTPVerify = ({
119
464
  const handleSubmit = async (e) => {
120
465
  e.preventDefault();
121
466
 
122
- if (!verificationCode.trim() || verificationCode.length !== 6) {
467
+ if (isBusy) {
468
+ return;
469
+ }
470
+
471
+ if (!isCompleteOtp(verificationCode, OTP_LENGTH)) {
123
472
  setCodeClass('inputError');
124
- toast.error('Please enter a valid 6-digit code');
473
+ toast.error(copy.invalidLength);
125
474
  return;
126
475
  }
127
476
 
128
477
  setIsLoading(true);
129
- toast.info('Verifying code...', { autoClose: 2000 });
478
+ toast.info(copy.verifying, { autoClose: 2000 });
130
479
 
131
480
  try {
132
- const verifyUrl = urls.verify;
133
- const result = await CustomFetch(verifyUrl, 'POST', {
134
- email: email,
135
- uuid: uuid,
136
- otp: verificationCode,
137
- previous_url: previousUrl,
138
- });
481
+ const result = await CustomFetch(
482
+ urls.verify,
483
+ 'POST',
484
+ wire.verifyBody({
485
+ // Which form of the identifier goes on the wire is the
486
+ // protocol's call: ThroughLife's login-otp wants the value
487
+ // as typed, even though request-otp got the completed one.
488
+ contact: verifyContactFor(wire, live),
489
+ contactField: field,
490
+ code: verificationCode,
491
+ challengeId: live?.challengeId ?? null,
492
+ previousUrl,
493
+ minimalResponse,
494
+ })
495
+ );
496
+
497
+ const body = result.data;
498
+
499
+ // Cleared only on failure: every success path leads off this
500
+ // screen, and a button that snaps back to "Verify" in between
501
+ // invites a second submit against a code that has just been spent.
502
+ if (!wire.isSuccess(body)) {
503
+ setIsLoading(false);
504
+ setCodeClass('inputError');
505
+ report(body, copy.invalidCode);
139
506
 
140
- setIsLoading(false);
507
+ return;
508
+ }
509
+
510
+ const session = wire.readSession(body);
511
+
512
+ // Same hazard as the staff 2FA screen: under the uuid protocol
513
+ // this continues into the portal without a reload, so a rotated
514
+ // token has to reach the meta tag before the consumer's callbacks
515
+ // start making requests. Inert under the contact protocol, whose
516
+ // backend authenticates with a bearer token and sends no
517
+ // csrf_token at all.
518
+ syncCsrfFromResponse(body);
519
+
520
+ toast.success(session.message || copy.success);
521
+
522
+ // From here the screen is a waiting room. Raised before the
523
+ // callback, so a consumer that navigates synchronously inside
524
+ // `onAuthenticated` still never shows a live-looking form.
525
+ setIsLeaving(true);
526
+
527
+ // The consumer owns the session and the destination. This is the
528
+ // only path on which the bearer token reaches an app that has to
529
+ // write it into a cookie.
530
+ if (typeof onAuthenticated === 'function') {
531
+ onAuthenticated(session.user, session.token, body);
141
532
 
142
- // Check if the result contains an error message
143
- if (!result.data || !result.data.success) {
144
- setCodeClass('inputError');
145
- toast.error(
146
- (result.data && result.data.message) ||
147
- 'Invalid verification code'
148
- );
149
533
  return;
150
534
  }
151
535
 
152
- // Successful verification
153
- setUserProfile(result.data.user);
154
- toast.success(result.data.message || 'Login successful!');
536
+ if (typeof setUserProfile === 'function') {
537
+ setUserProfile(session.user);
538
+ }
539
+
540
+ if (typeof setSystemAuth === 'function') {
541
+ setSystemAuth(true);
542
+ }
155
543
 
156
- // Set authentication state first
157
- setSystemAuth(true);
544
+ if (session.redirectUrl && typeof window !== 'undefined') {
545
+ window.location.href = session.redirectUrl;
158
546
 
159
- if (result.data.redirect_url && result.data.redirect_url !== '') {
160
- window.location.href = result.data.redirect_url;
161
- } else {
162
- // Use replace: true to avoid back button issues
163
- navigate('/client/portal', { replace: true });
547
+ return;
164
548
  }
549
+
550
+ // Use replace: true to avoid back button issues
551
+ go(routes.portal, { replace: true });
165
552
  } catch (error) {
166
553
  setIsLoading(false);
167
554
  setCodeClass('inputError');
168
- toast.error(String(error));
555
+ report(error, copy.invalidCode);
169
556
  }
170
557
  };
171
558
 
172
559
  return (
173
- <div className={styles.container}>
174
- <div className={styles.loginBox}>
175
- <img src={logo} alt="Client Portal" className={styles.logo} />
176
-
177
- <h1 className={styles.title}>Verification Code</h1>
560
+ <ClientAuthFrame
561
+ layout={layout}
562
+ styles={styles}
563
+ loginBg={loginBg}
564
+ branding={branding}
565
+ >
566
+ <img src={logo} alt="Client Portal" className={styles.logo} />
567
+
568
+ <h1 className={styles.title}>{copy.title}</h1>
178
569
  <p className={styles.subtitle}>
179
- Please enter the 6-digit code sent to your email address:{' '}
180
- <span className={styles.emailHighlight}>{email}</span>
570
+ {copy.subtitle}
571
+ {/* The destination moves into the contact field's caption
572
+ when there is one, rather than being said twice. */}
573
+ {showContactField ? null : (
574
+ <>
575
+ {' '}
576
+ <span className={styles.emailHighlight}>
577
+ {destination}
578
+ </span>
579
+ </>
580
+ )}
181
581
  </p>
182
582
 
183
583
  <form onSubmit={handleSubmit}>
584
+ {/* A read-only echo of who is signing in, above the code
585
+ field. It carries no information the screen does not
586
+ already have, which is exactly why it is opt-in — but
587
+ on a page reached by typing a contact and then waiting
588
+ for a text, seeing that contact still there is what
589
+ makes the second step read as the same errand as the
590
+ first. Matches the portal's pre-replatform layout. */}
591
+ {showContactField ? (
592
+ <div className={styles.formGroup}>
593
+ <label
594
+ htmlFor="otpContact"
595
+ className={styles.inputLabel}
596
+ >
597
+ {copy.contactFieldLabel}
598
+ </label>
599
+ <input
600
+ id="otpContact"
601
+ type="text"
602
+ name="contact"
603
+ value={rawContact || ''}
604
+ className={`${styles.input} ${styles.disabledInput}`}
605
+ disabled
606
+ readOnly
607
+ // Never submitted: the verify body is built by
608
+ // the protocol from the challenge, not read
609
+ // off the form.
610
+ tabIndex={-1}
611
+ />
612
+ <p className={styles.contactInfo}>
613
+ {fill(copy.sentTo, {
614
+ contact: maskedDestination,
615
+ })}
616
+ </p>
617
+ </div>
618
+ ) : null}
619
+
184
620
  <div className={styles.formGroup}>
185
621
  <label
186
622
  htmlFor="verificationCode"
187
623
  className={styles.inputLabel}
188
624
  >
189
- Verification Code
625
+ {copy.fieldLabel}
190
626
  </label>
191
627
  <input
192
628
  id="verificationCode"
@@ -197,26 +633,50 @@ const ClientOTPVerify = ({
197
633
  className={`${styles.input} ${styles.otpInput} ${
198
634
  codeClass === 'inputError' ? styles.error : ''
199
635
  }`}
200
- placeholder="Enter 6-digit code"
636
+ placeholder={copy.placeholder}
201
637
  autoComplete="one-time-code"
202
638
  inputMode="numeric"
203
- maxLength="6"
639
+ maxLength={OTP_LENGTH}
640
+ disabled={isBusy}
204
641
  />
205
642
  <span className={styles.inputIcon}>
206
643
  <Lock strokeWidth={2} size={18} />
207
644
  </span>
645
+
646
+ {/* Never in production: `canShowDevOtp` checks
647
+ NODE_ENV as well as the opt-in prop, so a build that
648
+ leaves the prop on cannot print a live code. */}
649
+ {canShowDevOtp ? (
650
+ <div className={styles.devOtp}>
651
+ <strong>
652
+ {copy.devOtpLabel}: {devOtp}
653
+ </strong>
654
+ <button
655
+ type="button"
656
+ onClick={() =>
657
+ setVerificationCode(
658
+ clampOtp(devOtp, OTP_LENGTH)
659
+ )
660
+ }
661
+ className={styles.devOtpFill}
662
+ disabled={isBusy}
663
+ >
664
+ {copy.devOtpAction}
665
+ </button>
666
+ </div>
667
+ ) : null}
208
668
  </div>
209
669
 
210
670
  <button
211
671
  className={styles.button}
212
672
  type="submit"
213
- disabled={isLoading}
673
+ disabled={isBusy}
214
674
  >
215
- {isLoading ? 'Verifying...' : 'Verify'}
675
+ {isBusy ? labels.submitting : labels.submit}
216
676
  </button>
217
677
 
218
678
  <div className={styles.linkText}>
219
- Didn't receive the code?{' '}
679
+ {copy.resendPrompt}{' '}
220
680
  <a
221
681
  href="#"
222
682
  onClick={(e) => {
@@ -224,14 +684,26 @@ const ClientOTPVerify = ({
224
684
  handleResendCode();
225
685
  }}
226
686
  className={
227
- resendDisabled
687
+ isBusy || resendDisabled || isResending
228
688
  ? styles.disabled
229
689
  : styles.resendLink
230
690
  }
691
+ // An <a> cannot be `disabled`; this is what tells
692
+ // assistive tech the link is currently inert, and
693
+ // the handler refuses to run regardless.
694
+ aria-disabled={
695
+ isBusy || resendDisabled || isResending
696
+ }
231
697
  >
232
- {resendDisabled
233
- ? `Resend in ${countdown}s`
234
- : 'Resend Code'}
698
+ {resendLabelFor({
699
+ isResending,
700
+ countdown,
701
+ labels: {
702
+ resending: copy.resendingLabel,
703
+ resendIn: copy.resendIn,
704
+ resend: copy.resend,
705
+ },
706
+ })}
235
707
  </a>
236
708
  </div>
237
709
 
@@ -240,17 +712,17 @@ const ClientOTPVerify = ({
240
712
  href="#"
241
713
  onClick={(e) => {
242
714
  e.preventDefault();
243
- navigate('/client/login');
715
+ handleBack();
244
716
  }}
245
717
  className={styles.backLink}
718
+ aria-disabled={isBusy}
246
719
  >
247
- <ArrowLeft strokeWidth={2} size={16} /> Back to
248
- Login
720
+ <ArrowLeft strokeWidth={2} size={16} />{' '}
721
+ {copy.back}
249
722
  </a>
250
723
  </div>
251
724
  </form>
252
- </div>
253
- </div>
725
+ </ClientAuthFrame>
254
726
  );
255
727
  };
256
728