@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.
- package/README.md +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +171 -37
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -1,344 +1,522 @@
|
|
|
1
1
|
import '../styles/global.css';
|
|
2
|
+
import './authFont';
|
|
2
3
|
|
|
3
|
-
import 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 {
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
140
|
+
const urls = resolveAuthEndpoints(endpoints);
|
|
141
|
+
const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
|
|
142
|
+
const branding = config.branding || {};
|
|
143
|
+
const isCodeMode = mode === 'code';
|
|
22
144
|
|
|
23
|
-
|
|
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 (!
|
|
26
|
-
navigate(
|
|
191
|
+
if (!hasChallenge) {
|
|
192
|
+
navigate(loginPath);
|
|
27
193
|
}
|
|
28
|
-
}, [
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
282
|
+
if (!e) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
47
285
|
|
|
48
|
-
|
|
49
|
-
setCodeClass('inputError');
|
|
50
|
-
toast.error('Please enter a valid 6-digit code');
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
286
|
+
e.preventDefault();
|
|
53
287
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
149
|
-
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
150
347
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
|