All files / layouts SessionTimeoutModal.tsx

79.16% Statements 57/72
57.69% Branches 15/26
81.25% Functions 13/16
79.68% Lines 51/64

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          48x   48x 48x 48x   112x 112x 112x 323x 323x 324x 112x 112x 112x 86x 86x                 86x           112x 87x 52x 48x 48x   4x 4x 2x 2x 2x 2x 2x 2x   2x 1x   1x     87x                             112x 2x 2x 2x 2x                                    
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)
    return () => 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>
  )
}