From 5d0e453a6846d2c14f4d62f7941fefd81b1bb4c9 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 17:23:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor(WEB):=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticatedLayout: FCM 통합 및 레이아웃 개선 - logout: 로그아웃 시 FCM 토큰 정리 로직 추가 - AccountInfoManagement: 계정 정보 관리 UI 개선 - not-found 페이지 스타일 개선 - 환경변수 예시 파일 업데이트 --- .env.example | 2 +- .env.production | 2 +- src/app/[locale]/(protected)/layout.tsx | 8 +- src/app/[locale]/(protected)/not-found.tsx | 12 +- .../settings/account-info/page.tsx | 39 +++- src/app/[locale]/not-found.tsx | 14 +- .../settings/AccountInfoManagement/index.tsx | 219 ++++++++++++------ src/layouts/AuthenticatedLayout.tsx | 29 ++- src/lib/auth/logout.ts | 25 +- 9 files changed, 247 insertions(+), 103 deletions(-) diff --git a/.env.example b/.env.example index 8b64a7a3..72380b1d 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # ============================================== # API Configuration # ============================================== -NEXT_PUBLIC_API_URL=https://api.5130.co.kr +API_URL=https://api.5130.co.kr # Frontend URL (for CORS) NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 diff --git a/.env.production b/.env.production index ce22447b..2ac8054f 100644 --- a/.env.production +++ b/.env.production @@ -1,7 +1,7 @@ # ============================================== # API Configuration # ============================================== -NEXT_PUBLIC_API_URL=https://api.codebridge-x.com +API_URL=https://api.codebridge-x.com # Frontend URL (for CORS) NEXT_PUBLIC_FRONTEND_URL=https://dev.codebridge-x.com diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index e36bca6b..b53eb0f4 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -4,6 +4,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard'; import AuthenticatedLayout from '@/layouts/AuthenticatedLayout'; import { RootProvider } from '@/contexts/RootProvider'; import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; +import { FCMProvider } from '@/contexts/FCMProvider'; /** * Protected Layout @@ -13,6 +14,7 @@ import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; * - Apply common layout (sidebar, header) to all protected pages * - Provide global context (RootProvider) * - Provide API error handling context (ApiErrorProvider) + * - Initialize FCM push notifications (Capacitor native apps) * - Prevent browser back button cache issues * - Centralized protection for all routes under (protected) * @@ -35,9 +37,9 @@ export default function ProtectedLayout({ // 🚨 ApiErrorProvider: Server Action 401 에러 시 자동 로그인 리다이렉트 return ( - - {children} - + + {children} + ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/not-found.tsx b/src/app/[locale]/(protected)/not-found.tsx index 6e209c17..292541ee 100644 --- a/src/app/[locale]/(protected)/not-found.tsx +++ b/src/app/[locale]/(protected)/not-found.tsx @@ -1,4 +1,7 @@ +'use client'; + import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { SearchX, Home, ArrowLeft, Map } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -12,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; * - 보호된 경로 내에서 404 발생 시 표시 */ export default function ProtectedNotFoundPage() { + const router = useRouter(); return (
@@ -56,13 +60,11 @@ export default function ProtectedNotFoundPage() {

1250 X 250px, 10MB 이하의 PNG, JPEG, GIF @@ -371,6 +422,7 @@ export function AccountInfoClient() { id="email-consent" checked={marketingConsent.email.agreed} onCheckedChange={(checked) => handleMarketingChange('email', checked as boolean)} + disabled={isSavingMarketing} />

+

+ 정말 탈퇴하시겠습니까? +
+ + 모든 테넌트에서 탈퇴 처리되며, SAM 서비스에서 완전히 탈퇴됩니다. + +

+
+ + setWithdrawPassword(e.target.value)} + disabled={isWithdrawing} + /> +
+
@@ -425,7 +496,7 @@ export function AccountInfoClient() { {isWithdrawing ? '처리 중...' : '확인'} diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 6705c992..ec9041e6 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -139,22 +139,39 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 + // 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 (예: /hr/attendance는 /hr/attendance-management와 매칭되면 안됨) + const isPathMatch = (menuPath: string, currentPath: string): boolean => { + if (currentPath === menuPath) return true; + // 하위 경로 확인: /menu/path/subpath 형태만 매칭 (슬래시로 구분) + return currentPath.startsWith(menuPath + '/'); + }; + const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { + // 모든 매칭 가능한 메뉴 수집 (가장 긴 경로가 가장 구체적) + const matches: { menuId: string; parentId?: string; pathLength: number }[] = []; + for (const item of items) { - // 서브메뉴가 있으면 먼저 확인 (더 구체적인 경로 우선) + // 서브메뉴 확인 if (item.children && item.children.length > 0) { for (const child of item.children) { - if (child.path && normalizedPath.startsWith(child.path)) { - return { menuId: child.id, parentId: item.id }; + if (child.path && isPathMatch(child.path, normalizedPath)) { + matches.push({ menuId: child.id, parentId: item.id, pathLength: child.path.length }); } } } - // 서브메뉴에서 매칭되지 않으면 현재 메뉴 확인 - if (item.path && normalizedPath.startsWith(item.path)) { - return { menuId: item.id }; + // 메인 메뉴 확인 + if (item.path && item.path !== '#' && isPathMatch(item.path, normalizedPath)) { + matches.push({ menuId: item.id, pathLength: item.path.length }); } } + + // 가장 긴 경로(가장 구체적인 매칭) 반환 + if (matches.length > 0) { + matches.sort((a, b) => b.pathLength - a.pathLength); + return { menuId: matches[0].menuId, parentId: matches[0].parentId }; + } + return null; }; diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts index 4a7bece4..940de6f1 100644 --- a/src/lib/auth/logout.ts +++ b/src/lib/auth/logout.ts @@ -5,7 +5,8 @@ * 1. Zustand 스토어 초기화 (메모리 캐시) * 2. sessionStorage 캐시 삭제 (page_config_*, mes-*) * 3. localStorage 사용자 데이터 삭제 (mes-currentUser, mes-users) - * 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) + * 4. FCM 토큰 해제 (Capacitor 네이티브 앱) + * 5. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) * * @see claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md */ @@ -13,6 +14,8 @@ import { useMasterDataStore } from '@/stores/masterDataStore'; import { useItemStore } from '@/stores/itemStore'; +// FCM은 Capacitor 환경에서만 사용 (동적 import로 웹 빌드 에러 방지) + // ===== 캐시 삭제 대상 Prefix ===== const SESSION_STORAGE_PREFIXES = [ @@ -144,7 +147,8 @@ export async function callLogoutAPI(): Promise { * 1. Zustand 스토어 초기화 (즉시 UI 반영) * 2. sessionStorage 캐시 삭제 * 3. localStorage 사용자 데이터 삭제 - * 4. 서버 로그아웃 API 호출 + * 4. FCM 토큰 해제 (Capacitor 네이티브 앱) + * 5. 서버 로그아웃 API 호출 * * @param options.skipServerLogout - 서버 로그아웃 생략 여부 (기본: false) * @param options.redirectTo - 로그아웃 후 리다이렉트 경로 (기본: null) @@ -168,14 +172,27 @@ export async function performFullLogout(options?: { // 3. localStorage 사용자 데이터 삭제 clearLocalStorageCache(); - // 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) + // 4. FCM 토큰 해제 (Capacitor 네이티브 앱에서만 실행) + // 동적 import로 @capacitor/core 빌드 에러 방지 + try { + const fcm = await import('@/lib/capacitor/fcm'); + if (fcm.isCapacitorNative()) { + await fcm.unregisterFCMToken(); + console.log('[Logout] FCM token unregistered'); + } + } catch { + // Capacitor 모듈이 없는 환경 (웹) - 무시 + console.log('[Logout] Skipping FCM (not in native app)'); + } + + // 5. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) if (!skipServerLogout) { await callLogoutAPI(); } console.log('[Logout] Full logout completed successfully'); - // 5. 리다이렉트 (선택적) + // 6. 리다이렉트 (선택적) if (redirectTo && typeof window !== 'undefined') { window.location.href = redirectTo; }