This is an automated email from the ASF dual-hosted git repository.
LauraXia123 pushed a commit to branch 1.2.0-hotfix
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/1.2.0-hotfix by this push:
new 5f8a3dab50 [#11630] web-v2(UI): Inactivity Session Timeout and Forced
Re-authentication (#11708)
5f8a3dab50 is described below
commit 5f8a3dab50c4b9a30fd70570de055f9f9c857dc2
Author: Qian Xia <[email protected]>
AuthorDate: Wed Jun 17 13:14:45 2026 +0800
[#11630] web-v2(UI): Inactivity Session Timeout and Forced
Re-authentication (#11708)
<img width="2942" height="1434" alt="image"
src="https://github.com/user-attachments/assets/81477a44-f4ae-47fc-a098-4697a0fb1759"
/>
Overview
When a user is inactive for a configurable period, a warning modal
appears with a countdown timer. If the user doesn't respond, they are
automatically signed out. The feature works across multiple browser tabs
via BroadcastChannel synchronization.
Configuration
│ Environment Variable │ Default │ Description │
│ NEXT_PUBLIC_IDLE_TIMEOUT_MS │ 900000 (15 min) │ Total idle timeout
before forced logout │
│ NEXT_PUBLIC_IDLE_WARNING_LEAD_MS │ 60000 (60s) │ Warning countdown
duration before timeout │
State Machine
active → warning → expired
- active: Normal operation, idle timer running
- warning: Warning modal shown with countdown
- expired: Auto-logout triggered
Cross-Tab Broadcast
Messages are broadcast via BroadcastChannel when:
- activity: User interacts with any tab → other tabs reset idle timer
- stay_signed_in: User clicks "Stay signed in" → other tabs dismiss
warning
- logout: User signs out or timeout → other tabs redirect to /login
Logout Behavior by Auth Mode
│ Auth Mode │ Logout Action │
│ Simple │ Clears sessionStorage, redirects to /login │ │ OAuth/OIDC │
Clears localStorage + OIDC user store, calls IdP signoutRedirect() with
id_token_hint │
Exclusions
- Login page (/login) does not show the warning modal
- Unauthenticated users are not affected
- Events fired while tab is hidden are ignored
N/A
Fix: #11630
N/A
Manually
[#11630] web-v2(UI): Inactivity Session Timeout and Forced
Re-authentication
<!--
1. Title: [#<issue>] <type>(<scope>): <subject>
Examples:
- "[#123] feat(operator): Support xxx"
- "[#233] fix: Check null before access result in xxx"
- "[MINOR] refactor: Fix typo in variable name"
- "[MINOR] docs: Fix typo in README"
- "[#255] test: Fix flaky test NameOfTheTest"
Reference: https://www.conventionalcommits.org/en/v1.0.0/
2. If the PR is unfinished, please mark this PR as draft.
-->
### What changes were proposed in this pull request?
(Please outline the changes and how this PR fixes the issue.)
### Why are the changes needed?
(Please clarify why the changes are needed. For instance,
1. If you propose a new API, clarify the use case for a new API.
2. If you fix a bug, describe the bug.)
Fix: #(issue)
### Does this PR introduce _any_ user-facing change?
(Please list the user-facing changes introduced by your change,
including
1. Change in user-facing APIs.
2. Addition or removal of property keys.)
### How was this patch tested?
(Please test your changes, and provide instructions on how to test it:
1. If you add a feature or fix a bug, add a test to cover your changes.
2. If you fix a flaky test, repeat it for many times to prove it works.)
---
web-v2/web/src/app/login/page.js | 27 ++-
web-v2/web/src/components/IdleWarningModal.js | 114 +++++++++
web-v2/web/src/lib/auth/providers/factory.js | 9 +
web-v2/web/src/lib/auth/providers/factory.test.js | 58 +++++
web-v2/web/src/lib/hooks/useBroadcastChannel.js | 149 ++++++++++++
web-v2/web/src/lib/hooks/useIdleTimeout.js | 176 ++++++++++++++
web-v2/web/src/lib/provider/IdleSessionContext.js | 64 +++++
web-v2/web/src/lib/provider/IdleSessionProvider.js | 265 +++++++++++++++++++++
web-v2/web/src/lib/provider/index.js | 5 +-
web-v2/web/src/lib/provider/session.js | 12 -
web-v2/web/src/lib/store/auth/index.js | 101 +++++---
11 files changed, 929 insertions(+), 51 deletions(-)
diff --git a/web-v2/web/src/app/login/page.js b/web-v2/web/src/app/login/page.js
index 2f08baedfa..ee5e5d7a53 100644
--- a/web-v2/web/src/app/login/page.js
+++ b/web-v2/web/src/app/login/page.js
@@ -20,10 +20,11 @@
'use client'
import Image from 'next/image'
+import { useSearchParams } from 'next/navigation'
import { Roboto } from 'next/font/google'
-import { Card, Flex, Typography } from 'antd'
+import { Alert, Card, Flex, Typography } from 'antd'
import { cn } from '@/lib/utils/tailwind'
-import { useEffect, useState } from 'react'
+import { useEffect, useState, Suspense } from 'react'
import OidcLogin from './components/OidcLogin'
import DefaultLogin from './components/DefaultLogin'
@@ -35,7 +36,9 @@ const fonts = Roboto({ subsets: ['latin'], weight: ['400'],
display: 'swap' })
const { Title } = Typography
-const LoginPage = () => {
+const LoginContent = () => {
+ const searchParams = useSearchParams()
+ const inactiveReason = searchParams.get('reason') === 'inactive'
const [providerType, setProviderType] = useState(null)
const dispatch = useAppDispatch()
@@ -70,10 +73,28 @@ const LoginPage = () => {
</Title>
</Flex>
+ {inactiveReason && (
+ <Alert
+ message='Your session has expired due to inactivity. Please sign
in again.'
+ type='info'
+ showIcon
+ closable
+ className='mb-6'
+ />
+ )}
+
{useOidcLogin ? <OidcLogin /> : <DefaultLogin />}
</Card>
</Flex>
)
}
+const LoginPage = () => {
+ return (
+ <Suspense>
+ <LoginContent />
+ </Suspense>
+ )
+}
+
export default LoginPage
diff --git a/web-v2/web/src/components/IdleWarningModal.js
b/web-v2/web/src/components/IdleWarningModal.js
new file mode 100644
index 0000000000..bc5b565801
--- /dev/null
+++ b/web-v2/web/src/components/IdleWarningModal.js
@@ -0,0 +1,114 @@
+/*
+ * 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 { useState, useEffect, useRef } from 'react'
+import { Modal, Typography, Button, Flex } from 'antd'
+import { ClockCircleOutlined } from '@ant-design/icons'
+
+const { Text } = Typography
+
+/**
+ * Warning modal displayed before idle session timeout (IST-REQ-003).
+ * Shows a live countdown and provides "Stay signed in" / "Sign out now"
actions.
+ *
+ * The modal is non-dismissible by clicking the overlay or pressing Escape
+ * to prevent accidental dismissal.
+ *
+ * @param {Object} props
+ * @param {boolean} props.open - Whether the modal is visible
+ * @param {number} props.countdownSeconds - Initial countdown value in seconds
+ * @param {() => void} props.onStaySignedIn - Callback when user clicks "Stay
signed in"
+ * @param {() => void} props.onSignOut - Callback when user clicks "Sign out
now" or countdown reaches zero
+ */
+export default function IdleWarningModal({ open, countdownSeconds,
onStaySignedIn, onSignOut }) {
+ const [remaining, setRemaining] = useState(countdownSeconds)
+ const onSignOutRef = useRef(onSignOut)
+
+ // Keep ref in sync with latest callback
+ useEffect(() => {
+ onSignOutRef.current = onSignOut
+ }, [onSignOut])
+
+ // Reset countdown when the modal opens or countdownSeconds changes
+ useEffect(() => {
+ if (open) {
+ setRemaining(countdownSeconds)
+ }
+ }, [open, countdownSeconds])
+
+ // Countdown timer - only depends on open, not remaining
+ // Uses functional setRemaining to avoid recreating interval every second
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ const timer = setInterval(() => {
+ setRemaining(prev => {
+ if (prev <= 1) {
+ clearInterval(timer)
+
+ // Auto-logout when countdown reaches zero
+ onSignOutRef.current()
+
+ return 0
+ }
+
+ return prev - 1
+ })
+ }, 1000)
+
+ return () => clearInterval(timer)
+ }, [open])
+
+ return (
+ <Modal
+ open={open}
+ title={
+ <Flex align='center' gap={8}>
+ <ClockCircleOutlined style={{ color: '#faad14', fontSize: 20 }} />
+ <span>Session Expiring Soon</span>
+ </Flex>
+ }
+ footer={[
+ <Button key='signout' danger onClick={onSignOut}>
+ Sign out now
+ </Button>,
+ <Button key='stay' type='primary' onClick={onStaySignedIn}>
+ Stay signed in
+ </Button>
+ ]}
+ closable={false}
+ maskClosable={false}
+ keyboard={false}
+ centered
+ width={420}
+ >
+ <div className='py-2'>
+ <Text>Your session will expire in {remaining} seconds due to
inactivity.</Text>
+ <div className='mt-4 text-center'>
+ <Text className='text-3xl font-bold
text-orange-500'>{remaining}</Text>
+ <Text className='ml-2 text-gray-500'>seconds</Text>
+ </div>
+ </div>
+ </Modal>
+ )
+}
diff --git a/web-v2/web/src/lib/auth/providers/factory.js
b/web-v2/web/src/lib/auth/providers/factory.js
index 4b8518a9aa..e520c468db 100644
--- a/web-v2/web/src/lib/auth/providers/factory.js
+++ b/web-v2/web/src/lib/auth/providers/factory.js
@@ -118,6 +118,15 @@ class OAuthProviderFactory {
return provider.getType()
}
+
+ /**
+ * Reset the factory state to force re-initialization on next getProvider()
call.
+ * This should be called during logout to ensure clean state for next login.
+ */
+ reset() {
+ this.currentProvider = null
+ this.configPromise = null
+ }
}
// Export singleton instance
diff --git a/web-v2/web/src/lib/auth/providers/factory.test.js
b/web-v2/web/src/lib/auth/providers/factory.test.js
index 3e1ac0855e..cf8ba14976 100644
--- a/web-v2/web/src/lib/auth/providers/factory.test.js
+++ b/web-v2/web/src/lib/auth/providers/factory.test.js
@@ -202,4 +202,62 @@ describe('OAuth Provider Factory', () => {
expect(providerType).toBe('oidc')
})
})
+
+ describe('reset', () => {
+ it('should clear cached provider and configPromise', async () => {
+ fetchMock.mockResolvedValueOnce(mockSuccessResponse(oidcConfig))
+
+ const provider1 = await factory.getProvider()
+
+ // Verify provider is cached
+ expect(factory.currentProvider).toBe(provider1)
+ expect(factory.configPromise).toBeDefined()
+
+ factory.reset()
+
+ // Verify cache is cleared
+ expect(factory.currentProvider).toBeNull()
+ expect(factory.configPromise).toBeNull()
+ })
+
+ it('should force fresh /configs fetch on next getProvider() call', async
() => {
+ fetchMock.mockResolvedValue(mockSuccessResponse(oidcConfig))
+
+ const provider1 = await factory.getProvider()
+
+ // Only one fetch call so far
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+
+ factory.reset()
+
+ const provider2 = await factory.getProvider()
+
+ // Should have fetched configs again
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock).toHaveBeenLastCalledWith('/configs')
+
+ // Should be a new provider instance
+ expect(provider2).not.toBe(provider1)
+ expect(provider2.getType()).toBe('oidc')
+ })
+
+ it('should allow recovery after fetch error', async () => {
+ // First call fails
+ fetchMock.mockRejectedValueOnce(new Error('Network error'))
+
+ await expect(factory.getProvider()).rejects.toThrow('Network error')
+
+ // configPromise should be cleared on error (existing behavior)
+ // but reset() should also work for explicit cleanup
+ factory.reset()
+
+ // Second call succeeds
+ fetchMock.mockResolvedValueOnce(mockSuccessResponse(oidcConfig))
+
+ const provider = await factory.getProvider()
+
+ expect(provider).toBeDefined()
+ expect(provider.getType()).toBe('oidc')
+ })
+ })
})
diff --git a/web-v2/web/src/lib/hooks/useBroadcastChannel.js
b/web-v2/web/src/lib/hooks/useBroadcastChannel.js
new file mode 100644
index 0000000000..903bf1fccb
--- /dev/null
+++ b/web-v2/web/src/lib/hooks/useBroadcastChannel.js
@@ -0,0 +1,149 @@
+/*
+ * 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, useCallback } from 'react'
+
+const CHANNEL_NAME = 'grtv-idle-session'
+const STORAGE_KEY_PREFIX = 'grtv-idle-broadcast-'
+
+/**
+ * Generates a unique tab identifier.
+ * Uses crypto.randomUUID() when available, falls back to a random string.
+ */
+function generateTabId() {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID()
+ }
+
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
+}
+
+/**
+ * Hook for cross-tab communication using BroadcastChannel with localStorage
fallback.
+ * Activity messages are throttled to max 1 per 2 seconds (IST-REQ-005).
+ *
+ * @returns {{ sendMessage: (message: { type: string, timestamp: number }) =>
void, onMessage: (callback: Function) => void, tabId: string }}
+ */
+export function useBroadcastChannel() {
+ const tabIdRef = useRef(generateTabId())
+ const channelRef = useRef(null)
+ const callbackRef = useRef(null)
+ const lastSendTimeRef = useRef(0)
+ const storageListenerRef = useRef(null)
+
+ // Initialize the communication channel
+ useEffect(() => {
+ const tabId = tabIdRef.current
+
+ if (typeof BroadcastChannel !== 'undefined') {
+ // Primary: BroadcastChannel API
+ const channel = new BroadcastChannel(CHANNEL_NAME)
+ channelRef.current = channel
+
+ channel.onmessage = event => {
+ const message = event.data
+
+ // Ignore messages from the same tab
+ if (message && message.tabId !== tabId && callbackRef.current) {
+ callbackRef.current(message)
+ }
+ }
+ } else {
+ // Fallback: localStorage storage events
+ const handleStorage = event => {
+ if (!event.key || !event.key.startsWith(STORAGE_KEY_PREFIX)) {
+ return
+ }
+
+ try {
+ const message = JSON.parse(event.newValue)
+
+ // Ignore messages from the same tab
+ if (message && message.tabId !== tabId && callbackRef.current) {
+ callbackRef.current(message)
+ }
+ } catch {
+ // Ignore invalid JSON
+ }
+ }
+
+ storageListenerRef.current = handleStorage
+ window.addEventListener('storage', handleStorage)
+ }
+
+ return () => {
+ if (channelRef.current) {
+ channelRef.current.close()
+ channelRef.current = null
+ }
+
+ if (storageListenerRef.current) {
+ window.removeEventListener('storage', storageListenerRef.current)
+ storageListenerRef.current = null
+ }
+ }
+ }, [])
+
+ /**
+ * Sends a message to all other tabs.
+ * Activity messages are throttled to max 1 per 2 seconds.
+ *
+ * @param {{ type: string, timestamp: number }} message
+ */
+ const sendMessage = useCallback(message => {
+ const now = Date.now()
+ const tabId = tabIdRef.current
+ const fullMessage = { ...message, tabId }
+
+ // Throttle activity messages to 1 per 2 seconds
+ if (message.type === 'activity') {
+ if (now - lastSendTimeRef.current < 2000) {
+ return
+ }
+
+ lastSendTimeRef.current = now
+ }
+
+ if (channelRef.current) {
+ channelRef.current.postMessage(fullMessage)
+ } else {
+ // Fallback: write to localStorage, which fires a storage event in other
tabs
+ const key = `${STORAGE_KEY_PREFIX}${now}`
+ localStorage.setItem(key, JSON.stringify(fullMessage))
+
+ // Clean up after a short delay
+ setTimeout(() => {
+ localStorage.removeItem(key)
+ }, 5000)
+ }
+ }, [])
+
+ /**
+ * Registers a callback to handle incoming messages from other tabs.
+ *
+ * @param {Function} callback
+ */
+ const onMessage = useCallback(callback => {
+ callbackRef.current = callback
+ }, [])
+
+ return { sendMessage, onMessage, tabId: tabIdRef.current }
+}
diff --git a/web-v2/web/src/lib/hooks/useIdleTimeout.js
b/web-v2/web/src/lib/hooks/useIdleTimeout.js
new file mode 100644
index 0000000000..dc4246d748
--- /dev/null
+++ b/web-v2/web/src/lib/hooks/useIdleTimeout.js
@@ -0,0 +1,176 @@
+/*
+ * 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, useCallback, useState } from 'react'
+
+/**
+ * DOM events that constitute genuine user interaction and reset the idle
timer.
+ * Events fired while the document is hidden are ignored.
+ */
+const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart',
'scroll', 'visibilitychange']
+
+/**
+ * Sentinel value indicating the hook should be effectively disabled.
+ * When idleTimeoutMs is this value, no polling loop or event listeners are
attached.
+ */
+const DISABLED_SENTINEL = Number.MAX_SAFE_INTEGER
+
+/**
+ * Default idle timeout: 15 minutes in milliseconds.
+ * Overridable via NEXT_PUBLIC_IDLE_TIMEOUT_MS environment variable.
+ */
+const DEFAULT_IDLE_TIMEOUT_MS = (() => {
+ const envVal = process.env.NEXT_PUBLIC_IDLE_TIMEOUT_MS
+ const parsed = envVal ? Number(envVal) : NaN
+
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 15 * 60 * 1000
+})()
+
+/**
+ * Throttle interval for activity events in milliseconds.
+ * Prevents excessive React state updates from high-frequency events like
mousemove/scroll.
+ */
+const ACTIVITY_THROTTLE_MS = 1000
+
+/**
+ * Custom hook for idle timeout detection using DOM event listeners and
+ * requestAnimationFrame polling. Provides full control over timer reset
+ * for "Stay signed in" and cross-tab synchronization.
+ *
+ * Performance optimizations:
+ * - Skips polling loop entirely when idleTimeoutMs is Number.MAX_SAFE_INTEGER
(unauthenticated)
+ * - Throttles state updates to once per second (only when remaining seconds
change)
+ * - Throttles activity event handling to avoid excessive resets from
high-frequency events
+ *
+ * @param {Object} options
+ * @param {number} [options.idleTimeoutMs] - Idle timeout in milliseconds
(default: 15 minutes).
+ * Pass Number.MAX_SAFE_INTEGER to disable the hook entirely.
+ * @param {boolean} [options.paused] - When true, DOM activity events are
ignored (timer keeps running).
+ * Used to keep the warning modal visible until the user explicitly acts.
+ * Programmatic calls to resetActivity() still work when paused.
+ * @returns {{ isIdle: boolean, resetActivity: () => void, idleTimeRemaining:
number }}
+ */
+export function useIdleTimeout({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
paused = false } = {}) {
+ const lastActivityRef = useRef(null)
+ const rafIdRef = useRef(null)
+ const pausedRef = useRef(paused)
+ const lastReportedSecondRef = useRef(null)
+ const lastResetTimeRef = useRef(0)
+ const [isIdle, setIsIdle] = useState(false)
+ const [idleTimeRemaining, setIdleTimeRemaining] = useState(idleTimeoutMs)
+
+ // Keep pausedRef in sync with the latest paused prop
+ useEffect(() => {
+ pausedRef.current = paused
+ }, [paused])
+
+ const resetActivity = useCallback(() => {
+ lastActivityRef.current = Date.now()
+ setIsIdle(false)
+ setIdleTimeRemaining(idleTimeoutMs)
+ lastReportedSecondRef.current = null
+ }, [idleTimeoutMs])
+
+ useEffect(() => {
+ // Skip entirely when disabled (unauthenticated sentinel)
+ if (idleTimeoutMs === DISABLED_SENTINEL) {
+ return
+ }
+
+ // Initialize the last activity timestamp on mount
+ lastActivityRef.current = Date.now()
+ lastReportedSecondRef.current = null
+ lastResetTimeRef.current = 0
+
+ const handleActivityEvent = event => {
+ // Ignore events fired while the tab is hidden (IST-REQ-001)
+ if (document.hidden) {
+ return
+ }
+
+ // When paused (e.g. warning modal is shown), ignore DOM activity
+ // so the modal stays visible until the user explicitly acts
+ if (pausedRef.current) {
+ return
+ }
+
+ // Throttle activity resets to avoid excessive React state updates
+ // from high-frequency events like mousemove and scroll
+ const now = Date.now()
+ if (now - lastResetTimeRef.current < ACTIVITY_THROTTLE_MS) {
+ return
+ }
+ lastResetTimeRef.current = now
+
+ resetActivity()
+ }
+
+ // Register activity event listeners
+ ACTIVITY_EVENTS.forEach(eventName => {
+ if (eventName === 'visibilitychange') {
+ document.addEventListener(eventName, handleActivityEvent, { passive:
true })
+ } else {
+ window.addEventListener(eventName, handleActivityEvent, { passive:
true })
+ }
+ })
+
+ // requestAnimationFrame polling loop to check idle state
+ // Throttled to update state only when the remaining seconds change
+ const checkIdle = () => {
+ const now = Date.now()
+ const elapsed = now - lastActivityRef.current
+ const remainingMs = Math.max(0, idleTimeoutMs - elapsed)
+ const remainingSeconds = Math.ceil(remainingMs / 1000)
+
+ // Only trigger re-render when the displayed second changes
+ if (remainingSeconds !== lastReportedSecondRef.current) {
+ lastReportedSecondRef.current = remainingSeconds
+ setIdleTimeRemaining(remainingMs)
+ }
+
+ if (elapsed >= idleTimeoutMs) {
+ setIsIdle(true)
+ } else {
+ rafIdRef.current = requestAnimationFrame(checkIdle)
+ }
+ }
+
+ rafIdRef.current = requestAnimationFrame(checkIdle)
+
+ return () => {
+ // Cleanup event listeners
+ ACTIVITY_EVENTS.forEach(eventName => {
+ if (eventName === 'visibilitychange') {
+ document.removeEventListener(eventName, handleActivityEvent)
+ } else {
+ window.removeEventListener(eventName, handleActivityEvent)
+ }
+ })
+
+ // Cancel the rAF loop
+ if (rafIdRef.current) {
+ cancelAnimationFrame(rafIdRef.current)
+ }
+ }
+ }, [idleTimeoutMs, resetActivity])
+
+ return { isIdle, resetActivity, idleTimeRemaining }
+}
diff --git a/web-v2/web/src/lib/provider/IdleSessionContext.js
b/web-v2/web/src/lib/provider/IdleSessionContext.js
new file mode 100644
index 0000000000..35a09ac65d
--- /dev/null
+++ b/web-v2/web/src/lib/provider/IdleSessionContext.js
@@ -0,0 +1,64 @@
+/*
+ * 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 { createContext, useContext } from 'react'
+
+/**
+ * Idle timer configuration interface.
+ *
+ * @typedef {Object} IdleTimeoutConfig
+ * @property {number} idleTimeoutMs - Idle timeout in milliseconds (default:
15 * 60 * 1000)
+ * @property {number} warningLeadMs - Warning lead time in milliseconds
(default: 60 * 1000)
+ */
+
+/**
+ * Idle timer state type: 'active' | 'warning' | 'expired'
+ * @typedef {'active' | 'warning' | 'expired'} IdleTimerState
+ */
+
+/**
+ * Cross-tab message protocol.
+ *
+ * @typedef {Object} IdleBroadcastMessage
+ * @property {'activity' | 'stay_signed_in' | 'logout' | 'sync'} type -
Message type
+ * @property {number} timestamp - Timestamp of the activity event
+ * @property {string} tabId - Source tab identifier
+ */
+
+const defaultContextValue = {
+ /** @type {IdleTimerState} */
+ state: 'active',
+
+ /** Remaining seconds before timeout (approximate, for display) */
+ countdown: 0,
+
+ /** Resets the idle timer as if the user just interacted */
+ staySignedIn: () => {},
+
+ /** Triggers immediate logout */
+ signOutNow: () => {}
+}
+
+const IdleSessionContext = createContext(defaultContextValue)
+
+export const useIdleSession = () => useContext(IdleSessionContext)
+
+export default IdleSessionContext
diff --git a/web-v2/web/src/lib/provider/IdleSessionProvider.js
b/web-v2/web/src/lib/provider/IdleSessionProvider.js
new file mode 100644
index 0000000000..1ac2e05829
--- /dev/null
+++ b/web-v2/web/src/lib/provider/IdleSessionProvider.js
@@ -0,0 +1,265 @@
+/*
+ * 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, 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 { useBroadcastChannel } from '@/lib/hooks/useBroadcastChannel'
+import { logoutAction } from '@/lib/store/auth'
+import IdleSessionContext from './IdleSessionContext'
+import IdleWarningModal from '@/components/IdleWarningModal'
+
+/**
+ * Default idle timeout: 15 minutes in milliseconds.
+ * Overridable via NEXT_PUBLIC_IDLE_TIMEOUT_MS environment variable.
+ */
+const DEFAULT_IDLE_TIMEOUT_MS = (() => {
+ const envVal = process.env.NEXT_PUBLIC_IDLE_TIMEOUT_MS
+ const parsed = envVal ? Number(envVal) : NaN
+
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 15 * 60 * 1000
+})()
+
+/**
+ * Default warning lead time: 60 seconds in milliseconds.
+ * Overridable via NEXT_PUBLIC_IDLE_WARNING_LEAD_MS environment variable.
+ */
+const DEFAULT_WARNING_LEAD_MS = (() => {
+ const envVal = process.env.NEXT_PUBLIC_IDLE_WARNING_LEAD_MS
+ const parsed = envVal ? Number(envVal) : NaN
+
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 60 * 1000
+})()
+
+/**
+ * Converts milliseconds to seconds, rounded up.
+ */
+function msToSeconds(ms) {
+ return Math.ceil(ms / 1000)
+}
+
+/**
+ * IdleSessionProvider manages the idle timeout state machine and cross-tab
+ * coordination (IST-REQ-001 through IST-REQ-006).
+ *
+ * State machine: active → warning → expired
+ *
+ * - ACTIVE: Normal operation, idle timer is running
+ * - WARNING: Warning lead time has elapsed, modal is shown with countdown
+ * - EXPIRED: Timeout reached, logout is triggered
+ *
+ * @param {Object} props
+ * @param {React.ReactNode} props.children
+ * @param {number} [props.idleTimeoutMs] - Idle timeout in milliseconds
(default: 15 min)
+ * @param {number} [props.warningLeadMs] - Warning lead time in milliseconds
(default: 60s)
+ */
+export default function IdleSessionProvider({
+ children,
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
+ warningLeadMs = DEFAULT_WARNING_LEAD_MS
+}) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const dispatch = useAppDispatch()
+ const authType = useAppSelector(state => state.auth.authType)
+ const authToken = useAppSelector(state => state.auth.authToken)
+
+ // Only enable idle timeout when the user is authenticated and not on login
page
+ const isAuthenticated = authType === 'simple' ?
!!sessionStorage.getItem('simpleAuthUser') : !!authToken
+ const isLoginPage = pathname === '/login'
+
+ const [state, setState] = useState('active')
+ const [warningCountdown, setWarningCountdown] =
useState(msToSeconds(warningLeadMs))
+ const loggedOutRef = useRef(false)
+ const wasAuthenticatedRef = useRef(isAuthenticated)
+
+ const {
+ isIdle,
+ resetActivity: resetIdleActivity,
+ idleTimeRemaining
+ } = useIdleTimeout({
+ idleTimeoutMs: isAuthenticated ? idleTimeoutMs : Number.MAX_SAFE_INTEGER,
+
+ // Pause DOM activity detection during warning state so the modal stays
+ // visible until the user explicitly clicks "Stay signed in" or "Sign out
now"
+ paused: state === 'warning'
+ })
+
+ const { sendMessage, onMessage } = useBroadcastChannel()
+
+ // Track warning threshold: when idleTimeRemaining drops below
warningLeadMs, show warning
+ const warningThreshold = warningLeadMs
+
+ // State machine transitions
+ useEffect(() => {
+ if (!isAuthenticated) {
+ return
+ }
+
+ if (isIdle) {
+ // Idle time exceeded full timeout → expired
+ setState('expired')
+ } else if (idleTimeRemaining <= warningThreshold && idleTimeRemaining > 0)
{
+ // Within warning window
+ if (state === 'active') {
+ setState('warning')
+ setWarningCountdown(msToSeconds(idleTimeRemaining))
+ }
+ } else if (idleTimeRemaining > warningThreshold) {
+ // Back above warning threshold (activity detected)
+ if (state !== 'active') {
+ setState('active')
+ }
+ }
+ }, [isIdle, idleTimeRemaining, warningThreshold, isAuthenticated])
+
+ // Update warning countdown based on remaining idle time
+ useEffect(() => {
+ if (state === 'warning') {
+ setWarningCountdown(msToSeconds(idleTimeRemaining))
+ }
+ }, [state, idleTimeRemaining])
+
+ // Handle logout (either from timeout or "Sign out now")
+ const handleLogout = useCallback(() => {
+ if (loggedOutRef.current) {
+ return
+ }
+
+ loggedOutRef.current = true
+ setState('expired')
+
+ // Broadcast logout with reason to other tabs
+ sendMessage({ type: 'logout', reason: 'inactive', timestamp: Date.now() })
+
+ // 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])
+
+ // Handle "Stay signed in" action (IST-REQ-003)
+ const handleStaySignedIn = useCallback(() => {
+ setState('active')
+ resetIdleActivity()
+ setWarningCountdown(msToSeconds(warningLeadMs))
+
+ // Broadcast stay_signed_in to other tabs so they also dismiss warning and
stay logged in
+ sendMessage({ type: 'stay_signed_in', timestamp: Date.now() })
+ }, [resetIdleActivity, warningLeadMs, sendMessage])
+
+ // Cross-tab message handling (IST-REQ-005)
+ useEffect(() => {
+ onMessage(message => {
+ if (message.type === 'activity') {
+ // Activity in another tab resets this tab's idle timer and dismisses
warning modal
+ resetIdleActivity()
+ if (state === 'warning') {
+ setState('active')
+ setWarningCountdown(msToSeconds(warningLeadMs))
+ }
+ } else if (message.type === 'stay_signed_in') {
+ // Another tab clicked "Stay signed in" — dismiss warning and stay
logged in
+ resetIdleActivity()
+ if (state === 'warning') {
+ setState('active')
+ setWarningCountdown(msToSeconds(warningLeadMs))
+ }
+ } else if (message.type === 'logout') {
+ // Another tab triggered logout — clear local auth state and redirect
+ if (!loggedOutRef.current) {
+ loggedOutRef.current = true
+ dispatch(logoutAction({ router, reason: message.reason }))
+ }
+ }
+ })
+ }, [onMessage, resetIdleActivity, router, state, warningLeadMs, dispatch])
+
+ // Broadcast activity to other tabs when this tab has activity (IST-REQ-005)
+ // Only broadcast when idleTimeRemaining jumps UP (indicating a timer reset),
+ // not on every tick down. This prevents continuously resetting other tabs'
timers.
+ const prevIdleTimeRemainingRef = useRef(idleTimeRemaining)
+ useEffect(() => {
+ if (!isAuthenticated) {
+ return
+ }
+
+ // Detect a jump UP in remaining time, which indicates the idle timer was
reset
+ // due to user activity. A steady decrease (idle countdown) should not
trigger a broadcast.
+ if (idleTimeRemaining > prevIdleTimeRemainingRef.current) {
+ sendMessage({ type: 'activity', timestamp: Date.now() })
+ }
+
+ prevIdleTimeRemainingRef.current = idleTimeRemaining
+ }, [idleTimeRemaining, isAuthenticated, sendMessage])
+
+ // Auto-logout when state becomes expired (from isIdle)
+ useEffect(() => {
+ if (state === 'expired' && !loggedOutRef.current) {
+ handleLogout()
+ }
+ }, [state, handleLogout])
+
+ // Detect authenticated→unauthenticated transition (e.g., manual logout from
user menu)
+ // and broadcast logout to other tabs
+ 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
+ if (!wasAuthenticated && isAuthenticated) {
+ loggedOutRef.current = false
+ setState('active')
+ resetIdleActivity()
+ setWarningCountdown(msToSeconds(warningLeadMs))
+ }
+
+ // Broadcast authenticated→unauthenticated transition (e.g., manual logout
from user menu)
+ if (wasAuthenticated && !isAuthenticated && !loggedOutRef.current) {
+ loggedOutRef.current = true
+ sendMessage({ type: 'logout', timestamp: Date.now() })
+ }
+
+ wasAuthenticatedRef.current = isAuthenticated
+ }, [isAuthenticated, resetIdleActivity, sendMessage, warningLeadMs])
+
+ // Context value
+ const contextValue = {
+ state,
+ countdown: state === 'warning' ? warningCountdown : 0,
+ staySignedIn: handleStaySignedIn,
+ signOutNow: handleLogout
+ }
+
+ return (
+ <IdleSessionContext.Provider value={contextValue}>
+ {children}
+ {isAuthenticated && !isLoginPage && (
+ <IdleWarningModal
+ open={state === 'warning'}
+ countdownSeconds={warningCountdown}
+ onStaySignedIn={handleStaySignedIn}
+ onSignOut={handleLogout}
+ />
+ )}
+ </IdleSessionContext.Provider>
+ )
+}
diff --git a/web-v2/web/src/lib/provider/index.js
b/web-v2/web/src/lib/provider/index.js
index 4a87c7019e..042c202c9e 100644
--- a/web-v2/web/src/lib/provider/index.js
+++ b/web-v2/web/src/lib/provider/index.js
@@ -23,13 +23,16 @@ import ClientOnly from './client'
import AuthProvider from './session'
import StoreProvider from './store'
import ThemeProvider from './ThemeProvider'
+import IdleSessionProvider from './IdleSessionProvider'
const ProviderNew = ({ children }) => {
return (
<ClientOnly>
<StoreProvider>
<AuthProvider>
- <ThemeProvider>{children}</ThemeProvider>
+ <IdleSessionProvider>
+ <ThemeProvider>{children}</ThemeProvider>
+ </IdleSessionProvider>
</AuthProvider>
</StoreProvider>
</ClientOnly>
diff --git a/web-v2/web/src/lib/provider/session.js
b/web-v2/web/src/lib/provider/session.js
index 7c6e442e94..fdabc5ce7f 100644
--- a/web-v2/web/src/lib/provider/session.js
+++ b/web-v2/web/src/lib/provider/session.js
@@ -28,8 +28,6 @@ import { oauthProviderFactory } from
'@/lib/auth/providers/factory'
import { to } from '../utils'
import { getAuthConfigs, setAuthToken, setAuthUser } from '../store/auth'
-import { useIdle } from 'react-use'
-
const authProvider = {
version: '',
token: null,
@@ -52,16 +50,6 @@ const AuthProvider = ({ children }) => {
const version = (typeof window !== 'undefined' &&
localStorage.getItem('version')) || null
- const expiredIn = localStorage.getItem('expiredIn') &&
JSON.parse(localStorage.getItem('expiredIn')) // seconds
- const idleOn = (expiredIn + 60) * 1000
- const isIdle = useIdle(idleOn)
-
- useEffect(() => {
- if (isIdle) {
- localStorage.setItem('isIdle', true)
- }
- }, [isIdle])
-
const goToMetalakeListPage = () => {
try {
let pathname = window.location.pathname
diff --git a/web-v2/web/src/lib/store/auth/index.js
b/web-v2/web/src/lib/store/auth/index.js
index ca11e64d22..bfa8bac819 100644
--- a/web-v2/web/src/lib/store/auth/index.js
+++ b/web-v2/web/src/lib/store/auth/index.js
@@ -86,7 +86,6 @@ export const loginAction =
createAsyncThunk('auth/loginAction', async ({ params,
localStorage.setItem('accessToken', access_token)
localStorage.setItem('expiredIn', expires_in)
- localStorage.setItem('isIdle', false)
dispatch(setAuthToken(access_token))
dispatch(setExpiredIn(expires_in))
await dispatch(initialVersion())
@@ -96,35 +95,76 @@ export const loginAction =
createAsyncThunk('auth/loginAction', async ({ params,
return { token: access_token, expired: expires_in }
})
-export const logoutAction = createAsyncThunk('auth/logoutAction', async ({
router }, { getState, dispatch }) => {
- // Clear provider authentication data first
- if (getState().auth.authType === 'oauth') {
- try {
- const provider = await oauthProviderFactory.getProvider()
- if (provider) {
- await provider.clearAuthData()
- console.log('[Logout Action] Provider cleanup completed')
+export const logoutAction = createAsyncThunk(
+ 'auth/logoutAction',
+ async ({ router, reason }, { getState, dispatch }) => {
+ // Clear provider authentication data first
+ if (getState().auth.authType === 'oauth') {
+ try {
+ const provider = await oauthProviderFactory.getProvider()
+ if (provider) {
+ // For OIDC providers, use signoutRedirect to end IdP session
+ if (provider.getUserManager) {
+ const userManager = provider.getUserManager()
+ if (userManager) {
+ // Read id_token before clearing — needed for id_token_hint
+ const user = await userManager.getUser()
+
+ // Clear OIDC user data from store
+ await provider.clearAuthData()
+
+ // Clear legacy auth tokens
+ localStorage.removeItem('accessToken')
+ localStorage.removeItem('authParams')
+ localStorage.removeItem('expiredIn')
+ localStorage.removeItem('version')
+
+ dispatch(clearIntervalId())
+ dispatch(setAuthToken(''))
+
+ // Only redirect to IdP logout endpoint if we have an id_token.
+ // After a completed signout redirect callback, getUser()
returns null
+ // and calling signoutRedirect() without id_token_hint would
cause a
+ // redirect loop (/oauth/logout -> signoutRedirect ->
/oauth/logout ...).
+ if (user?.id_token) {
+ await userManager.signoutRedirect({ id_token_hint:
user.id_token })
+
+ return { token: null } // unreachable — browser navigates away
+ }
+
+ // No id_token available — fall through to local cleanup +
navigation
+ }
+ }
+
+ await provider.clearAuthData()
+ }
+ } catch (error) {
+ console.warn('[Logout Action] Provider cleanup failed:', error)
}
- } catch (error) {
- console.warn('[Logout Action] Provider cleanup failed:', error)
+
+ // Clear legacy auth tokens
+ localStorage.removeItem('accessToken')
+ localStorage.removeItem('authParams')
+ localStorage.removeItem('expiredIn')
+ localStorage.removeItem('version')
+
+ dispatch(clearIntervalId())
+ dispatch(setAuthToken(''))
+ dispatch(setAuthUser(null))
+ } else {
+ dispatch(setAuthUser(null))
}
- // Clear legacy auth tokens
- localStorage.removeItem('accessToken')
- localStorage.removeItem('authParams')
- localStorage.removeItem('expiredIn')
- localStorage.removeItem('isIdle')
- localStorage.removeItem('version')
-
- dispatch(clearIntervalId())
- dispatch(setAuthToken(''))
- } else {
- dispatch(setAuthUser(null))
- }
- await router.push('/login')
+ // Reset provider factory to ensure clean state for next login
+ oauthProviderFactory.reset()
- return { token: null }
-})
+ // Build login URL with optional reason parameter
+ const loginUrl = reason ? `/login?reason=${encodeURIComponent(reason)}` :
'/login'
+ await router.push(loginUrl)
+
+ return { token: null }
+ }
+)
export const setIntervalIdAction =
createAsyncThunk('auth/setIntervalIdAction', async (expiredIn, { dispatch }) =>
{
const localExpiredIn = localStorage.getItem('expiredIn')
@@ -132,14 +172,6 @@ export const setIntervalIdAction =
createAsyncThunk('auth/setIntervalIdAction',
const defaultExpired = 299 * (2 / 3) * 1000
let intervalId = setInterval(() => {
- if (localStorage.getItem('isIdle') === 'true') {
- localStorage.removeItem('accessToken')
- localStorage.removeItem('authParams')
- dispatch(clearIntervalId())
- dispatch(setAuthToken(''))
-
- return
- }
dispatch(refreshToken())
}, expired || defaultExpired)
@@ -203,7 +235,6 @@ export const authSlice = createSlice({
builder.addCase(refreshToken.fulfilled, (state, action) => {
localStorage.setItem('accessToken', action.payload.token)
localStorage.setItem('expiredIn', action.payload.expiredIn)
- localStorage.setItem('isIdle', false)
state.authToken = action.payload.token
state.expiredIn = action.payload.expiredIn
})