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')