Generated by Spark: Add session expiry warnings and auto-logout for enhanced security

This commit is contained in:
2026-01-24 02:02:12 +00:00
committed by GitHub
parent f86c6480a9
commit 347f2af0b2
9 changed files with 594 additions and 88 deletions

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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`

View File

@@ -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>

View File

@@ -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`

View File

@@ -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'

View 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,
}
}

View 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,
}
}