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


Reply via email to