@visns-studio/visns-components 6.6.3 → 6.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -1,50 +1,327 @@
|
|
|
1
1
|
import '../styles/global.css';
|
|
2
2
|
|
|
3
|
-
import 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 =
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 (
|
|
31
|
-
|
|
32
|
-
navigate(loginUrl);
|
|
310
|
+
if (hasChallenge || challengeProp || typeof onBack === 'function') {
|
|
311
|
+
return;
|
|
33
312
|
}
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
361
|
+
const report = (error, fallback) => {
|
|
362
|
+
const message = normaliseAuthError(error, fallback);
|
|
71
363
|
|
|
72
|
-
|
|
73
|
-
setCountdown(60); // 60 seconds cooldown
|
|
364
|
+
toast.error(message);
|
|
74
365
|
|
|
75
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 (
|
|
467
|
+
if (isBusy) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!isCompleteOtp(verificationCode, OTP_LENGTH)) {
|
|
123
472
|
setCodeClass('inputError');
|
|
124
|
-
toast.error(
|
|
473
|
+
toast.error(copy.invalidLength);
|
|
125
474
|
return;
|
|
126
475
|
}
|
|
127
476
|
|
|
128
477
|
setIsLoading(true);
|
|
129
|
-
toast.info(
|
|
478
|
+
toast.info(copy.verifying, { autoClose: 2000 });
|
|
130
479
|
|
|
131
480
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
536
|
+
if (typeof setUserProfile === 'function') {
|
|
537
|
+
setUserProfile(session.user);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (typeof setSystemAuth === 'function') {
|
|
541
|
+
setSystemAuth(true);
|
|
542
|
+
}
|
|
155
543
|
|
|
156
|
-
|
|
157
|
-
|
|
544
|
+
if (session.redirectUrl && typeof window !== 'undefined') {
|
|
545
|
+
window.location.href = session.redirectUrl;
|
|
158
546
|
|
|
159
|
-
|
|
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
|
-
|
|
555
|
+
report(error, copy.invalidCode);
|
|
169
556
|
}
|
|
170
557
|
};
|
|
171
558
|
|
|
172
559
|
return (
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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=
|
|
636
|
+
placeholder={copy.placeholder}
|
|
201
637
|
autoComplete="one-time-code"
|
|
202
638
|
inputMode="numeric"
|
|
203
|
-
maxLength=
|
|
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={
|
|
673
|
+
disabled={isBusy}
|
|
214
674
|
>
|
|
215
|
-
{
|
|
675
|
+
{isBusy ? labels.submitting : labels.submit}
|
|
216
676
|
</button>
|
|
217
677
|
|
|
218
678
|
<div className={styles.linkText}>
|
|
219
|
-
|
|
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
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
715
|
+
handleBack();
|
|
244
716
|
}}
|
|
245
717
|
className={styles.backLink}
|
|
718
|
+
aria-disabled={isBusy}
|
|
246
719
|
>
|
|
247
|
-
<ArrowLeft strokeWidth={2} size={16} />
|
|
248
|
-
|
|
720
|
+
<ArrowLeft strokeWidth={2} size={16} />{' '}
|
|
721
|
+
{copy.back}
|
|
249
722
|
</a>
|
|
250
723
|
</div>
|
|
251
724
|
</form>
|
|
252
|
-
|
|
253
|
-
</div>
|
|
725
|
+
</ClientAuthFrame>
|
|
254
726
|
);
|
|
255
727
|
};
|
|
256
728
|
|