From 6fb7e2cbc52fb6094b36a4f8ce9a5e39a40ed7b6 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 6 Mar 2026 09:51:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94,?= =?UTF-8?q?=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85,=20=EC=97=B0=EA=B3=84=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/api/auth.js | 5 ++ src/api/client.js | 86 +++++++++++++++++++--- src/components/AnnouncementBoard.jsx | 6 +- src/components/DownloadSection.jsx | 5 +- src/components/admin/AnnouncementAdmin.jsx | 17 ++++- src/components/admin/DownloadAdmin.jsx | 5 ++ src/components/admin/UserAdmin.jsx | 22 ++++-- src/context/AuthContext.jsx | 23 ++++-- 8 files changed, 143 insertions(+), 26 deletions(-) diff --git a/src/api/auth.js b/src/api/auth.js index fe9a37f..be8a0df 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -13,3 +13,8 @@ export async function register(username, password) { body: JSON.stringify({ username, password }), }); } + +export async function logout() { + return apiFetch('/api/auth/logout', { method: 'POST' }); +} + diff --git a/src/api/client.js b/src/api/client.js index 1ab9630..2b46a88 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1,18 +1,84 @@ 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 headers = { 'Content-Type': 'application/json', ...options.headers }; - if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await doFetch(path, options, token); - const res = await fetch(BASE + path, { ...options, headers }); if (res.status === 401) { - window.dispatchEvent(new Event('auth:unauthorized')); + 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) { - const err = new Error(res.statusText); - err.status = res.status; - throw err; - } - return res.json(); + + if (!res.ok) throw await parseError(res); + return parseResponse(res); } diff --git a/src/components/AnnouncementBoard.jsx b/src/components/AnnouncementBoard.jsx index d583327..beeab97 100644 --- a/src/components/AnnouncementBoard.jsx +++ b/src/components/AnnouncementBoard.jsx @@ -5,14 +5,18 @@ import './AnnouncementBoard.css'; export default function AnnouncementBoard() { const [list, setList] = useState([]); const [expanded, setExpanded] = useState(null); + const [error, setError] = useState(false); useEffect(() => { - getAnnouncements().then(setList); + getAnnouncements() + .then(setList) + .catch(() => setError(true)); }, []); return (

공지사항

+ {error &&

공지사항을 불러오지 못했습니다.

}