- 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>
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
/**
|
|
* Authenticated Fetch Gateway
|
|
*
|
|
* 인증이 필요한 백엔드 API 호출의 유일한 게이트웨이.
|
|
* 401 감지 → refresh (globalThis 캐시) → retry 를 한 곳에서 처리.
|
|
*
|
|
* 이 모듈이 담당하는 것:
|
|
* - 요청 실행
|
|
* - 401 감지 → refreshAccessToken (globalThis 캐시로 프로세스 내 중복 방지)
|
|
* - 새 토큰으로 재시도
|
|
*
|
|
* 이 모듈이 담당하지 않는 것 (호출자가 처리):
|
|
* - 쿠키 읽기 (PROXY: request.cookies / Server Actions: cookies() API)
|
|
* - 쿠키 설정 (PROXY: Set-Cookie 헤더 / Server Actions: cookies() API)
|
|
* - 리다이렉트 (Server Actions: redirect('/login'))
|
|
* - 헤더 구성 (각 호출자가 자기 방식으로)
|
|
*/
|
|
|
|
import { refreshAccessToken, type RefreshResult } from './refresh-token';
|
|
|
|
export type AuthenticatedFetchResult = {
|
|
/** 백엔드 응답 (성공이든 실패든) */
|
|
response: Response;
|
|
/** refresh 성공 시 새 토큰 (호출자가 쿠키에 저장) */
|
|
newTokens?: RefreshResult;
|
|
/** true면 인증 실패 (호출자가 쿠키 삭제 + 리다이렉트 처리) */
|
|
authFailed?: boolean;
|
|
};
|
|
|
|
/**
|
|
* 인증된 백엔드 요청 실행
|
|
*
|
|
* 반환값 상태:
|
|
* - { response } → 정상 (401 아님)
|
|
* - { response, newTokens } → 401 → refresh 성공 → 재시도 성공
|
|
* - { response, authFailed: true } → 인증 실패 (refresh 불가/실패/재시도 실패)
|
|
*
|
|
* @param url 백엔드 API URL
|
|
* @param options fetch 옵션 (호출자가 Authorization 등 헤더 포함)
|
|
* @param refreshToken refresh_token (없으면 401 시 바로 실패 반환)
|
|
* @param caller 호출자 이름 (로그용: 'PROXY' | 'serverFetch' | 'ServerApiClient')
|
|
*/
|
|
export async function authenticatedFetch(
|
|
url: string,
|
|
options: RequestInit,
|
|
refreshToken: string | undefined,
|
|
caller: string = 'unknown'
|
|
): Promise<AuthenticatedFetchResult> {
|
|
// 1. 요청 실행 (호출자가 이미 모든 헤더 설정)
|
|
const response = await fetch(url, options);
|
|
|
|
// 2. 401이 아니면 그대로 반환
|
|
if (response.status !== 401) {
|
|
return { response };
|
|
}
|
|
|
|
// 3. 401이지만 refresh_token 없음 → 인증 실패
|
|
if (!refreshToken) {
|
|
console.warn(`🔴 [${caller}] 401 (no refresh token)`);
|
|
return { response, authFailed: true };
|
|
}
|
|
|
|
// 4. 401 + refresh_token 있음 → 갱신 시도 (globalThis 캐시로 중복 방지)
|
|
console.log(`🔄 [${caller}] Got 401, attempting token refresh...`);
|
|
const refreshResult = await refreshAccessToken(refreshToken, caller);
|
|
|
|
if (!refreshResult.success || !refreshResult.accessToken) {
|
|
console.warn(`🔴 [${caller}] Token refresh failed`);
|
|
return { response, authFailed: true };
|
|
}
|
|
|
|
// 5. 새 토큰으로 재시도
|
|
console.log(`✅ [${caller}] Token refreshed, retrying...`);
|
|
const retryHeaders = new Headers(options.headers || {});
|
|
retryHeaders.set('Authorization', `Bearer ${refreshResult.accessToken}`);
|
|
|
|
const retryResponse = await fetch(url, {
|
|
...options,
|
|
headers: retryHeaders,
|
|
});
|
|
|
|
console.log(`🔵 [${caller}] Retry status: ${retryResponse.status}`);
|
|
|
|
// 6. 재시도도 401 → 인증 실패
|
|
if (retryResponse.status === 401) {
|
|
console.warn(`🔴 [${caller}] Retry still 401, auth failed`);
|
|
return { response: retryResponse, authFailed: true };
|
|
}
|
|
|
|
// 7. 재시도 성공 → 새 토큰과 함께 반환 (호출자가 쿠키 설정)
|
|
return {
|
|
response: retryResponse,
|
|
newTokens: refreshResult,
|
|
};
|
|
}
|