This is an automated email from the ASF dual-hosted git repository.

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new c20b543b75 [#11792] web-v2(UI): Forces logout after 5 hours of total 
session time, regardless of activity. (#11799)
c20b543b75 is described below

commit c20b543b75319078eaa28ce5b236d3031dcc631d
Author: Qian Xia <[email protected]>
AuthorDate: Thu Jun 25 18:04:42 2026 +0800

    [#11792] web-v2(UI): Forces logout after 5 hours of total session time, 
regardless of activity. (#11799)
    
    ### What changes were proposed in this pull request?
    Timer starts when user logs in
    
    Timer does NOT reset when user clicks or types
    
    After exactly 5 hours, user is automatically logged out
    
    User must log in again with their credentials
    
    ### Why are the changes needed?
    N/A
    
    Fix: #11792
    
    ### Does this PR introduce _any_ user-facing change?
    N/A
    
    ### How was this patch tested?
    manually
---
 web-v2/web/.env                                    |  15 ++
 web-v2/web/.env.static                             |  15 ++
 web-v2/web/src/app/login/page.js                   |  11 ++
 .../web/src/lib/hooks/useAbsoluteSessionTimeout.js | 158 +++++++++++++++++++++
 web-v2/web/src/lib/provider/IdleSessionProvider.js |  81 ++++++++---
 5 files changed, 260 insertions(+), 20 deletions(-)

diff --git a/web-v2/web/.env b/web-v2/web/.env
index e3900338da..37a961f57f 100644
--- a/web-v2/web/.env
+++ b/web-v2/web/.env
@@ -24,3 +24,18 @@ NEXT_PUBLIC_BASE_PATH=''
 NEXT_PUBLIC_API_URL=http://localhost:8090
 NEXT_PUBLIC_OAUTH_URI=http://localhost:9000
 NEXT_PUBLIC_OAUTH_PATH=/oauth2/token
+# Absolute session duration in milliseconds (default: 5 hours = 18000000ms).
+# Forces logout after this duration regardless of user activity.
+# All tabs share the same session start time stored in localStorage.
+# NEXT_PUBLIC_MAX_SESSION_DURATION_MS=18000000
+
+# Idle timeout in milliseconds (default: 15 minutes = 900000ms).
+# Logout is triggered when no user activity (mouse, keyboard, touch, scroll)
+# is detected for this duration. Activity in any tab resets the timer across
+# all tabs via BroadcastChannel.
+# NEXT_PUBLIC_IDLE_TIMEOUT_MS=900000
+
+# Idle warning lead time in milliseconds (default: 60 seconds = 60000ms).
+# A warning modal is shown this many milliseconds before the idle timeout
+# expires, giving the user a chance to stay signed in.
+# NEXT_PUBLIC_IDLE_WARNING_LEAD_MS=60000
diff --git a/web-v2/web/.env.static b/web-v2/web/.env.static
index 3e4e094bb3..8ba84be7f7 100644
--- a/web-v2/web/.env.static
+++ b/web-v2/web/.env.static
@@ -26,3 +26,18 @@ NEXT_PUBLIC_BASE_PATH=/ui
 NEXT_PUBLIC_API_URL=http://localhost:8090
 NEXT_PUBLIC_OAUTH_URI=http://localhost:9000
 NEXT_PUBLIC_OAUTH_PATH=/oauth2/token
+# Absolute session duration in milliseconds (default: 5 hours = 18000000ms).
+# Forces logout after this duration regardless of user activity.
+# All tabs share the same session start time stored in localStorage.
+# NEXT_PUBLIC_MAX_SESSION_DURATION_MS=18000000
+
+# Idle timeout in milliseconds (default: 15 minutes = 900000ms).
+# Logout is triggered when no user activity (mouse, keyboard, touch, scroll)
+# is detected for this duration. Activity in any tab resets the timer across
+# all tabs via BroadcastChannel.
+# NEXT_PUBLIC_IDLE_TIMEOUT_MS=900000
+
+# Idle warning lead time in milliseconds (default: 60 seconds = 60000ms).
+# A warning modal is shown this many milliseconds before the idle timeout
+# expires, giving the user a chance to stay signed in.
+# NEXT_PUBLIC_IDLE_WARNING_LEAD_MS=60000
diff --git a/web-v2/web/src/app/login/page.js b/web-v2/web/src/app/login/page.js
index ee5e5d7a53..ea17bafcc2 100644
--- a/web-v2/web/src/app/login/page.js
+++ b/web-v2/web/src/app/login/page.js
@@ -39,6 +39,7 @@ const { Title } = Typography
 const LoginContent = () => {
   const searchParams = useSearchParams()
   const inactiveReason = searchParams.get('reason') === 'inactive'
+  const maxDurationReason = searchParams.get('reason') === 'max_duration'
   const [providerType, setProviderType] = useState(null)
   const dispatch = useAppDispatch()
 
@@ -83,6 +84,16 @@ const LoginContent = () => {
           />
         )}
 
+        {maxDurationReason && (
+          <Alert
+            message='Your session has reached its maximum duration. Please 
sign in again.'
+            type='warning'
+            showIcon
+            closable
+            className='mb-6'
+          />
+        )}
+
         {useOidcLogin ? <OidcLogin /> : <DefaultLogin />}
       </Card>
     </Flex>
diff --git a/web-v2/web/src/lib/hooks/useAbsoluteSessionTimeout.js 
b/web-v2/web/src/lib/hooks/useAbsoluteSessionTimeout.js
new file mode 100644
index 0000000000..58dab82730
--- /dev/null
+++ b/web-v2/web/src/lib/hooks/useAbsoluteSessionTimeout.js
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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.
+ */
+
+'use client'
+
+import { useEffect, useRef, useState, useCallback } from 'react'
+
+/**
+ * localStorage key for the session start timestamp.
+ * Stored in localStorage (not sessionStorage) so all tabs from the same
+ * browser share the same session start time.
+ */
+const SESSION_START_KEY = 'grtv-session-start'
+
+/**
+ * Default maximum session duration: 5 hours in milliseconds.
+ * Overridable via NEXT_PUBLIC_MAX_SESSION_DURATION_MS environment variable.
+ */
+const DEFAULT_MAX_SESSION_DURATION_MS = (() => {
+  const envVal = process.env.NEXT_PUBLIC_MAX_SESSION_DURATION_MS
+  const parsed = envVal ? Number(envVal) : NaN
+
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : 5 * 60 * 60 * 1000
+})()
+
+/**
+ * Custom hook for absolute session duration enforcement.
+ *
+ * Unlike idle timeout (which resets on user activity), the absolute session
+ * timer starts when the user logs in and never resets. After the configured
+ * duration (default: 5 hours), the session is forcibly terminated.
+ *
+ * The session start time is stored in localStorage so all tabs from the same
+ * browser share the same absolute deadline.
+ *
+ * @param {Object} options
+ * @param {boolean} options.isAuthenticated - Whether the user is currently 
authenticated.
+ *   When true and no session start exists, the current time is recorded.
+ *   When false, the session start is cleared.
+ * @param {number} [options.maxDurationMs] - Maximum session duration in 
milliseconds.
+ *   Defaults to NEXT_PUBLIC_MAX_SESSION_DURATION_MS env var or 5 hours.
+ * @returns {{ isExpired: boolean, remainingMs: number, clearSession: () => 
void }}
+ */
+export function useAbsoluteSessionTimeout({ isAuthenticated, maxDurationMs = 
DEFAULT_MAX_SESSION_DURATION_MS } = {}) {
+  const [isExpired, setIsExpired] = useState(false)
+  const [remainingMs, setRemainingMs] = useState(maxDurationMs)
+  const intervalRef = useRef(null)
+
+  // Record session start time when user becomes authenticated
+  useEffect(() => {
+    if (isAuthenticated) {
+      const existing = localStorage.getItem(SESSION_START_KEY)
+
+      if (existing) {
+        // Session start exists (e.g., another tab logged in). Recalculate
+        // expiry so that if a new login refreshed the timestamp, this tab
+        // clears its stale isExpired flag.
+        const start = Number(existing)
+        if (!Number.isFinite(start) || start <= 0) {
+          const now = Date.now()
+          localStorage.setItem(SESSION_START_KEY, String(now))
+          setRemainingMs(maxDurationMs)
+          setIsExpired(false)
+
+          return
+        }
+
+        const elapsed = Date.now() - start
+        const remaining = Math.max(0, maxDurationMs - elapsed)
+
+        setRemainingMs(remaining)
+        if (remaining > 0) {
+          setIsExpired(false)
+        }
+      } else {
+        localStorage.setItem(SESSION_START_KEY, String(Date.now()))
+      }
+    } else {
+      // Clear session start when user is not authenticated
+      localStorage.removeItem(SESSION_START_KEY)
+      setIsExpired(false)
+      setRemainingMs(maxDurationMs)
+    }
+  }, [isAuthenticated, maxDurationMs])
+
+  // Poll every second to check if the absolute timeout has been reached
+  useEffect(() => {
+    if (!isAuthenticated) {
+      return
+    }
+
+    const check = () => {
+      const startStr = localStorage.getItem(SESSION_START_KEY)
+
+      if (!startStr) {
+        return
+      }
+
+      const start = Number(startStr)
+
+      // Guard against corrupted or tampered values
+      if (!Number.isFinite(start) || start <= 0) {
+        const now = Date.now()
+
+        localStorage.setItem(SESSION_START_KEY, String(now))
+        setIsExpired(false)
+        setRemainingMs(maxDurationMs)
+
+        return
+      }
+
+      const now = Date.now()
+      const elapsed = now - start
+      const remaining = Math.max(0, maxDurationMs - elapsed)
+
+      setRemainingMs(remaining)
+      setIsExpired(remaining <= 0)
+    }
+
+    // Check immediately, then every second
+    check()
+    intervalRef.current = setInterval(check, 1000)
+
+    return () => {
+      if (intervalRef.current) {
+        clearInterval(intervalRef.current)
+        intervalRef.current = null
+      }
+    }
+  }, [isAuthenticated, maxDurationMs])
+
+  /**
+   * Clears the session start time. Called after logout to reset state.
+   */
+  const clearSession = useCallback(() => {
+    localStorage.removeItem(SESSION_START_KEY)
+    setIsExpired(false)
+    setRemainingMs(maxDurationMs)
+  }, [maxDurationMs])
+
+  return { isExpired, remainingMs, clearSession }
+}
diff --git a/web-v2/web/src/lib/provider/IdleSessionProvider.js 
b/web-v2/web/src/lib/provider/IdleSessionProvider.js
index 4b3422bc84..0ffdcb8302 100644
--- a/web-v2/web/src/lib/provider/IdleSessionProvider.js
+++ b/web-v2/web/src/lib/provider/IdleSessionProvider.js
@@ -23,6 +23,7 @@ import { useEffect, useCallback, useRef, useState } from 
'react'
 import { useRouter, usePathname } from 'next/navigation'
 import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
 import { useIdleTimeout } from '@/lib/hooks/useIdleTimeout'
+import { useAbsoluteSessionTimeout } from 
'@/lib/hooks/useAbsoluteSessionTimeout'
 import { useBroadcastChannel } from '@/lib/hooks/useBroadcastChannel'
 import { logoutAction } from '@/lib/store/auth'
 import IdleSessionContext from './IdleSessionContext'
@@ -84,13 +85,17 @@ export default function IdleSessionProvider({
   const authToken = useAppSelector(state => state.auth.authToken)
 
   // Only enable idle timeout when the user is authenticated and not on login 
page.
-  // During bootstrap (authType === null), fall back to persisted token in 
localStorage
-  // to avoid prematurely treating the user as unauthenticated.
+  // Fall back to persisted token in localStorage to avoid prematurely treating
+  // the user as unauthenticated during the bootstrap window where authType has
+  // been set (via getAuthConfigs) but authToken has not yet been dispatched
+  // (initAuth is still running). Without this fallback, a race between the two
+  // Redux updates causes isAuthenticated to briefly flip to false, which
+  // triggers a cross-tab logout via BroadcastChannel.
   const isAuthenticated =
     authType === 'simple'
       ? !!sessionStorage.getItem('simpleAuthUser')
-      : !!(authToken || (authType === null && 
localStorage.getItem('accessToken')))
-  const isLoginPage = pathname === '/login'
+      : !!(authToken || localStorage.getItem('accessToken'))
+  const isLoginPage = pathname.endsWith('/login')
 
   const [state, setState] = useState('active')
   const [warningCountdown, setWarningCountdown] = 
useState(msToSeconds(warningLeadMs))
@@ -109,6 +114,11 @@ export default function IdleSessionProvider({
     paused: state === 'warning'
   })
 
+  // Absolute session duration: forces logout after a fixed time regardless of 
activity
+  const { isExpired: isAbsoluteExpired } = useAbsoluteSessionTimeout({
+    isAuthenticated
+  })
+
   const { sendMessage, onMessage } = useBroadcastChannel()
 
   // Track warning threshold: when idleTimeRemaining drops below 
warningLeadMs, show warning
@@ -145,21 +155,36 @@ export default function IdleSessionProvider({
   }, [state, idleTimeRemaining])
 
   // Handle logout (either from timeout or "Sign out now")
-  const handleLogout = useCallback(() => {
-    if (loggedOutRef.current) {
-      return
-    }
-
-    loggedOutRef.current = true
-    setState('expired')
+  const handleLogout = useCallback(
+    (reason = 'inactive') => {
+      if (loggedOutRef.current) {
+        return
+      }
 
-    // Broadcast logout with reason to other tabs
-    sendMessage({ type: 'logout', reason: 'inactive', timestamp: Date.now() })
+      loggedOutRef.current = true
+      setState('expired')
 
-    // Dispatch logout action (handles both OAuth and simple auth)
-    // Pass reason to show inactivity message on login page
-    dispatch(logoutAction({ router, reason: 'inactive' }))
-  }, [dispatch, router, sendMessage])
+      // Broadcast logout with reason to other tabs
+      sendMessage({ type: 'logout', reason, timestamp: Date.now() })
+
+      // Dispatch logout action (handles both OAuth and simple auth)
+      // Pass reason to show appropriate message on login page
+      dispatch(logoutAction({ router, reason }))
+
+      // Safety net: navigate to login page after a short delay.
+      // logoutAction is an async thunk — if it rejects (e.g., OIDC
+      // signoutRedirect throws) the error is silently swallowed and
+      // router.push inside the thunk never executes.  This fallback
+      // guarantees the user lands on /login regardless.
+      const loginUrl = reason ? `/login?reason=${encodeURIComponent(reason)}` 
: '/login'
+      setTimeout(() => {
+        if (!window.location.pathname.endsWith('/login')) {
+          router.push(loginUrl)
+        }
+      }, 1500)
+    },
+    [dispatch, router, sendMessage]
+  )
 
   // Handle "Stay signed in" action (IST-REQ-003)
   const handleStaySignedIn = useCallback(() => {
@@ -193,6 +218,15 @@ export default function IdleSessionProvider({
         if (!loggedOutRef.current) {
           loggedOutRef.current = true
           dispatch(logoutAction({ router, reason: message.reason }))
+
+          // Same safety net as handleLogout — ensures navigation even if
+          // logoutAction's internal router.push never executes.
+          const loginUrl = message.reason ? 
`/login?reason=${encodeURIComponent(message.reason)}` : '/login'
+          setTimeout(() => {
+            if (!window.location.pathname.endsWith('/login')) {
+              router.push(loginUrl)
+            }
+          }, 1500)
         }
       }
     })
@@ -223,13 +257,20 @@ export default function IdleSessionProvider({
     }
   }, [state, handleLogout])
 
-  // Detect authenticated→unauthenticated transition (e.g., manual logout from 
user menu)
-  // and broadcast logout to other tabs
+  // Auto-logout when absolute session duration is exceeded
+  useEffect(() => {
+    if (isAbsoluteExpired && !loggedOutRef.current) {
+      handleLogout('max_duration')
+    }
+  }, [isAbsoluteExpired, handleLogout])
+
+  // Detect authentication state transitions and coordinate cross-tab state
   useEffect(() => {
     const wasAuthenticated = wasAuthenticatedRef.current
 
     // Reset guard and timer state on unauthenticated→authenticated transition
-    // This handles SPA flows where user logs out and logs back in without a 
full page reload
+    // This handles both SPA flows and cross-tab re-login scenarios where
+    // another tab triggered a login that refreshed localStorage tokens.
     if (!wasAuthenticated && isAuthenticated) {
       loggedOutRef.current = false
       setState('active')

Reply via email to