diff --git a/src/App.tsx b/src/App.tsx index 748d536..caf1c73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,11 +8,14 @@ import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' import { useSkipLink } from '@/hooks/use-skip-link' import { useAnnounce } from '@/hooks/use-announce' import { useSessionStorage } from '@/hooks/use-session-storage' +import { useSessionTimeout } from '@/hooks/use-session-timeout' +import { useSessionTimeoutPreferences } from '@/hooks/use-session-timeout-preferences' import { Sidebar } from '@/components/navigation' import { NotificationCenter } from '@/components/NotificationCenter' import { ViewRouter } from '@/components/ViewRouter' import { LanguageSwitcher } from '@/components/LanguageSwitcher' import { KeyboardShortcutsDialog } from '@/components/KeyboardShortcutsDialog' +import { SessionExpiryDialog } from '@/components/SessionExpiryDialog' import LoginScreen from '@/components/LoginScreen' import { useAppSelector, useAppDispatch } from '@/store/hooks' import { setCurrentView, setSearchQuery } from '@/store/slices/uiSlice' @@ -38,7 +41,19 @@ function App() { useViewPreload() useLocaleInit() useSkipLink(mainContentRef, 'Skip to main content') - useSessionStorage() + const { destroySession } = useSessionStorage() + const { preferences: timeoutPreferences } = useSessionTimeoutPreferences() + + const { + isWarningShown, + timeRemaining, + extendSession, + config: timeoutConfig + } = useSessionTimeout({ + timeoutMinutes: timeoutPreferences?.timeoutMinutes ?? 30, + warningMinutes: timeoutPreferences?.warningMinutes ?? 5, + checkIntervalSeconds: 30, + }) const { notifications, addNotification, markAsRead, markAllAsRead, deleteNotification, unreadCount } = useNotifications() @@ -180,6 +195,14 @@ function App() { open={showShortcuts} onOpenChange={setShowShortcuts} /> + + ) } diff --git a/src/components/SessionExpiryDialog.tsx b/src/components/SessionExpiryDialog.tsx new file mode 100644 index 0000000..e2fbc6c --- /dev/null +++ b/src/components/SessionExpiryDialog.tsx @@ -0,0 +1,89 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Progress } from '@/components/ui/progress' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Clock, Warning } from '@phosphor-icons/react' + +interface SessionExpiryDialogProps { + open: boolean + timeRemaining: number + totalWarningTime: number + onExtend: () => void + onLogout: () => void +} + +export function SessionExpiryDialog({ + open, + timeRemaining, + totalWarningTime, + onExtend, + onLogout, +}: SessionExpiryDialogProps) { + const minutes = Math.floor(timeRemaining / 60) + const seconds = timeRemaining % 60 + const progressPercentage = (timeRemaining / totalWarningTime) * 100 + + const formatTime = () => { + if (minutes > 0) { + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + return `${seconds} seconds` + } + + return ( + + + +
+
+
+ Session Expiring Soon +
+ + Your session will automatically expire due to inactivity. You will be logged out in: + +
+ +
+
+
+ + + + + + Click Stay Logged In to continue your session, or{' '} + Log Out to end your session now. + + +
+ + + + Log Out + + + Stay Logged In + + +
+
+ ) +} diff --git a/src/components/SessionManager.tsx b/src/components/SessionManager.tsx index 9871481..82e055d 100644 --- a/src/components/SessionManager.tsx +++ b/src/components/SessionManager.tsx @@ -2,11 +2,11 @@ import { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Alert, AlertDescription } from '@/components/ui/alert' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Spinner } from '@/components/ui/spinner' import { SessionData } from '@/lib/indexed-db' import { useSessionStorage } from '@/hooks/use-session-storage' -import { Clock, DeviceMobile, Desktop, Trash, Info } from '@phosphor-icons/react' +import { Clock, Desktop, Trash, Info, ShieldCheck } from '@phosphor-icons/react' import { formatDistanceToNow } from 'date-fns' export function SessionManager() { @@ -70,102 +70,151 @@ export function SessionManager() { } return ( - - - Active Sessions - - You have {sessions.length} active {sessions.length === 1 ? 'session' : 'sessions'} on this device - - - - {sessions.length > 0 && ( - - - - Sessions are stored locally on this device and automatically expire after 24 hours of inactivity. - - - )} +
+ + + Active Sessions + + You have {sessions.length} active {sessions.length === 1 ? 'session' : 'sessions'} on this device + + + + {sessions.length > 0 && ( + + + + Sessions are stored locally on this device and automatically expire after 24 hours of inactivity. + + + )} -
- {sessions.map((session) => ( -
-
- -
+
+ {sessions.map((session) => ( +
+
+ +
-
-
-

- {session.name} • {session.currentEntity} -

- {session.id === sessionId && ( - - Current - +
+
+

+ {session.name} • {session.currentEntity} +

+ {session.id === sessionId && ( + + Current + + )} +
+ +
+
+ + + Last active {formatDistanceToNow(session.lastActivityTimestamp, { addSuffix: true })} + +
+
+ Logged in {formatDistanceToNow(session.loginTimestamp, { addSuffix: true })} +
+
+ + {session.expiresAt && ( +
+ Expires {formatDistanceToNow(session.expiresAt, { addSuffix: true })} +
)}
-
-
- - - Last active {formatDistanceToNow(session.lastActivityTimestamp, { addSuffix: true })} - -
-
- Logged in {formatDistanceToNow(session.loginTimestamp, { addSuffix: true })} -
-
- - {session.expiresAt && ( -
- Expires {formatDistanceToNow(session.expiresAt, { addSuffix: true })} -
- )} +
+ ))} +
+ {sessions.length === 0 && ( +
+

No active sessions found

+
+ )} + + {sessions.length > 1 && ( +
+
- ))} -
+ )} + + - {sessions.length === 0 && ( -
-

No active sessions found

+ + +
+ + Session Security
- )} + + Automatic timeout and security settings + +
+ + + + Auto-Logout Protection + +

+ For your security, you will be automatically logged out after 30 minutes of inactivity. +

+

+ You'll receive a warning 5 minutes before your session expires, giving you the option to extend it. +

+
+
- {sessions.length > 1 && ( -
- - +
+
+
+

+ Sessions are automatically extended when you interact with the application +

+
+
+
+

+ All sessions are stored securely in your browser's local database +

+
+
+
+

+ Close your browser to end all sessions immediately +

+
- )} - - + + +
) } diff --git a/src/components/ui/NEW_COMPONENTS_LATEST.md b/src/components/ui/NEW_COMPONENTS_LATEST.md index 434195a..47f62dd 100644 --- a/src/components/ui/NEW_COMPONENTS_LATEST.md +++ b/src/components/ui/NEW_COMPONENTS_LATEST.md @@ -2,6 +2,50 @@ This document lists all newly added UI components to the library. +## Security & Session Components + +### `SessionExpiryDialog` +Modal dialog that warns users when their session is about to expire with a countdown timer. + +```tsx + extendSession()} + onLogout={() => handleLogout()} +/> +``` + +**Props:** +- `open`: boolean - controls dialog visibility +- `timeRemaining`: number - seconds until auto-logout +- `totalWarningTime`: number - total warning period for progress bar +- `onExtend`: () => void - callback to extend session +- `onLogout`: () => void - callback for manual logout + +**Features:** +- Live countdown timer with minutes:seconds format +- Visual progress bar showing time remaining +- Clear call-to-action buttons +- Accessible with ARIA live regions +- Auto-focuses "Stay Logged In" button + +### `SessionManager` +Comprehensive session management interface showing active sessions and security settings. + +```tsx + +``` + +**Features:** +- List of all active sessions with timestamps +- Last activity tracking +- Manual session termination +- Bulk session management +- Security information panel +- Auto-logout configuration details + ## Layout Components ### `Container` diff --git a/src/components/views/profile-view.tsx b/src/components/views/profile-view.tsx index e9ef92d..d2f743a 100644 --- a/src/components/views/profile-view.tsx +++ b/src/components/views/profile-view.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { SessionManager } from '@/components/SessionManager' import { useAuth } from '@/hooks/use-auth' +import { useSessionTimeoutPreferences } from '@/hooks/use-session-timeout-preferences' import { toast } from 'sonner' import { User, @@ -28,6 +29,7 @@ import { export function ProfileView() { const { user } = useAuth() const [isEditing, setIsEditing] = useState(false) + const { preferences: timeoutPrefs, updateTimeout } = useSessionTimeoutPreferences() const [profileData, setProfileData] = useState({ name: user?.name || '', @@ -57,6 +59,15 @@ export function ProfileView() { loginAlerts: true, }) + useEffect(() => { + if (timeoutPrefs) { + setSecurity(prev => ({ + ...prev, + sessionTimeout: String(timeoutPrefs.timeoutMinutes) + })) + } + }, [timeoutPrefs]) + const getUserInitials = () => { if (!user) return 'U' return user.name @@ -89,7 +100,10 @@ export function ProfileView() { } const handleSaveSecurity = () => { - toast.success('Security settings updated') + updateTimeout(Number(security.sessionTimeout)) + toast.success('Security settings updated', { + description: `Session timeout set to ${security.sessionTimeout} minutes`, + }) } return ( @@ -459,7 +473,13 @@ export function ProfileView() {
- + +

+ Automatically log out after this period of inactivity +

diff --git a/src/hooks/NEW_HOOKS_LATEST.md b/src/hooks/NEW_HOOKS_LATEST.md index 7969bda..5125d6f 100644 --- a/src/hooks/NEW_HOOKS_LATEST.md +++ b/src/hooks/NEW_HOOKS_LATEST.md @@ -2,6 +2,56 @@ This document lists all newly added custom hooks to the library. +## Security & Session Management Hooks + +### `useSessionTimeout` +Monitors user activity and automatically logs out users after a period of inactivity with configurable warnings. + +```tsx +const { + isWarningShown, + timeRemaining, + isTimedOut, + extendSession, + resetActivity, + config +} = useSessionTimeout({ + timeoutMinutes: 30, + warningMinutes: 5, + checkIntervalSeconds: 30 +}) + +// Shows warning dialog when isWarningShown is true +// timeRemaining counts down in seconds +// Call extendSession() to reset the timeout +// Automatically logs out when timeRemaining reaches 0 +``` + +### `useSessionTimeoutPreferences` +Manages persistent user preferences for session timeout configuration. + +```tsx +const { + preferences, + updateTimeout, + updateWarning, + toggleEnabled, + resetToDefaults +} = useSessionTimeoutPreferences() + +// Update timeout duration +updateTimeout(60) // 60 minutes + +// Update warning period +updateWarning(10) // 10 minutes before timeout + +// Enable/disable auto-logout +toggleEnabled(true) + +// Reset to default settings +resetToDefaults() +``` + ## Business Logic Hooks (Specialized) ### `useInvoicing` diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 05662c5..57346cf 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -103,6 +103,8 @@ export { useKeyboardShortcuts } from './use-keyboard-shortcuts' export { useSkipLink } from './use-skip-link' export { useSessionStorage } from './use-session-storage' +export { useSessionTimeout } from './use-session-timeout' +export { useSessionTimeoutPreferences } from './use-session-timeout-preferences' export { useIndexedDBState, useIndexedDBCache } from './use-indexed-db-state' export type { AsyncState } from './use-async' @@ -156,3 +158,5 @@ export type { UseAsyncActionResult } from './use-async-action' export type { UseMutationOptions, UseMutationResult } from './use-mutation' export type { UseFavoritesOptions, UseFavoritesResult } from './use-favorites' export type { UseClipboardResult } from './use-clipboard-copy' +export type { SessionTimeoutConfig, SessionTimeoutState } from './use-session-timeout' +export type { SessionTimeoutPreferences } from './use-session-timeout-preferences' diff --git a/src/hooks/use-session-timeout-preferences.ts b/src/hooks/use-session-timeout-preferences.ts new file mode 100644 index 0000000..14d8d35 --- /dev/null +++ b/src/hooks/use-session-timeout-preferences.ts @@ -0,0 +1,53 @@ +import { useKV } from '@github/spark/hooks' + +export interface SessionTimeoutPreferences { + timeoutMinutes: number + warningMinutes: number + enabled: boolean +} + +const DEFAULT_PREFERENCES: SessionTimeoutPreferences = { + timeoutMinutes: 30, + warningMinutes: 5, + enabled: true, +} + +export function useSessionTimeoutPreferences() { + const [preferences, setPreferences] = useKV( + 'session-timeout-preferences', + DEFAULT_PREFERENCES + ) + + const updateTimeout = (minutes: number) => { + setPreferences((current) => ({ + ...(current || DEFAULT_PREFERENCES), + timeoutMinutes: minutes, + })) + } + + const updateWarning = (minutes: number) => { + setPreferences((current) => ({ + ...(current || DEFAULT_PREFERENCES), + warningMinutes: minutes, + })) + } + + const toggleEnabled = (enabled: boolean) => { + setPreferences((current) => ({ + ...(current || DEFAULT_PREFERENCES), + enabled, + })) + } + + const resetToDefaults = () => { + setPreferences(DEFAULT_PREFERENCES) + } + + return { + preferences, + updateTimeout, + updateWarning, + toggleEnabled, + resetToDefaults, + } +} diff --git a/src/hooks/use-session-timeout.ts b/src/hooks/use-session-timeout.ts new file mode 100644 index 0000000..12e83cf --- /dev/null +++ b/src/hooks/use-session-timeout.ts @@ -0,0 +1,173 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useAppSelector, useAppDispatch } from '@/store/hooks' +import { logout } from '@/store/slices/authSlice' +import { toast } from 'sonner' + +export interface SessionTimeoutConfig { + timeoutMinutes: number + warningMinutes: number + checkIntervalSeconds: number +} + +export interface SessionTimeoutState { + isWarningShown: boolean + timeRemaining: number + lastActivity: number + isTimedOut: boolean +} + +const DEFAULT_CONFIG: SessionTimeoutConfig = { + timeoutMinutes: 30, + warningMinutes: 5, + checkIntervalSeconds: 30, +} + +export function useSessionTimeout(config: Partial = {}) { + const fullConfig = { ...DEFAULT_CONFIG, ...config } + const { timeoutMinutes, warningMinutes, checkIntervalSeconds } = fullConfig + + const dispatch = useAppDispatch() + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated) + + const [isWarningShown, setIsWarningShown] = useState(false) + const [timeRemaining, setTimeRemaining] = useState(timeoutMinutes * 60) + const [isTimedOut, setIsTimedOut] = useState(false) + const lastActivityRef = useRef(Date.now()) + const checkIntervalRef = useRef(undefined) + const countdownIntervalRef = useRef(undefined) + + const resetActivity = useCallback(() => { + lastActivityRef.current = Date.now() + setTimeRemaining(timeoutMinutes * 60) + setIsWarningShown(false) + setIsTimedOut(false) + }, [timeoutMinutes]) + + const handleLogout = useCallback(() => { + setIsTimedOut(true) + setIsWarningShown(false) + dispatch(logout()) + + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } + }, [dispatch]) + + const extendSession = useCallback(() => { + resetActivity() + toast.success('Session Extended', { + description: 'Your session has been extended.', + duration: 3000, + }) + }, [resetActivity]) + + const checkTimeout = useCallback(() => { + if (!isAuthenticated) return + + const now = Date.now() + const elapsedSeconds = Math.floor((now - lastActivityRef.current) / 1000) + const remaining = (timeoutMinutes * 60) - elapsedSeconds + + setTimeRemaining(remaining) + + if (remaining <= 0) { + handleLogout() + toast.error('Session Expired', { + description: 'You have been logged out due to inactivity.', + duration: 5000, + }) + } else if (remaining <= warningMinutes * 60 && !isWarningShown) { + setIsWarningShown(true) + toast.warning('Session Expiring Soon', { + description: `Your session will expire in ${warningMinutes} minutes due to inactivity.`, + duration: 10000, + }) + } + }, [isAuthenticated, timeoutMinutes, warningMinutes, isWarningShown, handleLogout]) + + useEffect(() => { + if (!isAuthenticated) { + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } + setIsWarningShown(false) + setIsTimedOut(false) + return + } + + resetActivity() + + checkIntervalRef.current = setInterval(() => { + checkTimeout() + }, checkIntervalSeconds * 1000) + + const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'] + const handleActivity = () => { + if (!isWarningShown) { + resetActivity() + } + } + + activityEvents.forEach(event => { + window.addEventListener(event, handleActivity, { passive: true }) + }) + + return () => { + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } + activityEvents.forEach(event => { + window.removeEventListener(event, handleActivity) + }) + } + }, [isAuthenticated, checkIntervalSeconds, checkTimeout, resetActivity, isWarningShown]) + + useEffect(() => { + if (isWarningShown && !countdownIntervalRef.current) { + countdownIntervalRef.current = setInterval(() => { + setTimeRemaining(prev => { + const newValue = prev - 1 + if (newValue <= 0) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = undefined + } + handleLogout() + return 0 + } + return newValue + }) + }, 1000) + } + + if (!isWarningShown && countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = undefined + } + + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = undefined + } + } + }, [isWarningShown, handleLogout]) + + return { + isWarningShown, + timeRemaining, + isTimedOut, + extendSession, + resetActivity, + config: fullConfig, + } +}