Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>Quespot</title>
</head>
<body>
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/pwa-maskable-512x512.png
Binary file not shown.
52 changes: 13 additions & 39 deletions src/apis/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -119,19 +120,12 @@ const requestLoginMethods = async <T,>(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<T> | null;
if (!response.ok || !payload?.isSuccess) {
const response = await apiClient.request<ApiEnvelope<T>>({
url: `/api/users/me/login-methods${path}`,
method,
});
const payload = response.data;
if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "로그인 수단 요청을 처리하지 못했습니다.",
response.status,
Expand All @@ -156,19 +150,9 @@ export const logout = async (): Promise<void> => {
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> | null;
if (!response.ok || !payload?.isSuccess) {
const response = await apiClient.post<ApiEnvelope<null>>("/api/auth/logout");
const payload = response.data;
if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "로그아웃을 처리하지 못했습니다.",
response.status,
Expand All @@ -181,19 +165,9 @@ export const withdraw = async (): Promise<void> => {
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> | null;
if (!response.ok || !payload?.isSuccess) {
const response = await apiClient.delete<ApiEnvelope<null>>("/api/auth/withdraw");
const payload = response.data;
if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "회원탈퇴를 처리하지 못했습니다. 잠시 후 다시 시도해주세요.",
response.status,
Expand Down
94 changes: 87 additions & 7 deletions src/apis/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<T> = {
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<string> | null = null;

function normalizeBearerToken(token: string) {
const trimmedToken = token.trim();

Expand All @@ -34,9 +55,50 @@ function setAuthorizationHeader(headers: unknown, token: string) {
}
}

async function reissueAccessToken() {
const response = await axios.post<ApiEnvelope<ReissueResult>>(
`${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",
},
Expand All @@ -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);
},
);
42 changes: 8 additions & 34 deletions src/apis/like.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
isSuccess: boolean;
Expand All @@ -19,35 +15,13 @@ async function likeRequest<T>(
method: "POST" | "DELETE";
},
): Promise<T> {
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<T>
| null;
const response = await apiClient.request<ApiEnvelope<T>>({
url: path,
method: options.method,
});
const payload = response.data;

if (!response.ok || !payload?.isSuccess) {
if (!payload.isSuccess) {
throw new ApiError(
payload?.message || "좋아요 요청을 처리하지 못했습니다.",
response.status,
Expand Down Expand Up @@ -80,4 +54,4 @@ export function unlikeMissionCourse(courseId: number) {
return likeRequest<string>(`/api/mission-courses/${courseId}/like`, {
method: "DELETE",
});
}
}
59 changes: 10 additions & 49 deletions src/apis/mission.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiError } from "@/apis/auth";
import { apiClient } from "@/apis/client";
import type {
GetMissionDetailParams,
GetMissionsParams,
Expand All @@ -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();
Expand Down Expand Up @@ -55,60 +50,25 @@ function createMissionDetailQueryParams(params: GetMissionDetailParams) {
return searchParams;
}

async function missionRequest<T>(url: string): Promise<T> {
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<MissionListResponse>(
`${API_BASE_URL}/api/missions?${searchParams.toString()}`,
const { data } = await apiClient.get<MissionListResponse>(
`/api/missions?${searchParams.toString()}`,
);
return data;
}

export async function getMissionDetail(params: GetMissionDetailParams) {
const searchParams = createMissionDetailQueryParams(params);
const queryString = searchParams.toString();

return missionRequest<MissionDetailResponse>(
`${API_BASE_URL}/api/missions/${params.missionId}${
const { data } = await apiClient.get<MissionDetailResponse>(
`/api/missions/${params.missionId}${
queryString ? `?${queryString}` : ""
}`,
);
return data;
}

export async function getRecommendedMissions(
Expand All @@ -129,7 +89,8 @@ export async function getRecommendedMissions(

searchParams.set("size", String(params.size ?? 20));

return missionRequest<RecommendedMissionListResponse>(
`${API_BASE_URL}/api/missions/recommendations?${searchParams.toString()}`,
const { data } = await apiClient.get<RecommendedMissionListResponse>(
`/api/missions/recommendations?${searchParams.toString()}`,
);
return data;
}
Loading
Loading