diff --git a/messages/en.json b/messages/en.json index 1c2d4073..13041424 100644 --- a/messages/en.json +++ b/messages/en.json @@ -348,5 +348,12 @@ "altLinkInstruction": "If the button above does not work, copy and paste this URL into your web browser:", "ignoreNotice": "If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.", "footerBrand": "Heliobond — Sunlight made financial." + }, + "SessionTimeout": { + "title": "Your session will expire soon", + "body": "You have been inactive for a while. To protect your unsaved changes and account security, your session will automatically expire.", + "expiresIn": "Session expiring in", + "extendCta": "Stay connected", + "logoutCta": "Disconnect now" } } diff --git a/messages/fr.json b/messages/fr.json index 4ef4182a..b4f3aa40 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -348,5 +348,12 @@ "altLinkInstruction": "Si le bouton ci-dessus ne fonctionne pas, copiez et collez cette URL dans votre navigateur :", "ignoreNotice": "Si vous n’avez pas demandé cette réinitialisation, vous pouvez ignorer cet e-mail en toute sécurité. Votre mot de passe restera inchangé.", "footerBrand": "Heliobond — L’énergie solaire devenue finance." + }, + "SessionTimeout": { + "title": "Votre session va bientôt expirer", + "body": "Vous êtes inactif depuis un moment. Pour protéger vos modifications non enregistrées et la sécurité de votre compte, votre session va expirer automatiquement.", + "expiresIn": "Expiration de la session dans", + "extendCta": "Rester connecté", + "logoutCta": "Se déconnecter" } } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 1a7fc2ee..8b8cb118 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,8 +2,35 @@ import type { ReactNode } from 'react' import { ThemeProvider } from '../theme/ThemeProvider' -import { WalletProvider } from '../wallet/WalletProvider' -import { ToastProvider } from '../components' +import { WalletProvider, useWallet } from '../wallet/WalletProvider' +import { ToastProvider, SessionTimeoutModal, useToast } from '../components' +import { useSessionTimeout } from '../hooks/useSessionTimeout' + +function SessionWatcher() { + const { connected, disconnect } = useWallet() + const { toast } = useToast() + + const { isWarningOpen, formattedRemaining, extendSession, expireNow } = useSessionTimeout({ + enabled: connected, + onTimeout: () => { + disconnect() + toast({ + tone: 'ember', + title: 'Session expired', + description: 'You have been disconnected due to inactivity.', + }) + }, + }) + + return ( + + ) +} /** * Client providers that must persist across route changes: theme (After Sunset @@ -14,7 +41,10 @@ export function Providers({ children }: { children: ReactNode }) { return ( - {children} + + + {children} + ) diff --git a/src/components/SessionTimeoutModal.test.tsx b/src/components/SessionTimeoutModal.test.tsx new file mode 100644 index 00000000..92335967 --- /dev/null +++ b/src/components/SessionTimeoutModal.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@/test/render' +import { SessionTimeoutModal } from './SessionTimeoutModal' + +describe('SessionTimeoutModal', () => { + it('does not render when open is false', () => { + render( + , + ) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('renders modal with title, message, formatted time, and action buttons when open is true', () => { + render( + , + ) + + const dialog = screen.getByRole('alertdialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('Your session will expire soon')).toBeInTheDocument() + expect(screen.getByText('01:45')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Stay connected' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disconnect now' })).toBeInTheDocument() + }) + + it('fires onExtend when Stay connected button is clicked', () => { + const onExtend = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Stay connected' })) + expect(onExtend).toHaveBeenCalledTimes(1) + }) + + it('fires onLogout when Disconnect now button is clicked', () => { + const onLogout = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect now' })) + expect(onLogout).toHaveBeenCalledTimes(1) + }) + + it('fires onExtend when Escape key is pressed to prevent accidental session drop', () => { + const onExtend = vi.fn() + render( + , + ) + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onExtend).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/SessionTimeoutModal.tsx b/src/components/SessionTimeoutModal.tsx new file mode 100644 index 00000000..20f93c29 --- /dev/null +++ b/src/components/SessionTimeoutModal.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from './Button' + +export interface SessionTimeoutModalProps { + /** Whether the modal is currently visible. */ + open: boolean + /** Formatted MM:SS time remaining string. */ + formattedTime: string + /** Callback fired when user chooses to stay logged in. */ + onExtend: () => void + /** Callback fired when user chooses to disconnect immediately. */ + onLogout: () => void +} + +/** + * Accessible alert dialog notifying users of an impending session timeout, + * giving them an opportunity to extend their session and prevent unsaved form data loss. + */ +export function SessionTimeoutModal({ + open, + formattedTime, + onExtend, + onLogout, +}: SessionTimeoutModalProps) { + const t = useTranslations('SessionTimeout') + const extendBtnRef = useRef(null) + const modalRef = useRef(null) + + // Auto-focus the primary extend button when modal opens + useEffect(() => { + if (open) { + const prevActive = document.activeElement as HTMLElement | null + const timer = setTimeout(() => { + extendBtnRef.current?.focus() + }, 50) + + return () => { + clearTimeout(timer) + prevActive?.focus() + } + } + }, [open]) + + // Trap focus & keyboard escape handler + useEffect(() => { + if (!open) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Esc extends the session by default to prevent accidental data loss + onExtend() + } + + if (e.key === 'Tab' && modalRef.current) { + const focusables = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + if (focusables.length === 0) return + + const first = focusables[0] + const last = focusables[focusables.length - 1] + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onExtend]) + + if (!open) return null + + return ( + + + {/* Animated Warning Icon */} + + ⏳ + + + + {t('title')} + + + + {t('body')} + + + {/* Live Countdown Display */} + + + {t('expiresIn')} + + + {formattedTime} + + + + {/* Action Buttons */} + + + {t('logoutCta')} + + + {t('extendCta')} + + + + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index a8108a59..95770812 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -34,4 +34,6 @@ export type { } from './FormField' export { Sparkline } from './Sparkline' export type { SparklineProps } from './Sparkline' +export { SessionTimeoutModal } from './SessionTimeoutModal' +export type { SessionTimeoutModalProps } from './SessionTimeoutModal' export * from './icons' diff --git a/src/hooks/useSessionTimeout.test.ts b/src/hooks/useSessionTimeout.test.ts new file mode 100644 index 00000000..2dca712c --- /dev/null +++ b/src/hooks/useSessionTimeout.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSessionTimeout } from './useSessionTimeout' + +describe('useSessionTimeout', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('initializes in active state without warning open', () => { + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 2000, + throttleMs: 100, + }), + ) + + expect(result.current.isWarningOpen).toBe(false) + expect(result.current.remainingSeconds).toBe(2) + expect(result.current.formattedRemaining).toBe('0:02') + }) + + it('triggers warning when idle threshold is reached', () => { + const onWarning = vi.fn() + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + throttleMs: 100, + onWarning, + onTimeout, + }), + ) + + // Fast-forward to warning threshold (10s - 3s = 7s) + act(() => { + vi.advanceTimersByTime(7000) + }) + + expect(result.current.isWarningOpen).toBe(true) + expect(onWarning).toHaveBeenCalledTimes(1) + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('counts down remaining seconds during warning phase and triggers timeout on zero', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + // Advance to warning (7s) + act(() => { + vi.advanceTimersByTime(7000) + }) + expect(result.current.isWarningOpen).toBe(true) + expect(result.current.remainingSeconds).toBe(3) + expect(result.current.formattedRemaining).toBe('0:03') + + // Advance 1s + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.remainingSeconds).toBe(2) + expect(result.current.formattedRemaining).toBe('0:02') + + // Advance remaining 2s -> reaches 0 and triggers timeout + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + }) + + it('resets timer when user clicks extendSession', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + // Advance to warning + act(() => { + vi.advanceTimersByTime(7000) + }) + expect(result.current.isWarningOpen).toBe(true) + + // User extends session + act(() => { + result.current.extendSession() + }) + + expect(result.current.isWarningOpen).toBe(false) + + // Advance 5s (total 12s from start, but 5s from extension -> should not timeout) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('triggers onTimeout immediately when expireNow is called', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + act(() => { + result.current.expireNow() + }) + + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + }) + + it('resets warning timer on user activity event when not in warning state', () => { + const onWarning = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + throttleMs: 500, + onWarning, + }), + ) + + // Advance 5s (warning would normally trigger at 7s) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current.isWarningOpen).toBe(false) + + // Simulate user activity event + act(() => { + window.dispatchEvent(new Event('mousemove')) + }) + + // Advance another 5s (total 10s from start, but 5s from last activity) + act(() => { + vi.advanceTimersByTime(5000) + }) + + // Warning should NOT have fired yet because user moved mouse at 5s + expect(result.current.isWarningOpen).toBe(false) + expect(onWarning).not.toHaveBeenCalled() + + // Advance 2s more (7s from mousemove) -> now warning fires + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(result.current.isWarningOpen).toBe(true) + expect(onWarning).toHaveBeenCalledTimes(1) + }) + + it('cleans up all timers when enabled becomes false', () => { + const onTimeout = vi.fn() + const { rerender } = renderHook( + ({ enabled }) => + useSessionTimeout({ + timeoutMs: 5000, + warningMs: 1000, + enabled, + onTimeout, + }), + { initialProps: { enabled: true } }, + ) + + // Disable monitoring + rerender({ enabled: false }) + + act(() => { + vi.advanceTimersByTime(10000) + }) + + expect(onTimeout).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useSessionTimeout.ts b/src/hooks/useSessionTimeout.ts new file mode 100644 index 00000000..b6442a5b --- /dev/null +++ b/src/hooks/useSessionTimeout.ts @@ -0,0 +1,179 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface UseSessionTimeoutOptions { + /** Total idle time before session times out in milliseconds. Default: 15 minutes (900,000 ms). */ + timeoutMs?: number + /** Duration before timeout when warning modal appears in milliseconds. Default: 2 minutes (120,000 ms). */ + warningMs?: number + /** Throttling delay for activity event listeners in milliseconds. Default: 1,000 ms. */ + throttleMs?: number + /** Whether timeout monitoring is active (e.g. true only when user is logged in). */ + enabled?: boolean + /** Callback fired when timeout occurs and user should be logged out. */ + onTimeout?: () => void + /** Callback fired when warning modal is triggered. */ + onWarning?: () => void +} + +export interface UseSessionTimeoutReturn { + /** Whether the session expiration warning modal should be visible. */ + isWarningOpen: boolean + /** Remaining seconds until the session expires. */ + remainingSeconds: number + /** Resets the inactivity timer and dismisses the warning modal. */ + extendSession: () => void + /** Immediately expires the session and triggers the timeout callback. */ + expireNow: () => void + /** Formats remaining seconds as MM:SS string. */ + formattedRemaining: string +} + +export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes +export const DEFAULT_WARNING_MS = 2 * 60 * 1000 // 2 minutes +export const DEFAULT_THROTTLE_MS = 1000 // 1 second + +const ACTIVITY_EVENTS: (keyof WindowEventMap)[] = [ + 'mousemove', + 'mousedown', + 'keydown', + 'touchstart', + 'scroll', +] + +/** + * Hook for detecting user inactivity, providing a preemptive timeout warning, + * and protecting against unsaved form data loss. + */ +export function useSessionTimeout({ + timeoutMs = DEFAULT_TIMEOUT_MS, + warningMs = DEFAULT_WARNING_MS, + throttleMs = DEFAULT_THROTTLE_MS, + enabled = true, + onTimeout, + onWarning, +}: UseSessionTimeoutOptions = {}): UseSessionTimeoutReturn { + const [isWarningOpen, setIsWarningOpen] = useState(false) + const [remainingSeconds, setRemainingSeconds] = useState(Math.round(warningMs / 1000)) + + const lastActivityRef = useRef(Date.now()) + const lastThrottleRef = useRef(0) + const warningTimerRef = useRef | null>(null) + const countdownIntervalRef = useRef | null>(null) + + const onTimeoutRef = useRef(onTimeout) + const onWarningRef = useRef(onWarning) + + useEffect(() => { + onTimeoutRef.current = onTimeout + }, [onTimeout]) + + useEffect(() => { + onWarningRef.current = onWarning + }, [onWarning]) + + const clearTimers = useCallback(() => { + if (warningTimerRef.current) { + clearTimeout(warningTimerRef.current) + warningTimerRef.current = null + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + }, []) + + const expireNow = useCallback(() => { + clearTimers() + setIsWarningOpen(false) + if (onTimeoutRef.current) { + onTimeoutRef.current() + } + }, [clearTimers]) + + const startCountdown = useCallback(() => { + clearTimers() + setIsWarningOpen(true) + if (onWarningRef.current) { + onWarningRef.current() + } + + const expiryTime = lastActivityRef.current + timeoutMs + + const updateCountdown = () => { + const remainingMs = expiryTime - Date.now() + const secs = Math.max(0, Math.ceil(remainingMs / 1000)) + setRemainingSeconds(secs) + + if (secs <= 0) { + expireNow() + } + } + + updateCountdown() + countdownIntervalRef.current = setInterval(updateCountdown, 1000) + }, [clearTimers, expireNow, timeoutMs]) + + const scheduleWarning = useCallback(() => { + clearTimers() + setIsWarningOpen(false) + lastActivityRef.current = Date.now() + + const warningDelay = Math.max(0, timeoutMs - warningMs) + warningTimerRef.current = setTimeout(() => { + startCountdown() + }, warningDelay) + }, [clearTimers, startCountdown, timeoutMs, warningMs]) + + const extendSession = useCallback(() => { + scheduleWarning() + }, [scheduleWarning]) + + // Track user activity with throttled event handler + useEffect(() => { + if (!enabled) { + clearTimers() + setIsWarningOpen(false) + return + } + + scheduleWarning() + + const handleUserActivity = () => { + // Do not reset activity automatically while the warning modal is actively open + // (user must explicitly click Extend Session) + if (isWarningOpen) return + + const now = Date.now() + if (now - lastThrottleRef.current > throttleMs) { + lastThrottleRef.current = now + scheduleWarning() + } + } + + ACTIVITY_EVENTS.forEach((event) => { + window.addEventListener(event, handleUserActivity, { passive: true }) + }) + + return () => { + clearTimers() + ACTIVITY_EVENTS.forEach((event) => { + window.removeEventListener(event, handleUserActivity) + }) + } + }, [enabled, isWarningOpen, scheduleWarning, clearTimers, throttleMs]) + + // Format MM:SS for countdown display + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + const formattedRemaining = `${minutes}:${seconds.toString().padStart(2, '0')}` + + return { + isWarningOpen, + remainingSeconds, + extendSession, + expireNow, + formattedRemaining, + } +}
+ {t('body')} +