Skip to content

Commit 91f4659

Browse files
authored
Merge pull request #50 from Leets-Official/feat/49-login-api
[FEAT] 로그인 api 연동
2 parents eef741f + eeca918 commit 91f4659

18 files changed

Lines changed: 333 additions & 145 deletions

File tree

src/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
import { QueryClientProvider } from "@tanstack/react-query";
12
import { RouterProvider } from "react-router/dom";
23

4+
import { queryClient } from "@/api/queryClient";
35
import { GlobalModal } from "@/components/modal/GlobalModal";
46
import { GlobalToast } from "@/components/toast/GlobalToast";
57
import { router } from "@/routes/router";
68

79
function App() {
810
return (
9-
<>
11+
<QueryClientProvider client={queryClient}>
1012
<RouterProvider router={router} />
1113
<GlobalModal />
1214
<GlobalToast />
13-
</>
15+
</QueryClientProvider>
1416
);
1517
}
1618

src/api/auth/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { authInstance, publicInstance } from "@/api/instance";
2+
import type { ApiResponse } from "@/types/api";
3+
import type { AuthTokenResponse, LoginRequest } from "@/types/auth/auth";
4+
5+
export const login = async (body: LoginRequest) => {
6+
const { data } = await publicInstance.post<ApiResponse<AuthTokenResponse>>(
7+
"/auth/login",
8+
body
9+
);
10+
11+
return data.data;
12+
};
13+
14+
export const logout = async () => {
15+
await authInstance.post<ApiResponse<string>>("/auth/logout");
16+
};

src/api/auth/query.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { useNavigate } from "react-router";
3+
4+
import { login, logout } from "@/api/auth/api";
5+
import { PATH } from "@/routes/paths";
6+
import { useAuthStore } from "@/stores/useAuthStore";
7+
8+
export const useLogin = () => {
9+
const navigate = useNavigate();
10+
const setTokens = useAuthStore((state) => state.setTokens);
11+
12+
return useMutation({
13+
mutationFn: login,
14+
onSuccess: (tokens) => {
15+
setTokens(tokens);
16+
navigate(PATH.HOME, { replace: true });
17+
},
18+
});
19+
};
20+
21+
export const useLogout = () => {
22+
const clearAuth = useAuthStore((state) => state.clearAuth);
23+
const queryClient = useQueryClient();
24+
25+
return useMutation({
26+
mutationFn: logout,
27+
onSettled: () => {
28+
clearAuth();
29+
queryClient.removeQueries();
30+
},
31+
});
32+
};

src/api/error.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { isAxiosError } from "axios";
2+
3+
import { API_ERROR_MESSAGE } from "@/constants/errorMessage";
4+
import type { ApiErrorResponse, ApiFieldErrors } from "@/types/api";
5+
6+
// 서버가 내려준 에러 메시지, 비어 있으면 화면별 기본 문구
7+
export const getApiErrorMessage = (
8+
error: unknown,
9+
fallback: string = API_ERROR_MESSAGE.DEFAULT
10+
) => {
11+
if (isAxiosError<ApiErrorResponse>(error)) {
12+
return error.response?.data?.message?.trim() || fallback;
13+
}
14+
15+
return fallback;
16+
};
17+
18+
// 필드 단위 검증 에러 — 폼 입력에 매핑
19+
export const getApiFieldErrors = (error: unknown): ApiFieldErrors => {
20+
if (isAxiosError<ApiErrorResponse>(error)) {
21+
return error.response?.data?.data?.fieldErrors ?? {};
22+
}
23+
24+
return {};
25+
};

