@visns-studio/visns-components 6.6.3 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -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
- window.location.href = '/auth/azure';
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
- Sign in with Microsoft
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
- '/login/authenticate',
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
- setIsLoading(false);
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
- // Redirect to 2FA page with user data and remember flag
134
- navigate('/2fa', {
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
- userData: result.user,
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 the
194
- rest of this system exists, so it carries the brand side and
195
- the form is left to be a form. */}
196
- <aside
197
- className={styles.brand}
198
- style={
199
- loginBg ? { backgroundImage: `url(${loginBg})` } : undefined
200
- }
201
- aria-hidden="true"
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}>Sign in</h1>
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
- Sign in with email instead
335
+ {copy.altToggle}
247
336
  </button>
248
337
  )}
249
338
 
250
339
  {showEmail && ssoAvailable && (
251
340
  <div className={styles.divider}>
252
- <span>or sign in with email</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">Email</label>
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">Password</label>
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
- Remember me
416
+ {copy.remember}
325
417
  </label>
326
418
 
327
419
  {config.passwordReset !== false && (
328
- <Link to="/reset" className={styles.link}>
329
- Forgot password?
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={isLoading}
429
+ disabled={isBusy}
338
430
  >
339
- {isLoading ? 'Signing in…' : 'Sign in with email'}
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;