diff --git a/public/unsupported-browser.html b/public/unsupported-browser.html new file mode 100644 index 00000000..ba67e977 --- /dev/null +++ b/public/unsupported-browser.html @@ -0,0 +1,256 @@ + + + + + + + 브라우저 업그레이드 안내 - SAM ERP + + + +
+ +
⚠️
+

지원하지 않는 브라우저입니다

+

Internet Explorer는 더 이상 지원되지 않습니다

+ +

+ 안정적이고 안전한 서비스 이용을 위해
+ 아래의 최신 브라우저를 설치하여 이용해 주세요. +

+ +
+ +
권장
+
🌐
+
Microsoft Edge
+
Windows 권장 브라우저
+
+ + +
🔵
+
Google Chrome
+
가장 많이 사용되는 브라우저
+
+ + +
🧭
+
Safari
+
macOS/iOS 기본 브라우저
+
+
+ +
+
+ + Internet Explorer 지원 중단 안내 +
+ +
+ + +
+ + diff --git a/src/app/api/auth/check/route.ts b/src/app/api/auth/check/route.ts index e7f813b3..a1366a83 100644 --- a/src/app/api/auth/check/route.ts +++ b/src/app/api/auth/check/route.ts @@ -72,23 +72,26 @@ export async function GET(request: NextRequest) { if (refreshResponse.ok) { const data = await refreshResponse.json(); - // Set new tokens + // Set new tokens with Safari-compatible configuration + // Safari compatibility: Secure only in production (HTTPS) + const isProduction = process.env.NODE_ENV === 'production'; + const accessTokenCookie = [ `access_token=${data.access_token}`, - 'HttpOnly', - 'Secure', - 'SameSite=Strict', + 'HttpOnly', // ✅ JavaScript cannot access + ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) + 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', `Max-Age=${data.expires_in || 7200}`, ].join('; '); const refreshTokenCookie = [ `refresh_token=${data.refresh_token}`, - 'HttpOnly', - 'Secure', - 'SameSite=Strict', + 'HttpOnly', // ✅ JavaScript cannot access + ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) + 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', - 'Max-Age=604800', + 'Max-Age=604800', // 7 days (longer for refresh token) ].join('; '); console.log('✅ Token auto-refreshed in auth check'); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ab41a9f3..0bd988ba 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -148,11 +148,14 @@ export async function POST(request: NextRequest) { }; // Set HttpOnly cookies for both access_token and refresh_token + // Safari compatibility: Secure only in production (HTTPS) + const isProduction = process.env.NODE_ENV === 'production'; + const accessTokenCookie = [ `access_token=${data.access_token}`, 'HttpOnly', // ✅ JavaScript cannot access - 'Secure', // ✅ HTTPS only (production) - 'SameSite=Strict', // ✅ CSRF protection + ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) + 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', `Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours) ].join('; '); @@ -160,8 +163,8 @@ export async function POST(request: NextRequest) { const refreshTokenCookie = [ `refresh_token=${data.refresh_token}`, 'HttpOnly', // ✅ JavaScript cannot access - 'Secure', // ✅ HTTPS only (production) - 'SameSite=Strict', // ✅ CSRF protection + ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) + 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', 'Max-Age=604800', // 7 days (longer for refresh token) ].join('; '); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index f0ca5ba7..eaec24b0 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -49,11 +49,14 @@ export async function POST(request: NextRequest) { } // Clear both HttpOnly cookies + // Safari compatibility: Must use same attributes as when setting cookies + const isProduction = process.env.NODE_ENV === 'production'; + const clearAccessToken = [ 'access_token=', 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), // ✅ Match login/check cookie attributes + 'SameSite=Lax', // ✅ Match login/check cookie attributes 'Path=/', 'Max-Age=0', // Delete immediately ].join('; '); @@ -61,8 +64,8 @@ export async function POST(request: NextRequest) { const clearRefreshToken = [ 'refresh_token=', 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), // ✅ Match login/check cookie attributes + 'SameSite=Lax', // ✅ Match login/check cookie attributes 'Path=/', 'Max-Age=0', // Delete immediately ].join('; '); diff --git a/src/app/globals.css b/src/app/globals.css index 633f52a5..c523d588 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -297,6 +297,51 @@ .dark ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } + + /* Sidebar scroll - hide by default, show on hover */ + .sidebar-scroll::-webkit-scrollbar { + width: 6px; + } + + .sidebar-scroll::-webkit-scrollbar-track { + background: transparent; + } + + .sidebar-scroll::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 3px; + transition: background 0.2s ease; + } + + .sidebar-scroll:hover::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + } + + .dark .sidebar-scroll:hover::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + } + + .sidebar-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25) !important; + } + + .dark .sidebar-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25) !important; + } + + /* Firefox */ + .sidebar-scroll { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + } + + .sidebar-scroll:hover { + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + } + + .dark .sidebar-scroll:hover { + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + } } /* Clean modern component styles */ diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0564ed49..feba4f1e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,6 @@ import { ChevronRight } from 'lucide-react'; import type { MenuItem } from '@/store/menuStore'; +import { useEffect, useRef } from 'react'; interface SidebarProps { menuItems: MenuItem[]; @@ -22,6 +23,24 @@ export default function Sidebar({ onToggleSubmenu, onCloseMobileSidebar, }: SidebarProps) { + // 활성 메뉴 자동 스크롤을 위한 ref + // eslint-disable-next-line no-undef + const activeMenuRef = useRef(null); + // eslint-disable-next-line no-undef + const menuContainerRef = useRef(null); + + // 활성 메뉴가 변경될 때 자동 스크롤 + useEffect(() => { + if (activeMenuRef.current && menuContainerRef.current) { + // 부드러운 스크롤로 활성 메뉴를 화면에 표시 + activeMenuRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + }, [activeMenu]); // activeMenu 변경 시에만 스크롤 (메뉴 클릭 시) + const handleMenuClick = (menuId: string, path: string, hasChildren: boolean) => { if (hasChildren) { onToggleSubmenu(menuId); @@ -67,9 +86,12 @@ export default function Sidebar({ {/* 메뉴 */} -
+
@@ -80,7 +102,11 @@ export default function Sidebar({ const isActive = activeMenu === item.id; return ( -
+
{/* 메인 메뉴 버튼 */} + +
); })}
diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index f619e519..2cb0f591 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -89,12 +89,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { for (const item of items) { - // 현재 메뉴의 경로와 일치하는지 확인 - if (item.path && normalizedPath.startsWith(item.path)) { - return { menuId: item.id }; - } - - // 서브메뉴가 있으면 재귀적으로 탐색 + // 서브메뉴가 있으면 먼저 확인 (더 구체적인 경로 우선) if (item.children && item.children.length > 0) { for (const child of item.children) { if (child.path && normalizedPath.startsWith(child.path)) { @@ -102,6 +97,11 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { } } } + + // 서브메뉴에서 매칭되지 않으면 현재 메뉴 확인 + if (item.path && normalizedPath.startsWith(item.path)) { + return { menuId: item.id }; + } } return null; }; @@ -163,7 +163,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
{/* 데스크톱 사이드바 (모바일에서 숨김) */}