[feat]: Safari 쿠키 호환성 및 UI/UX 개선

주요 변경사항:
- Safari 쿠키 호환성 개선 (SameSite=Lax, 개발 환경 Secure 제외)
- Sidebar 활성 메뉴 자동 스크롤 기능 추가
- Sidebar 스크롤바 스타일링 (호버 시에만 표시)
- DashboardLayout sticky 포지셔닝 적용
- IE 브라우저 차단 및 안내 페이지 추가
- 메뉴 탐색 로직 개선 (서브메뉴 우선 매칭)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-13 14:32:14 +09:00
parent 46aff1a6a2
commit 85e51b2e2a
8 changed files with 399 additions and 37 deletions

View File

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

View File

@@ -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('; ');

View File

@@ -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('; ');

View File

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

View File

@@ -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<HTMLDivElement | null>(null);
// eslint-disable-next-line no-undef
const menuContainerRef = useRef<HTMLDivElement | null>(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({
</div>
{/* 메뉴 */}
<div className={`flex-1 overflow-y-auto transition-all duration-300 ${
sidebarCollapsed ? 'px-3 py-4' : 'p-4 md:p-6'
}`}>
<div
ref={menuContainerRef}
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
sidebarCollapsed ? 'px-3 py-4' : 'p-4 md:p-6'
}`}
>
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'space-y-2' : 'space-y-3'
}`}>
@@ -80,7 +102,11 @@ export default function Sidebar({
const isActive = activeMenu === item.id;
return (
<div key={item.id} className="relative">
<div
key={item.id}
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
@@ -129,19 +155,24 @@ export default function Sidebar({
<div className="mt-2 ml-4 space-y-1 border-l-2 border-primary/20 pl-4">
{item.children?.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = activeMenu === subItem.id;
return (
<button
<div
key={subItem.id}
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2 space-x-2 group ${
activeMenu === subItem.id
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
ref={isSubActive ? activeMenuRef : null}
>
<SubIcon className="h-4 w-4" />
<span className="text-xs font-medium">{subItem.label}</span>
</button>
<button
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2 space-x-2 group ${
isSubActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<SubIcon className="h-4 w-4" />
<span className="text-xs font-medium">{subItem.label}</span>
</button>
</div>
);
})}
</div>

View File

@@ -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) {
<div className="min-h-screen flex w-full p-3 gap-3">
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
<div
className={`border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
className={`sticky top-3 h-[calc(100vh-24px)] border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
sidebarCollapsed ? 'w-24' : 'w-80'
}`}
>

View File

@@ -92,6 +92,17 @@ function isBot(userAgent: string): boolean {
return BOT_PATTERNS.some(pattern => pattern.test(userAgent));
}
/**
* Check if user-agent is Internet Explorer
* IE 11: Contains "Trident" in user-agent
* IE 10 and below: Contains "MSIE" in user-agent
*/
function isInternetExplorer(userAgent: string): boolean {
if (!userAgent) return false;
return /MSIE|Trident/.test(userAgent);
}
/**
* Check if the path should be protected from bots
*/
@@ -180,6 +191,16 @@ export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const userAgent = request.headers.get('user-agent') || '';
// 🚨 0⃣ Internet Explorer Detection (최우선 처리)
// IE 사용자는 지원 안내 페이지로 리다이렉트
if (isInternetExplorer(userAgent)) {
// unsupported-browser.html 페이지 자체는 제외 (무한 리다이렉트 방지)
if (!pathname.includes('unsupported-browser')) {
console.log(`[IE Blocked] ${userAgent} attempted to access ${pathname}`);
return NextResponse.redirect(new URL('/unsupported-browser.html', request.url));
}
}
// 1⃣ 로케일 제거
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);