mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Add session expiry warnings and auto-logout for enhanced security
This commit is contained in:
25
src/App.tsx
25
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}
|
||||
/>
|
||||
|
||||
<SessionExpiryDialog
|
||||
open={isWarningShown}
|
||||
timeRemaining={timeRemaining}
|
||||
totalWarningTime={timeoutConfig.warningMinutes * 60}
|
||||
onExtend={extendSession}
|
||||
onLogout={destroySession}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
89
src/components/SessionExpiryDialog.tsx
Normal file
89
src/components/SessionExpiryDialog.tsx
Normal file
@@ -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 (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-warning/10 text-warning">
|
||||
<Warning size={24} weight="fill" aria-hidden="true" />
|
||||
</div>
|
||||
<AlertDialogTitle className="text-xl">Session Expiring Soon</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogDescription className="text-base">
|
||||
Your session will automatically expire due to inactivity. You will be logged out in:
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-4xl font-mono font-semibold text-foreground">
|
||||
<Clock size={40} weight="bold" className="text-warning" aria-hidden="true" />
|
||||
<span role="timer" aria-live="polite" aria-atomic="true">
|
||||
{formatTime()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="h-2"
|
||||
aria-label={`Time remaining: ${formatTime()}`}
|
||||
/>
|
||||
|
||||
<Alert className="bg-muted border-border">
|
||||
<AlertDescription className="text-sm">
|
||||
Click <strong>Stay Logged In</strong> to continue your session, or{' '}
|
||||
<strong>Log Out</strong> to end your session now.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onLogout} className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
Log Out
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onExtend} autoFocus>
|
||||
Stay Logged In
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
You have {sessions.length} active {sessions.length === 1 ? 'session' : 'sessions'} on this device
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sessions.length > 0 && (
|
||||
<Alert>
|
||||
<Info size={16} />
|
||||
<AlertDescription>
|
||||
Sessions are stored locally on this device and automatically expire after 24 hours of inactivity.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
You have {sessions.length} active {sessions.length === 1 ? 'session' : 'sessions'} on this device
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sessions.length > 0 && (
|
||||
<Alert>
|
||||
<Info size={16} />
|
||||
<AlertDescription>
|
||||
Sessions are stored locally on this device and automatically expire after 24 hours of inactivity.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-start gap-3 p-4 border border-border rounded-lg bg-card"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
<Desktop size={20} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-start gap-3 p-4 border border-border rounded-lg bg-card"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
<Desktop size={20} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-medium text-sm">
|
||||
{session.name} • {session.currentEntity}
|
||||
</p>
|
||||
{session.id === sessionId && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-medium text-sm">
|
||||
{session.name} • {session.currentEntity}
|
||||
</p>
|
||||
{session.id === sessionId && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={14} />
|
||||
<span>
|
||||
Last active {formatDistanceToNow(session.lastActivityTimestamp, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Logged in {formatDistanceToNow(session.loginTimestamp, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.expiresAt && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Expires {formatDistanceToNow(session.expiresAt, { addSuffix: true })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={14} />
|
||||
<span>
|
||||
Last active {formatDistanceToNow(session.lastActivityTimestamp, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Logged in {formatDistanceToNow(session.loginTimestamp, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.expiresAt && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Expires {formatDistanceToNow(session.expiresAt, { addSuffix: true })}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEndSession(session.id)}
|
||||
aria-label={session.id === sessionId ? "Sign out" : "End this session"}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No active sessions found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEndSession(session.id)}
|
||||
aria-label={session.id === sessionId ? "Sign out" : "End this session"}
|
||||
onClick={handleEndAllOtherSessions}
|
||||
disabled={sessions.length <= 1}
|
||||
>
|
||||
<Trash size={16} />
|
||||
End All Other Sessions
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAllSessions}
|
||||
>
|
||||
Clear All Sessions
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No active sessions found</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck size={20} className="text-accent" weight="fill" />
|
||||
<CardTitle>Session Security</CardTitle>
|
||||
</div>
|
||||
)}
|
||||
<CardDescription>
|
||||
Automatic timeout and security settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert className="bg-accent/10 border-accent/30">
|
||||
<ShieldCheck size={16} className="text-accent" weight="fill" />
|
||||
<AlertTitle className="text-accent-foreground mb-2">Auto-Logout Protection</AlertTitle>
|
||||
<AlertDescription className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
For your security, you will be automatically logged out after <strong>30 minutes</strong> of inactivity.
|
||||
</p>
|
||||
<p>
|
||||
You'll receive a warning <strong>5 minutes</strong> before your session expires, giving you the option to extend it.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEndAllOtherSessions}
|
||||
disabled={sessions.length <= 1}
|
||||
>
|
||||
End All Other Sessions
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAllSessions}
|
||||
>
|
||||
Clear All Sessions
|
||||
</Button>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5" />
|
||||
<p className="text-muted-foreground">
|
||||
Sessions are automatically extended when you interact with the application
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5" />
|
||||
<p className="text-muted-foreground">
|
||||
All sessions are stored securely in your browser's local database
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5" />
|
||||
<p className="text-muted-foreground">
|
||||
Close your browser to end all sessions immediately
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<SessionExpiryDialog
|
||||
open={isWarningShown}
|
||||
timeRemaining={300} // seconds
|
||||
totalWarningTime={300} // total warning period in seconds
|
||||
onExtend={() => 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
|
||||
<SessionManager />
|
||||
```
|
||||
|
||||
**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`
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sessionTimeout">Session Timeout (minutes)</Label>
|
||||
<Label htmlFor="sessionTimeout" className="flex items-center gap-2">
|
||||
<Clock size={16} />
|
||||
Session Timeout
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Automatically log out after this period of inactivity
|
||||
</p>
|
||||
<Select
|
||||
value={security.sessionTimeout}
|
||||
onValueChange={(value) => setSecurity({ ...security, sessionTimeout: value })}
|
||||
@@ -469,9 +489,10 @@ export function ProfileView() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes (Recommended)</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
<SelectItem value="120">2 hours</SelectItem>
|
||||
<SelectItem value="240">4 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
53
src/hooks/use-session-timeout-preferences.ts
Normal file
53
src/hooks/use-session-timeout-preferences.ts
Normal file
@@ -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<SessionTimeoutPreferences>(
|
||||
'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,
|
||||
}
|
||||
}
|
||||
173
src/hooks/use-session-timeout.ts
Normal file
173
src/hooks/use-session-timeout.ts
Normal file
@@ -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<SessionTimeoutConfig> = {}) {
|
||||
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<NodeJS.Timeout | undefined>(undefined)
|
||||
const countdownIntervalRef = useRef<NodeJS.Timeout | undefined>(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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user