@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
@@ -1,344 +1,522 @@
1
1
  import '../styles/global.css';
2
+ import './authFont';
2
3
 
3
- import React, { useState, useEffect } from 'react';
4
+ import React, { useCallback, useEffect, useState } from 'react';
4
5
  import { useNavigate, useLocation } from 'react-router-dom';
5
- import Reveal from '../utils/Reveal';
6
6
  import { toast } from 'react-toastify';
7
- import { Lock } from 'lucide-react';
7
+ import { ArrowLeft, Check, MessageSquare } from 'lucide-react';
8
8
 
9
9
  import CustomFetch from '../Fetch';
10
+ import AuthShell from './AuthShell';
11
+ import AuthLoading from './AuthLoading';
12
+ import { resolveAuthEndpoints } from './authEndpoints';
13
+ import {
14
+ OTP_LENGTH,
15
+ clampOtp,
16
+ hasChallengeState,
17
+ isCompleteOtp,
18
+ normaliseAuthError,
19
+ resendLabelFor,
20
+ syncCsrfFromResponse,
21
+ twoFactorSuccessTarget,
22
+ } from './authHelpers';
10
23
 
11
24
  import styles from '../styles/TwoFactorAuth.module.scss';
12
25
 
13
- const TwoFactorAuth = ({ logo, setSystemAuth, setUserProfile }) => {
26
+ /**
27
+ * Two-factor verification — the screen between a correct password and the app.
28
+ *
29
+ * Two modes, because the two second factors ask the user for different things:
30
+ *
31
+ * - `totp` (the default, and what every 6.6.x deployment gets): the code is
32
+ * already on the user's phone in an authenticator app. There is nothing to
33
+ * re-send and nothing to expire, so the screen is the field and a button.
34
+ * - `code`: the server texted a code. That code can go astray and it does
35
+ * expire, so the screen says where it went, says how long it lasts, and
36
+ * offers to send another.
37
+ *
38
+ * Backend contract, unchanged:
39
+ * POST {authenticate} -> {error, previous, user, requires_two_factor}
40
+ * POST {challenge} <- {code, previous_url, remember}, -> the user object
41
+ * POST {resend} <- {} (code mode only), -> {error}
42
+ *
43
+ * Login hands the challenge over through the router as
44
+ * `{challenge, userData, maskedContact, previousUrl, remember}`.
45
+ *
46
+ * `userData` IS NULL ON A CODE-DRIVER CHALLENGE and nothing here may assume
47
+ * otherwise. The packages' AuthController returns `user: null` deliberately —
48
+ * on that driver the account is identified by a code sent out of band, so
49
+ * echoing the record would hand an unauthenticated caller a user lookup. Only
50
+ * TOTP sends the user, because its challenge screen renders the account it is
51
+ * challenging.
52
+ *
53
+ * What marks a challenge as being in progress is therefore the presence of the
54
+ * handed-over state itself, not any field inside it.
55
+ */
56
+
57
+ /** Copy, all of it overridable so an app can speak in its own voice. */
58
+ const DEFAULT_COPY = {
59
+ title: 'Two-Factor Authentication',
60
+ /** Shown under the title in `totp` mode. */
61
+ totpSubtitle:
62
+ 'Please enter the 6-digit code from your authenticator app.',
63
+ /** Shown under the title in `code` mode. */
64
+ codeSubtitle:
65
+ 'We have sent a 6-digit code by text message. Enter it below to continue.',
66
+ /** Prefix for the masked destination chip in `code` mode. */
67
+ sentTo: 'Sent to',
68
+ fieldLabel: 'Verification Code',
69
+ submit: 'Verify',
70
+ submitting: 'Verifying...',
71
+ /** `code` mode only. `{minutes}` is substituted. */
72
+ expiry: 'This code expires in {minutes} minutes.',
73
+ resend: 'Resend code',
74
+ resending: 'Sending…',
75
+ /** `{seconds}` is substituted while a cooldown is running. */
76
+ resendIn: 'Resend in {seconds}s',
77
+ resent: 'A new code is on its way.',
78
+ help: 'Having trouble? Contact your administrator for assistance.',
79
+ back: 'Back to Login',
80
+ remembered: 'You will stay signed in on this device.',
81
+ /** Caption on the full-screen curtain during the hand-off to the app. */
82
+ leaving: 'Signing you in…',
83
+ /** Client-side validation. */
84
+ invalidLength: 'Please enter a valid 6-digit code',
85
+ /** Fallbacks when the server says nothing usable. */
86
+ failed: 'Invalid verification code. Please try again.',
87
+ expired: 'Your session has expired. Please log in again.',
88
+ resendFailed: 'Could not send a new code. Please try again.',
89
+ };
90
+
91
+ /** The server's wording for a challenge attempted without a live session. */
92
+ const SESSION_EXPIRED = 'Invalid two-factor authentication session';
93
+
94
+ /**
95
+ * The `successPath` value that means "let GenericAuth take it from here"
96
+ * rather than "hard-navigate to this URL".
97
+ */
98
+ const DEFAULT_SUCCESS_PATH = '/';
99
+
100
+ /** How long the expiry toast is left up before the bounce to /login. */
101
+ const REDIRECT_MS = 1500;
102
+
103
+ /** Simple `{token}` substitution, so copy stays one readable string. */
104
+ const fill = (template, values) =>
105
+ String(template ?? '').replace(/\{(\w+)\}/g, (match, key) =>
106
+ Object.prototype.hasOwnProperty.call(values, key)
107
+ ? String(values[key])
108
+ : match
109
+ );
110
+
111
+ const TwoFactorAuth = ({
112
+ logo,
113
+ loginBg,
114
+ setSystemAuth,
115
+ setUserProfile,
116
+ config = {},
117
+ /** 'totp' (default, unchanged) or 'code' for a texted one-time code. */
118
+ mode = 'totp',
119
+ /** Merged over the 6.6.3 literals — see authEndpoints.js. */
120
+ endpoints,
121
+ /** Merged over DEFAULT_COPY above. */
122
+ copy: copyOverrides,
123
+ /** `code` mode: how long the server says a code lasts, for the expiry line. */
124
+ expiryMinutes = 10,
125
+ /**
126
+ * `code` mode: seconds to keep Resend disabled after a successful send.
127
+ * 0 (the default) leaves it disabled only while the request is in flight,
128
+ * which is the behaviour the brief asks for — a user who did not receive
129
+ * the first text should not be made to wait to ask for a second.
130
+ */
131
+ resendCooldown = 0,
132
+ /** Where "Back to Login" and the session-expiry bounce go. */
133
+ loginPath = '/login',
134
+ /** Where a successful verification lands when no previousUrl was carried. */
135
+ successPath = DEFAULT_SUCCESS_PATH,
136
+ }) => {
14
137
  const navigate = useNavigate();
15
138
  const location = useLocation();
16
- const [isLoading, setIsLoading] = useState(false);
17
139
 
18
- // Get user data from location state
19
- const userData = location.state?.userData || null;
20
- const previousUrl = location.state?.previousUrl || '';
21
- const remember = location.state?.remember || false;
140
+ const urls = resolveAuthEndpoints(endpoints);
141
+ const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
142
+ const branding = config.branding || {};
143
+ const isCodeMode = mode === 'code';
22
144
 
23
- // If no userData, redirect to login
145
+ const [isLoading, setIsLoading] = useState(false);
146
+ const [isResending, setIsResending] = useState(false);
147
+ // Raised the moment a verification succeeds and never lowered: everything
148
+ // after that point leads off this screen, and the user should be told so
149
+ // rather than left looking at a form that will not respond.
150
+ const [isLeaving, setIsLeaving] = useState(false);
151
+ const [cooldown, setCooldown] = useState(0);
152
+
153
+ // Handed over by Login through the router.
154
+ const handover = location.state ?? null;
155
+ // Null on a code-driver challenge, by design: there the account is
156
+ // identified by a code sent out of band, so the server does not echo the
157
+ // record back to an unauthenticated caller. Nothing on this screen may
158
+ // assume it is present.
159
+ const userData = handover?.userData || null;
160
+ const previousUrl = handover?.previousUrl || '';
161
+ const remember = handover?.remember || false;
162
+ // Only `code` mode has anywhere to show it. Not currently sent by the
163
+ // packages' AuthController on either driver — neither the challenge
164
+ // response nor the resend carries one — so this is normally '' and the
165
+ // destination chip is simply not rendered. Read from three places so a
166
+ // backend that grows the field works without a change here.
167
+ const maskedContact =
168
+ handover?.maskedContact ||
169
+ handover?.masked_contact ||
170
+ userData?.masked_contact ||
171
+ '';
172
+
173
+ /**
174
+ * Is there a challenge in progress?
175
+ *
176
+ * The handed-over STATE is the marker, never the user payload. Keying this
177
+ * off `userData` bounced every code-driver sign-in straight back to the
178
+ * login screen — the SMS arrived and the screen to type it into never
179
+ * appeared, because that driver answers with `user: null`.
180
+ *
181
+ * `challenge: true` is what Login sends now; `userData` is accepted on its
182
+ * own so a consumer still running an older Login (or driving this screen
183
+ * itself) keeps working.
184
+ */
185
+ const hasChallenge = hasChallengeState(handover);
186
+
187
+ // No state at all means a direct URL hit or a reload, and there is nothing
188
+ // to answer. That — not the shape of the payload — is the only reason to
189
+ // bounce.
24
190
  useEffect(() => {
25
- if (!userData) {
26
- navigate('/login');
191
+ if (!hasChallenge) {
192
+ navigate(loginPath);
27
193
  }
28
- }, [userData, navigate]);
194
+ }, [hasChallenge, navigate, loginPath]);
29
195
 
30
196
  const [verificationCode, setVerificationCode] = useState('');
31
197
  const [codeClass, setCodeClass] = useState('');
32
198
 
199
+ /** Tick the resend cooldown down to zero, when one is configured. */
200
+ useEffect(() => {
201
+ if (cooldown <= 0) {
202
+ return undefined;
203
+ }
204
+
205
+ const timer = setTimeout(() => setCooldown((s) => s - 1), 1000);
206
+
207
+ return () => clearTimeout(timer);
208
+ }, [cooldown]);
209
+
33
210
  const handleChange = (e) => {
34
- if (e) {
35
- const value = e.target.value;
36
- // Only allow digits and limit to 6 characters
37
- if (/^\d*$/.test(value) && value.length <= 6) {
38
- setVerificationCode(value);
211
+ if (!e) {
212
+ return;
213
+ }
214
+
215
+ // Strip rather than reject, so a code pasted out of a text message as
216
+ // "123 456" lands as 123456 instead of being dropped.
217
+ setVerificationCode(clampOtp(e.target.value, OTP_LENGTH));
218
+ setCodeClass('');
219
+ };
220
+
221
+ /** Toast, then bounce to the login screen — the challenge is unrecoverable. */
222
+ const bounceToLogin = useCallback(() => {
223
+ setCodeClass('inputError');
224
+ toast.error(copy.expired);
225
+ // The toast is deliberately given time to be read, and a screen that
226
+ // sits inert for a second and a half without saying why reads as a
227
+ // hang. The curtain says why.
228
+ setIsLeaving(true);
229
+
230
+ setTimeout(() => {
231
+ navigate(loginPath);
232
+ }, REDIRECT_MS);
233
+ }, [copy.expired, loginPath, navigate]);
234
+
235
+ /** `code` mode: ask the server for another text. */
236
+ const handleResend = () => {
237
+ if (isResending || cooldown > 0) {
238
+ return;
239
+ }
240
+
241
+ setIsResending(true);
242
+
243
+ CustomFetch(
244
+ urls.twoFactorResend,
245
+ 'POST',
246
+ {},
247
+ (result) => {
248
+ setIsResending(false);
249
+
250
+ const failure =
251
+ (result && typeof result === 'object' && result.error) ||
252
+ '';
253
+
254
+ if (failure) {
255
+ toast.error(failure);
256
+
257
+ return;
258
+ }
259
+
260
+ setVerificationCode('');
39
261
  setCodeClass('');
262
+ setCooldown(resendCooldown > 0 ? resendCooldown : 0);
263
+ toast.success(result?.message || copy.resent);
264
+ },
265
+ (error) => {
266
+ setIsResending(false);
267
+
268
+ const message = normaliseAuthError(error, copy.resendFailed);
269
+
270
+ if (message.includes(SESSION_EXPIRED)) {
271
+ bounceToLogin();
272
+
273
+ return;
274
+ }
275
+
276
+ toast.error(message);
40
277
  }
41
- }
278
+ );
42
279
  };
43
280
 
44
281
  const handleSubmit = (e) => {
45
- if (e) {
46
- e.preventDefault();
282
+ if (!e) {
283
+ return;
284
+ }
47
285
 
48
- if (!verificationCode.trim() || verificationCode.length !== 6) {
49
- setCodeClass('inputError');
50
- toast.error('Please enter a valid 6-digit code');
51
- return;
52
- }
286
+ e.preventDefault();
53
287
 
54
- setIsLoading(true);
55
- toast.info('Verifying code...', { autoClose: 2000 });
56
-
57
- CustomFetch(
58
- '/login/two-factor-challenge',
59
- 'POST',
60
- {
61
- code: verificationCode,
62
- previous_url: previousUrl,
63
- remember: remember,
64
- },
65
- (result) => {
288
+ // The button is disabled too; this also covers Enter in the field.
289
+ if (isLoading || isLeaving) {
290
+ return;
291
+ }
292
+
293
+ if (!isCompleteOtp(verificationCode, OTP_LENGTH)) {
294
+ setCodeClass('inputError');
295
+ toast.error(copy.invalidLength);
296
+
297
+ return;
298
+ }
299
+
300
+ setIsLoading(true);
301
+ toast.info('Verifying code...', { autoClose: 2000 });
302
+
303
+ CustomFetch(
304
+ urls.twoFactorChallenge,
305
+ 'POST',
306
+ {
307
+ code: verificationCode,
308
+ previous_url: previousUrl,
309
+ remember: remember,
310
+ },
311
+ (result) => {
312
+ // NOTE: `isLoading` is deliberately NOT cleared up front. It
313
+ // is cleared on each failure branch instead, so that on the
314
+ // success path the button stays in its pending state right
315
+ // through to the transition. Clearing it here was the reported
316
+ // bug: the label snapped back to "Verify", the screen sat
317
+ // there looking idle, and half a second later the browser
318
+ // reloaded out from under it.
319
+
320
+ // A 200 carrying an `error` is how this endpoint reports a
321
+ // wrong code; anything else is the user object. Only `error`
322
+ // is inspected — the success body is a user record, and a user
323
+ // record is perfectly entitled to carry a `message` field.
324
+ const failure =
325
+ typeof result === 'string' &&
326
+ result.includes(SESSION_EXPIRED)
327
+ ? SESSION_EXPIRED
328
+ : (result && typeof result === 'object' && result.error) ||
329
+ '';
330
+
331
+ if (failure) {
66
332
  setIsLoading(false);
67
333
 
68
- // Log the result to help with debugging
69
- console.log('2FA Result:', result);
70
-
71
- // Check if the result contains an error message
72
- if (result && result.error) {
73
- setCodeClass('inputError');
74
- console.log('Found error in result:', result.error);
75
-
76
- // Check if it's a session timeout error
77
- if (
78
- result.error ===
79
- 'Invalid two-factor authentication session.'
80
- ) {
81
- console.log(
82
- 'Session timeout detected, redirecting to login'
83
- );
84
- toast.error(
85
- 'Your session has expired. Please log in again.'
86
- );
87
-
88
- // Redirect to login page
89
- setTimeout(() => {
90
- navigate('/login');
91
- }, 1500); // Short delay to allow the toast to be seen
92
- } else {
93
- // Display the error message
94
- toast.error(result.error);
95
- }
96
- return;
97
- }
334
+ const message = normaliseAuthError(failure, copy.failed);
335
+
336
+ if (message.includes(SESSION_EXPIRED)) {
337
+ bounceToLogin();
98
338
 
99
- // Additional check for error in different format
100
- if (
101
- typeof result === 'string' &&
102
- result.includes(
103
- 'Invalid two-factor authentication session'
104
- )
105
- ) {
106
- console.log(
107
- 'Session timeout detected in string result, redirecting to login'
108
- );
109
- setCodeClass('inputError');
110
- toast.error(
111
- 'Your session has expired. Please log in again.'
112
- );
113
-
114
- // Redirect to login page
115
- setTimeout(() => {
116
- navigate('/login');
117
- }, 1500);
118
339
  return;
119
340
  }
120
341
 
121
- // If we get here, it means we received a 200 response with the user object
122
- console.log(
123
- 'Authentication successful, updating user profile'
124
- );
125
- toast.success('Two-factor authentication successful');
126
-
127
- // Update the user profile with the received data
128
- setUserProfile(result);
129
-
130
- // Set authentication state to true
131
- setSystemAuth(true);
132
-
133
- // Use a small delay to ensure state updates are processed
134
- setTimeout(() => {
135
- // Navigate based on previous URL if available
136
- if (previousUrl && previousUrl !== '') {
137
- window.location.href = previousUrl;
138
- } else {
139
- // Use window.location for a full page refresh to ensure session is recognized
140
- window.location.href = '/';
141
- }
142
- }, 500);
143
- },
144
- (error) => {
145
- setIsLoading(false);
146
342
  setCodeClass('inputError');
343
+ toast.error(message);
147
344
 
148
- // Log the error to help with debugging
149
- console.log('2FA Error:', error);
345
+ return;
346
+ }
150
347
 
151
- // Log more detailed information about the error response
152
- if (error.response) {
153
- console.log('Error Response:', error.response);
154
- console.log(
155
- 'Error Response Data:',
156
- error.response.data
157
- );
158
- }
348
+ toast.success('Two-factor authentication successful');
349
+
350
+ // THE reason this helper exists. A challenge used to end in a
351
+ // full page load, which fetched a new document and a new meta
352
+ // tag; handing off without one keeps the document, so a token
353
+ // rotated by this response has to be written into the tag
354
+ // here — before the callbacks below hand control to an app
355
+ // that will immediately start making requests with it.
356
+ syncCsrfFromResponse(result);
357
+
358
+ // The curtain goes up before anything else, and never comes
359
+ // down: from here the screen is a waiting room, and every path
360
+ // out of it ends somewhere other than this component.
361
+ setIsLeaving(true);
362
+ setUserProfile(result);
363
+ setSystemAuth(true);
364
+
365
+ // An arbitrary URL from before sign-in — possibly outside the
366
+ // SPA entirely — so it still needs a real navigation. The
367
+ // curtain above covers the whole of it, which is what the
368
+ // bare `window.location.href` never did: that left a live-
369
+ // looking form on screen until the browser got round to
370
+ // unloading it.
371
+ const target = twoFactorSuccessTarget({
372
+ previousUrl,
373
+ successPath,
374
+ defaultSuccessPath: DEFAULT_SUCCESS_PATH,
375
+ });
376
+
377
+ if (target.mode === 'hard') {
378
+ window.location.href = target.url;
379
+
380
+ return;
381
+ }
159
382
 
160
- // Extract the error message from the response
161
- let errorMessage =
162
- 'Invalid verification code. Please try again.';
163
-
164
- // Direct check for the specific error we're looking for
165
- // This is a temporary solution to force the redirect
166
- if (
167
- error &&
168
- error.message === 'Request failed with status code 401'
169
- ) {
170
- // Force redirect to login for 401 errors
171
- console.log('Detected 401 error, redirecting to login');
172
- toast.error(
173
- 'Your session has expired. Please log in again.'
174
- );
175
- setTimeout(() => {
176
- navigate('/login');
177
- }, 1500);
178
- return;
179
- }
383
+ // Otherwise: no reload at all. Setting the auth state is what
384
+ // GenericAuth watches — it drops its own curtain over this one
385
+ // (the same component, so nothing changes on screen), re-reads
386
+ // the profile with the session the challenge just issued, and
387
+ // performs the one navigation to '/'. Exactly the path Login's
388
+ // non-2FA success branch takes.
389
+ //
390
+ // The old code hard-reloaded here instead, on the theory that
391
+ // the new session cookie needed a fresh document. It does not:
392
+ // the cookie is already set by the time this response lands,
393
+ // and GenericAuth's own profile request carries it.
394
+ },
395
+ (error) => {
396
+ setIsLoading(false);
397
+ setCodeClass('inputError');
180
398
 
181
- try {
182
- // Try to parse the error response
183
- if (error.response && error.response.data) {
184
- console.log(
185
- 'Response data type:',
186
- typeof error.response.data
187
- );
188
- console.log('Response data:', error.response.data);
189
-
190
- // If data is a string, use it directly
191
- if (typeof error.response.data === 'string') {
192
- errorMessage = error.response.data;
193
- }
194
- // If data is an object with an error property
195
- else if (typeof error.response.data === 'object') {
196
- if (error.response.data.error) {
197
- errorMessage = error.response.data.error;
198
- console.log(
199
- 'Found error in object:',
200
- errorMessage
201
- );
202
- } else if (error.response.data.message) {
203
- errorMessage = error.response.data.message;
204
- console.log(
205
- 'Found message in object:',
206
- errorMessage
207
- );
208
- }
209
- }
210
- // If data is JSON string, try to parse it
211
- else if (
212
- typeof error.response.data === 'string' &&
213
- error.response.data.includes('{')
214
- ) {
215
- const parsedData = JSON.parse(
216
- error.response.data
217
- );
218
- if (parsedData.error) {
219
- errorMessage = parsedData.error;
220
- console.log(
221
- 'Found error in parsed JSON:',
222
- errorMessage
223
- );
224
- }
225
- }
226
- }
227
- } catch (e) {
228
- console.error('Error parsing error response:', e);
229
- }
399
+ // A 401 here is always a dead challenge — the code itself comes
400
+ // back as a 200 with an `error`, never as a status code.
401
+ const isUnauthorised =
402
+ error?.response?.status === 401 ||
403
+ (typeof error?.message === 'string' &&
404
+ error.message.includes('401'));
230
405
 
231
- console.log('Extracted error message:', errorMessage);
232
-
233
- // Check if it's a session timeout error
234
- if (
235
- errorMessage ===
236
- 'Invalid two-factor authentication session.' ||
237
- errorMessage.includes(
238
- 'Invalid two-factor authentication session'
239
- )
240
- ) {
241
- toast.error(
242
- 'Your session has expired. Please log in again.'
243
- );
244
- // Redirect to login page
245
- setTimeout(() => {
246
- navigate('/login');
247
- }, 1500); // Short delay to allow the toast to be seen
248
- } else {
249
- toast.error(errorMessage);
250
- }
406
+ const message = normaliseAuthError(error, copy.failed);
407
+
408
+ if (isUnauthorised || message.includes(SESSION_EXPIRED)) {
409
+ bounceToLogin();
410
+
411
+ return;
251
412
  }
252
- );
253
- }
413
+
414
+ toast.error(message);
415
+ }
416
+ );
254
417
  };
255
418
 
419
+ const resendLabel = resendLabelFor({
420
+ isResending,
421
+ countdown: cooldown,
422
+ labels: copy,
423
+ });
424
+
425
+ // Anything in flight, or the screen on its way out, makes every control
426
+ // here inert — a second submit during either would either double-spend the
427
+ // code or race the navigation.
428
+ const isBusy = isLoading || isLeaving;
429
+
256
430
  return (
257
- <div className={styles.lcontainer}>
258
- <div className={styles.lwrap}>
259
- <div className={styles.logincontainer}>
260
- <form onSubmit={handleSubmit}>
261
- <Reveal effect="fadeInUp">
262
- <div className={styles.login}>
263
- <img
264
- src={logo}
265
- alt="CRM"
266
- className={styles.loginlogo}
267
- />
268
- <h2>Two-Factor Authentication</h2>
269
- <p className={styles.instructions}>
270
- Please enter the 6-digit code from your
271
- authenticator app.
272
- </p>
273
- <div
274
- className={`${styles.formItem} ${styles.fwItem}`}
275
- >
276
- <label className={styles.fi__label}>
277
- <input
278
- type="text"
279
- name="verificationCode"
280
- value={verificationCode}
281
- onChange={handleChange}
282
- tabIndex="1"
283
- className={codeClass}
284
- placeholder=" "
285
- autoComplete="one-time-code"
286
- inputMode="numeric"
287
- maxLength="6"
288
- />
289
- <span className={styles.fi__span}>
290
- Verification Code
291
- </span>
292
- <small>
293
- <Lock
294
- strokeWidth={2}
295
- size={18}
296
- />
297
- </small>
298
- </label>
299
- </div>
300
- <div
301
- className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}
302
- >
303
- <button
304
- className={styles.btn}
305
- type="submit"
306
- disabled={isLoading}
307
- tabIndex="2"
308
- >
309
- {isLoading ? 'Verifying...' : 'Verify'}
310
- </button>
311
- </div>
312
- <div
313
- className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem} ${styles.forgotten}`}
314
- >
315
- <span>
316
- Having trouble? Contact your
317
- administrator for assistance.
318
- </span>
319
- </div>
320
- <div
321
- className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem} ${styles.forgotten}`}
322
- >
323
- <span>
324
- <a
325
- href="#"
326
- onClick={(e) => {
327
- e.preventDefault();
328
- navigate('/login');
329
- }}
330
- tabIndex="3"
331
- >
332
- Back to Login
333
- </a>
334
- </span>
335
- </div>
336
- </div>
337
- </Reveal>
338
- </form>
431
+ <>
432
+ {isLeaving ? <AuthLoading label={copy.leaving} /> : null}
433
+ <AuthShell
434
+ styles={styles}
435
+ logo={logo}
436
+ loginBg={loginBg}
437
+ branding={branding}
438
+ title={copy.title}
439
+ subtitle={isCodeMode ? copy.codeSubtitle : copy.totpSubtitle}
440
+ footer={branding.footer}
441
+ >
442
+ {isCodeMode && maskedContact ? (
443
+ <p className={styles.destination}>
444
+ <MessageSquare size={15} strokeWidth={2} />
445
+ {copy.sentTo} {maskedContact}
446
+ </p>
447
+ ) : null}
448
+
449
+ <form onSubmit={handleSubmit} className={styles.form}>
450
+ <div className={`${styles.field} ${styles.code}`}>
451
+ <input
452
+ id="two-factor-code"
453
+ type="text"
454
+ name="verificationCode"
455
+ value={verificationCode}
456
+ onChange={handleChange}
457
+ tabIndex="1"
458
+ className={codeClass}
459
+ placeholder=" "
460
+ autoComplete="one-time-code"
461
+ inputMode="numeric"
462
+ maxLength={OTP_LENGTH}
463
+ autoFocus
464
+ disabled={isBusy}
465
+ />
466
+ <label htmlFor="two-factor-code">{copy.fieldLabel}</label>
467
+ </div>
468
+
469
+ <button
470
+ className={styles.submit}
471
+ type="submit"
472
+ disabled={isBusy}
473
+ tabIndex="2"
474
+ >
475
+ {isBusy ? copy.submitting : copy.submit}
476
+ </button>
477
+ </form>
478
+
479
+ {/* Expiry and resend belong together: one is the reason for the
480
+ other. Neither has anything to say about an authenticator app,
481
+ so both are `code` mode only. */}
482
+ {isCodeMode ? (
483
+ <div className={styles.resendRow}>
484
+ <p className={styles.expiry}>
485
+ {fill(copy.expiry, { minutes: expiryMinutes })}
486
+ </p>
487
+ <button
488
+ type="button"
489
+ className={`${styles.link} ${styles.resend}`}
490
+ onClick={handleResend}
491
+ disabled={isBusy || isResending || cooldown > 0}
492
+ tabIndex="3"
493
+ >
494
+ {resendLabel}
495
+ </button>
339
496
  </div>
340
- </div>
341
- </div>
497
+ ) : null}
498
+
499
+ {remember ? (
500
+ <p className={styles.remembered}>
501
+ <Check size={15} strokeWidth={2.5} />
502
+ {copy.remembered}
503
+ </p>
504
+ ) : null}
505
+
506
+ <p className={styles.help}>{copy.help}</p>
507
+
508
+ <button
509
+ type="button"
510
+ className={styles.back}
511
+ onClick={() => navigate(loginPath)}
512
+ disabled={isBusy}
513
+ tabIndex="4"
514
+ >
515
+ <ArrowLeft size={15} strokeWidth={2} />
516
+ {copy.back}
517
+ </button>
518
+ </AuthShell>
519
+ </>
342
520
  );
343
521
  };
344
522