diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts index bf199e16..a5b79076 100644 --- a/src/components/production/WorkOrders/actions.ts +++ b/src/components/production/WorkOrders/actions.ts @@ -624,6 +624,48 @@ export async function updateWorkOrderItemStatus( } } +// ===== 중간검사 데이터 저장 ===== +export async function saveInspectionData( + workOrderId: string, + processType: string, + data: unknown +): Promise<{ success: boolean; error?: string }> { + try { + console.log('[WorkOrderActions] POST inspection data:', { workOrderId, processType }); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/inspection`, + { + method: 'POST', + body: JSON.stringify({ + process_type: processType, + inspection_data: data, + }), + } + ); + + if (error || !response) { + return { success: false, error: error?.message || 'API 요청 실패' }; + } + + const result = await response.json(); + console.log('[WorkOrderActions] POST inspection response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '검사 데이터 저장에 실패했습니다.', + }; + } + + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderActions] saveInspectionData error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + // ===== 수주 목록 조회 (작업지시 생성용) ===== export interface SalesOrderForWorkOrder { id: number; diff --git a/src/components/production/WorkOrders/documents/BendingInspectionContent.tsx b/src/components/production/WorkOrders/documents/BendingInspectionContent.tsx index 89ff0e5a..c2794990 100644 --- a/src/components/production/WorkOrders/documents/BendingInspectionContent.tsx +++ b/src/components/production/WorkOrders/documents/BendingInspectionContent.tsx @@ -14,9 +14,13 @@ * - 부적합 내용 / 종합판정(자동) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import type { WorkOrder } from '../types'; +export interface InspectionContentRef { + getInspectionData: () => unknown; +} + interface BendingInspectionContentProps { data: WorkOrder; readOnly?: boolean; @@ -98,7 +102,7 @@ const INITIAL_PRODUCTS: Omit(function BendingInspectionContent({ data: order, readOnly = false }, ref) { const fullDate = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', @@ -166,6 +170,27 @@ export function BendingInspectionContent({ data: order, readOnly = false }: Bend return null; }, [products, getProductJudgment]); + useImperativeHandle(ref, () => ({ + getInspectionData: () => ({ + products: products.map(p => ({ + id: p.id, + category: p.category, + productName: p.productName, + productType: p.productType, + bendingStatus: p.bendingStatus, + lengthMeasured: p.lengthMeasured, + widthMeasured: p.widthMeasured, + gapPoints: p.gapPoints.map(gp => ({ + point: gp.point, + designValue: gp.designValue, + measured: gp.measured, + })), + })), + inadequateContent, + overallResult, + }), + }), [products, inadequateContent, overallResult]); + const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs'; // 전체 행 수 계산 (간격 포인트 수 합계) @@ -478,4 +503,4 @@ export function BendingInspectionContent({ data: order, readOnly = false }: Bend ); -} +}); diff --git a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx index 171af40c..06c8bcee 100644 --- a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx +++ b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx @@ -9,14 +9,17 @@ * - bending: BendingInspectionContent */ -import { useState, useEffect } from 'react'; -import { Loader2 } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Loader2, Save } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; -import { getWorkOrderById } from '../actions'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { getWorkOrderById, saveInspectionData } from '../actions'; import type { WorkOrder, ProcessType } from '../types'; import { ScreenInspectionContent } from './ScreenInspectionContent'; import { SlatInspectionContent } from './SlatInspectionContent'; import { BendingInspectionContent } from './BendingInspectionContent'; +import type { InspectionContentRef } from './ScreenInspectionContent'; const PROCESS_LABELS: Record = { screen: '스크린', @@ -29,6 +32,7 @@ interface InspectionReportModalProps { onOpenChange: (open: boolean) => void; workOrderId: string | null; processType?: ProcessType; + readOnly?: boolean; } export function InspectionReportModal({ @@ -36,10 +40,13 @@ export function InspectionReportModal({ onOpenChange, workOrderId, processType = 'screen', + readOnly = true, }: InspectionReportModalProps) { const [order, setOrder] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const contentRef = useRef(null); // 목업 WorkOrder 생성 const createMockOrder = (id: string, pType: ProcessType): WorkOrder => ({ @@ -112,6 +119,25 @@ export function InspectionReportModal({ } }, [open, workOrderId, processType]); + const handleSave = useCallback(async () => { + if (!workOrderId || !contentRef.current) return; + + const data = contentRef.current.getInspectionData(); + setIsSaving(true); + try { + const result = await saveInspectionData(workOrderId, processType, data); + if (result.success) { + toast.success('검사 데이터가 저장되었습니다.'); + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } + } catch { + toast.error('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }, [workOrderId, processType]); + if (!workOrderId) return null; const processLabel = PROCESS_LABELS[processType] || '스크린'; @@ -122,16 +148,27 @@ export function InspectionReportModal({ switch (processType) { case 'screen': - return ; + return ; case 'slat': - return ; + return ; case 'bending': - return ; + return ; default: - return ; + return ; } }; + const toolbarExtra = !readOnly ? ( + + ) : undefined; + return ( {isLoading ? (
diff --git a/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx b/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx index 6d5180db..7e039937 100644 --- a/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx +++ b/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx @@ -13,9 +13,13 @@ * - 부적합 내용 / 종합판정(자동) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import type { WorkOrder } from '../types'; +export interface InspectionContentRef { + getInspectionData: () => unknown; +} + interface ScreenInspectionContentProps { data: WorkOrder; readOnly?: boolean; @@ -39,7 +43,7 @@ interface InspectionRow { const DEFAULT_ROW_COUNT = 6; -export function ScreenInspectionContent({ data: order, readOnly = false }: ScreenInspectionContentProps) { +export const ScreenInspectionContent = forwardRef(function ScreenInspectionContent({ data: order, readOnly = false }, ref) { const fullDate = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', @@ -115,6 +119,22 @@ export function ScreenInspectionContent({ data: order, readOnly = false }: Scree return null; }, [rows, getRowJudgment]); + useImperativeHandle(ref, () => ({ + getInspectionData: () => ({ + rows: rows.map(row => ({ + id: row.id, + processStatus: row.processStatus, + sewingStatus: row.sewingStatus, + assemblyStatus: row.assemblyStatus, + lengthMeasured: row.lengthMeasured, + widthMeasured: row.widthMeasured, + gapResult: row.gapResult, + })), + inadequateContent, + overallResult, + }), + }), [rows, inadequateContent, overallResult]); + // 체크박스 렌더 (양호/불량) const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => ( @@ -380,4 +400,4 @@ export function ScreenInspectionContent({ data: order, readOnly = false }: Scree
); -} +}); diff --git a/src/components/production/WorkOrders/documents/SlatInspectionContent.tsx b/src/components/production/WorkOrders/documents/SlatInspectionContent.tsx index df171d10..4a0a0fc7 100644 --- a/src/components/production/WorkOrders/documents/SlatInspectionContent.tsx +++ b/src/components/production/WorkOrders/documents/SlatInspectionContent.tsx @@ -14,9 +14,13 @@ * - 부적합 내용 / 종합판정(자동) */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import type { WorkOrder } from '../types'; +export interface InspectionContentRef { + getInspectionData: () => unknown; +} + interface SlatInspectionContentProps { data: WorkOrder; readOnly?: boolean; @@ -38,7 +42,7 @@ interface InspectionRow { const DEFAULT_ROW_COUNT = 6; -export function SlatInspectionContent({ data: order, readOnly = false }: SlatInspectionContentProps) { +export const SlatInspectionContent = forwardRef(function SlatInspectionContent({ data: order, readOnly = false }, ref) { const fullDate = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', @@ -100,6 +104,21 @@ export function SlatInspectionContent({ data: order, readOnly = false }: SlatIns return null; }, [rows, getRowJudgment]); + useImperativeHandle(ref, () => ({ + getInspectionData: () => ({ + rows: rows.map(row => ({ + id: row.id, + processStatus: row.processStatus, + assemblyStatus: row.assemblyStatus, + height1Measured: row.height1Measured, + height2Measured: row.height2Measured, + lengthMeasured: row.lengthMeasured, + })), + inadequateContent, + overallResult, + }), + }), [rows, inadequateContent, overallResult]); + // 체크박스 렌더 (양호/불량) const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => ( @@ -339,4 +358,4 @@ export function SlatInspectionContent({ data: order, readOnly = false }: SlatIns ); -} +}); diff --git a/src/components/production/WorkOrders/documents/index.ts b/src/components/production/WorkOrders/documents/index.ts index 48377ddf..ffd1afe5 100644 --- a/src/components/production/WorkOrders/documents/index.ts +++ b/src/components/production/WorkOrders/documents/index.ts @@ -7,6 +7,7 @@ export { BendingWorkLogContent } from './BendingWorkLogContent'; export { ScreenInspectionContent } from './ScreenInspectionContent'; export { SlatInspectionContent } from './SlatInspectionContent'; export { BendingInspectionContent } from './BendingInspectionContent'; +export type { InspectionContentRef } from './ScreenInspectionContent'; // 모달 export { InspectionReportModal } from './InspectionReportModal'; diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 838728a0..0654c9f8 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -705,6 +705,7 @@ export default function WorkerScreen() { onOpenChange={setIsInspectionModalOpen} workOrderId={selectedOrder?.id || null} processType={activeTab} + readOnly={false} /> | null; - timestamp: number; - result: { success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number } | null; -} = { - promise: null, - timestamp: 0, - result: null, -}; - -const MIDDLEWARE_REFRESH_CACHE_TTL = 5000; // 5초 - // Create i18n middleware const intlMiddleware = createMiddleware({ locales, @@ -156,58 +134,45 @@ function getPathnameWithoutLocale(pathname: string): string { * 인증 체크 함수 * 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key * - * 🔄 추가: needsRefresh - access_token이 없고 refresh_token만 있는 경우 - * 이 경우 미들웨어에서 사전 갱신하여 페이지 로드 시 race condition 방지 + * refresh_token만 있는 경우도 인증된 것으로 간주 + * (실제 토큰 갱신은 PROXY에서 처리) */ function checkAuthentication(request: NextRequest): { isAuthenticated: boolean; authMode: 'sanctum' | 'bearer' | 'api-key' | null; - needsRefresh: boolean; - refreshToken: string | null; } { // 1. Bearer Token 확인 (쿠키에서) const accessToken = request.cookies.get('access_token'); const refreshToken = request.cookies.get('refresh_token'); - // 🔄 access_token이 없고 refresh_token만 있으면 사전 갱신 필요 - if (!accessToken?.value && refreshToken?.value) { + // access_token 또는 refresh_token이 있으면 인증된 것으로 간주 + // refresh_token만 있는 경우: 페이지 접근 허용, 실제 API 호출 시 PROXY에서 갱신 처리 + if (accessToken?.value || refreshToken?.value) { return { isAuthenticated: true, authMode: 'bearer', - needsRefresh: true, - refreshToken: refreshToken.value, - }; - } - - // access_token이 있으면 갱신 불필요 - if (accessToken?.value) { - return { - isAuthenticated: true, - authMode: 'bearer', - needsRefresh: false, - refreshToken: refreshToken?.value || null, }; } // 2. Bearer Token 확인 (Authorization 헤더) const authHeader = request.headers.get('authorization'); if (authHeader?.startsWith('Bearer ')) { - return { isAuthenticated: true, authMode: 'bearer', needsRefresh: false, refreshToken: null }; + return { isAuthenticated: true, authMode: 'bearer' }; } // 3. Sanctum 세션 쿠키 확인 (레거시 지원) const sessionCookie = request.cookies.get('laravel_session'); if (sessionCookie) { - return { isAuthenticated: true, authMode: 'sanctum', needsRefresh: false, refreshToken: null }; + return { isAuthenticated: true, authMode: 'sanctum' }; } // 4. API Key 확인 const apiKey = request.headers.get('x-api-key'); if (apiKey) { - return { isAuthenticated: true, authMode: 'api-key', needsRefresh: false, refreshToken: null }; + return { isAuthenticated: true, authMode: 'api-key' }; } - return { isAuthenticated: false, authMode: null, needsRefresh: false, refreshToken: null }; + return { isAuthenticated: false, authMode: null }; } /** @@ -230,80 +195,6 @@ function isPublicRoute(pathname: string): boolean { }); } -/** - * 🔄 미들웨어에서 토큰 갱신 (페이지 렌더링 전) - * - * 목적: Race Condition 방지 - * - 문제: auth/check와 serverFetch가 동시에 refresh_token 사용 - * - 해결: 미들웨어에서 먼저 갱신하여 페이지 로드 전에 새 토큰 준비 - * - * 5초 캐싱으로 중복 요청 방지 - */ -async function refreshTokenInMiddleware( - refreshToken: string -): Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number }> { - const now = Date.now(); - - // 1. 캐시된 성공 결과가 유효하면 즉시 반환 - if (middlewareRefreshCache.result && middlewareRefreshCache.result.success && now - middlewareRefreshCache.timestamp < MIDDLEWARE_REFRESH_CACHE_TTL) { - console.log(`🔵 [Middleware] Using cached refresh result (age: ${now - middlewareRefreshCache.timestamp}ms)`); - return middlewareRefreshCache.result; - } - - // 2. 진행 중인 refresh가 있으면 기다림 - if (middlewareRefreshCache.promise && !middlewareRefreshCache.result && now - middlewareRefreshCache.timestamp < MIDDLEWARE_REFRESH_CACHE_TTL) { - console.log(`🔵 [Middleware] Waiting for ongoing refresh...`); - return middlewareRefreshCache.promise; - } - - // 3. 이전 refresh가 실패했으면 캐시 초기화 - if (middlewareRefreshCache.result && !middlewareRefreshCache.result.success) { - middlewareRefreshCache.promise = null; - middlewareRefreshCache.result = null; - } - - // 4. 새 refresh 시작 - console.log(`🔄 [Middleware] Starting pre-refresh before page render...`); - middlewareRefreshCache.timestamp = now; - middlewareRefreshCache.result = null; - - middlewareRefreshCache.promise = (async () => { - try { - const refreshUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`; - const response = await fetch(refreshUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - - if (!response.ok) { - console.warn('🔴 [Middleware] Pre-refresh failed:', response.status); - return { success: false }; - } - - const data = await response.json(); - console.log('✅ [Middleware] Pre-refresh successful'); - - return { - success: true, - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - }; - } catch (error) { - console.error('🔴 [Middleware] Pre-refresh error:', error); - return { success: false }; - } - })(); - - middlewareRefreshCache.result = await middlewareRefreshCache.promise; - return middlewareRefreshCache.result; -} - export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const userAgent = request.headers.get('user-agent') || ''; @@ -312,7 +203,6 @@ export async function middleware(request: NextRequest) { // 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch // 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님 if (pathname.includes('[') && pathname.includes(']')) { - // console.log(`[Internal Request Skip] Dynamic segment in path: ${pathname}`); return NextResponse.next(); } @@ -362,7 +252,7 @@ export async function middleware(request: NextRequest) { } // 4️⃣ 인증 체크 - const { isAuthenticated, authMode, needsRefresh, refreshToken } = checkAuthentication(request); + const { isAuthenticated, authMode } = checkAuthentication(request); // 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04) // 회원가입 기능은 운영 페이지로 이동 예정 @@ -375,87 +265,9 @@ export async function middleware(request: NextRequest) { // 대전제: "게스트 전용" = 로그인 안 한 사람만 접근 가능 // 이미 로그인한 사람이 오면 → /dashboard로 보냄 if (isGuestOnlyRoute(pathnameWithoutLocale)) { - // 🔄 needsRefresh인 경우: 먼저 refresh 시도해서 "진짜 로그인 상태"인지 확인 - // refresh_token만 있는 상태 = "로그인 가능성 있음" (확정 아님) - if (needsRefresh && refreshToken) { - console.log(`🔄 [Middleware] Verifying auth status on guest route: ${pathname}`); - - const refreshResult = await refreshTokenInMiddleware(refreshToken); - - if (refreshResult.success && refreshResult.accessToken) { - // ✅ refresh 성공 = 진짜 로그인됨 → /dashboard로 (게스트 전용이니까) - console.log(`✅ [Middleware] Authenticated, redirecting to dashboard from guest route`); - - const isProduction = process.env.NODE_ENV === 'production'; - const response = NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url)); - - // 새 쿠키 설정 - response.headers.append('Set-Cookie', [ - `access_token=${refreshResult.accessToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${refreshResult.expiresIn || 7200}`, - ].join('; ')); - - response.headers.append('Set-Cookie', [ - `refresh_token=${refreshResult.refreshToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=604800', - ].join('; ')); - - response.headers.append('Set-Cookie', [ - 'is_authenticated=true', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${refreshResult.expiresIn || 7200}`, - ].join('; ')); - - return response; - } else { - // ❌ refresh 실패 = 로그인 안 됨 → 쿠키 삭제 후 로그인 페이지 표시 (왕복 없이!) - console.log(`🔴 [Middleware] Not authenticated, showing guest page directly`); - - const isProduction = process.env.NODE_ENV === 'production'; - const intlResponse = intlMiddleware(request); - - // 만료된 쿠키 삭제 - intlResponse.headers.append('Set-Cookie', [ - 'access_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; ')); - - intlResponse.headers.append('Set-Cookie', [ - 'refresh_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; ')); - - intlResponse.headers.append('Set-Cookie', [ - 'is_authenticated=', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; ')); - - return intlResponse; - } - } - - // access_token 있음 = 확실히 로그인됨 → /dashboard로 + // access_token 또는 refresh_token 있음 → 인증 상태로 간주 → /dashboard로 + // refresh_token만 있는 경우: /dashboard에서 PROXY가 갱신 처리 + // 만약 refresh_token도 만료되었다면 /dashboard에서 API 실패 → /login으로 리다이렉트됨 if (isAuthenticated) { console.log(`[Already Authenticated] Redirecting to /dashboard from ${pathname}`); return NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url)); @@ -480,140 +292,6 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url); } - // 7️⃣.5️⃣ 🔄 토큰 사전 갱신 (Race Condition 방지) - // access_token이 없고 refresh_token만 있는 경우, 페이지 렌더링 전에 미리 갱신 - // - // 🔴 중요: refresh 성공 후 같은 페이지로 리다이렉트해야 함! - // - 미들웨어에서 Set-Cookie를 설정해도 동시에 발생하는 API 요청은 이전 쿠키 사용 - // - 리다이렉트하면 브라우저가 새 쿠키를 적용한 후 다시 요청 - // - 이렇게 해야 클라이언트의 API 호출이 새 토큰을 사용 - if (needsRefresh && refreshToken) { - // 🔄 무한 리다이렉트 방지: 이미 refresh 시도 후 돌아온 요청인지 확인 - const url = new URL(request.url); - if (url.searchParams.has('_refreshed')) { - // 이미 리프레시 시도 후 돌아왔는데도 needsRefresh=true면 쿠키 저장 실패 - // 무한 루프 방지를 위해 로그인 페이지로 리다이렉트 - console.warn(`🔴 [Middleware] Cookie not saved after refresh, redirecting to login`); - - const isProduction = process.env.NODE_ENV === 'production'; - const loginUrl = new URL('/login', request.url); - - const response = NextResponse.redirect(loginUrl); - - // 쿠키 삭제 - response.headers.append('Set-Cookie', [ - 'access_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', 'Path=/', 'Max-Age=0', - ].join('; ')); - response.headers.append('Set-Cookie', [ - 'refresh_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', 'Path=/', 'Max-Age=0', - ].join('; ')); - response.headers.append('Set-Cookie', [ - 'is_authenticated=', ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', 'Path=/', 'Max-Age=0', - ].join('; ')); - - return response; - } - - console.log(`🔄 [Middleware] Pre-refreshing token before page render: ${pathname}`); - - const refreshResult = await refreshTokenInMiddleware(refreshToken); - - if (refreshResult.success && refreshResult.accessToken) { - const isProduction = process.env.NODE_ENV === 'production'; - - // 🆕 리다이렉트로 새 쿠키 적용 후 다시 로드 - // 이렇게 해야 클라이언트의 useEffect에서 호출하는 API들이 새 토큰을 사용 - url.searchParams.set('_refreshed', '1'); - const response = NextResponse.redirect(url); - - // 새 access_token 쿠키 설정 - const accessTokenCookie = [ - `access_token=${refreshResult.accessToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${refreshResult.expiresIn || 7200}`, - ].join('; '); - - // 새 refresh_token 쿠키 설정 - const refreshTokenCookie = [ - `refresh_token=${refreshResult.refreshToken}`, - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=604800', // 7 days - ].join('; '); - - // 인증 상태 쿠키 - const isAuthenticatedCookie = [ - 'is_authenticated=true', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - `Max-Age=${refreshResult.expiresIn || 7200}`, - ].join('; '); - - response.headers.append('Set-Cookie', accessTokenCookie); - response.headers.append('Set-Cookie', refreshTokenCookie); - response.headers.append('Set-Cookie', isAuthenticatedCookie); - - console.log(`✅ [Middleware] Pre-refresh complete, redirecting to apply new cookies`); - return response; - } else { - // 갱신 실패 시 쿠키 삭제 후 로그인 페이지로 - // 🔴 CRITICAL: 쿠키를 삭제하지 않으면 무한 리다이렉트 루프 발생 - // - /login 접근 시 refresh_token 있으면 isAuthenticated=true 판정 - // - "Already Authenticated" → /dashboard로 리다이렉트 - // - 다시 needsRefresh=true → pre-refresh 시도 → 401 실패 → /login - // - 무한 루프! - console.warn(`🔴 [Middleware] Pre-refresh failed, clearing cookies and redirecting to login`); - - const isProduction = process.env.NODE_ENV === 'production'; - const url = new URL('/login', request.url); - url.searchParams.set('redirect', pathname); - - const response = NextResponse.redirect(url); - - // 쿠키 삭제 (Max-Age=0으로 만료 처리) - const clearAccessToken = [ - 'access_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; '); - - const clearRefreshToken = [ - 'refresh_token=', - 'HttpOnly', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; '); - - const clearIsAuthenticated = [ - 'is_authenticated=', - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=0', - ].join('; '); - - response.headers.append('Set-Cookie', clearAccessToken); - response.headers.append('Set-Cookie', clearRefreshToken); - response.headers.append('Set-Cookie', clearIsAuthenticated); - - return response; - } - } - // 8️⃣ 인증 모드 로깅 (디버깅용) if (isAuthenticated) { console.log(`[Authenticated] Mode: ${authMode}, Path: ${pathname}`); @@ -655,4 +333,4 @@ export const config = { */ '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)', ], -}; \ No newline at end of file +};