feat(WEB): 중간검사 성적서 편집 모드 및 저장 기능 추가
- 3개 검사 콘텐츠 컴포넌트에 forwardRef + useImperativeHandle 추가 (getInspectionData 노출)
- InspectionReportModal에 readOnly prop, 저장 버튼, ref 연결 추가
- saveInspectionData 서버 액션 추가 (POST /api/v1/work-orders/{id}/inspection)
- 작업자 화면에서 readOnly={false} 전달 (편집+저장 가능)
- 작업지시 관리에서는 readOnly 기본값(true)으로 읽기 전용 유지
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'w
|
||||
},
|
||||
];
|
||||
|
||||
export function BendingInspectionContent({ data: order, readOnly = false }: BendingInspectionContentProps) {
|
||||
export const BendingInspectionContent = forwardRef<InspectionContentRef, BendingInspectionContentProps>(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
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<ProcessType, string> = {
|
||||
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<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const contentRef = useRef<InspectionContentRef>(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 <ScreenInspectionContent data={order} />;
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={order} />;
|
||||
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={order} />;
|
||||
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
default:
|
||||
return <ScreenInspectionContent data={order} />;
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
}
|
||||
};
|
||||
|
||||
const toolbarExtra = !readOnly ? (
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="중간검사 성적서"
|
||||
@@ -139,6 +176,7 @@ export function InspectionReportModal({
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64 bg-white">
|
||||
|
||||
@@ -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<InspectionContentRef, ScreenInspectionContentProps>(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) => (
|
||||
<td className="border border-gray-400 p-1">
|
||||
@@ -380,4 +400,4 @@ export function ScreenInspectionContent({ data: order, readOnly = false }: Scree
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<InspectionContentRef, SlatInspectionContentProps>(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) => (
|
||||
<td className="border border-gray-400 p-1">
|
||||
@@ -339,4 +358,4 @@ export function SlatInspectionContent({ data: order, readOnly = false }: SlatIns
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -705,6 +705,7 @@ export default function WorkerScreen() {
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
readOnly={false}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
|
||||
@@ -11,38 +11,16 @@ import { locales, defaultLocale } from '@/i18n/config';
|
||||
* 2. Bot Detection and blocking for security
|
||||
* 3. Authentication and authorization (Sanctum/Bearer/API-Key)
|
||||
*
|
||||
* Strategy: Moderate bot blocking + Session-based auth
|
||||
* - Allows legitimate browsers and necessary crawlers
|
||||
* - Blocks bots from accessing sensitive ERP areas
|
||||
* - Protects routes with session/token authentication
|
||||
* - Prevents Chrome security warnings by not being too aggressive
|
||||
* 🔴 중요: 미들웨어에서 토큰 갱신(refresh)을 절대 하지 않음!
|
||||
* - 미들웨어는 Edge Runtime, PROXY/ServerApiClient는 Node.js Runtime
|
||||
* - 별개 프로세스라 refresh_token 캐시가 공유되지 않음
|
||||
* - 미들웨어가 refresh_token을 소비하면 PROXY에서 사용할 토큰이 없어짐
|
||||
* - 토큰 갱신은 PROXY(/api/proxy)와 ServerApiClient에서만 처리
|
||||
*/
|
||||
|
||||
// Auth configuration
|
||||
import { AUTH_CONFIG } from '@/lib/api/auth/auth-config';
|
||||
|
||||
/**
|
||||
* 🔄 미들웨어 전용 토큰 갱신 캐시 (Edge Runtime)
|
||||
*
|
||||
* 목적: 페이지 렌더링 전에 토큰을 미리 갱신하여 race condition 방지
|
||||
* - auth/check와 serverFetch가 동시에 refresh_token을 사용하는 문제 해결
|
||||
* - 미들웨어에서 먼저 갱신하면 이후 요청들은 새 access_token 사용
|
||||
*
|
||||
* 주의: Edge Runtime에서는 모듈 레벨 변수가 요청 간 공유되지 않을 수 있음
|
||||
* 따라서 5초 캐시로 같은 요청 내 중복 갱신만 방지
|
||||
*/
|
||||
let middlewareRefreshCache: {
|
||||
promise: Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number }> | 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).*)',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user