diff --git a/portals/admin/src/main/webapp/site/public/locales/en.json b/portals/admin/src/main/webapp/site/public/locales/en.json index 0bcddc228b3..327e577b535 100644 --- a/portals/admin/src/main/webapp/site/public/locales/en.json +++ b/portals/admin/src/main/webapp/site/public/locales/en.json @@ -974,6 +974,10 @@ "RolePermissions.TreeView.PermissionsSelector.update.scope.error": "Something went wrong while updating the permission", "RolePermissions.TreeView.PermissionsSelector.update.scope.success": "Update permissions for {role} successfully", "ScopeAssignments.List.search.default": "Search by Role Name", + "SessionTimeout.dialog.label.cancel": "Logout", + "SessionTimeout.dialog.label.ok": "Stay Logged In", + "SessionTimeout.dialog.message": "Your session is about to expire due to inactivity. To keep your session active, click \"Stay Logged In\". If no action is taken, you will be logged out automatically in {time} seconds, for security reasons.", + "SessionTimeout.dialog.title": "Are you still there?", "Settings.Advanced.TenantConf.edit.success": "Advanced Configuration saved successfully", "Settings.Advanced.TenantConfSave.form.cancel": "Cancel", "Settings.Advanced.TenantConfSave.form.save": "Save", diff --git a/portals/admin/src/main/webapp/site/public/locales/fr.json b/portals/admin/src/main/webapp/site/public/locales/fr.json index 0bcddc228b3..327e577b535 100644 --- a/portals/admin/src/main/webapp/site/public/locales/fr.json +++ b/portals/admin/src/main/webapp/site/public/locales/fr.json @@ -974,6 +974,10 @@ "RolePermissions.TreeView.PermissionsSelector.update.scope.error": "Something went wrong while updating the permission", "RolePermissions.TreeView.PermissionsSelector.update.scope.success": "Update permissions for {role} successfully", "ScopeAssignments.List.search.default": "Search by Role Name", + "SessionTimeout.dialog.label.cancel": "Logout", + "SessionTimeout.dialog.label.ok": "Stay Logged In", + "SessionTimeout.dialog.message": "Your session is about to expire due to inactivity. To keep your session active, click \"Stay Logged In\". If no action is taken, you will be logged out automatically in {time} seconds, for security reasons.", + "SessionTimeout.dialog.title": "Are you still there?", "Settings.Advanced.TenantConf.edit.success": "Advanced Configuration saved successfully", "Settings.Advanced.TenantConfSave.form.cancel": "Cancel", "Settings.Advanced.TenantConfSave.form.save": "Save", diff --git a/portals/admin/src/main/webapp/source/src/app/ProtectedApp.jsx b/portals/admin/src/main/webapp/source/src/app/ProtectedApp.jsx index 0f37a545529..277cee9cd1e 100644 --- a/portals/admin/src/main/webapp/source/src/app/ProtectedApp.jsx +++ b/portals/admin/src/main/webapp/source/src/app/ProtectedApp.jsx @@ -29,6 +29,7 @@ import { import Hidden from '@mui/material/Hidden'; import Configurations from 'Config'; import Themes from 'Themes'; +import SessionTimeout from 'AppComponents/SessionTimeout'; import ResourceNotFound from './components/Base/Errors/ResourceNotFound'; import User from './data/User'; import Utils from './data/Utils'; @@ -200,6 +201,7 @@ class Protected extends Component { + {settings ? ( diff --git a/portals/admin/src/main/webapp/source/src/app/components/SessionTimeout.jsx b/portals/admin/src/main/webapp/source/src/app/components/SessionTimeout.jsx new file mode 100644 index 00000000000..3dc67e3219f --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/SessionTimeout.jsx @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + * OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Configurations from 'Config'; +import ConfirmDialog from 'AppComponents/Shared/ConfirmDialog'; + +const SessionTimeout = () => { + const [openDialog, setOpenDialog] = useState(false); + const [remainingTime, setRemainingTime] = useState(0); // To track remaining time + const openDialogRef = useRef(openDialog); // Create a ref to track openDialog state + + // Use refs to hold values that are mutated from closures and should persist + const idleTimeoutRef = useRef(0); + const idleWarningTimeoutRef = useRef(0); + const idleSecondsCounterRef = useRef(0); + + const handleTimeOut = (idleSecondsCount) => { + // Only update the remaining time if the warning timeout is reached + if (idleSecondsCount >= idleWarningTimeoutRef.current) { + setRemainingTime(idleTimeoutRef.current - idleSecondsCount); // Update remaining time + } + if (idleSecondsCount === idleWarningTimeoutRef.current) { + setOpenDialog(true); // Open dialog when warning timeout is reached + } + if (idleSecondsCount === idleTimeoutRef.current) { + // Logout if the idle timeout is reached + setOpenDialog(false); // Close dialog if it was open + window.location = Configurations.app.context + '/services/logout'; + } + }; + + useEffect(() => { + openDialogRef.current = openDialog; // Update the ref whenever openDialog changes + }, [openDialog]); + + useEffect(() => { + if (!(Configurations.sessionTimeout && Configurations.sessionTimeout.enable)) { + return () => { }; + } + + idleTimeoutRef.current = Configurations.sessionTimeout.idleTimeout; + idleWarningTimeoutRef.current = Configurations.sessionTimeout.idleWarningTimeout; + + const resetIdleTimer = () => { + if (!openDialogRef.current) { + idleSecondsCounterRef.current = 0; + } + }; + + document.addEventListener('click', resetIdleTimer); + document.addEventListener('mousemove', resetIdleTimer); + document.addEventListener('keydown', resetIdleTimer); + + const worker = new Worker(new URL('../webWorkers/timer.worker.js', import.meta.url)); + worker.onmessage = () => { + // increment the ref and pass the new value + idleSecondsCounterRef.current += 1; + handleTimeOut(idleSecondsCounterRef.current); + }; + + // Cleanup function to remove event listeners and terminate the worker + return () => { + document.removeEventListener('click', resetIdleTimer); + document.removeEventListener('mousemove', resetIdleTimer); + document.removeEventListener('keydown', resetIdleTimer); + worker.terminate(); + }; + }, []); + + const handleConfirmDialog = (res) => { + if (res) { + setOpenDialog(false); + idleSecondsCounterRef.current = 0; // Reset the idle timer stored in ref + } else { + window.location = Configurations.app.context + '/services/logout'; + } + }; + + return ( +
+ + )} + title={( + + )} + message={( + + )} + labelOk={( + + )} + callback={handleConfirmDialog} + open={openDialog} + /> +
+ ); +}; + +export default injectIntl(SessionTimeout); diff --git a/portals/admin/src/main/webapp/source/src/app/webWorkers/timer.worker.js b/portals/admin/src/main/webapp/source/src/app/webWorkers/timer.worker.js new file mode 100644 index 00000000000..ef3903913ac --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/webWorkers/timer.worker.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// This is a web worker that will be used to run the timer. +// This timer will be used to execute the session timeout +// functionality in the background without blocking the main thread. + +// This is the timer interval in milliseconds. +const TIMER_INTERVAL = 1000; + +// This is the timer function that will be executed in the background. +setInterval(() => { + // disable following rule because linter is unaware of the worker source + // eslint-disable-next-line no-restricted-globals + self.postMessage(''); +}, TIMER_INTERVAL); + +export default {}; diff --git a/portals/devportal/src/main/webapp/site/public/locales/en.json b/portals/devportal/src/main/webapp/site/public/locales/en.json index ba8fc54beaf..cd1f264e940 100644 --- a/portals/devportal/src/main/webapp/site/public/locales/en.json +++ b/portals/devportal/src/main/webapp/site/public/locales/en.json @@ -609,6 +609,10 @@ "LoginDenied.message": "You don't have sufficient privileges to access the Developer Portal.", "LoginDenied.title": "Error 403 : Forbidden", "Pgb3Xj": "Subscribed", + "SessionTimeout.dialog.label.cancel": "Logout", + "SessionTimeout.dialog.label.ok": "Stay Logged In", + "SessionTimeout.dialog.message": "Your session is about to expire due to inactivity. To keep your session active, click \"Stay Logged In\". If no action is taken, you will be logged out automatically in {time} seconds, for security reasons.", + "SessionTimeout.dialog.title": "Are you still there?", "Settings.ChangePasswordForm.Cancel.Button.text": "Cancel", "Settings.ChangePasswordForm.Save.Button.text": "Save", "Settings.ChangePasswordForm.confirm.new.password": "Confirm new Password", diff --git a/portals/devportal/src/main/webapp/source/src/app/AppRouts.jsx b/portals/devportal/src/main/webapp/source/src/app/AppRouts.jsx index fdc1587f960..f5e5e4a5454 100644 --- a/portals/devportal/src/main/webapp/source/src/app/AppRouts.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/AppRouts.jsx @@ -26,6 +26,7 @@ import RedirectToLogin from 'AppComponents/Login/RedirectToLogin'; import Progress from 'AppComponents/Shared/Progress'; import PortalModeRouteGuard from 'AppComponents/Shared/PortalModeRouteGuard'; import { useTheme } from '@mui/material'; +import SessionTimeout from 'AppComponents/SessionTimeout'; import { usePortalMode, PORTAL_MODES } from './utils/PortalModeUtils'; const Apis = lazy(() => import('AppComponents/Apis/Apis' /* webpackChunkName: "Apis" */)); @@ -71,6 +72,7 @@ function AppRouts(props) { return ( }> + {isAuthenticated && } diff --git a/portals/devportal/src/main/webapp/source/src/app/components/SessionTimeout.jsx b/portals/devportal/src/main/webapp/source/src/app/components/SessionTimeout.jsx new file mode 100644 index 00000000000..4334e3b9256 --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/components/SessionTimeout.jsx @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + * OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Settings from 'Settings'; +import ConfirmDialog from 'AppComponents/Shared/ConfirmDialog'; + +const SessionTimeout = () => { + const [openDialog, setOpenDialog] = useState(false); + const [remainingTime, setRemainingTime] = useState(0); // To track remaining time + const openDialogRef = useRef(openDialog); // Create a ref to track openDialog state + + // Use refs to hold values that are mutated from closures and should persist + const idleTimeoutRef = useRef(0); + const idleWarningTimeoutRef = useRef(0); + const idleSecondsCounterRef = useRef(0); + + const handleTimeOut = (idleSecondsCount) => { + // Only update the remaining time if the warning timeout is reached + if (idleSecondsCount >= idleWarningTimeoutRef.current) { + setRemainingTime(idleTimeoutRef.current - idleSecondsCount); // Update remaining time + } + if (idleSecondsCount === idleWarningTimeoutRef.current) { + setOpenDialog(true); // Open dialog when warning timeout is reached + } + if (idleSecondsCount === idleTimeoutRef.current) { + // Logout if the idle timeout is reached + setOpenDialog(false); // Close dialog if it was open + window.location = Settings.app.context + '/services/logout'; + } + }; + + useEffect(() => { + openDialogRef.current = openDialog; // Update the ref whenever openDialog changes + }, [openDialog]); + + useEffect(() => { + if (!(Settings.sessionTimeout && Settings.sessionTimeout.enable)) { + return () => { }; + } + + idleTimeoutRef.current = Settings.sessionTimeout.idleTimeout; + idleWarningTimeoutRef.current = Settings.sessionTimeout.idleWarningTimeout; + + const resetIdleTimer = () => { + if (!openDialogRef.current) { + idleSecondsCounterRef.current = 0; + } + }; + + document.addEventListener('click', resetIdleTimer); + document.addEventListener('mousemove', resetIdleTimer); + document.addEventListener('keydown', resetIdleTimer); + + const worker = new Worker(new URL('../webWorkers/timer.worker.js', import.meta.url)); + worker.onmessage = () => { + // increment the ref and pass the new value + idleSecondsCounterRef.current += 1; + handleTimeOut(idleSecondsCounterRef.current); + }; + + // Cleanup function to remove event listeners and terminate the worker + return () => { + document.removeEventListener('click', resetIdleTimer); + document.removeEventListener('mousemove', resetIdleTimer); + document.removeEventListener('keydown', resetIdleTimer); + worker.terminate(); + }; + }, []); + + const handleConfirmDialog = (res) => { + if (res) { + setOpenDialog(false); + idleSecondsCounterRef.current = 0; // Reset the idle timer stored in ref + } else { + window.location = Settings.app.context + '/services/logout'; + } + }; + + return ( +
+ + )} + title={( + + )} + message={( + + )} + labelOk={( + + )} + callback={handleConfirmDialog} + open={openDialog} + /> +
+ ); +}; + +export default injectIntl(SessionTimeout); diff --git a/portals/devportal/src/main/webapp/source/src/app/webWorkers/timer.worker.js b/portals/devportal/src/main/webapp/source/src/app/webWorkers/timer.worker.js new file mode 100644 index 00000000000..ef3903913ac --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/webWorkers/timer.worker.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// This is a web worker that will be used to run the timer. +// This timer will be used to execute the session timeout +// functionality in the background without blocking the main thread. + +// This is the timer interval in milliseconds. +const TIMER_INTERVAL = 1000; + +// This is the timer function that will be executed in the background. +setInterval(() => { + // disable following rule because linter is unaware of the worker source + // eslint-disable-next-line no-restricted-globals + self.postMessage(''); +}, TIMER_INTERVAL); + +export default {}; diff --git a/portals/publisher/src/main/webapp/site/public/locales/en.json b/portals/publisher/src/main/webapp/site/public/locales/en.json index e603dadf7b0..3d02b7d7ea5 100644 --- a/portals/publisher/src/main/webapp/site/public/locales/en.json +++ b/portals/publisher/src/main/webapp/site/public/locales/en.json @@ -2473,6 +2473,10 @@ "ServiceCatalog.ServicesTableView.ServicesTableView.service.url": "Service URL", "ServiceCatalog.ServicesTableView.ServicesTableView.usage": "Number of Usages", "ServiceCatalog.ServicesTableView.ServicesTableView.version": "Version", + "SessionTimeout.dialog.label.cancel": "Logout", + "SessionTimeout.dialog.label.ok": "Stay Logged In", + "SessionTimeout.dialog.message": "Your session is about to expire due to inactivity. To keep your session active, click “Stay Logged In”. If no action is taken, you will be logged out automatically in {time} seconds, for security reasons.", + "SessionTimeout.dialog.title": "Are you still there?", "SubscriptionApproval.Addons.ListBase.reload": "Reload", "SubscriptionAproval.Addons.ListBase.nodata.message": "No items yet", "Task.SubscriptionCreation.table.button.reject": "Reject", diff --git a/portals/publisher/src/main/webapp/source/src/app/ProtectedApp.jsx b/portals/publisher/src/main/webapp/source/src/app/ProtectedApp.jsx index a815ae534e5..b06409b30b3 100644 --- a/portals/publisher/src/main/webapp/source/src/app/ProtectedApp.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/ProtectedApp.jsx @@ -44,6 +44,7 @@ import Scopes from 'AppComponents/Scopes/Scopes'; import Subscription from 'AppComponents/Subscription/Subscription'; import CommonPolicies from 'AppComponents/CommonPolicies/CommonPolicies'; import GlobalPolicies from 'AppComponents/GlobalPolicies/GlobalPolicies'; +import SessionTimeout from 'AppComponents/SessionTimeout'; import merge from 'lodash/merge'; import User from './data/User'; import Utils from './data/Utils'; @@ -212,6 +213,7 @@ export default class Protected extends Component { + { + const [openDialog, setOpenDialog] = useState(false); + const [remainingTime, setRemainingTime] = useState(0); // To track remaining time + const openDialogRef = useRef(openDialog); // Create a ref to track openDialog state + + // Use refs to hold values that are mutated from closures and should persist + const idleTimeoutRef = useRef(0); + const idleWarningTimeoutRef = useRef(0); + const idleSecondsCounterRef = useRef(0); + + const handleTimeOut = (idleSecondsCount) => { + // Only update the remaining time if the warning timeout is reached + if (idleSecondsCount >= idleWarningTimeoutRef.current) { + setRemainingTime(idleTimeoutRef.current - idleSecondsCount); // Update remaining time + } + if (idleSecondsCount === idleWarningTimeoutRef.current) { + setOpenDialog(true); // Open dialog when warning timeout is reached + } + if (idleSecondsCount === idleTimeoutRef.current) { + // Logout if the idle timeout is reached + setOpenDialog(false); // Close dialog if it was open + window.location = Configurations.app.context + '/services/logout'; + } + }; + + useEffect(() => { + openDialogRef.current = openDialog; // Update the ref whenever openDialog changes + }, [openDialog]); + + useEffect(() => { + if (!(Configurations.sessionTimeout && Configurations.sessionTimeout.enable)) { + return () => { }; + } + + idleTimeoutRef.current = Configurations.sessionTimeout.idleTimeout; + idleWarningTimeoutRef.current = Configurations.sessionTimeout.idleWarningTimeout; + + const resetIdleTimer = () => { + if (!openDialogRef.current) { + idleSecondsCounterRef.current = 0; + } + }; + + document.addEventListener('click', resetIdleTimer); + document.addEventListener('mousemove', resetIdleTimer); + document.addEventListener('keydown', resetIdleTimer); + + const worker = new Worker(new URL('../webWorkers/timer.worker.js', import.meta.url)); + worker.onmessage = () => { + // increment the ref and pass the new value + idleSecondsCounterRef.current += 1; + handleTimeOut(idleSecondsCounterRef.current); + }; + + // Cleanup function to remove event listeners and terminate the worker + return () => { + document.removeEventListener('click', resetIdleTimer); + document.removeEventListener('mousemove', resetIdleTimer); + document.removeEventListener('keydown', resetIdleTimer); + worker.terminate(); + }; + }, []); + + const handleConfirmDialog = (res) => { + if (res) { + setOpenDialog(false); + idleSecondsCounterRef.current = 0; // Reset the idle timer stored in ref + } else { + window.location = Configurations.app.context + '/services/logout'; + } + }; + + return ( +
+ } + title={} + message={ + + } + labelOk={} + callback={handleConfirmDialog} + open={openDialog} + confirmPrimary + /> +
+ ); +}; + +export default injectIntl(SessionTimeout); diff --git a/portals/publisher/src/main/webapp/source/src/app/webWorkers/timer.worker.js b/portals/publisher/src/main/webapp/source/src/app/webWorkers/timer.worker.js new file mode 100644 index 00000000000..ef3903913ac --- /dev/null +++ b/portals/publisher/src/main/webapp/source/src/app/webWorkers/timer.worker.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// This is a web worker that will be used to run the timer. +// This timer will be used to execute the session timeout +// functionality in the background without blocking the main thread. + +// This is the timer interval in milliseconds. +const TIMER_INTERVAL = 1000; + +// This is the timer function that will be executed in the background. +setInterval(() => { + // disable following rule because linter is unaware of the worker source + // eslint-disable-next-line no-restricted-globals + self.postMessage(''); +}, TIMER_INTERVAL); + +export default {};