summaryrefslogtreecommitdiff
path: root/components/layout/SessionManager.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/layout/SessionManager.tsx')
-rw-r--r--components/layout/SessionManager.tsx361
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