@salesforce/webapp-template-app-react-sample-b2x-experimental 1.79.2 → 1.80.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/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls +68 -0
- package/dist/force-app/main/default/classes/WebAppAuthUtils.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppChangePassword.cls +77 -0
- package/dist/force-app/main/default/classes/WebAppChangePassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppForgotPassword.cls +71 -0
- package/dist/force-app/main/default/classes/WebAppForgotPassword.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppLogin.cls +105 -0
- package/dist/force-app/main/default/classes/WebAppLogin.cls-meta.xml +5 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls +162 -0
- package/dist/force-app/main/default/classes/WebAppRegistration.cls-meta.xml +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +15 -6
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/app.tsx +4 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +155 -88
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/api/userProfileApi.ts +81 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authHelpers.ts +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/authenticationConfig.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/context/AuthContext.tsx +95 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/footers/footer-link.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/auth-form.tsx +81 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/forms/submit-button.tsx +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/form.tsx +120 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useCountdownTimer.ts +266 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/hooks/useRetryWithBackoff.ts +109 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/card-skeleton.tsx +38 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layout/centered-page-layout.tsx +87 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/AuthAppLayout.tsx +12 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/authenticationRouteLayout.tsx +21 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/layouts/privateRouteLayout.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ChangePassword.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ForgotPassword.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Login.tsx +97 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Profile.tsx +139 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/Register.tsx +133 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/pages/ResetPassword.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +616 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeService.ts +161 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/sessionTimeout/sessionTimeoutConfig.ts +77 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/authentication/utils/helpers.ts +121 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +201 -114
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +67 -13
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session timeout validator component
|
|
3
|
+
* Main orchestrator for session monitoring and timeout warnings
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
7
|
+
import { useLocation } from "react-router";
|
|
8
|
+
import { X } from "lucide-react";
|
|
9
|
+
import { useAuth } from "../context/AuthContext";
|
|
10
|
+
import { pollSessionTimeServlet, extendSessionTime } from "./sessionTimeService";
|
|
11
|
+
import { useCountdownTimer } from "../hooks/useCountdownTimer";
|
|
12
|
+
import { useRetryWithBackoff } from "../hooks/useRetryWithBackoff";
|
|
13
|
+
import { Alert, AlertTitle, AlertDescription } from "../../../components/ui/alert";
|
|
14
|
+
import { Button } from "../../../components/ui/button";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
} from "../../../components/ui/dialog";
|
|
23
|
+
import {
|
|
24
|
+
STORAGE_KEYS,
|
|
25
|
+
LABELS,
|
|
26
|
+
INITIAL_RETRY_DELAY,
|
|
27
|
+
MAX_RETRY_ATTEMPTS,
|
|
28
|
+
MAX_RETRY_DELAY,
|
|
29
|
+
SESSION_WARNING_TIME,
|
|
30
|
+
} from "./sessionTimeoutConfig";
|
|
31
|
+
import { ROUTES } from "../authenticationConfig";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configuration for session timeout monitoring
|
|
35
|
+
*/
|
|
36
|
+
interface SessionTimeoutConfig {
|
|
37
|
+
/** Community base path (e.g., "/sfsites/c/") */
|
|
38
|
+
basePath: string;
|
|
39
|
+
/** Whether current user is a guest user */
|
|
40
|
+
isGuest: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return value from useSessionTimeout hook
|
|
45
|
+
*/
|
|
46
|
+
interface SessionTimeoutResult {
|
|
47
|
+
/** Seconds remaining in current session (null if no response received yet) */
|
|
48
|
+
timeLeftInSession: number | null;
|
|
49
|
+
/** Whether warning modal should be displayed */
|
|
50
|
+
showWarningModal: boolean;
|
|
51
|
+
/** Function to extend the session */
|
|
52
|
+
extendSession: () => Promise<void>;
|
|
53
|
+
/** Function to logout the user */
|
|
54
|
+
logout: () => void;
|
|
55
|
+
/** Function to check session status via API */
|
|
56
|
+
checkSession: () => Promise<void>;
|
|
57
|
+
/** Number of failed retry attempts */
|
|
58
|
+
retryAttempts: number;
|
|
59
|
+
/** Whether currently polling the session API */
|
|
60
|
+
isPolling: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Props for SessionTimeoutValidator component
|
|
65
|
+
*/
|
|
66
|
+
export interface SessionTimeoutValidatorProps {
|
|
67
|
+
/** Community base path */
|
|
68
|
+
basePath: string;
|
|
69
|
+
|
|
70
|
+
// Optional callbacks
|
|
71
|
+
/** Called when session expires and user is logged out */
|
|
72
|
+
onSessionExpired?: () => void;
|
|
73
|
+
/** Called when session is extended with new time remaining */
|
|
74
|
+
onSessionExtended?: (newTimeRemaining: number) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Props for SessionWarningModal component
|
|
79
|
+
*/
|
|
80
|
+
interface SessionWarningModalProps {
|
|
81
|
+
/** Whether modal is visible */
|
|
82
|
+
isOpen: boolean;
|
|
83
|
+
/** Seconds remaining until session expires */
|
|
84
|
+
timeRemaining: number;
|
|
85
|
+
/** Called when user clicks "Continue Working" button */
|
|
86
|
+
onExtendSession: () => void;
|
|
87
|
+
/** Called when user clicks "Log Out" button */
|
|
88
|
+
onLogout: () => void;
|
|
89
|
+
/** Called when countdown timer reaches 0 (before logout) */
|
|
90
|
+
onCountdownExpire: () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Props for SessionExpiredAlert component
|
|
95
|
+
*/
|
|
96
|
+
interface SessionExpiredAlertProps {
|
|
97
|
+
/** Whether alert should be shown */
|
|
98
|
+
show: boolean;
|
|
99
|
+
/** Called when user dismisses the alert */
|
|
100
|
+
onDismiss: () => void;
|
|
101
|
+
/** Custom message to display (optional) */
|
|
102
|
+
message?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Custom hook for session timeout monitoring
|
|
107
|
+
* Polls SessionTimeServlet at calculated intervals, handles retry with exponential backoff
|
|
108
|
+
*
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
function useSessionTimeout(config: SessionTimeoutConfig): SessionTimeoutResult {
|
|
112
|
+
const { basePath, isGuest } = config;
|
|
113
|
+
|
|
114
|
+
// Session state
|
|
115
|
+
const [timeLeftInSession, setTimeLeftInSession] = useState<number | null>(null);
|
|
116
|
+
const [showWarningModal, setShowWarningModal] = useState<boolean>(false);
|
|
117
|
+
const [isPolling, setIsPolling] = useState<boolean>(false);
|
|
118
|
+
|
|
119
|
+
// Retry logic with exponential backoff
|
|
120
|
+
const retry = useRetryWithBackoff({
|
|
121
|
+
initialDelay: INITIAL_RETRY_DELAY,
|
|
122
|
+
maxAttempts: MAX_RETRY_ATTEMPTS,
|
|
123
|
+
maxDelay: MAX_RETRY_DELAY,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Refs for timer management (prevents closure issues)
|
|
127
|
+
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
128
|
+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
129
|
+
const isPollingRef = useRef<boolean>(false);
|
|
130
|
+
const checkSessionRef = useRef<(() => Promise<void>) | null>(null);
|
|
131
|
+
const extendSessionRef = useRef<(() => Promise<void>) | null>(null);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clear the current polling timeout
|
|
135
|
+
*/
|
|
136
|
+
const clearPollTimeout = useCallback(() => {
|
|
137
|
+
if (pollTimeoutRef.current) {
|
|
138
|
+
clearTimeout(pollTimeoutRef.current);
|
|
139
|
+
pollTimeoutRef.current = null;
|
|
140
|
+
}
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Clear the current retry timeout
|
|
145
|
+
*/
|
|
146
|
+
const clearRetryTimeout = useCallback(() => {
|
|
147
|
+
if (retryTimeoutRef.current) {
|
|
148
|
+
clearTimeout(retryTimeoutRef.current);
|
|
149
|
+
retryTimeoutRef.current = null;
|
|
150
|
+
}
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Schedule the next session check
|
|
155
|
+
* Clears any existing poll or retry timeouts to prevent concurrent polling
|
|
156
|
+
*
|
|
157
|
+
* @param delay - Milliseconds to wait before next check
|
|
158
|
+
*/
|
|
159
|
+
const scheduleCheck = useCallback(
|
|
160
|
+
(delay: number) => {
|
|
161
|
+
clearPollTimeout();
|
|
162
|
+
clearRetryTimeout();
|
|
163
|
+
pollTimeoutRef.current = setTimeout(() => {
|
|
164
|
+
checkSessionRef.current?.();
|
|
165
|
+
}, delay);
|
|
166
|
+
},
|
|
167
|
+
[clearPollTimeout, clearRetryTimeout],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Handle retry with exponential backoff
|
|
172
|
+
* Prevents concurrent retries by checking if a poll is already in progress
|
|
173
|
+
*/
|
|
174
|
+
const handleRetryWithBackoff = useCallback(
|
|
175
|
+
(retryAction: () => void) => {
|
|
176
|
+
// Don't schedule retry if max attempts reached
|
|
177
|
+
if (retry.maxRetriesReached) {
|
|
178
|
+
console.error("[useSessionTimeout] Max retry attempts reached. Stopping polling.");
|
|
179
|
+
setIsPolling(false);
|
|
180
|
+
isPollingRef.current = false;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Don't schedule retry if a poll is already in progress
|
|
185
|
+
if (isPollingRef.current) {
|
|
186
|
+
console.warn("[useSessionTimeout] Poll already in progress, skipping retry scheduling");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Clear any existing retry timeout before scheduling new one
|
|
191
|
+
clearRetryTimeout();
|
|
192
|
+
|
|
193
|
+
// Calculate delay and schedule retry
|
|
194
|
+
const delay = retry.currentRetryDelay;
|
|
195
|
+
console.warn(
|
|
196
|
+
`[useSessionTimeout] Scheduling retry attempt ${retry.retryAttempts + 1}/${MAX_RETRY_ATTEMPTS} in ${delay}ms`,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Let retry.scheduleRetry handle both the timeout and counter increment
|
|
200
|
+
const timeoutId = retry.scheduleRetry(() => {
|
|
201
|
+
retryTimeoutRef.current = null;
|
|
202
|
+
retryAction();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (timeoutId) {
|
|
206
|
+
retryTimeoutRef.current = timeoutId;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
[retry, clearRetryTimeout],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Process the session timeout response and schedule next check
|
|
214
|
+
*
|
|
215
|
+
* @param secondsRemaining - Seconds remaining in session
|
|
216
|
+
*/
|
|
217
|
+
const processTimeoutResponse = useCallback(
|
|
218
|
+
(secondsRemaining: number) => {
|
|
219
|
+
setTimeLeftInSession(secondsRemaining);
|
|
220
|
+
|
|
221
|
+
// Session expired
|
|
222
|
+
if (secondsRemaining <= 0) {
|
|
223
|
+
setShowWarningModal(false);
|
|
224
|
+
// Note: logout() will be called by the component
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const shouldShowWarning = secondsRemaining <= SESSION_WARNING_TIME;
|
|
229
|
+
|
|
230
|
+
if (shouldShowWarning) {
|
|
231
|
+
// Show warning modal and schedule check for when session expires
|
|
232
|
+
setShowWarningModal(true);
|
|
233
|
+
scheduleCheck(secondsRemaining * 1000);
|
|
234
|
+
} else {
|
|
235
|
+
// Schedule check for when warning should appear
|
|
236
|
+
const timeUntilWarning = (secondsRemaining - SESSION_WARNING_TIME) * 1000;
|
|
237
|
+
setShowWarningModal(false);
|
|
238
|
+
scheduleCheck(timeUntilWarning);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
[scheduleCheck],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check session status via API
|
|
246
|
+
*/
|
|
247
|
+
const checkSession = useCallback(async () => {
|
|
248
|
+
// Prevent concurrent polling
|
|
249
|
+
if (isPollingRef.current) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
isPollingRef.current = true;
|
|
254
|
+
setIsPolling(true);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const response = await pollSessionTimeServlet(basePath);
|
|
258
|
+
|
|
259
|
+
// Success - reset retry state and process response
|
|
260
|
+
isPollingRef.current = false;
|
|
261
|
+
setIsPolling(false);
|
|
262
|
+
retry.resetRetry();
|
|
263
|
+
processTimeoutResponse(response.sr);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error("[useSessionTimeout] Poll failed:", error);
|
|
266
|
+
// Reset polling flags before retry so handleRetryWithBackoff doesn't skip
|
|
267
|
+
isPollingRef.current = false;
|
|
268
|
+
setIsPolling(false);
|
|
269
|
+
handleRetryWithBackoff(() => checkSessionRef.current?.());
|
|
270
|
+
}
|
|
271
|
+
}, [basePath, retry, processTimeoutResponse, handleRetryWithBackoff]);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Extend the session (called when user clicks "Continue Working")
|
|
275
|
+
*/
|
|
276
|
+
const extendSession = useCallback(async () => {
|
|
277
|
+
try {
|
|
278
|
+
const response = await extendSessionTime(basePath);
|
|
279
|
+
|
|
280
|
+
// Reset retry state and process the new session time
|
|
281
|
+
retry.resetRetry();
|
|
282
|
+
processTimeoutResponse(response.sr);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error("[useSessionTimeout] Failed to extend session:", error);
|
|
285
|
+
// On failure, retry extending session (not checkSession)
|
|
286
|
+
handleRetryWithBackoff(() => extendSessionRef.current?.());
|
|
287
|
+
}
|
|
288
|
+
}, [basePath, retry, processTimeoutResponse, handleRetryWithBackoff]);
|
|
289
|
+
|
|
290
|
+
// Update refs to always point to latest functions
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
checkSessionRef.current = checkSession;
|
|
293
|
+
}, [checkSession]);
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
extendSessionRef.current = extendSession;
|
|
297
|
+
}, [extendSession]);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Logout the user
|
|
301
|
+
* Note: Navigation is handled by the component, not this hook
|
|
302
|
+
*/
|
|
303
|
+
const logout = useCallback(() => {
|
|
304
|
+
clearPollTimeout();
|
|
305
|
+
clearRetryTimeout();
|
|
306
|
+
setShowWarningModal(false);
|
|
307
|
+
setTimeLeftInSession(null);
|
|
308
|
+
setIsPolling(false);
|
|
309
|
+
isPollingRef.current = false;
|
|
310
|
+
}, [clearPollTimeout, clearRetryTimeout]);
|
|
311
|
+
|
|
312
|
+
// Initialize polling on mount (if authenticated)
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (isGuest) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
checkSessionRef.current?.();
|
|
319
|
+
|
|
320
|
+
// Cleanup on unmount
|
|
321
|
+
return () => {
|
|
322
|
+
clearPollTimeout();
|
|
323
|
+
clearRetryTimeout();
|
|
324
|
+
isPollingRef.current = false;
|
|
325
|
+
};
|
|
326
|
+
}, [isGuest, clearPollTimeout, clearRetryTimeout]);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
timeLeftInSession,
|
|
330
|
+
showWarningModal,
|
|
331
|
+
extendSession,
|
|
332
|
+
logout,
|
|
333
|
+
checkSession,
|
|
334
|
+
retryAttempts: retry.retryAttempts,
|
|
335
|
+
isPolling,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Session Expired Alert
|
|
341
|
+
* Toast-like alert shown on login page after user is redirected due to session expiration
|
|
342
|
+
*
|
|
343
|
+
* @internal
|
|
344
|
+
*/
|
|
345
|
+
function SessionExpiredAlert({
|
|
346
|
+
show,
|
|
347
|
+
onDismiss,
|
|
348
|
+
message = LABELS.invalidSessionMessage,
|
|
349
|
+
}: SessionExpiredAlertProps) {
|
|
350
|
+
if (!show) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md px-4">
|
|
356
|
+
<Alert variant="destructive" role="alert">
|
|
357
|
+
<AlertTitle className="flex items-center justify-between">
|
|
358
|
+
<span>{LABELS.sessionWarningTitle}</span>
|
|
359
|
+
<Button
|
|
360
|
+
variant="ghost"
|
|
361
|
+
size="sm"
|
|
362
|
+
onClick={onDismiss}
|
|
363
|
+
aria-label={LABELS.closeLabel}
|
|
364
|
+
className="h-auto p-1 hover:bg-destructive/10"
|
|
365
|
+
>
|
|
366
|
+
<X className="h-4 w-4" />
|
|
367
|
+
</Button>
|
|
368
|
+
</AlertTitle>
|
|
369
|
+
<AlertDescription>{message}</AlertDescription>
|
|
370
|
+
</Alert>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Session Warning Modal
|
|
377
|
+
* Modal dialog with countdown timer shown when session is near expiration
|
|
378
|
+
*
|
|
379
|
+
* @internal
|
|
380
|
+
*/
|
|
381
|
+
function SessionWarningModal({
|
|
382
|
+
isOpen,
|
|
383
|
+
timeRemaining,
|
|
384
|
+
onExtendSession,
|
|
385
|
+
onLogout,
|
|
386
|
+
onCountdownExpire,
|
|
387
|
+
}: SessionWarningModalProps) {
|
|
388
|
+
const continueButtonRef = useRef<HTMLButtonElement>(null);
|
|
389
|
+
|
|
390
|
+
// Countdown timer with accessibility
|
|
391
|
+
const timer = useCountdownTimer({
|
|
392
|
+
initialTime: timeRemaining,
|
|
393
|
+
onExpire: onCountdownExpire,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const { start, stop, reset, formattedTime, isoTime, accessibilityAnnouncement } = timer;
|
|
397
|
+
|
|
398
|
+
// Consolidated timer management: handle both open/close and timeRemaining changes
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (!isOpen) {
|
|
401
|
+
stop();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Modal is open: reset and start timer
|
|
406
|
+
reset();
|
|
407
|
+
start();
|
|
408
|
+
}, [isOpen, timeRemaining, start, stop, reset]);
|
|
409
|
+
|
|
410
|
+
// Focus the continue button when dialog opens
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (isOpen) {
|
|
413
|
+
continueButtonRef.current?.focus();
|
|
414
|
+
}
|
|
415
|
+
}, [isOpen]);
|
|
416
|
+
|
|
417
|
+
// Handle "Continue Working" button click
|
|
418
|
+
const handleContinue = useCallback(() => {
|
|
419
|
+
stop();
|
|
420
|
+
onExtendSession();
|
|
421
|
+
}, [stop, onExtendSession]);
|
|
422
|
+
|
|
423
|
+
// Handle "Log Out" button click
|
|
424
|
+
const handleLogoutClick = useCallback(() => {
|
|
425
|
+
stop();
|
|
426
|
+
onLogout();
|
|
427
|
+
}, [stop, onLogout]);
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<Dialog open={isOpen} onOpenChange={() => {}}>
|
|
431
|
+
<DialogContent
|
|
432
|
+
showCloseButton={false}
|
|
433
|
+
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
434
|
+
onPointerDownOutside={(e) => e.preventDefault()}
|
|
435
|
+
onInteractOutside={(e) => e.preventDefault()}
|
|
436
|
+
className="max-w-md"
|
|
437
|
+
>
|
|
438
|
+
<DialogHeader>
|
|
439
|
+
<DialogTitle id="session-warning-title">{LABELS.sessionWarningTitle}</DialogTitle>
|
|
440
|
+
<DialogDescription id="session-warning-description">
|
|
441
|
+
{LABELS.sessionWarningMessage}
|
|
442
|
+
</DialogDescription>
|
|
443
|
+
</DialogHeader>
|
|
444
|
+
|
|
445
|
+
<div className="flex flex-col items-center gap-4 py-6">
|
|
446
|
+
{/* Countdown Timer Display */}
|
|
447
|
+
<time
|
|
448
|
+
dateTime={isoTime}
|
|
449
|
+
role="timer"
|
|
450
|
+
aria-live="off"
|
|
451
|
+
aria-atomic="true"
|
|
452
|
+
aria-describedby="session-warning-description"
|
|
453
|
+
className="text-5xl font-bold text-center tabular-nums text-destructive"
|
|
454
|
+
>
|
|
455
|
+
{formattedTime}
|
|
456
|
+
</time>
|
|
457
|
+
|
|
458
|
+
{/* Accessibility Announcement Region */}
|
|
459
|
+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
|
460
|
+
{accessibilityAnnouncement}
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
<DialogFooter className="flex gap-4 justify-end">
|
|
465
|
+
<Button variant="outline" onClick={handleLogoutClick} tabIndex={2}>
|
|
466
|
+
{LABELS.logoutButton}
|
|
467
|
+
</Button>
|
|
468
|
+
<Button variant="default" onClick={handleContinue} tabIndex={1} ref={continueButtonRef}>
|
|
469
|
+
{LABELS.continueButton}
|
|
470
|
+
</Button>
|
|
471
|
+
</DialogFooter>
|
|
472
|
+
</DialogContent>
|
|
473
|
+
</Dialog>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Session Timeout Validator
|
|
479
|
+
* Main component that monitors user session and displays warnings before expiration
|
|
480
|
+
*
|
|
481
|
+
* Features:
|
|
482
|
+
* - Polls SessionTimeServlet to monitor session status
|
|
483
|
+
* - Shows warning modal when session nears expiration
|
|
484
|
+
* - Handles session extension when user clicks "Continue Working"
|
|
485
|
+
* - Uses AuthContext.logout() for centralized logout handling
|
|
486
|
+
* - Shows expired message on login page after timeout redirect
|
|
487
|
+
* - Implements exponential backoff retry for failed API calls
|
|
488
|
+
* - Skips monitoring for guest users
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* <SessionTimeoutValidator
|
|
492
|
+
* basePath={basePath}
|
|
493
|
+
* />
|
|
494
|
+
*/
|
|
495
|
+
export default function SessionTimeoutValidator({
|
|
496
|
+
basePath,
|
|
497
|
+
onSessionExpired,
|
|
498
|
+
onSessionExtended,
|
|
499
|
+
}: SessionTimeoutValidatorProps) {
|
|
500
|
+
// Get authentication state and logout method
|
|
501
|
+
const { isAuthenticated, logout } = useAuth();
|
|
502
|
+
const isGuest = !isAuthenticated;
|
|
503
|
+
|
|
504
|
+
// Get current location from React Router
|
|
505
|
+
const location = useLocation();
|
|
506
|
+
|
|
507
|
+
// State for session expired alert
|
|
508
|
+
const [showExpiredAlert, setShowExpiredAlert] = useState(false);
|
|
509
|
+
|
|
510
|
+
// Session timeout monitoring hook
|
|
511
|
+
const sessionTimeout = useSessionTimeout({
|
|
512
|
+
basePath,
|
|
513
|
+
isGuest,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Check if we should show expired session message
|
|
518
|
+
* Called on mount and whenever pathname changes
|
|
519
|
+
*/
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
// Check if we're on the login page and should show expired message
|
|
522
|
+
const isLoginPage = location.pathname === ROUTES.LOGIN.PATH;
|
|
523
|
+
const shouldShowMessage = sessionStorage.getItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE) === "true";
|
|
524
|
+
|
|
525
|
+
if (isLoginPage && shouldShowMessage) {
|
|
526
|
+
setShowExpiredAlert(true);
|
|
527
|
+
// Clear the flag immediately after reading
|
|
528
|
+
sessionStorage.removeItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE);
|
|
529
|
+
}
|
|
530
|
+
}, [location.pathname]);
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Handle session extension
|
|
534
|
+
* Called when user clicks "Continue Working" in warning modal
|
|
535
|
+
*/
|
|
536
|
+
const handleExtendSession = useCallback(async () => {
|
|
537
|
+
await sessionTimeout.extendSession();
|
|
538
|
+
|
|
539
|
+
// Call optional callback
|
|
540
|
+
if (onSessionExtended && sessionTimeout.timeLeftInSession !== null) {
|
|
541
|
+
onSessionExtended(sessionTimeout.timeLeftInSession);
|
|
542
|
+
}
|
|
543
|
+
}, [sessionTimeout, onSessionExtended]);
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Handle countdown expiration
|
|
547
|
+
* Checks session status with server before logging out to prevent premature logout
|
|
548
|
+
*/
|
|
549
|
+
const handleCountdownExpired = useCallback(async () => {
|
|
550
|
+
await sessionTimeout.checkSession();
|
|
551
|
+
}, [sessionTimeout]);
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Handle logout
|
|
555
|
+
* Called when session expires or user clicks "Log Out"
|
|
556
|
+
*/
|
|
557
|
+
const handleLogout = useCallback(() => {
|
|
558
|
+
// Set flag in sessionStorage to show message on login page
|
|
559
|
+
sessionStorage.setItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE, "true");
|
|
560
|
+
|
|
561
|
+
// Stop session monitoring (clears timeouts, resets hook state)
|
|
562
|
+
sessionTimeout.logout();
|
|
563
|
+
|
|
564
|
+
// Call optional callback
|
|
565
|
+
if (onSessionExpired) {
|
|
566
|
+
onSessionExpired();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Use centralized logout from AuthContext
|
|
570
|
+
// This clears auth state and redirects to logout URL
|
|
571
|
+
// Pass current location as retUrl to redirect back after logout
|
|
572
|
+
// Use window.location.pathname to include the base path
|
|
573
|
+
logout(window.location.pathname);
|
|
574
|
+
}, [sessionTimeout, logout, onSessionExpired]);
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Handle session timeout (automatic logout when countdown reaches 0)
|
|
578
|
+
* Only trigger logout if we've received a response (not null) and session is expired
|
|
579
|
+
*/
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (
|
|
582
|
+
sessionTimeout.timeLeftInSession !== null &&
|
|
583
|
+
sessionTimeout.timeLeftInSession <= 0 &&
|
|
584
|
+
!isGuest
|
|
585
|
+
) {
|
|
586
|
+
handleLogout();
|
|
587
|
+
}
|
|
588
|
+
}, [sessionTimeout.timeLeftInSession, isGuest, handleLogout]);
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Dismiss expired session alert
|
|
592
|
+
*/
|
|
593
|
+
const handleDismissAlert = useCallback(() => {
|
|
594
|
+
setShowExpiredAlert(false);
|
|
595
|
+
}, []);
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<>
|
|
599
|
+
{/* Session Warning Modal - only render when open */}
|
|
600
|
+
{sessionTimeout.showWarningModal && (
|
|
601
|
+
<SessionWarningModal
|
|
602
|
+
isOpen={sessionTimeout.showWarningModal}
|
|
603
|
+
timeRemaining={sessionTimeout.timeLeftInSession ?? 0}
|
|
604
|
+
onExtendSession={handleExtendSession}
|
|
605
|
+
onLogout={handleLogout}
|
|
606
|
+
onCountdownExpire={handleCountdownExpired}
|
|
607
|
+
/>
|
|
608
|
+
)}
|
|
609
|
+
|
|
610
|
+
{/* Session Expired Alert (shown on login page) - only render when showExpiredAlert is true */}
|
|
611
|
+
{showExpiredAlert && (
|
|
612
|
+
<SessionExpiredAlert show={showExpiredAlert} onDismiss={handleDismissAlert} />
|
|
613
|
+
)}
|
|
614
|
+
</>
|
|
615
|
+
);
|
|
616
|
+
}
|