Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router/dom";

import { queryClient } from "@/api/queryClient";
import { GlobalModal } from "@/components/modal/GlobalModal";
import { GlobalToast } from "@/components/toast/GlobalToast";
import { router } from "@/routes/router";

function App() {
return (
<>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<GlobalModal />
<GlobalToast />
</>
</QueryClientProvider>
);
}

Expand Down
16 changes: 16 additions & 0 deletions src/api/auth/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { authInstance, publicInstance } from "@/api/instance";
import type { ApiResponse } from "@/types/api";
import type { AuthTokenResponse, LoginRequest } from "@/types/auth/auth";

export const login = async (body: LoginRequest) => {
const { data } = await publicInstance.post<ApiResponse<AuthTokenResponse>>(
"/auth/login",
body
);

return data.data;
};

export const logout = async () => {
await authInstance.post<ApiResponse<string>>("/auth/logout");
};
32 changes: 32 additions & 0 deletions src/api/auth/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";

import { login, logout } from "@/api/auth/api";
import { PATH } from "@/routes/paths";
import { useAuthStore } from "@/stores/useAuthStore";

export const useLogin = () => {
const navigate = useNavigate();
const setTokens = useAuthStore((state) => state.setTokens);

return useMutation({
mutationFn: login,
onSuccess: (tokens) => {
setTokens(tokens);
navigate(PATH.HOME, { replace: true });
},
});
};

export const useLogout = () => {
const clearAuth = useAuthStore((state) => state.clearAuth);
const queryClient = useQueryClient();

return useMutation({
mutationFn: logout,
onSettled: () => {
clearAuth();
queryClient.removeQueries();
},
});
};
File renamed without changes.
25 changes: 25 additions & 0 deletions src/api/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isAxiosError } from "axios";

import { API_ERROR_MESSAGE } from "@/constants/errorMessage";
import type { ApiErrorResponse, ApiFieldErrors } from "@/types/api";

