- api/client: 리프레시 토큰 자동 갱신 (401 시 재시도, 동시 요청 dedup) - api/client: 204 No Content 처리 추가 (res.json() 크래시 방지) - api/client: 서버 에러 메시지 body에서 파싱하여 전달 - api/auth: logout 함수 추가 (서버 세션 삭제), 미사용 refreshToken 함수 제거 - AuthContext: 로그인 시 refreshToken 저장, 로그아웃 시 서버 호출 분리 - AuthContext: 401 이벤트는 로컬 세션만 정리 (clearSession 분리) - DownloadSection: 게임 시작 토큰을 localStorage에서 직접 읽기 (스테일 방지) - DownloadAdmin: XHR 401 처리, Content-Type 헤더 추가 - AnnouncementAdmin: 등록/수정/삭제 에러 상태 표시 추가 - AnnouncementBoard: API 실패 시 에러 메시지 표시 - UserAdmin: 권한 변경/삭제 에러 처리 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
85 lines
2.5 KiB
JavaScript
85 lines
2.5 KiB
JavaScript
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
|
|
|
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
|
|
let refreshingPromise = null;
|
|
|
|
async function tryRefresh() {
|
|
if (refreshingPromise) return refreshingPromise;
|
|
|
|
refreshingPromise = (async () => {
|
|
const rt = localStorage.getItem('refreshToken');
|
|
if (!rt) throw new Error('no_refresh_token');
|
|
|
|
const res = await fetch(BASE + '/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken: rt }),
|
|
});
|
|
|
|
if (!res.ok) throw new Error('refresh_failed');
|
|
|
|
const data = await res.json();
|
|
localStorage.setItem('token', data.token);
|
|
localStorage.setItem('refreshToken', data.refreshToken);
|
|
return data.token;
|
|
})().finally(() => {
|
|
refreshingPromise = null;
|
|
});
|
|
|
|
return refreshingPromise;
|
|
}
|
|
|
|
async function doFetch(path, options, token) {
|
|
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
return fetch(BASE + path, { ...options, headers });
|
|
}
|
|
|
|
async function parseError(res) {
|
|
let message = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
if (body.error) message = body.error;
|
|
} catch {}
|
|
const err = new Error(message);
|
|
err.status = res.status;
|
|
return err;
|
|
}
|
|
|
|
// 204 No Content는 null 반환, 나머지는 JSON 파싱
|
|
async function parseResponse(res) {
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
export async function apiFetch(path, options = {}) {
|
|
const token = localStorage.getItem('token');
|
|
const res = await doFetch(path, options, token);
|
|
|
|
if (res.status === 401) {
|
|
try {
|
|
const newToken = await tryRefresh();
|
|
// 새 토큰으로 원래 요청 재시도
|
|
const retryRes = await doFetch(path, options, newToken);
|
|
if (retryRes.status === 401) {
|
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
|
throw await parseError(retryRes);
|
|
}
|
|
if (!retryRes.ok) throw await parseError(retryRes);
|
|
return parseResponse(retryRes);
|
|
} catch (e) {
|
|
// refresh 자체 실패 → 로그아웃
|
|
if (e.message === 'no_refresh_token' || e.message === 'refresh_failed') {
|
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
|
const err = new Error('인증이 필요합니다');
|
|
err.status = 401;
|
|
throw err;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) throw await parseError(res);
|
|
return parseResponse(res);
|
|
}
|