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 &&

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

}