@visns-studio/visns-components 6.6.4 → 6.15.1
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 +181 -38
- 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/styles/global-datagrid.css +27 -2
- 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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import '../styles/global.css';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import CustomFetch from '../Fetch';
|
|
6
|
+
import { normaliseAuthError, syncCsrfFromResponse } from './authHelpers';
|
|
7
|
+
|
|
8
|
+
import styles from '../styles/ImpersonateGate.module.scss';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Impersonation hand-off — "open the client's portal as them".
|
|
12
|
+
*
|
|
13
|
+
* A staff member follows a one-use link, this exchanges its token for a
|
|
14
|
+
* session, and the browser lands in the portal. It is a curtain, not a screen:
|
|
15
|
+
* the only two things a user ever sees are a spinner and, if the token is not
|
|
16
|
+
* accepted, one card explaining why.
|
|
17
|
+
*
|
|
18
|
+
* Framework-agnostic on purpose. It does not read the URL, it does not write
|
|
19
|
+
* cookies and it does not know what a router is:
|
|
20
|
+
* - the consumer extracts the token from wherever it lives and passes it in
|
|
21
|
+
* - `onAuthenticated(user, token)` is where the consumer stores its session
|
|
22
|
+
* however it likes (the Next.js portal writes two cookies there)
|
|
23
|
+
* - `onNavigate(path)` is how it moves, falling back to a full page load
|
|
24
|
+
* That is what lets the same component serve a Next.js app and a React Router
|
|
25
|
+
* SPA without either importing the other's navigation.
|
|
26
|
+
*
|
|
27
|
+
* SECURITY: neither the token nor the URL is ever logged, at any level, in any
|
|
28
|
+
* branch. The token is a bearer credential for someone else's account and a
|
|
29
|
+
* console-capturing monitoring tool would ship it off the machine. The portal
|
|
30
|
+
* page this was ported from logged around it carefully; that care is kept, and
|
|
31
|
+
* the diagnostic lines it did have are gone.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const DEFAULT_COPY = {
|
|
35
|
+
loading: 'Logging in as client...',
|
|
36
|
+
errorTitle: 'Authentication Error',
|
|
37
|
+
loginAction: 'Go to Login',
|
|
38
|
+
/** Shown when the consumer passed no token at all. */
|
|
39
|
+
missingToken: 'No token provided',
|
|
40
|
+
/** Shown when the server declines without saying why. */
|
|
41
|
+
failed: 'This link is no longer valid. Please ask for a new one.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ImpersonateGate = ({
|
|
45
|
+
/** The impersonation token, extracted from the URL by the consumer. */
|
|
46
|
+
token,
|
|
47
|
+
/** POST target that exchanges the token for a session. */
|
|
48
|
+
validateUrl = '/api/validateImpersonationToken',
|
|
49
|
+
/**
|
|
50
|
+
* Request body builder. The default is what the portal sends; a backend
|
|
51
|
+
* expecting a different shape passes its own.
|
|
52
|
+
*/
|
|
53
|
+
buildBody = (value) => ({ token: value, minimal_response: true }),
|
|
54
|
+
/**
|
|
55
|
+
* Called with `(user, token)` on success, before the redirect. This is
|
|
56
|
+
* where the consumer persists its own session — cookies, storage, context.
|
|
57
|
+
* May return a promise; the settle delay starts once it resolves.
|
|
58
|
+
*/
|
|
59
|
+
onAuthenticated,
|
|
60
|
+
/**
|
|
61
|
+
* Milliseconds to wait after `onAuthenticated` before navigating. The
|
|
62
|
+
* portal waits 500ms so a `Set-Cookie` written by the callback is visible
|
|
63
|
+
* to the next request; that is the default here.
|
|
64
|
+
*/
|
|
65
|
+
settleDelay = 500,
|
|
66
|
+
/** Where a successful hand-off lands. */
|
|
67
|
+
redirectPath = '/portal/dashboard',
|
|
68
|
+
/** Where the error card's button goes. */
|
|
69
|
+
loginPath = '/portal',
|
|
70
|
+
/**
|
|
71
|
+
* How to move. Given a path. When absent, a full page load is used — which
|
|
72
|
+
* is usually what a session hand-off wants anyway.
|
|
73
|
+
*/
|
|
74
|
+
onNavigate,
|
|
75
|
+
/** Merged over DEFAULT_COPY. */
|
|
76
|
+
copy: copyOverrides,
|
|
77
|
+
/** Replace the spinner with something of the consumer's own. */
|
|
78
|
+
spinner,
|
|
79
|
+
/** Notified with the normalised message when validation fails. */
|
|
80
|
+
onError,
|
|
81
|
+
}) => {
|
|
82
|
+
const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
|
|
83
|
+
|
|
84
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
85
|
+
const [error, setError] = useState(null);
|
|
86
|
+
|
|
87
|
+
// Timers are cleared on unmount so no setState fires against an unmounted
|
|
88
|
+
// tree, and so a navigation is not attempted after the user left.
|
|
89
|
+
const timers = useRef([]);
|
|
90
|
+
|
|
91
|
+
const go = (path) => {
|
|
92
|
+
if (typeof onNavigate === 'function') {
|
|
93
|
+
onNavigate(path);
|
|
94
|
+
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof window !== 'undefined') {
|
|
99
|
+
window.location.href = path;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
useEffect(
|
|
104
|
+
() => () => {
|
|
105
|
+
timers.current.forEach((timer) => clearTimeout(timer));
|
|
106
|
+
timers.current = [];
|
|
107
|
+
},
|
|
108
|
+
[]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
|
|
114
|
+
if (!token) {
|
|
115
|
+
setError(copy.missingToken);
|
|
116
|
+
setIsLoading(false);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fail = (message) => {
|
|
124
|
+
if (cancelled) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setError(message);
|
|
129
|
+
setIsLoading(false);
|
|
130
|
+
|
|
131
|
+
if (typeof onError === 'function') {
|
|
132
|
+
onError(message);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const succeed = async (result) => {
|
|
137
|
+
if (cancelled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Same class of hand-off as the sign-in screens: the app continues
|
|
142
|
+
// without a reload, so a token issued with this response has to
|
|
143
|
+
// reach the meta tag before `onAuthenticated` hands control to a
|
|
144
|
+
// consumer that will start making requests. Inert for a
|
|
145
|
+
// bearer-token backend, which sends no csrf_token.
|
|
146
|
+
syncCsrfFromResponse(result);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (typeof onAuthenticated === 'function') {
|
|
150
|
+
// Awaited: a consumer that writes its session
|
|
151
|
+
// asynchronously must have finished before the delay — and
|
|
152
|
+
// therefore the navigation — begins.
|
|
153
|
+
await onAuthenticated(result?.user, token);
|
|
154
|
+
}
|
|
155
|
+
} catch (callbackError) {
|
|
156
|
+
fail(normaliseAuthError(callbackError, copy.failed));
|
|
157
|
+
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (cancelled) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
go(redirectPath);
|
|
167
|
+
}, Math.max(0, Number(settleDelay) || 0));
|
|
168
|
+
|
|
169
|
+
timers.current.push(timer);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const request = CustomFetch(
|
|
174
|
+
validateUrl,
|
|
175
|
+
'POST',
|
|
176
|
+
buildBody(token),
|
|
177
|
+
(result) => {
|
|
178
|
+
const failure =
|
|
179
|
+
(result &&
|
|
180
|
+
typeof result === 'object' &&
|
|
181
|
+
result.error) ||
|
|
182
|
+
'';
|
|
183
|
+
|
|
184
|
+
if (failure) {
|
|
185
|
+
fail(normaliseAuthError(failure, copy.failed));
|
|
186
|
+
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
succeed(result);
|
|
191
|
+
},
|
|
192
|
+
(requestError) => {
|
|
193
|
+
fail(normaliseAuthError(requestError, copy.failed));
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// The callbacks above own every outcome; this only stops an
|
|
198
|
+
// unhandled rejection reaching the console (where it would print
|
|
199
|
+
// the request URL — which carries nothing secret here, but the
|
|
200
|
+
// habit is what keeps it that way).
|
|
201
|
+
if (request && typeof request.catch === 'function') {
|
|
202
|
+
request.catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
} catch (thrown) {
|
|
205
|
+
fail(normaliseAuthError(thrown, copy.failed));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return () => {
|
|
209
|
+
cancelled = true;
|
|
210
|
+
};
|
|
211
|
+
// `token` is the whole input; re-running on a changed callback identity
|
|
212
|
+
// would re-POST a one-use token.
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
}, [token, validateUrl]);
|
|
215
|
+
|
|
216
|
+
if (isLoading) {
|
|
217
|
+
return (
|
|
218
|
+
<div className={styles.loading}>
|
|
219
|
+
<div className={styles.card} role="status" aria-live="polite">
|
|
220
|
+
<h1 className={styles.title}>{copy.loading}</h1>
|
|
221
|
+
{spinner === undefined ? (
|
|
222
|
+
<div className={styles.spinner} />
|
|
223
|
+
) : (
|
|
224
|
+
spinner
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (error) {
|
|
232
|
+
return (
|
|
233
|
+
<div className={styles.error}>
|
|
234
|
+
<div className={styles.card} role="alert">
|
|
235
|
+
<h1 className={styles.title}>{copy.errorTitle}</h1>
|
|
236
|
+
<p className={styles.message}>{error}</p>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => go(loginPath)}
|
|
240
|
+
className={styles.button}
|
|
241
|
+
>
|
|
242
|
+
{copy.loginAction}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Validated and waiting on the redirect — the page is about to be replaced,
|
|
250
|
+
// so there is nothing worth painting.
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export default ImpersonateGate;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import '../styles/global.css';
|
|
2
|
+
// Barlow is what Login.module.scss asks for; nothing loaded it before 6.7.0.
|
|
3
|
+
import './authFont';
|
|
2
4
|
|
|
3
5
|
import React, { useEffect, useRef, useState } from 'react';
|
|
4
6
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
@@ -6,9 +8,34 @@ import { toast } from 'react-toastify';
|
|
|
6
8
|
import { Eye, EyeOff } from 'lucide-react';
|
|
7
9
|
|
|
8
10
|
import CustomFetch from '../Fetch';
|
|
11
|
+
import AuthLoading from './AuthLoading';
|
|
12
|
+
import AuthBrandPanel from './AuthBrandPanel';
|
|
13
|
+
import { resolveAuthEndpoints } from './authEndpoints';
|
|
14
|
+
import { syncCsrfFromResponse } from './authHelpers';
|
|
9
15
|
|
|
10
16
|
import styles from '../styles/Login.module.scss';
|
|
11
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Copy, defaulting to the strings this screen has always rendered. Added in
|
|
20
|
+
* 6.8.1 only because the pending states needed labels of their own; every
|
|
21
|
+
* default here is what was previously hardcoded.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_COPY = {
|
|
24
|
+
title: 'Sign in',
|
|
25
|
+
sso: 'Sign in with Microsoft',
|
|
26
|
+
ssoRedirecting: 'Redirecting…',
|
|
27
|
+
altToggle: 'Sign in with email instead',
|
|
28
|
+
divider: 'or sign in with email',
|
|
29
|
+
emailLabel: 'Email',
|
|
30
|
+
passwordLabel: 'Password',
|
|
31
|
+
remember: 'Remember me',
|
|
32
|
+
forgot: 'Forgot password?',
|
|
33
|
+
submit: 'Sign in with email',
|
|
34
|
+
submitting: 'Signing in…',
|
|
35
|
+
/** Caption on the full-screen curtain while the browser is leaving. */
|
|
36
|
+
leaving: 'Signing you in…',
|
|
37
|
+
};
|
|
38
|
+
|
|
12
39
|
const Login = ({
|
|
13
40
|
logo,
|
|
14
41
|
loginBg,
|
|
@@ -16,16 +43,36 @@ const Login = ({
|
|
|
16
43
|
setSystemAuth,
|
|
17
44
|
setUserProfile,
|
|
18
45
|
config = {},
|
|
46
|
+
/** Merged over DEFAULT_COPY above. */
|
|
47
|
+
copy: copyOverrides,
|
|
48
|
+
// Every URL this screen talks to, merged over the 6.6.3 literals. See
|
|
49
|
+
// authEndpoints.js — passing nothing keeps `/login/authenticate` and
|
|
50
|
+
// `/auth/azure` exactly as they were.
|
|
51
|
+
endpoints,
|
|
52
|
+
// Where a 2FA challenge sends the user. Only the path changes; the state
|
|
53
|
+
// handed over (userData / previousUrl / remember) is the same contract
|
|
54
|
+
// TwoFactorAuth has always read.
|
|
55
|
+
twoFactorPath = '/2fa',
|
|
56
|
+
resetPath = '/reset',
|
|
19
57
|
}) => {
|
|
20
58
|
const location = useLocation();
|
|
21
59
|
const navigate = useNavigate();
|
|
60
|
+
const urls = resolveAuthEndpoints(endpoints);
|
|
22
61
|
|
|
23
62
|
// Brand copy comes from the consuming project — this library is shared
|
|
24
63
|
// across every VISNS CRM, so nothing project-specific may be hardcoded
|
|
25
64
|
// here. Absent entries simply don't render (except the sign-in hint,
|
|
26
65
|
// which falls back to a generic line).
|
|
27
66
|
const branding = config.branding || {};
|
|
67
|
+
const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
|
|
28
68
|
const [isLoading, setIsLoading] = useState(false);
|
|
69
|
+
// Raised for a transition that ends in the browser unloading — the SSO
|
|
70
|
+
// redirect, and a `previous` URL after sign-in. Never lowered: there is
|
|
71
|
+
// nothing to come back to.
|
|
72
|
+
const [isLeaving, setIsLeaving] = useState(false);
|
|
73
|
+
// Any request in flight, or the page on its way out. Every control on the
|
|
74
|
+
// screen keys off this, so a second submit is impossible during either.
|
|
75
|
+
const isBusy = isLoading || isLeaving;
|
|
29
76
|
const [showPassword, setShowPassword] = useState(false);
|
|
30
77
|
|
|
31
78
|
// Collapsed behind a button, since Microsoft is how nearly everyone signs
|
|
@@ -69,9 +116,21 @@ const Login = ({
|
|
|
69
116
|
<button
|
|
70
117
|
type="button"
|
|
71
118
|
className={styles.sso}
|
|
119
|
+
// The redirect to the identity provider can take a
|
|
120
|
+
// moment to begin, and this used to give no sign it
|
|
121
|
+
// had been pressed at all — so it got pressed
|
|
122
|
+
// again. The curtain goes up and the button goes
|
|
123
|
+
// inert for the whole of it.
|
|
124
|
+
disabled={isBusy}
|
|
72
125
|
onClick={(e) => {
|
|
73
126
|
e.preventDefault();
|
|
74
|
-
|
|
127
|
+
|
|
128
|
+
if (isBusy) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setIsLeaving(true);
|
|
133
|
+
window.location.href = urls.azureSso;
|
|
75
134
|
}}
|
|
76
135
|
>
|
|
77
136
|
{/* The Microsoft mark keeps its own light tile.
|
|
@@ -85,7 +144,9 @@ const Login = ({
|
|
|
85
144
|
alt=""
|
|
86
145
|
/>
|
|
87
146
|
</span>
|
|
88
|
-
|
|
147
|
+
{isLeaving
|
|
148
|
+
? copy.ssoRedirecting
|
|
149
|
+
: copy.sso}
|
|
89
150
|
</button>
|
|
90
151
|
);
|
|
91
152
|
break;
|
|
@@ -116,7 +177,7 @@ const Login = ({
|
|
|
116
177
|
toast.info('Logging in...', { autoClose: 2000 });
|
|
117
178
|
|
|
118
179
|
CustomFetch(
|
|
119
|
-
|
|
180
|
+
urls.authenticate,
|
|
120
181
|
'POST',
|
|
121
182
|
{
|
|
122
183
|
...auth,
|
|
@@ -126,14 +187,49 @@ const Login = ({
|
|
|
126
187
|
: '',
|
|
127
188
|
},
|
|
128
189
|
(result) => {
|
|
129
|
-
|
|
190
|
+
// Cleared per-branch, not up front: on every success path
|
|
191
|
+
// this screen is on its way somewhere, and dropping the
|
|
192
|
+
// button back to "Sign in with email" first left a gap
|
|
193
|
+
// where the form looked ready for a second attempt.
|
|
130
194
|
if (result.error === '') {
|
|
195
|
+
// Before anything else on the success path, and
|
|
196
|
+
// before either branch: a token issued with this
|
|
197
|
+
// response has to be in the meta tag by the time the
|
|
198
|
+
// next request is built, and both branches lead
|
|
199
|
+
// somewhere that makes requests. Inert when the
|
|
200
|
+
// backend does not send one.
|
|
201
|
+
syncCsrfFromResponse(result);
|
|
202
|
+
|
|
131
203
|
// Check if 2FA is required
|
|
132
204
|
if (result.requires_two_factor) {
|
|
133
|
-
//
|
|
134
|
-
|
|
205
|
+
// `challenge: true` is the marker, NOT the user
|
|
206
|
+
// payload. The packages' code driver deliberately
|
|
207
|
+
// answers a challenge with `user: null` — the
|
|
208
|
+
// account is identified by a code sent out of
|
|
209
|
+
// band, so echoing the record would hand an
|
|
210
|
+
// unauthenticated caller a user lookup. Keying the
|
|
211
|
+
// 2FA screen's mount guard off `userData` meant
|
|
212
|
+
// that null bounced every SMS sign-in straight
|
|
213
|
+
// back here: the code arrived, the screen to type
|
|
214
|
+
// it into never appeared.
|
|
215
|
+
//
|
|
216
|
+
// TOTP still sends the user and the screen still
|
|
217
|
+
// receives it, so nothing changes there.
|
|
218
|
+
// Stays pending: the route change is instant, but
|
|
219
|
+
// the 2FA screen mounts over this one and a button
|
|
220
|
+
// that has snapped back to idle in between reads
|
|
221
|
+
// as "nothing happened".
|
|
222
|
+
navigate(twoFactorPath, {
|
|
135
223
|
state: {
|
|
136
|
-
|
|
224
|
+
challenge: true,
|
|
225
|
+
userData: result.user ?? null,
|
|
226
|
+
// Not currently sent by the packages'
|
|
227
|
+
// AuthController on either driver; read
|
|
228
|
+
// anyway so a backend that grows one needs
|
|
229
|
+
// no change here. Absent, the 2FA screen
|
|
230
|
+
// simply omits its destination chip.
|
|
231
|
+
maskedContact:
|
|
232
|
+
result.masked_contact ?? '',
|
|
137
233
|
previousUrl:
|
|
138
234
|
location.state?.previousUrl || '',
|
|
139
235
|
remember: auth.remember,
|
|
@@ -144,6 +240,11 @@ const Login = ({
|
|
|
144
240
|
setUserProfile(result.user);
|
|
145
241
|
|
|
146
242
|
if (result.previous && result.previous !== '') {
|
|
243
|
+
// A real navigation to wherever the user was
|
|
244
|
+
// headed before. The curtain covers it until
|
|
245
|
+
// the browser unloads, so the form is never
|
|
246
|
+
// left on screen looking operable.
|
|
247
|
+
setIsLeaving(true);
|
|
147
248
|
window.location.href = result.previous;
|
|
148
249
|
} else {
|
|
149
250
|
// GenericAuth owns the transition from here:
|
|
@@ -155,6 +256,7 @@ const Login = ({
|
|
|
155
256
|
}
|
|
156
257
|
}
|
|
157
258
|
} else {
|
|
259
|
+
setIsLoading(false);
|
|
158
260
|
setSystemAuth(false);
|
|
159
261
|
setAuthClass((prevState) => ({
|
|
160
262
|
...prevState,
|
|
@@ -189,31 +291,18 @@ const Login = ({
|
|
|
189
291
|
};
|
|
190
292
|
|
|
191
293
|
return (
|
|
294
|
+
<>
|
|
295
|
+
{isLeaving ? <AuthLoading label={copy.leaving} /> : null}
|
|
192
296
|
<div className={styles.auth}>
|
|
193
|
-
{/* The company's own work, not decoration. It is the reason
|
|
194
|
-
rest of this system exists, so it carries the brand side
|
|
195
|
-
the form is left to be a form.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
>
|
|
203
|
-
{(branding.eyebrow || branding.name || branding.tagline) && (
|
|
204
|
-
<div className={styles.brandInk}>
|
|
205
|
-
{branding.eyebrow && (
|
|
206
|
-
<p className={styles.eyebrow}>{branding.eyebrow}</p>
|
|
207
|
-
)}
|
|
208
|
-
{branding.name && (
|
|
209
|
-
<p className={styles.brandLine}>{branding.name}</p>
|
|
210
|
-
)}
|
|
211
|
-
{branding.tagline && (
|
|
212
|
-
<p className={styles.brandSub}>{branding.tagline}</p>
|
|
213
|
-
)}
|
|
214
|
-
</div>
|
|
215
|
-
)}
|
|
216
|
-
</aside>
|
|
297
|
+
{/* The company's own work, not decoration. It is the reason
|
|
298
|
+
the rest of this system exists, so it carries the brand side
|
|
299
|
+
and the form is left to be a form. Shared markup — see
|
|
300
|
+
AuthBrandPanel. */}
|
|
301
|
+
<AuthBrandPanel
|
|
302
|
+
styles={styles}
|
|
303
|
+
loginBg={loginBg}
|
|
304
|
+
branding={branding}
|
|
305
|
+
/>
|
|
217
306
|
|
|
218
307
|
<main className={styles.panel}>
|
|
219
308
|
<div className={styles.panelInner}>
|
|
@@ -223,7 +312,7 @@ const Login = ({
|
|
|
223
312
|
className={styles.logo}
|
|
224
313
|
/>
|
|
225
314
|
|
|
226
|
-
<h1 className={styles.title}>
|
|
315
|
+
<h1 className={styles.title}>{copy.title}</h1>
|
|
227
316
|
<p className={styles.subtitle}>
|
|
228
317
|
{branding.signInHint ||
|
|
229
318
|
(ssoAvailable
|
|
@@ -243,13 +332,13 @@ const Login = ({
|
|
|
243
332
|
aria-expanded="false"
|
|
244
333
|
aria-controls="email-signin"
|
|
245
334
|
>
|
|
246
|
-
|
|
335
|
+
{copy.altToggle}
|
|
247
336
|
</button>
|
|
248
337
|
)}
|
|
249
338
|
|
|
250
339
|
{showEmail && ssoAvailable && (
|
|
251
340
|
<div className={styles.divider}>
|
|
252
|
-
<span>
|
|
341
|
+
<span>{copy.divider}</span>
|
|
253
342
|
</div>
|
|
254
343
|
)}
|
|
255
344
|
|
|
@@ -275,8 +364,9 @@ const Login = ({
|
|
|
275
364
|
className={authClass.username}
|
|
276
365
|
autoComplete="username"
|
|
277
366
|
placeholder=" "
|
|
367
|
+
disabled={isBusy}
|
|
278
368
|
/>
|
|
279
|
-
<label htmlFor="login-email">
|
|
369
|
+
<label htmlFor="login-email">{copy.emailLabel}</label>
|
|
280
370
|
</div>
|
|
281
371
|
|
|
282
372
|
<div className={styles.field}>
|
|
@@ -290,8 +380,9 @@ const Login = ({
|
|
|
290
380
|
className={authClass.password}
|
|
291
381
|
autoComplete="current-password"
|
|
292
382
|
placeholder=" "
|
|
383
|
+
disabled={isBusy}
|
|
293
384
|
/>
|
|
294
|
-
<label htmlFor="login-password">
|
|
385
|
+
<label htmlFor="login-password">{copy.passwordLabel}</label>
|
|
295
386
|
{/* Typing a password blind on a phone in the sun is
|
|
296
387
|
where most failed sign-ins come from. */}
|
|
297
388
|
<button
|
|
@@ -320,13 +411,14 @@ const Login = ({
|
|
|
320
411
|
name="remember"
|
|
321
412
|
checked={auth.remember}
|
|
322
413
|
onChange={handleChange}
|
|
414
|
+
disabled={isBusy}
|
|
323
415
|
/>
|
|
324
|
-
|
|
416
|
+
{copy.remember}
|
|
325
417
|
</label>
|
|
326
418
|
|
|
327
419
|
{config.passwordReset !== false && (
|
|
328
|
-
<Link to=
|
|
329
|
-
|
|
420
|
+
<Link to={resetPath} className={styles.link}>
|
|
421
|
+
{copy.forgot}
|
|
330
422
|
</Link>
|
|
331
423
|
)}
|
|
332
424
|
</div>
|
|
@@ -334,9 +426,9 @@ const Login = ({
|
|
|
334
426
|
<button
|
|
335
427
|
className={styles.submit}
|
|
336
428
|
type="submit"
|
|
337
|
-
disabled={
|
|
429
|
+
disabled={isBusy}
|
|
338
430
|
>
|
|
339
|
-
{
|
|
431
|
+
{isBusy ? copy.submitting : copy.submit}
|
|
340
432
|
</button>
|
|
341
433
|
</form>
|
|
342
434
|
</div>
|
|
@@ -348,6 +440,7 @@ const Login = ({
|
|
|
348
440
|
</div>
|
|
349
441
|
</main>
|
|
350
442
|
</div>
|
|
443
|
+
</>
|
|
351
444
|
);
|
|
352
445
|
};
|
|
353
446
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import '../styles/global.css';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
import { LogOut } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import styles from '../styles/LogoutScreen.module.scss';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sign out — the confirmation shown while a session is being torn down.
|
|
10
|
+
*
|
|
11
|
+
* Ported from the Next.js portal's /portal/logout page, which held the screen
|
|
12
|
+
* for 1.5s and then hard-navigated. The reason for the delay is worth keeping:
|
|
13
|
+
* signing out is the one action where "did that work?" matters and where an
|
|
14
|
+
* instant redirect leaves no evidence that it did.
|
|
15
|
+
*
|
|
16
|
+
* Callback-driven and framework-agnostic. This component clears nothing itself
|
|
17
|
+
* — a library has no business deciding which of an app's cookies and storage
|
|
18
|
+
* keys constitute its session. The consumer clears them in `onLogout`, which
|
|
19
|
+
* runs once on mount, and moves the browser in `onComplete`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DEFAULT_COPY = {
|
|
23
|
+
title: 'Logging out...',
|
|
24
|
+
message: 'Please wait while we log you out.',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const LogoutScreen = ({
|
|
28
|
+
/**
|
|
29
|
+
* Runs once on mount, before the delay: clear cookies, storage, caches,
|
|
30
|
+
* whatever this app's session is made of. May return a promise; it is not
|
|
31
|
+
* awaited, since the 1.5s delay is generous cover for local teardown.
|
|
32
|
+
*/
|
|
33
|
+
onLogout,
|
|
34
|
+
/**
|
|
35
|
+
* Runs after `delay` ms. Given no `redirectPath`, this is the only thing
|
|
36
|
+
* that moves the browser — a consumer with a router navigates here.
|
|
37
|
+
*/
|
|
38
|
+
onComplete,
|
|
39
|
+
/** Full page load performed after the delay when `onComplete` is absent. */
|
|
40
|
+
redirectPath,
|
|
41
|
+
/** How long the confirmation is held. */
|
|
42
|
+
delay = 1500,
|
|
43
|
+
/** Merged over DEFAULT_COPY. */
|
|
44
|
+
copy: copyOverrides,
|
|
45
|
+
/** Replace the sign-out glyph. */
|
|
46
|
+
icon,
|
|
47
|
+
/** Set false to drop the spinner. */
|
|
48
|
+
showSpinner = true,
|
|
49
|
+
}) => {
|
|
50
|
+
const copy = { ...DEFAULT_COPY, ...(copyOverrides || {}) };
|
|
51
|
+
|
|
52
|
+
// Strict mode mounts effects twice in development; the teardown must not
|
|
53
|
+
// run twice, since a consumer may be revoking a token on the server.
|
|
54
|
+
const started = useRef(false);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (started.current) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
started.current = true;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (typeof onLogout === 'function') {
|
|
65
|
+
const result = onLogout();
|
|
66
|
+
|
|
67
|
+
if (result && typeof result.catch === 'function') {
|
|
68
|
+
result.catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// A failed local teardown must not strand the user on this screen;
|
|
73
|
+
// the redirect below still runs.
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
if (typeof onComplete === 'function') {
|
|
78
|
+
onComplete();
|
|
79
|
+
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (redirectPath && typeof window !== 'undefined') {
|
|
84
|
+
// A hard navigation, not a route change: the point is to leave
|
|
85
|
+
// no in-memory state behind.
|
|
86
|
+
window.location.href = redirectPath;
|
|
87
|
+
}
|
|
88
|
+
}, Math.max(0, Number(delay) || 0));
|
|
89
|
+
|
|
90
|
+
return () => clearTimeout(timer);
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={styles.page}>
|
|
96
|
+
<div className={styles.card} role="status" aria-live="polite">
|
|
97
|
+
<div className={styles.icon}>
|
|
98
|
+
{icon === undefined ? (
|
|
99
|
+
<LogOut size={32} strokeWidth={2} />
|
|
100
|
+
) : (
|
|
101
|
+
icon
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<h2 className={styles.title}>{copy.title}</h2>
|
|
105
|
+
<p className={styles.message}>{copy.message}</p>
|
|
106
|
+
{showSpinner ? <div className={styles.spinner} /> : null}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export default LogoutScreen;
|