diff --git a/index.html b/index.html index 97af30f..90e44d7 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + Quespot diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..4ac6512 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/pwa-maskable-512x512.png b/public/pwa-maskable-512x512.png deleted file mode 100644 index 4ddd703..0000000 Binary files a/public/pwa-maskable-512x512.png and /dev/null differ diff --git a/src/apis/auth.ts b/src/apis/auth.ts index ac9aca7..cd05e6d 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -1,4 +1,5 @@ import { getAccessToken } from "@/utils/auth"; +import { apiClient } from "@/apis/client"; const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site").replace(/\/$/, ""); const FRONTEND_REDIRECT_URI = import.meta.env.PROD @@ -119,19 +120,12 @@ const requestLoginMethods = async (method: "GET" | "POST" | "DELETE", path = const accessToken = getAccessToken(); if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401); - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/api/users/me/login-methods${path}`, { - method, - credentials: "include", - headers: { Authorization: `Bearer ${accessToken}` }, - }); - } catch { - throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0); - } - - const payload = await response.json().catch(() => null) as ApiEnvelope | null; - if (!response.ok || !payload?.isSuccess) { + const response = await apiClient.request>({ + url: `/api/users/me/login-methods${path}`, + method, + }); + const payload = response.data; + if (!payload.isSuccess) { throw new ApiError( payload?.message || "로그인 수단 요청을 처리하지 못했습니다.", response.status, @@ -156,19 +150,9 @@ export const logout = async (): Promise => { const accessToken = getAccessToken(); if (!accessToken) return; - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/api/auth/logout`, { - method: "POST", - credentials: "include", - headers: { Authorization: `Bearer ${accessToken}` }, - }); - } catch { - throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0); - } - - const payload = await response.json().catch(() => null) as ApiEnvelope | null; - if (!response.ok || !payload?.isSuccess) { + const response = await apiClient.post>("/api/auth/logout"); + const payload = response.data; + if (!payload.isSuccess) { throw new ApiError( payload?.message || "로그아웃을 처리하지 못했습니다.", response.status, @@ -181,19 +165,9 @@ export const withdraw = async (): Promise => { const accessToken = getAccessToken(); if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401); - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/api/auth/withdraw`, { - method: "DELETE", - credentials: "include", - headers: { Authorization: `Bearer ${accessToken}` }, - }); - } catch { - throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0); - } - - const payload = await response.json().catch(() => null) as ApiEnvelope | null; - if (!response.ok || !payload?.isSuccess) { + const response = await apiClient.delete>("/api/auth/withdraw"); + const payload = response.data; + if (!payload.isSuccess) { throw new ApiError( payload?.message || "회원탈퇴를 처리하지 못했습니다. 잠시 후 다시 시도해주세요.", response.status, diff --git a/src/apis/client.ts b/src/apis/client.ts index e4d0a00..b02f6b1 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -1,5 +1,6 @@ -import { getAccessToken } from "@/utils/auth"; -import axios, { AxiosHeaders } from "axios"; +import { PATH } from "@/routes/paths"; +import { clearAuth, getAccessToken, saveAccessToken } from "@/utils/auth"; +import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site"; @@ -9,6 +10,26 @@ const DEV_ACCESS_TOKEN = import.meta.env.DEV ? (import.meta.env.VITE_DEV_ACCESS_TOKEN as string | undefined) : undefined; +type ApiEnvelope = { + isSuccess: boolean; + code: string; + message: string; + result: T; + errorDetail?: unknown; +}; + +type ReissueResult = { + accessToken: string; + profileCompleted: boolean; +}; + +type RetriableRequestConfig = InternalAxiosRequestConfig & { + _retry?: boolean; +}; + +const ACCESS_TOKEN_EXPIRED_CODE = "AUTH_401_003"; +let reissuePromise: Promise | null = null; + function normalizeBearerToken(token: string) { const trimmedToken = token.trim(); @@ -34,9 +55,50 @@ function setAuthorizationHeader(headers: unknown, token: string) { } } +async function reissueAccessToken() { + const response = await axios.post>( + `${BASE_URL}/api/auth/reissue`, + undefined, + { + withCredentials: true, + headers: { + Accept: "application/json", + }, + }, + ); + + const accessToken = response.data.result?.accessToken; + + if (!response.data.isSuccess || !accessToken) { + throw new Error(response.data.message || "토큰을 재발급하지 못했습니다."); + } + + saveAccessToken(accessToken); + return accessToken; +} + +function getReissuedAccessToken() { + if (!reissuePromise) { + reissuePromise = reissueAccessToken().finally(() => { + reissuePromise = null; + }); + } + + return reissuePromise; +} + +function redirectToLogin() { + clearAuth(); + + if (window.location.pathname !== PATH.LOGIN) { + window.location.replace(PATH.LOGIN); + } +} + export const apiClient = axios.create({ baseURL: BASE_URL, timeout: 10000, + withCredentials: true, headers: { "Content-Type": "application/json", }, @@ -63,15 +125,33 @@ apiClient.interceptors.request.use((config) => { apiClient.interceptors.response.use( (response) => response, - (error) => { + async (error) => { const status = error.response?.status; + const code = error.response?.data?.code; + const originalRequest = error.config as RetriableRequestConfig | undefined; + + if (status !== 401) { + return Promise.reject(error); + } - if (status === 401) { - console.warn( - "인증 오류가 발생했습니다. accessToken 만료 또는 권한 문제를 확인해주세요.", - ); + if ( + code === ACCESS_TOKEN_EXPIRED_CODE && + originalRequest && + !originalRequest._retry + ) { + originalRequest._retry = true; + + try { + const accessToken = await getReissuedAccessToken(); + setAuthorizationHeader(originalRequest.headers, accessToken); + return apiClient(originalRequest); + } catch (reissueError) { + redirectToLogin(); + return Promise.reject(reissueError); + } } + redirectToLogin(); return Promise.reject(error); }, ); diff --git a/src/apis/like.ts b/src/apis/like.ts index 7ab0a3a..3eb6726 100644 --- a/src/apis/like.ts +++ b/src/apis/like.ts @@ -1,9 +1,5 @@ import { ApiError } from "@/apis/auth"; -import { getAccessToken } from "@/utils/auth"; - -const API_BASE_URL = ( - import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site" -).replace(/\/$/, ""); +import { apiClient } from "@/apis/client"; type ApiEnvelope = { isSuccess: boolean; @@ -19,35 +15,13 @@ async function likeRequest( method: "POST" | "DELETE"; }, ): Promise { - const accessToken = getAccessToken(); - - if (!accessToken) { - throw new ApiError("로그인이 필요합니다.", 401); - } - - let response: Response; - - try { - response = await fetch(`${API_BASE_URL}${path}`, { - method: options.method, - credentials: "include", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - } catch { - throw new ApiError( - "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", - 0, - ); - } - - const payload = (await response.json().catch(() => null)) as - | ApiEnvelope - | null; + const response = await apiClient.request>({ + url: path, + method: options.method, + }); + const payload = response.data; - if (!response.ok || !payload?.isSuccess) { + if (!payload.isSuccess) { throw new ApiError( payload?.message || "좋아요 요청을 처리하지 못했습니다.", response.status, @@ -80,4 +54,4 @@ export function unlikeMissionCourse(courseId: number) { return likeRequest(`/api/mission-courses/${courseId}/like`, { method: "DELETE", }); -} \ No newline at end of file +} diff --git a/src/apis/mission.ts b/src/apis/mission.ts index 6948b5f..c0a6901 100644 --- a/src/apis/mission.ts +++ b/src/apis/mission.ts @@ -1,4 +1,4 @@ -import { ApiError } from "@/apis/auth"; +import { apiClient } from "@/apis/client"; import type { GetMissionDetailParams, GetMissionsParams, @@ -7,11 +7,6 @@ import type { MissionListResponse, RecommendedMissionListResponse, } from "@/types/mission"; -import { getAccessToken } from "@/utils/auth"; - -const API_BASE_URL = ( - import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site" -).replace(/\/$/, ""); function createMissionQueryParams(params: GetMissionsParams) { const searchParams = new URLSearchParams(); @@ -55,60 +50,25 @@ function createMissionDetailQueryParams(params: GetMissionDetailParams) { return searchParams; } -async function missionRequest(url: string): Promise { - const accessToken = getAccessToken(); - - if (!accessToken) { - throw new ApiError("로그인이 필요합니다.", 401); - } - - let response: Response; - - try { - response = await fetch(url, { - method: "GET", - credentials: "include", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - } catch { - throw new ApiError( - "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", - 0, - ); - } - - const payload = (await response.json().catch(() => null)) as T | null; - - if (!response.ok || !payload) { - throw new ApiError( - "미션 요청을 처리하지 못했습니다.", - response.status, - ); - } - - return payload; -} - export async function getMissions(params: GetMissionsParams) { const searchParams = createMissionQueryParams(params); - return missionRequest( - `${API_BASE_URL}/api/missions?${searchParams.toString()}`, + const { data } = await apiClient.get( + `/api/missions?${searchParams.toString()}`, ); + return data; } export async function getMissionDetail(params: GetMissionDetailParams) { const searchParams = createMissionDetailQueryParams(params); const queryString = searchParams.toString(); - return missionRequest( - `${API_BASE_URL}/api/missions/${params.missionId}${ + const { data } = await apiClient.get( + `/api/missions/${params.missionId}${ queryString ? `?${queryString}` : "" }`, ); + return data; } export async function getRecommendedMissions( @@ -129,7 +89,8 @@ export async function getRecommendedMissions( searchParams.set("size", String(params.size ?? 20)); - return missionRequest( - `${API_BASE_URL}/api/missions/recommendations?${searchParams.toString()}`, + const { data } = await apiClient.get( + `/api/missions/recommendations?${searchParams.toString()}`, ); + return data; } diff --git a/src/apis/missionAttempt.ts b/src/apis/missionAttempt.ts index a0ae7c9..1242e71 100644 --- a/src/apis/missionAttempt.ts +++ b/src/apis/missionAttempt.ts @@ -1,5 +1,5 @@ import { ApiError } from "@/apis/auth"; -import { getAccessToken } from "@/utils/auth"; +import { apiClient } from "@/apis/client"; import type { MissionArrivalResult, MissionAttempt, @@ -10,10 +10,6 @@ import type { MissionVerificationGuide, } from "@/types/missionAttempt"; -const API_BASE_URL = ( - import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site" -).replace(/\/$/, ""); - type ApiEnvelope = { isSuccess: boolean; code: string; @@ -31,40 +27,15 @@ async function missionAttemptRequest( body?: unknown; }, ): Promise { - const accessToken = getAccessToken(); - - if (!accessToken) { - throw new ApiError("로그인이 필요합니다.", 401); - } - const method = options?.method ?? "GET"; - const hasBody = options?.body !== undefined; - - let response: Response; - - try { - response = await fetch(`${API_BASE_URL}${path}`, { - method, - credentials: "include", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - ...(hasBody ? { "Content-Type": "application/json" } : {}), - }, - ...(hasBody ? { body: JSON.stringify(options.body) } : {}), - }); - } catch { - throw new ApiError( - "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", - 0, - ); - } - - const payload = (await response.json().catch(() => null)) as - | ApiEnvelope - | null; - - if (!response.ok || !payload?.isSuccess) { + const response = await apiClient.request>({ + url: path, + method, + data: options?.body, + }); + const payload = response.data; + + if (!payload.isSuccess) { throw new ApiError( payload?.message || "미션 인증 요청을 처리하지 못했습니다.", response.status, @@ -168,4 +139,4 @@ export function quitMissionAttempt(attemptId: number) { method: "POST", }, ); -} \ No newline at end of file +} diff --git a/src/apis/missionCourse.ts b/src/apis/missionCourse.ts index 5468327..ec34202 100644 --- a/src/apis/missionCourse.ts +++ b/src/apis/missionCourse.ts @@ -1,5 +1,5 @@ import { ApiError } from "@/apis/auth"; -import { getAccessToken } from "@/utils/auth"; +import { apiClient } from "@/apis/client"; import type { CourseAttempt, CreateMissionCourseRequest, @@ -7,10 +7,6 @@ import type { MissionCourseItem, } from "@/types/missionCourse"; -const API_BASE_URL = ( - import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site" -).replace(/\/$/, ""); - type ApiEnvelope = { isSuccess: boolean; code: string; @@ -28,40 +24,15 @@ async function missionCourseRequest( body?: unknown; }, ): Promise { - const accessToken = getAccessToken(); - - if (!accessToken) { - throw new ApiError("로그인이 필요합니다.", 401); - } - const method = options?.method ?? "GET"; - const hasBody = options?.body !== undefined; - - let response: Response; - - try { - response = await fetch(`${API_BASE_URL}${path}`, { - method, - credentials: "include", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - ...(hasBody ? { "Content-Type": "application/json" } : {}), - }, - ...(hasBody ? { body: JSON.stringify(options.body) } : {}), - }); - } catch { - throw new ApiError( - "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", - 0, - ); - } - - const payload = (await response.json().catch(() => null)) as - | ApiEnvelope - | null; + const response = await apiClient.request>({ + url: path, + method, + data: options?.body, + }); + const payload = response.data; - if (!response.ok || !payload?.isSuccess) { + if (!payload.isSuccess) { throw new ApiError( payload?.message || "미션 코스 요청을 처리하지 못했습니다.", response.status, @@ -102,4 +73,4 @@ export function quitCourseAttempt(courseAttemptId: number) { method: "POST", }, ); -} \ No newline at end of file +} diff --git a/src/apis/profile.ts b/src/apis/profile.ts index a679b8b..fa1fd26 100644 --- a/src/apis/profile.ts +++ b/src/apis/profile.ts @@ -1,8 +1,6 @@ import { ApiError } from "@/apis/auth"; +import { apiClient } from "@/apis/client"; import type { TravelStyle } from "@/constants/profile"; -import { getAccessToken } from "@/utils/auth"; - -const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "https://api.quespot.site").replace(/\/$/, ""); interface ApiEnvelope { isSuccess: boolean; @@ -38,26 +36,13 @@ export type BasicProfileUpdate = Pick(method: "GET" | "POST" | "PATCH", body?: unknown): Promise { - const accessToken = getAccessToken(); - if (!accessToken) throw new ApiError("로그인이 필요합니다.", 401); - - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/api/users/me/profile`, { - method, - credentials: "include", - headers: { - Authorization: `Bearer ${accessToken}`, - ...(body === undefined ? {} : { "Content-Type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - } catch { - throw new ApiError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", 0); - } - - const payload = await response.json().catch(() => null) as ApiEnvelope | null; - if (!response.ok || !payload?.isSuccess) { + const response = await apiClient.request>({ + url: "/api/users/me/profile", + method, + data: body, + }); + const payload = response.data; + if (!payload.isSuccess) { throw new ApiError(payload?.message || "프로필 요청을 처리하지 못했습니다.", response.status, payload?.code); } return payload.result; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index ccfc3ae..ffd09b3 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -45,7 +45,7 @@ export const router = createBrowserRouter([ children: [ { path: PATH.LANDING, - element: , + element: , }, { path: PATH.LOGIN, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5ab010a..268757d 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -41,6 +41,10 @@ export const saveAuth = (accessToken: string, userId: number) => { localStorage.setItem(USER_ID_KEY, String(userId)); }; +export const saveAccessToken = (accessToken: string) => { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); +}; + export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY); export const beginSocialLogin = (provider: SocialProviderId) => diff --git a/vite.config.ts b/vite.config.ts index d26f942..bf63714 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,12 +37,6 @@ export default defineConfig({ type: "image/png", purpose: "any", }, - { - src: "/pwa-maskable-512x512.png", - sizes: "512x512", - type: "image/png", - purpose: "maskable", - }, ], }, }),