diff options
Diffstat (limited to 'components/layout/SessionManager.tsx')
| -rw-r--r-- | components/layout/SessionManager.tsx | 361 |
1 files changed, 361 insertions, 0 deletions
diff --git a/components/layout/SessionManager.tsx b/components/layout/SessionManager.tsx new file mode 100644 index 00000000..c917c5f3 --- /dev/null +++ b/components/layout/SessionManager.tsx @@ -0,0 +1,361 @@ +// components/layout/SessionManager.tsx +'use client' + +import { useSession } from "next-auth/react" +import { useEffect, useState, useCallback } from "react" +import { useRouter } from "next/navigation" +import { AlertCircle, Clock, RefreshCw, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Progress } from "@/components/ui/progress" +import { useToast } from "@/hooks/use-toast" +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +interface SessionManagerProps { + lng: string; +} + +// 다국어 메시지 +const messages = { + ko: { + sessionExpiring: "세션 만료 경고", + sessionWillExpire: "세션이 {minutes}분 후에 만료됩니다.", + sessionExpired: "세션이 만료되었습니다", + pleaseRelogin: "다시 로그인해주세요.", + extend: "연장", + extending: "연장 중...", + close: "닫기", + sessionExtended: "세션이 연장되었습니다", + sessionExtendFailed: "세션 연장에 실패했습니다", + autoLogoutIn: "{seconds}초 후 자동 로그아웃됩니다", + staySignedIn: "로그인 유지", + logout: "로그아웃" + }, + en: { + sessionExpiring: "Session Expiring", + sessionWillExpire: "Your session will expire in {minutes} minute(s).", + sessionExpired: "Session Expired", + pleaseRelogin: "Please log in again.", + extend: "Extend", + extending: "Extending...", + close: "Close", + sessionExtended: "Session has been extended", + sessionExtendFailed: "Failed to extend session", + autoLogoutIn: "Auto logout in {seconds} seconds", + staySignedIn: "Stay Signed In", + logout: "Logout" + } +} as const; + +export function SessionManager({ lng }: SessionManagerProps) { + const { data: session, update } = useSession() + const router = useRouter() + const { toast } = useToast() + + const [showWarning, setShowWarning] = useState(false) + const [showExpiredModal, setShowExpiredModal] = useState(false) + const [isExtending, setIsExtending] = useState(false) + const [autoLogoutCountdown, setAutoLogoutCountdown] = useState(0) + const [timeLeft, setTimeLeft] = useState<number | null>(null) + + const t = messages[lng as keyof typeof messages] || messages.en + + // 세션 연장 함수 + const extendSession = useCallback(async () => { + if (isExtending) return; + + setIsExtending(true) + try { + await update({ + reAuthTime: Date.now() + }) + + setShowWarning(false) + setTimeLeft(null) + + toast({ + title: t.sessionExtended, + description: "세션이 성공적으로 연장되었습니다.", + duration: 3000, + }) + } catch (error) { + console.error('Failed to extend session:', error) + toast({ + title: t.sessionExtendFailed, + description: "다시 시도해주세요.", + variant: "destructive", + duration: 5000, + }) + } finally { + setIsExtending(false) + } + }, [isExtending, update, toast, t]) + + // 자동 로그아웃 처리 + const handleAutoLogout = useCallback(() => { + setShowExpiredModal(false) + setShowWarning(false) + window.location.href = `/${lng}/evcp?reason=expired` + }, [lng]) + + // 세션 만료 체크 + useEffect(() => { + if (!session?.user?.sessionExpiredAt) return + + const checkSession = () => { + const now = Date.now() + const expiresAt = session.user.sessionExpiredAt! + const timeUntilExpiry = expiresAt - now + const warningThreshold = 5 * 60 * 1000 // 5분 + const criticalThreshold = 1 * 60 * 1000 // 1분 + + setTimeLeft(timeUntilExpiry) + + // 세션 만료됨 + if (timeUntilExpiry <= 0) { + setShowWarning(false) + setShowExpiredModal(true) + setAutoLogoutCountdown(10) // 10초 후 자동 로그아웃 + return + } + + // 1분 이내 - 긴급 경고 + if (timeUntilExpiry <= criticalThreshold) { + setShowWarning(true) + return + } + + // 5분 이내 - 일반 경고 + if (timeUntilExpiry <= warningThreshold && !showWarning) { + setShowWarning(true) + return + } + + // 경고 해제 + if (timeUntilExpiry > warningThreshold && showWarning) { + setShowWarning(false) + } + } + + // 즉시 체크 + checkSession() + + // 5초마다 체크 (더 정확한 카운트다운을 위해) + const interval = setInterval(checkSession, 5000) + + return () => clearInterval(interval) + }, [session, showWarning]) + + // 자동 로그아웃 카운트다운 + useEffect(() => { + if (autoLogoutCountdown <= 0) return + + const timer = setTimeout(() => { + if (autoLogoutCountdown === 1) { + handleAutoLogout() + } else { + setAutoLogoutCountdown(prev => prev - 1) + } + }, 1000) + + return () => clearTimeout(timer) + }, [autoLogoutCountdown, handleAutoLogout]) + + // 사용자 활동 감지 + useEffect(() => { + let activityTimer: NodeJS.Timeout + let lastActivity = Date.now() + + const resetActivityTimer = () => { + const now = Date.now() + const timeSinceLastActivity = now - lastActivity + + // 5분 이상 비활성 후 첫 활동이면 세션 연장 + if (timeSinceLastActivity > 5 * 60 * 1000) { + extendSession() + } + + lastActivity = now + clearTimeout(activityTimer) + + // 10분간 비활성이면 경고 표시 + activityTimer = setTimeout(() => { + if (!showWarning && session?.user?.sessionExpiredAt) { + const timeUntilExpiry = session.user.sessionExpiredAt - Date.now() + if (timeUntilExpiry > 0 && timeUntilExpiry <= 10 * 60 * 1000) { + setShowWarning(true) + } + } + }, 10 * 60 * 1000) + } + + const activities = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'] + + activities.forEach(activity => { + document.addEventListener(activity, resetActivityTimer, true) + }) + + resetActivityTimer() // 초기 타이머 설정 + + return () => { + clearTimeout(activityTimer) + activities.forEach(activity => { + document.removeEventListener(activity, resetActivityTimer, true) + }) + } + }, [extendSession, showWarning, session]) + + const formatTime = (ms: number) => { + const minutes = Math.floor(ms / (1000 * 60)) + const seconds = Math.floor((ms % (1000 * 60)) / 1000) + return { minutes, seconds } + } + + // 세션 만료 모달 + if (showExpiredModal) { + return ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100]"> + <Card className="w-full max-w-md mx-4"> + <CardContent className="p-6"> + <div className="flex items-center space-x-3 mb-4"> + <AlertCircle className="h-6 w-6 text-destructive" /> + <h3 className="text-lg font-semibold">{t.sessionExpired}</h3> + </div> + + <p className="text-muted-foreground mb-4"> + {t.pleaseRelogin} + </p> + + {autoLogoutCountdown > 0 && ( + <div className="mb-4"> + <p className="text-sm text-muted-foreground mb-2"> + {t.autoLogoutIn.replace('{seconds}', autoLogoutCountdown.toString())} + </p> + <Progress + value={(10 - autoLogoutCountdown) * 10} + className="h-2" + /> + </div> + )} + + <div className="flex space-x-2"> + <Button + onClick={() => { + setAutoLogoutCountdown(0) + extendSession() + setShowExpiredModal(false) + }} + className="flex-1" + disabled={isExtending} + > + {isExtending ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + {t.extending} + </> + ) : ( + t.staySignedIn + )} + </Button> + <Button + variant="outline" + onClick={handleAutoLogout} + className="flex-1" + > + {t.logout} + </Button> + </div> + </CardContent> + </Card> + </div> + ) + } + + // 세션 경고 알림 + if (showWarning && timeLeft) { + const { minutes, seconds } = formatTime(timeLeft) + const isCritical = timeLeft <= 60000 // 1분 이내 + const progressValue = Math.max(0, Math.min(100, (timeLeft / (5 * 60 * 1000)) * 100)) + + return ( + <div className={cn( + "fixed top-4 right-4 z-50 w-full max-w-sm", + "animate-in slide-in-from-right-full duration-300" + )}> + <Alert className={cn( + "border-2 shadow-lg", + isCritical + ? "border-destructive bg-destructive/5" + : "border-warning bg-warning/5" + )}> + <Clock className={cn( + "h-4 w-4", + isCritical ? "text-destructive" : "text-warning" + )} /> + + <div className="flex-1"> + <div className="flex items-center justify-between mb-2"> + <h4 className="font-medium text-sm"> + {t.sessionExpiring} + </h4> + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-transparent" + onClick={() => setShowWarning(false)} + > + <X className="h-3 w-3" /> + </Button> + </div> + + <AlertDescription className="text-xs mb-3"> + {minutes > 0 + ? t.sessionWillExpire.replace('{minutes}', minutes.toString()) + : `${seconds}초 후 세션이 만료됩니다.` + } + </AlertDescription> + + <div className="space-y-2"> + <Progress + value={progressValue} + className={cn( + "h-1.5", + isCritical && "bg-destructive/20" + )} + /> + + <div className="flex space-x-2"> + <Button + size="sm" + onClick={extendSession} + disabled={isExtending} + className="flex-1 h-7 text-xs" + > + {isExtending ? ( + <> + <RefreshCw className="h-3 w-3 mr-1 animate-spin" /> + {t.extending} + </> + ) : ( + t.extend + )} + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setShowWarning(false)} + className="h-7 text-xs px-2" + > + {t.close} + </Button> + </div> + </div> + </div> + </Alert> + </div> + ) + } + + return null +}
\ No newline at end of file |
