summaryrefslogtreecommitdiff
path: root/components/layout
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
commite9897d416b3e7327bbd4d4aef887eee37751ae82 (patch)
treebd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/layout
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/layout')
-rw-r--r--components/layout/SessionManager.tsx361
-rw-r--r--components/layout/providers.tsx31
2 files changed, 380 insertions, 12 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
diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx
index 376c419a..78f96d61 100644
--- a/components/layout/providers.tsx
+++ b/components/layout/providers.tsx
@@ -6,9 +6,9 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { SessionProvider } from "next-auth/react"
import { CacheProvider } from '@emotion/react'
-import { SWRConfig } from 'swr' // ✅ SWR 추가
-
+import { SWRConfig } from 'swr'
import { TooltipProvider } from "@/components/ui/tooltip"
+import { SessionManager } from "@/components/layout/SessionManager" // ✅ SessionManager 추가
import createEmotionCache from './createEmotionCashe'
const cache = createEmotionCache()
@@ -21,18 +21,18 @@ const swrConfig = {
shouldRetryOnError: false, // 에러시 자동 재시도 비활성화 (수동으로 제어)
dedupingInterval: 2000, // 2초 내 중복 요청 방지
refreshInterval: 0, // 기본적으로 자동 갱신 비활성화 (개별 훅에서 설정)
-
+
// 간단한 전역 에러 핸들러 (토스트 없이 로깅만)
onError: (error: any, key: string) => {
// 개발 환경에서만 상세 로깅
if (process.env.NODE_ENV === 'development') {
- console.warn('SWR fetch failed:', {
- url: key,
- status: error?.status,
- message: error?.message
+ console.warn('SWR fetch failed:', {
+ url: key,
+ status: error?.status,
+ message: error?.message
})
}
-
+
// 401 Unauthorized의 경우 특별 처리 (선택사항)
if (error?.status === 401) {
console.warn('Authentication required')
@@ -40,16 +40,21 @@ const swrConfig = {
// window.location.href = '/login'
}
},
-
+
// 전역 성공 핸들러는 제거 (너무 많은 로그 방지)
-
+
// 기본 fetcher 제거 (각 훅에서 개별 관리)
}
+interface ThemeProviderProps extends React.ComponentProps<typeof NextThemesProvider> {
+ lng?: string; // ✅ lng prop 추가
+}
+
export function ThemeProvider({
children,
+ lng = 'ko', // ✅ 기본값 설정
...props
-}: React.ComponentProps<typeof NextThemesProvider>) {
+}: ThemeProviderProps) {
return (
<JotaiProvider>
<CacheProvider value={cache}>
@@ -60,6 +65,8 @@ export function ThemeProvider({
{/* ✅ 간소화된 SWR 설정 적용 */}
<SWRConfig value={swrConfig}>
{children}
+ {/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */}
+ <SessionManager lng={lng} />
</SWRConfig>
</SessionProvider>
</NuqsAdapter>
@@ -68,4 +75,4 @@ export function ThemeProvider({
</CacheProvider>
</JotaiProvider>
)
-}
+} \ No newline at end of file