src/api/instance.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import axios from "axios";
2+
3+
import { PATH } from "@/routes/paths";
4+
import { useAuthStore } from "@/stores/useAuthStore";
5+
import type { ApiResponse } from "@/types/api";
6+
import type { AuthTokenResponse } from "@/types/auth/auth";
7+
8+
const BASE_CONFIG = {
9+
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api/v1`,
10+
headers: { "Content-Type": "application/json" },
11+
timeout: 10000,
12+
};
13+
14+
// 토큰이 필요 없는 요청 — 로그인, 회원가입, 토큰 재발급
15+
export const publicInstance = axios.create(BASE_CONFIG);
16+
17+
// 토큰이 필요한 요청 — 헤더 자동 첨부, 401이면 재발급 후 재시도
18+
export const authInstance = axios.create(BASE_CONFIG);
19+
20+
authInstance.interceptors.request.use((config) => {
21+
const { accessToken } = useAuthStore.getState();
22+
if (accessToken) {
23+
config.headers.Authorization = `Bearer ${accessToken}`;
24+
}
25+
26+
return config;
27+
});
28+
29+
let reissuePromise: Promise<string> | null = null;
30+
31+
const reissueAccessToken = async () => {
32+
const { refreshToken } = useAuthStore.getState();
33+
if (!refreshToken) return null;
34+
35+
reissuePromise ??= publicInstance
36+
.post<ApiResponse<AuthTokenResponse>>("/auth/reissue", { refreshToken })
37+
.then((response) => {
38+
const tokens = response.data.data;
39+
useAuthStore.getState().setTokens(tokens);
40+
41+
return tokens.accessToken;
42+
})
43+
.finally(() => {
44+
reissuePromise = null;
45+
});
46+
47+
return reissuePromise.catch(() => null);
48+
};
49+
50+
authInstance.interceptors.response.use(
51+
(response) => response,
52+
async (error) => {
53+
const status = error.response?.status;
54+
const originRequest = error.config;
55+
56+
if (status === 401 && originRequest) {
57+
if (!originRequest.isRetried) {
58+
originRequest.isRetried = true;
59+
60+
const accessToken = await reissueAccessToken();
61+
if (accessToken) {
62+
originRequest.headers.Authorization = `Bearer ${accessToken}`;
63+
return authInstance(originRequest);
64+
}
65+
}
66+
67+
useAuthStore.getState().clearAuth();
68+
69+
if (window.location.pathname !== PATH.LOGIN) {
70+
window.location.replace(PATH.LOGIN);
71+
}
72+
}
73+
74+
return Promise.reject(error);
75+
}
76+
);

src/api/queryClient.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { QueryClient } from "@tanstack/react-query";
2+
import { isAxiosError, isCancel } from "axios";
3+
4+
// 응답이 없는 네트워크 오류와 5xx만 재시도
5+
const shouldRetry = (failureCount: number, error: Error) => {
6+
if (isCancel(error)) return false;
7+
if (!isAxiosError(error)) return false;
8+
9+
const status = error.response?.status;
10+
const isRetryable = status === undefined || status >= 500;
11+
12+
return isRetryable && failureCount < 1;
13+
};
14+
15+
export const queryClient = new QueryClient({
16+
defaultOptions: {
17+
queries: {
18+
staleTime: 1000 * 20,
19+
gcTime: 1000 * 60 * 5,
20+
retry: shouldRetry,
21+
refetchOnWindowFocus: false,
22+
},
23+
},
24+
});

src/constants/errorMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// 서버가 에러 메시지를 안 내려줄 때 노출할 기본 문구
2+
export const API_ERROR_MESSAGE = {
3+
DEFAULT: "요청에 실패했어요. 잠시 후 다시 시도해주세요",
4+
LOGIN: "로그인에 실패했어요. 잠시 후 다시 시도해주세요",
5+
} as const;

src/hooks/useSubmitDormVerification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from "react";
22

3-
import { submitDormVerification } from "@/api/dormVerification";
3+
import { submitDormVerification } from "@/api/dormitory/dormVerification";
44
import type { DormVerification } from "@/types/dormVerification";
55

66
interface SubmitDormVerificationOptions {

src/pages/login/LoginPage.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,42 @@ import { useForm } from "react-hook-form";
77
import { zodResolver } from "@hookform/resolvers/zod";
88
import { loginSchema, type LoginFormValues } from "@/schemas/auth";
99
import { PATH } from "@/routes/paths";
10+
import { useLogin } from "@/api/auth/query";
11+
import { getApiErrorMessage, getApiFieldErrors } from "@/api/error";
12+
import { API_ERROR_MESSAGE } from "@/constants/errorMessage";
1013

1114
export default function LoginPage() {
1215
const navigate = useNavigate();
16+
const { mutate: login, isPending } = useLogin();
1317
const {
1418
register,
1519
handleSubmit,
20+
setError,
1621
formState: { errors },
1722
} = useForm<LoginFormValues>({
1823
resolver: zodResolver(loginSchema),
1924
});
2025

21-
const onSubmit = (_values: LoginFormValues) => {
22-
// TODO: 로그인 API 연동
26+
// TODO: 임시 alert — error 변형 토스트로 교체
27+
const onSubmit = (values: LoginFormValues) => {
28+
login(values, {
29+
onError: (error) => {
30+
const fieldErrors = getApiFieldErrors(error);
31+
const invalidFields = (
32+
Object.keys(values) as (keyof LoginFormValues)[]
33+
).filter((field) => fieldErrors[field]);
34+
35+
if (invalidFields.length > 0) {
36+
invalidFields.forEach((field) =>
37+
setError(field, { message: fieldErrors[field] })
38+
);
39+
40+
return;
41+
}
42+
43+
alert(getApiErrorMessage(error, API_ERROR_MESSAGE.LOGIN));
44+
},
45+
});
2346
};
2447

2548
return (
@@ -59,12 +82,12 @@ export default function LoginPage() {
5982
<Button
6083
variant="text"
6184
size="small"
62-
onClick={() => navigate("/signup")}
85+
onClick={() => navigate(PATH.SIGNUP)}
6386
>
6487
학교 이메일로 회원가입
6588
</Button>
6689
</div>
67-
<Button type="submit" className="mt-[58px] w-full">
90+
<Button type="submit" disabled={isPending} className="mt-[58px] w-full">
6891
로그인
6992
</Button>
7093
</form>

0 commit comments

Comments
 (0)