All files / layouts SessionTimeoutModal.tsx

51.63% Statements 63/122
35% Branches 14/40
74.07% Functions 20/27
68.75% Lines 55/80

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95          55x 1x 55x 55x 55x   132x 132x 132x 385x 385x 385x 132x 132x 132x 102x 102x                 102x           132x 106x 46x 39x 39x   7x 7x 4x 4x 4x 4x 4x 4x   3x 3x         106x             1x 1x 1x 1x         132x 6x 6x 6x 6x                                    
import { useEffect, useState, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useUiStore } from './uiStore'
import { useAuthStore } from '@/features/auth/authStore'
import { Button } from '@/components/ui/button'
 
const WARNING_BEFORE = 5 * 60 * 1000
const CHECK_INTERVAL = 1000
 
export function SessionTimeoutModal() {
  const navigate = useNavigate()
  const location = useLocation()
  const sessionTimeoutAt = useUiStore((s) => s.sessionTimeoutAt)
  const setSessionTimeoutAt = useUiStore((s) => s.setSessionTimeoutAt)
  const clearAuth = useAuthStore((s) => s.clearAuth)
  const [remaining, setRemaining] = useState<number | null>(null)
  const channelRef = useRef<BroadcastChannel | null>(null)
 
  useEffect(() => {
    channelRef.current = new BroadcastChannel('session-timeout')
    channelRef.current.onmessage = (event) => {
      if (event.data.type === 'extend') {
        setSessionTimeoutAt(event.data.timeoutAt)
      }
      if (event.data.type === 'logout') {
        clearAuth()
        navigate('/login', { replace: true })
      }
    }
    return () => channelRef.current?.close()
  }, [setSessionTimeoutAt, clearAuth, navigate])
 
  useEffect(() => {
    const interval = setInterval(() => {
      if (!sessionTimeoutAt) {
        setRemaining(null)
        return
      }
      const diff = sessionTimeoutAt - Date.now()
      if (diff <= 0) {
        sessionStorage.setItem('returnUrl', location.pathname + location.search)
        clearAuth()
        navigate('/login', { replace: true })
        channelRef.current?.postMessage({ type: 'logout' })
        clearInterval(interval)
        return
      }
      if (diff <= WARNING_BEFORE) {
        setRemaining(diff)
      } else {
        setRemaining(null)
      }
    }, CHECK_INTERVAL)
    returnE () => clearInterval(interval)
  }, [sessionTimeoutAt, clearAuth, navigate])
 
  function extendSession() {
    const newTimeout = Date.now() + 30 * 60 * 1000
    setSessionTimeoutAt(newTimeout)
    setRemaining(null)
    channelRef.current?.postMessage({ type: 'extend', timeoutAt: newTimeout })
  }
 
  if (remaining === null || remaining > WARNING_BEFORE) return null
 
  const seconds = Math.ceil(remaining / 1000)
  const minutes = Math.floor(seconds / 60)
  const secs = seconds % 60
 
  return (
    <div
      role="dialog"
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
    >
      <div className="w-80 rounded-lg border border-border bg-card p-6 shadow-lg">
        <h2 className="mb-2 text-lg font-semibold">Session Timeout</h2>
        <p className="mb-4 text-sm text-muted-foreground">
          Your session will expire in {minutes}:{secs.toString().padStart(2, '0')}
        </p>
        <div className="mb-4 h-2 w-full overflow-hidden rounded-full bg-muted">
          <div
            className="h-full rounded-full bg-primary transition-all duration-1000"
            style={{
              width: `${(remaining / WARNING_BEFORE) * 100}%`,
            }}
          />
        </div>
        <Button className="w-full" onClick={extendSession}>
          Stay logged in
        </Button>
      </div>
    </div>
  )
}