feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화
- API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가 - CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장 - 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선 - 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫) - 레이아웃: Sidebar/AuthenticatedLayout 수정 - 작업자화면: WorkerScreen 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,16 +2,16 @@
|
||||
* 전역 Fetch Wrapper
|
||||
*
|
||||
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
||||
* - 401 에러 자동 감지 및 토큰 자동 갱신
|
||||
* - 401 에러 자동 감지 및 토큰 자동 갱신 (authenticatedFetch 게이트웨이 위임)
|
||||
* - 일관된 에러 처리
|
||||
* - 헤더 자동 설정
|
||||
*/
|
||||
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
import { authenticatedFetch } from './authenticated-fetch';
|
||||
|
||||
/**
|
||||
* 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
|
||||
@@ -22,13 +22,12 @@ import { refreshAccessToken } from './refresh-token';
|
||||
async function clearTokenCookies() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
// 토큰 쿠키 삭제
|
||||
cookieStore.delete('access_token');
|
||||
cookieStore.delete('refresh_token');
|
||||
cookieStore.delete('token_refreshed_at');
|
||||
cookieStore.delete('is_authenticated');
|
||||
|
||||
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
|
||||
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +50,7 @@ async function setNewTokenCookies(tokens: {
|
||||
maxAge: tokens.expiresIn || 7200,
|
||||
});
|
||||
|
||||
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
|
||||
// HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함
|
||||
// 토큰 갱신 신호 쿠키 (클라이언트 useMenuPolling 감지용)
|
||||
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
@@ -76,24 +74,10 @@ async function setNewTokenCookies(tokens: {
|
||||
|
||||
/**
|
||||
* API 헤더 생성 (Server Side)
|
||||
*
|
||||
* 🆕 미들웨어에서 전달한 새 토큰 우선 사용
|
||||
* - 미들웨어 pre-refresh 성공 시 request headers에 'x-refreshed-access-token' 설정
|
||||
* - Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음
|
||||
* - 따라서 request headers를 먼저 확인
|
||||
*/
|
||||
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
|
||||
// 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
|
||||
const headerStore = await headers();
|
||||
const refreshedAccessToken = headerStore.get('x-refreshed-access-token');
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value;
|
||||
|
||||
// 디버깅: 어떤 토큰을 사용하는지 로그
|
||||
if (refreshedAccessToken) {
|
||||
console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers');
|
||||
}
|
||||
const accessToken = token || cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
@@ -106,16 +90,13 @@ export async function getServerApiHeaders(token?: string): Promise<HeadersInit>
|
||||
/**
|
||||
* Server Action용 Fetch Wrapper
|
||||
*
|
||||
* 🔄 토큰 갱신 로직:
|
||||
* 1. 현재 access_token으로 요청
|
||||
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
|
||||
* 3. 새 토큰으로 원래 요청 재시도
|
||||
* 4. 재시도도 실패하면 → 로그인 페이지로 리다이렉트
|
||||
* 401 감지 → refresh → retry 는 authenticatedFetch 게이트웨이에 위임.
|
||||
* 이 함수는 쿠키 읽기/설정/삭제, 리다이렉트만 담당.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
* if (error) return error; // 에러 응답 반환 (클라이언트에서 처리)
|
||||
* if (error) return error;
|
||||
* // response 사용...
|
||||
* ```
|
||||
*/
|
||||
@@ -126,18 +107,14 @@ export async function serverFetch(
|
||||
}
|
||||
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
||||
try {
|
||||
// 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인
|
||||
const headerStore = await headers();
|
||||
const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token');
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value;
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||
|
||||
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
|
||||
|
||||
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
||||
const isFormData = options?.body instanceof FormData;
|
||||
const requestHeaders: HeadersInit = isFormData
|
||||
const requestHeaders: Record<string, string> = isFormData
|
||||
? {
|
||||
Accept: baseHeaders.Accept,
|
||||
Authorization: baseHeaders.Authorization,
|
||||
@@ -145,64 +122,30 @@ export async function serverFetch(
|
||||
}
|
||||
: baseHeaders;
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
...options?.headers,
|
||||
// authenticatedFetch 게이트웨이로 요청 실행
|
||||
// skipAuthCheck=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
|
||||
const { response, newTokens, authFailed } = await authenticatedFetch(
|
||||
url,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: options?.cache || 'no-store',
|
||||
},
|
||||
cache: options?.cache || 'no-store',
|
||||
});
|
||||
options?.skipAuthCheck ? undefined : refreshToken,
|
||||
'serverFetch'
|
||||
);
|
||||
|
||||
// 🔄 401 응답 시 토큰 갱신 후 재시도
|
||||
if (response.status === 401 && !options?.skipAuthCheck && refreshToken) {
|
||||
console.log('🔄 [serverFetch] Got 401, attempting token refresh...');
|
||||
// 새 토큰 → 쿠키 저장
|
||||
if (newTokens) {
|
||||
await setNewTokenCookies(newTokens);
|
||||
}
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [serverFetch] Token refreshed, retrying original request...');
|
||||
|
||||
// 새 토큰을 쿠키에 저장
|
||||
await setNewTokenCookies(refreshResult);
|
||||
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
|
||||
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
||||
const newRequestHeaders: HeadersInit = isFormData
|
||||
? {
|
||||
Accept: newBaseHeaders.Accept,
|
||||
Authorization: newBaseHeaders.Authorization,
|
||||
'X-API-KEY': newBaseHeaders['X-API-KEY'],
|
||||
}
|
||||
: newBaseHeaders;
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...newRequestHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: options?.cache || 'no-store',
|
||||
});
|
||||
|
||||
console.log('🔵 [serverFetch] Retry response status:', response.status);
|
||||
|
||||
// 재시도도 401이면 로그인으로
|
||||
if (response.status === 401) {
|
||||
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
|
||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||
redirect('/login');
|
||||
}
|
||||
} else {
|
||||
// 리프레시 실패 → 로그인 페이지로
|
||||
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||
redirect('/login');
|
||||
}
|
||||
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
||||
// refresh_token이 없는 경우
|
||||
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
||||
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||
// 인증 실패 → 쿠키 삭제 + 로그인 리다이렉트
|
||||
if (authFailed) {
|
||||
await clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
@@ -217,7 +160,7 @@ export async function serverFetch(
|
||||
|
||||
return { response, error: null };
|
||||
} catch (error) {
|
||||
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
|
||||
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error(`[serverFetch] Network error: ${url}`, error);
|
||||
return {
|
||||
@@ -278,4 +221,4 @@ export async function serverApiCall<T>(
|
||||
|
||||
const data = await parseJsonResponse<T>(response);
|
||||
return { data, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user