Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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.
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,
},
},
});
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
22 changes: 18 additions & 4 deletions src/pages/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ 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 { isAxiosError } from "axios";

const LOGIN_FAILED_MESSAGE = "로그인에 실패했어요. 잠시 후 다시 시도해주세요";

export default function LoginPage() {
const navigate = useNavigate();
const { mutate: login, isPending } = useLogin();
const {
register,
handleSubmit,
Expand All @@ -18,8 +23,17 @@ export default function LoginPage() {
resolver: zodResolver(loginSchema),
});

const onSubmit = (_values: LoginFormValues) => {
// TODO: 로그인 API 연동
// TODO: 임시 alert — 폼 하단 에러 메시지 또는 error 변형 토스트로 교체
const onSubmit = (values: LoginFormValues) => {
login(values, {
onError: (error) => {
const message = isAxiosError<{ message?: string }>(error)
? error.response?.data?.message
: undefined;

alert(message ?? LOGIN_FAILED_MESSAGE);
},
});
};

return (
Expand Down Expand Up @@ -59,12 +73,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
113 changes: 0 additions & 113 deletions src/pages/signup/components/ProfileForm.tsx

This file was deleted.

14 changes: 14 additions & 0 deletions src/routes/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Navigate, Outlet } from "react-router";

import { PATH } from "@/routes/paths";
import { useAuthStore } from "@/stores/useAuthStore";

export function ProtectedRoute() {
const accessToken = useAuthStore((state) => state.accessToken);

if (!accessToken) {
return <Navigate to={PATH.LOGIN} replace />;
}

return <Outlet />;
}
Loading
Loading