// 서버가 내려준 에러 메시지, 없으면 화면별 기본 문구
export const getApiErrorMessage = (
error: unknown,
fallback: string = API_ERROR_MESSAGE.DEFAULT
) => {
if (isAxiosError<ApiErrorResponse>(error)) {
return error.response?.data?.message ?? fallback;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

return fallback;
};

// 필드 단위 검증 에러 — 폼 입력에 매핑
export const getApiFieldErrors = (error: unknown): ApiFieldErrors => {
if (isAxiosError<ApiErrorResponse>(error)) {
return error.response?.data?.data?.fieldErrors ?? {};
}

return {};
};
76 changes: 76 additions & 0 deletions src/api/instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import axios from "axios";

import { PATH } from "@/routes/paths";
import { useAuthStore } from "@/stores/useAuthStore";
import type { ApiResponse } from "@/types/api";
import type { AuthTokenResponse } from "@/types/auth/auth";

const BASE_CONFIG = {
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api/v1`,
headers: { "Content-Type": "application/json" },
timeout: 10000,
};

// 토큰이 필요 없는 요청 — 로그인, 회원가입, 토큰 재발급
export const publicInstance = axios.create(BASE_CONFIG);

// 토큰이 필요한 요청 — 헤더 자동 첨부, 401이면 재발급 후 재시도
export const authInstance = axios.create(BASE_CONFIG);

authInstance.interceptors.request.use((config) => {
const { accessToken } = useAuthStore.getState();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}

return config;
});

let reissuePromise: Promise<string> | null = null;

const reissueAccessToken = async () => {
const { refreshToken } = useAuthStore.getState();
if (!refreshToken) return null;

reissuePromise ??= publicInstance
.post<ApiResponse<AuthTokenResponse>>("/auth/reissue", { refreshToken })
.then((response) => {
const tokens = response.data.data;
useAuthStore.getState().setTokens(tokens);

return tokens.accessToken;
})
.finally(() => {
reissuePromise = null;
});

return reissuePromise.catch(() => null);
};

authInstance.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
const originRequest = error.config;

if (status === 401 && originRequest) {
if (!originRequest.isRetried) {
originRequest.isRetried = true;

const accessToken = await reissueAccessToken();
if (accessToken) {
originRequest.headers.Authorization = `Bearer ${accessToken}`;
return authInstance(originRequest);
}
}

useAuthStore.getState().clearAuth();

if (window.location.pathname !== PATH.LOGIN) {
window.location.replace(PATH.LOGIN);
}
}

return Promise.reject(error);
}
);
23 changes: 23 additions & 0 deletions src/api/queryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { QueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";

// 응답이 없는 네트워크 오류와 5xx만 재시도
const shouldRetry = (failureCount: number, error: Error) => {
if (!isAxiosError(error)) return false;

const status = error.response?.status;
const isRetryable = status === undefined || status >= 500;

return isRetryable && failureCount < 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 20,
gcTime: 1000 * 60 * 5,
retry: shouldRetry,
refetchOnWindowFocus: false,
},
},
});
5 changes: 5 additions & 0 deletions src/constants/errorMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// 서버가 에러 메시지를 안 내려줄 때 노출할 기본 문구
export const API_ERROR_MESSAGE = {
DEFAULT: "요청에 실패했어요. 잠시 후 다시 시도해주세요",
LOGIN: "로그인에 실패했어요. 잠시 후 다시 시도해주세요",
} as const;
2 changes: 1 addition & 1 deletion src/hooks/useSubmitDormVerification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";

import { submitDormVerification } from "@/api/dormVerification";
import { submitDormVerification } from "@/api/dormitory/dormVerification";
import type { DormVerification } from "@/types/dormVerification";

interface SubmitDormVerificationOptions {
Expand Down
31 changes: 27 additions & 4 deletions src/pages/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,42 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema, type LoginFormValues } from "@/schemas/auth";
import { PATH } from "@/routes/paths";
import { useLogin } from "@/api/auth/query";
import { getApiErrorMessage, getApiFieldErrors } from "@/api/error";
import { API_ERROR_MESSAGE } from "@/constants/errorMessage";

export default function LoginPage() {
const navigate = useNavigate();
const { mutate: login, isPending } = useLogin();
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});

const onSubmit = (_values: LoginFormValues) => {
// TODO: 로그인 API 연동
// TODO: 임시 alert — error 변형 토스트로 교체
const onSubmit = (values: LoginFormValues) => {
login(values, {
onError: (error) => {
const fieldErrors = getApiFieldErrors(error);
const invalidFields = (
Object.keys(values) as (keyof LoginFormValues)[]
).filter((field) => fieldErrors[field]);

if (invalidFields.length > 0) {
invalidFields.forEach((field) =>
setError(field, { message: fieldErrors[field] })
);

return;
}

alert(getApiErrorMessage(error, API_ERROR_MESSAGE.LOGIN));
},
});
};

return (
Expand Down Expand Up @@ -59,12 +82,12 @@ export default function LoginPage() {
<Button
variant="text"
size="small"
onClick={() => navigate("/signup")}
onClick={() => navigate(PATH.SIGNUP)}
>
학교 이메일로 회원가입
</Button>
</div>
<Button type="submit" className="mt-[58px] w-full">
<Button type="submit" disabled={isPending} className="mt-[58px] w-full">
로그인
</Button>
</form>
Expand Down
8 changes: 4 additions & 4 deletions src/pages/mypage/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useNavigate } from "react-router";

import { useLogout } from "@/api/auth/query";
import { useModal } from "@/hooks/useModal";
import { BackHeader } from "@/layouts/BackHeader";
import { PageShell } from "@/layouts/PageShell";
Expand All @@ -10,6 +11,7 @@ import { MenuRow } from "../components/MenuRow";
export function SettingsPage() {
const navigate = useNavigate();
const { openModal } = useModal();
const { mutate: logout } = useLogout();

const handleLogout = () => {
openModal({
Expand All @@ -18,10 +20,8 @@ export function SettingsPage() {
outlineLabel: "아니요",
primaryLabel: "네",
},
onConfirm: () => {
// TODO: 로그아웃 로직 연결
navigate(PATH.LOGIN, { replace: true });
},
// 토큰이 비워지면 ProtectedRoute가 로그인 페이지로 보냄
onConfirm: () => logout(),
});
};

Expand Down
Loading
Loading