diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..82eb577 --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,270 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +interface LoginCredentials { + memberEmail: string; + memberPassword: string; +} + +interface LoginResponse { + token: string; + memberId: number; + memberName: string; + memberNickName: string; + annualIncome: number; + deposit: number; +} + +interface AuthActionResult { + success: boolean; + error?: string; + data?: { + memberId: string; + memberName: string; + memberNickName: string; + annualIncome: string; + deposit: string; + }; +} + +interface SessionResult { + success: boolean; + error?: string; +} + +// 공통 쿠키 옵션 +const getCookieOptions = () => ({ + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 30 * 60, // 30분 + path: "/", +}); + +/** + * 로그인 서버 액션 + * @param credentials - 로그인 자격 증명 (이메일, 비밀번호) + * @returns 로그인 결과 (성공/실패, 사용자 정보) + */ +export async function loginAction( + credentials: LoginCredentials, +): Promise { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(credentials), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + return { + success: false, + error: errorData.message || "로그인에 실패했습니다.", + }; + } + + const data: LoginResponse = await response.json(); + + // 서버에서만 httpOnly 쿠키 설정 + const cookieStore = cookies(); + const cookieOptions = getCookieOptions(); + + // 모든 인증 정보를 httpOnly 쿠키로 저장 + cookieStore.set("token", data.token, cookieOptions); + cookieStore.set("memberId", data.memberId.toString(), cookieOptions); + cookieStore.set("memberName", data.memberName, cookieOptions); + cookieStore.set("memberNickName", data.memberNickName, cookieOptions); + cookieStore.set( + "annualIncome", + data.annualIncome.toString(), + cookieOptions, + ); + cookieStore.set("deposit", data.deposit.toString(), cookieOptions); + + return { + success: true, + data: { + memberId: data.memberId.toString(), + memberName: data.memberName, + memberNickName: data.memberNickName, + annualIncome: data.annualIncome.toString(), + deposit: data.deposit.toString(), + }, + }; + } catch (error) { + console.error("로그인 서버 액션 오류:", error); //eslint-disable-line + return { + success: false, + error: "로그인 중 오류가 발생했습니다.", + }; + } +} + +/** + * 로그아웃 서버 액션 + * 모든 인증 관련 쿠키를 삭제하고 로그인 페이지로 리다이렉트 + */ +export async function logoutAction(): Promise { + try { + const cookieStore = cookies(); + + // 모든 인증 관련 쿠키 삭제 + const cookiesToDelete = [ + "token", + "memberId", + "memberName", + "memberNickName", + "annualIncome", + "deposit", + ]; + + cookiesToDelete.forEach((cookieName) => { + cookieStore.delete(cookieName); + }); + } catch (error) { + console.error("로그아웃 중 오류:", error); //eslint-disable-line + } finally { + // 오류가 발생해도 로그인 페이지로 리다이렉트 + redirect("/login"); + } +} + +/** + * 세션 연장 서버 액션 + * 기존 쿠키들의 만료시간을 연장 (30분) + * @returns 세션 연장 결과 + */ +export async function extendSessionAction(): Promise { + try { + const cookieStore = cookies(); + const token = cookieStore.get("token")?.value; + + if (!token) { + return { + success: false, + error: "인증 토큰이 없습니다.", + }; + } + + // 기존 사용자 정보 가져오기 + const userInfo = { + token, + memberId: cookieStore.get("memberId")?.value, + memberName: cookieStore.get("memberName")?.value, + memberNickName: cookieStore.get("memberNickName")?.value, + annualIncome: cookieStore.get("annualIncome")?.value, + deposit: cookieStore.get("deposit")?.value, + }; + + // 필수 정보가 없으면 실패 + if (!userInfo.memberId) { + return { + success: false, + error: "사용자 정보가 없습니다.", + }; + } + + // 새로운 만료시간으로 쿠키 재설정 + const cookieOptions = getCookieOptions(); + + cookieStore.set("token", userInfo.token, cookieOptions); + cookieStore.set("memberId", userInfo.memberId, cookieOptions); + cookieStore.set("memberName", userInfo.memberName || "", cookieOptions); + cookieStore.set( + "memberNickName", + userInfo.memberNickName || "", + cookieOptions, + ); + cookieStore.set("annualIncome", userInfo.annualIncome || "", cookieOptions); + cookieStore.set("deposit", userInfo.deposit || "", cookieOptions); + + return { success: true }; + } catch (error) { + console.error("세션 연장 중 오류:", error); //eslint-disable-line + return { + success: false, + error: "세션 연장 중 오류가 발생했습니다.", + }; + } +} + +/** + * 사용자 정보 업데이트 서버 액션 + * 특정 사용자 정보만 업데이트 (예: 예수금 변경) + * @param updates - 업데이트할 정보 + */ +export async function updateUserInfoAction(updates: { + deposit?: string; + annualIncome?: string; +}): Promise { + try { + const cookieStore = cookies(); + const token = cookieStore.get("token")?.value; + + if (!token) { + return { + success: false, + error: "인증 토큰이 없습니다.", + }; + } + + const cookieOptions = getCookieOptions(); + + // 업데이트할 정보만 새로 설정 + if (updates.deposit !== undefined) { + cookieStore.set("deposit", updates.deposit, cookieOptions); + } + + if (updates.annualIncome !== undefined) { + cookieStore.set("annualIncome", updates.annualIncome, cookieOptions); + } + + console.log("사용자 정보 업데이트 완료:", updates); //eslint-disable-line + + return { success: true }; + } catch (error) { + console.error("사용자 정보 업데이트 중 오류:", error); //eslint-disable-line + return { + success: false, + error: "사용자 정보 업데이트 중 오류가 발생했습니다.", + }; + } +} + +/** + * 토큰 유효성 검증 서버 액션 + * @returns 토큰 유효성 결과 + */ +export async function validateTokenAction(): Promise { + try { + const cookieStore = cookies(); + const token = cookieStore.get("token")?.value; + + if (!token) { + return { + success: false, + error: "토큰이 없습니다.", + }; + } + + // 실제 구현에서는 JWT 토큰 검증 로직 추가 + // const isValid = jwt.verify(token, process.env.JWT_SECRET); + + // 현재는 토큰 존재 여부만 확인 + return { success: true }; + } catch (error) { + console.error("토큰 검증 중 오류:", error); //eslint-disable-line + return { + success: false, + error: "토큰 검증에 실패했습니다.", + }; + } +} diff --git a/src/api/make-api-request.ts b/src/api/make-api-request.ts index 8657384..cd7b405 100644 --- a/src/api/make-api-request.ts +++ b/src/api/make-api-request.ts @@ -2,20 +2,18 @@ export default async function makeApiRequest( method: "GET" | "POST" | "PUT" | "DELETE", endpoint: string, options: { - token?: string | null; data?: T; responseType?: "json" | "text"; }, ): Promise { - const { token, data, responseType = "json" } = options; - + const { data, responseType = "json" } = options; const headers: HeadersInit = { ...(data && { "Content-Type": "application/json" }), - ...(token && { Authorization: `Bearer ${token}` }), }; const config: RequestInit = { method, + credentials: "include", // httpOnly 쿠키 자동 포함 headers, ...(data && { body: JSON.stringify(data) }), }; diff --git a/src/api/portfolio/index.ts b/src/api/portfolio/index.ts index da77293..a74de8d 100644 --- a/src/api/portfolio/index.ts +++ b/src/api/portfolio/index.ts @@ -1,14 +1,14 @@ import type { PortfolioData } from "@/app/portfolio/types"; -const fetchPortfolios = async (token: string): Promise => { +const fetchPortfolios = async (): Promise => { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/portfolio/recommend`, { method: "GET", cache: "no-store", + credentials: "include", // httpOnly 쿠키 자동 포함 headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, }, ); diff --git a/src/api/side-Info/index.ts b/src/api/side-Info/index.ts index b4b237a..f7239f8 100644 --- a/src/api/side-Info/index.ts +++ b/src/api/side-Info/index.ts @@ -10,12 +10,13 @@ interface StockCountResponse { count: string; } -export async function fetchMyStocks(token: string): Promise { +export async function fetchMyStocks(): Promise { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/account/accounts`, { + credentials: "include", // httpOnly 쿠키 자동 포함 headers: { - Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, }, ); @@ -27,14 +28,13 @@ export async function fetchMyStocks(token: string): Promise { return response.json(); } -export async function fetchStockCount( - token: string, -): Promise { +export async function fetchStockCount(): Promise { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/home/sidebar/myStockCount`, { + credentials: "include", // httpOnly 쿠키 자동 포함 headers: { - Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, }, ); diff --git a/src/api/transaction/index.ts b/src/api/transaction/index.ts index 655b98a..c4bea02 100644 --- a/src/api/transaction/index.ts +++ b/src/api/transaction/index.ts @@ -15,86 +15,67 @@ import makeApiRequest from "../make-api-request"; // 현재가 매수 export async function buyAtMarketPrice({ - token, data, }: TradeAtMarketPriceFormDataType): Promise { - return makeApiRequest("POST", "/api/account/buy", { token, data }); + return makeApiRequest("POST", "/api/account/buy", { data }); } // 현재가 매도 export async function sellAtMarketPrice({ - token, data, }: TradeAtMarketPriceFormDataType): Promise { - return makeApiRequest("POST", "/api/account/sell", { token, data }); + return makeApiRequest("POST", "/api/account/sell", { data }); } // 지정가 매수 export async function buyAtLimitPrice({ - token, data, }: TradeAtLimitPriceFormDataType): Promise { - return makeApiRequest("POST", "/api/account/order/buy", { token, data }); + return makeApiRequest("POST", "/api/account/order/buy", { data }); } // 지정가 매도 export async function sellAtLimitPrice({ - token, data, }: TradeAtLimitPriceFormDataType): Promise { - return makeApiRequest("POST", "/api/account/order/sell", { token, data }); + return makeApiRequest("POST", "/api/account/order/sell", { data }); } // 정정취소 화면의 매수/매도 체결내역 조회 export async function getHistory( - token: string, stockName: string, ): Promise { - return makeApiRequest("GET", `/api/account/${stockName}`, { - token, - }); + return makeApiRequest("GET", `/api/account/${stockName}`, {}); } // 지정가 매수/매도 내역 export async function getTrade( - token: string | null, stockName: string, ): Promise { - return makeApiRequest("GET", `/api/account/orders/${stockName}`, { - token, - }); + return makeApiRequest("GET", `/api/account/orders/${stockName}`, {}); } // 지정가 정정 export async function modifyTrade({ - token, orderId, data, }: ModifyTradeFormData): Promise { return makeApiRequest("PUT", `/api/account/order/${orderId}/modify`, { - token, data, responseType: "text", }); } // 지정가 취소 -export async function cancelTrade({ - token, - orderId, -}: CancelData): Promise { +export async function cancelTrade({ orderId }: CancelData): Promise { return makeApiRequest("DELETE", `/api/account/order/${orderId}/cancel`, { - token, responseType: "text", }); } // 체결내역 export async function getTradeHistory( - token: string | null, stockName: string, ): Promise { - return makeApiRequest("GET", `/api/account/accounts/save/${stockName}`, { - token, - }); + return makeApiRequest("GET", `/api/account/accounts/save/${stockName}`, {}); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0ad45bc..7669ddb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import Toast from "@/components/common/toast/index"; import TutorialContainer from "@/components/common/tutorial/_components/tutorial-container"; import MainContent from "@/components/main-content"; import NavBar from "@/components/nav-bar"; +import { getServerAuth } from "@/lib/auth-server"; import AuthInitializer from "@/provider/AuthInitializer"; import AuthRefreshHandler from "@/utils/auth-handler"; @@ -45,11 +46,17 @@ export default async function RootLayout({ const queryClient = new QueryClient(); const dehydratedState = dehydrate(queryClient); + // 서버에서 인증 상태 확인 + const { userInfo, isAuthenticated } = await getServerAuth(); + return ( - + diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index e750d4c..401c32c 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -3,15 +3,16 @@ import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { memo } from "react"; +import { memo, useTransition } from "react"; import { useForm } from "react-hook-form"; +import { loginAction } from "@/actions/auth"; import { EmailInput, PasswordInput } from "@/components/auth-input/index"; import Button from "@/components/common/button/index"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import { useToast } from "@/store/use-toast-store"; import { AuthFormData } from "@/types/auth"; -import { LoginResponse, loginSchema } from "@/validation/schema/auth/index"; +import { loginSchema } from "@/validation/schema/auth/index"; const FormLinks = memo(() => (
@@ -28,8 +29,9 @@ FormLinks.displayName = "FormLinks"; export default function LoginForm() { const router = useRouter(); - const { setAuth } = useAuth(); + const { setUserInfo, setAuthenticated } = useAuth(); const { showToast } = useToast(); + const [isPending, startTransition] = useTransition(); const { control, @@ -41,40 +43,26 @@ export default function LoginForm() { }); const onSubmit = async (data: AuthFormData) => { - showToast("로그인 시도 중...", "pending"); + startTransition(async () => { + showToast("로그인 시도 중...", "pending"); - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/login`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }, - ); + const result = await loginAction({ + memberEmail: data.memberEmail, + memberPassword: data.memberPassword, + }); - if (response.ok) { - const responseData: LoginResponse = await response.json(); + if (result.success && result.data) { + // 클라이언트 스토어에는 사용자 정보만 저장 (토큰 제외) + setUserInfo(result.data); + setAuthenticated(true); - await setAuth(responseData); showToast("로그인에 성공했습니다.", "success"); - router.push("/"); router.refresh(); } else { - const errorData = await response.json(); - showToast(errorData.message || "로그인에 실패했습니다.", "error"); - throw new Error(errorData.message || "로그인에 실패했습니다."); - } - } catch (error) { - if (error instanceof Error) { - showToast(error.message, "error"); - } else { - showToast("로그인 중 오류가 발생했습니다.", "error"); + showToast(result.error || "로그인에 실패했습니다.", "error"); } - } + }); }; return ( @@ -83,10 +71,10 @@ export default function LoginForm() { diff --git a/src/app/main/_components/asset-info.tsx b/src/app/main/_components/asset-info.tsx index 9e255f6..66e4252 100644 --- a/src/app/main/_components/asset-info.tsx +++ b/src/app/main/_components/asset-info.tsx @@ -2,9 +2,10 @@ import Image from "next/image"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useTransition } from "react"; -import { useAuth } from "@/hooks/use-auth"; +import { logoutAction } from "@/actions/auth"; +import useAuth from "@/hooks/use-auth"; import coinsIcon from "@/images/coin.png"; import shieldIcon from "@/images/shield.png"; import { useToast } from "@/store/use-toast-store"; @@ -36,10 +37,10 @@ const formatKoreanCurrency = (amount: number) => { const INITIAL_ASSET = 100_000_000; export default function AssetInfo() { - const { isAuthenticated, memberNickName, token, clearAuth, isInitialized } = - useAuth(); + const { isAuthenticated, userInfo, clearAuth, isInitialized } = useAuth(); const { showToast } = useToast(); const [assetInfo, setAssetInfo] = useState(null); + const [isPending, startTransition] = useTransition(); useEffect(() => { const fetchAssetInfo = async () => { @@ -47,9 +48,9 @@ export default function AssetInfo() { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/home/sidebar/asset`, { + credentials: "include", // httpOnly 쿠키 자동 포함 headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, }, ); @@ -59,14 +60,14 @@ export default function AssetInfo() { setAssetInfo(data); } } catch (error) { - console.error("자산 정보 조회 실패:", error); // eslint-disable-line + console.error("자산 정보 조회 실패:", error); // eslint-disable-line no-console } }; - if (isAuthenticated && token) { + if (isAuthenticated) { fetchAssetInfo(); } - }, [isAuthenticated, token]); + }, [isAuthenticated]); // 상승 또는 하락 비율 계산 const getChangeText = () => { @@ -97,9 +98,11 @@ export default function AssetInfo() { // 로그아웃 처리 const handleLogout = async () => { - showToast("로그아웃 중...", "pending"); - await clearAuth(); - showToast("로그아웃되었습니다.", "success"); + startTransition(async () => { + showToast("로그아웃 중...", "pending"); + clearAuth(); // 클라이언트 상태 먼저 정리 + await logoutAction(); // 서버 액션으로 쿠키 정리 및 리다이렉트 + }); }; if (!isInitialized) { @@ -150,9 +153,10 @@ export default function AssetInfo() {
@@ -162,7 +166,7 @@ export default function AssetInfo() {

- {memberNickName}님의 총 자산 + {userInfo.memberNickName}님의 총 자산

{assetInfo?.asset diff --git a/src/app/main/_components/stock-info.tsx b/src/app/main/_components/stock-info.tsx index 9badec7..43b2bac 100644 --- a/src/app/main/_components/stock-info.tsx +++ b/src/app/main/_components/stock-info.tsx @@ -8,7 +8,7 @@ import type { StockHolding } from "@/api/side-Info/index"; import { fetchMyStocks, fetchStockCount } from "@/api/side-Info/index"; import type { CommonTableColumn } from "@/components/common/table"; import { TableBody } from "@/components/common/table"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import magnifierIcon from "@/images/stockInfo.png"; import { MyStockInfoSkeleton } from "./skeleton"; @@ -64,13 +64,13 @@ function StockTable({ data }: { data: StockHolding[] }) { } export default function MyStockInfo() { - const { isAuthenticated, token, isInitialized } = useAuth(); + const { isAuthenticated, isInitialized } = useAuth(); const [stockCount, setStockCount] = useState(null); const { data: stockHoldings } = useQuery({ queryKey: ["myStocks"], - queryFn: () => fetchMyStocks(token!), - enabled: !!isAuthenticated && !!token, + queryFn: () => fetchMyStocks(), + enabled: !!isAuthenticated, refetchOnMount: true, staleTime: 0, }); @@ -78,17 +78,17 @@ export default function MyStockInfo() { useEffect(() => { const getStockCount = async () => { try { - const countData = await fetchStockCount(token!); + const countData = await fetchStockCount(); setStockCount(countData.count); } catch (error) { console.error("보유 주식 수 조회 실패:", error); //eslint-disable-line } }; - if (isAuthenticated && token) { + if (isAuthenticated) { getStockCount(); } - }, [isAuthenticated, token]); + }, [isAuthenticated]); if (!isInitialized) { return ; diff --git a/src/app/my-account/page.tsx b/src/app/my-account/page.tsx index 1bc57ab..748015e 100644 --- a/src/app/my-account/page.tsx +++ b/src/app/my-account/page.tsx @@ -1,16 +1,11 @@ -import { getCookie } from "@/utils/next-cookies"; +import { makeAuthenticatedRequest, requireAuth } from "@/lib/auth-server"; import StockSummary from "./_components/stock-summary"; import StockTable from "./_components/stock-table"; -async function getStocks(token: string) { - const response = await fetch( +async function getStocks() { + const response = await makeAuthenticatedRequest( `${process.env.NEXT_PUBLIC_API_URL}/account/stocks`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, ); if (!response.ok) { @@ -20,14 +15,9 @@ async function getStocks(token: string) { return response.json(); } -async function getTotalStocks(token: string) { - const response = await fetch( +async function getTotalStocks() { + const response = await makeAuthenticatedRequest( `${process.env.NEXT_PUBLIC_API_URL}/account/all-stocks`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, ); if (!response.ok) { @@ -38,16 +28,13 @@ async function getTotalStocks(token: string) { } export default async function StockPortfolioPage() { - try { - const token = await getCookie("token"); - - if (!token) { - throw new Error("토큰이 없습니다"); - } + // 인증 필수 - 인증되지 않으면 자동으로 /login으로 리다이렉트 + await requireAuth(); + try { const [stocks, totalStocks] = await Promise.all([ - getStocks(token), - getTotalStocks(token), + getStocks(), + getTotalStocks(), ]); return ( @@ -73,7 +60,6 @@ export default async function StockPortfolioPage() { rank: 0, }; - // 에러 발생 시에도 UI는 표시하되, 빈 데이터로 표시 return (

내 계좌

diff --git a/src/app/portfolio/_components/portfoilo-card.tsx b/src/app/portfolio/_components/portfoilo-card.tsx index 963eeb9..84eac7f 100644 --- a/src/app/portfolio/_components/portfoilo-card.tsx +++ b/src/app/portfolio/_components/portfoilo-card.tsx @@ -2,7 +2,7 @@ import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import { CHART_ITEMS, COLORS } from "../constants"; import { ChartDataItem, PortfolioData } from "../types"; @@ -14,7 +14,7 @@ interface PortfolioRecommendProps { export default function PortfolioRecommend({ portfolios, }: PortfolioRecommendProps) { - const { annualIncome } = useAuth(); + const { userInfo } = useAuth(); // annualIncome 대신 userInfo 사용 const getPortfolioTypeText = (type: PortfolioData["type"]) => { switch (type) { @@ -61,7 +61,12 @@ export default function PortfolioRecommend({

연봉

-

{annualIncome?.toLocaleString() ?? 0}원

+

+ {userInfo.annualIncome + ? parseInt(userInfo.annualIncome).toLocaleString() //eslint-disable-line + : 0} + 원 +

diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 000cb22..9b3fc6f 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -1,19 +1,13 @@ -import { redirect } from "next/navigation"; - import fetchPortfolios from "@/api/portfolio/index"; -import { getCookie } from "@/utils/next-cookies"; +import { requireAuth } from "@/lib/auth-server"; import PortfolioRecommend from "./_components/portfoilo-card"; export default async function PortfolioPage() { - const token = await getCookie("token"); - - if (!token) { - redirect("/login"); - } + await requireAuth(); // 간단한 인증 확인 try { - const portfolios = await fetchPortfolios(token); + const portfolios = await fetchPortfolios(); // 토큰 제거 return ; } catch (error) { throw new Error( diff --git a/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx index 8e6fdbf..f3402c3 100644 --- a/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/buyable-quantity.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { fetchMyStocks } from "@/api/side-Info"; import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import MyStockMap from "@/utils/my-stock-count"; import { calculateBuyableQuantity, getKoreanPrice } from "@/utils/price"; @@ -17,12 +17,12 @@ export default function BuyableQuantity({ type, bidding, }: BuyableQuantityProps) { - const { isAuthenticated, token, deposit } = useAuth(); + const { isAuthenticated, userInfo } = useAuth(); // token 제거, userInfo 사용 const { stockName } = useStockInfoContext(); const { data: stockHoldings } = useQuery({ queryKey: ["myStocks"], - queryFn: () => fetchMyStocks(token!), - enabled: !!isAuthenticated && !!token, + queryFn: () => fetchMyStocks(), // token 제거 + enabled: !!isAuthenticated, // token 조건 제거 }); const stockMap = new MyStockMap(stockHoldings); @@ -34,7 +34,9 @@ export default function BuyableQuantity({
{type === TradeType.Buy - ? getKoreanPrice(calculateBuyableQuantity(deposit, bidding)) + ? getKoreanPrice( + calculateBuyableQuantity(userInfo.deposit, bidding), + ) : stockMap.findStockCount(stockName)} 주 diff --git a/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx b/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx index 7470d7a..6459ca0 100644 --- a/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx +++ b/src/app/search/[id]/_components/order-stock/buy-and-sell/index.tsx @@ -7,7 +7,6 @@ import { useForm } from "react-hook-form"; import { colorMap } from "@/constants/trade"; import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; import { LimitPriceOrderHistory, ModifyTradeFormData, @@ -43,7 +42,6 @@ export default function BuyAndSell({ const [isConfirmationPage, setIsConfirmationPage] = useState(false); const { stockName, stockInfo } = useStockInfoContext(); - const { token } = useAuth(); const trades = useTradeMutations(); const { @@ -101,11 +99,9 @@ export default function BuyAndSell({ [TradeType.Buy]: () => priceType === PriceType.Market ? trades.buyAtMarketPrice.mutate({ - token, data: { stockName, quantity: watchedCount }, }) : trades.buyAtLimitPrice.mutate({ - token, data: { stockName, limitPrice: watchedBidding, @@ -116,11 +112,9 @@ export default function BuyAndSell({ [TradeType.Sell]: () => priceType === PriceType.Market ? trades.sellAtMarketPrice.mutate({ - token, data: { stockName, quantity: watchedCount }, }) : trades.sellAtLimitPrice.mutate({ - token, data: { stockName, limitPrice: watchedBidding, @@ -131,7 +125,6 @@ export default function BuyAndSell({ [TradeType.Edit]: () => { if (type !== TradeType.Edit || !handleMutate) return; handleMutate({ - token, orderId: defaultData?.OrderId, data: { stockName, diff --git a/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx b/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx index c3ae99c..ddc8ba0 100644 --- a/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx +++ b/src/app/search/[id]/_components/order-stock/edit-cancel/index.tsx @@ -1,9 +1,8 @@ -// "use client"; +"use client"; import { useState } from "react"; import Button from "@/components/common/button"; -import { useAuth } from "@/hooks/use-auth"; import { useToast } from "@/store/use-toast-store"; import useLimitOrderData from "../../../hooks/use-limit-order-data"; @@ -20,7 +19,6 @@ export default function EditCancel() { const [isCancelTable, setIsCancelTable] = useState(false); const { showToast } = useToast(); - const { token } = useAuth(); const { data: limitOrderData, isLoading, @@ -46,7 +44,7 @@ export default function EditCancel() { }; const handleCancelConfirm = (orderId: string) => { - cancelTradeMutation.mutate({ token, orderId }); + cancelTradeMutation.mutate({ orderId }); setIsCancelTable(false); }; diff --git a/src/app/search/[id]/_components/order-stock/index.tsx b/src/app/search/[id]/_components/order-stock/index.tsx index e368d45..0724b20 100644 --- a/src/app/search/[id]/_components/order-stock/index.tsx +++ b/src/app/search/[id]/_components/order-stock/index.tsx @@ -7,7 +7,7 @@ import { TabsTrigger, } from "@/components/common/tabs"; import { StockInfoProvider } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import { StockInfo, TradeType } from "../../types"; import LoadingSpinner from "../loading-spinner"; diff --git a/src/app/search/[id]/_components/order-stock/order-history/index.tsx b/src/app/search/[id]/_components/order-stock/order-history/index.tsx index 440b3bb..0abab96 100644 --- a/src/app/search/[id]/_components/order-stock/order-history/index.tsx +++ b/src/app/search/[id]/_components/order-stock/order-history/index.tsx @@ -3,13 +3,13 @@ import Image from "next/image"; import { getTradeHistory } from "@/api/transaction"; import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; import LoadingSpinner from "../../loading-spinner"; import TradeTable from "../trade-table"; export default function OrderHistory() { - const { token, isAuthenticated } = useAuth(); + const { isAuthenticated } = useAuth(); // token 제거 const { stockName } = useStockInfoContext(); const { @@ -18,8 +18,8 @@ export default function OrderHistory() { isPending, } = useQuery({ queryKey: ["tradeHistory", `${stockName}`], - queryFn: () => getTradeHistory(token, stockName), - enabled: !!isAuthenticated && !!token, + queryFn: () => getTradeHistory(stockName), // token 제거 + enabled: !!isAuthenticated, // token 조건 제거 }); if (isLoading || isPending) { diff --git a/src/app/search/[id]/hooks/use-limit-order-data.ts b/src/app/search/[id]/hooks/use-limit-order-data.ts index a99724f..a9cb41e 100644 --- a/src/app/search/[id]/hooks/use-limit-order-data.ts +++ b/src/app/search/[id]/hooks/use-limit-order-data.ts @@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"; import { getTrade } from "@/api/transaction"; import { useStockInfoContext } from "@/context/stock-info-context"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; export default function useLimitOrderData() { const { stockName } = useStockInfoContext(); - const { token, isAuthenticated } = useAuth(); + const { isAuthenticated } = useAuth(); const queryResult = useQuery({ queryKey: ["limitOrder", stockName], - queryFn: () => getTrade(token, stockName), - enabled: !!isAuthenticated && !!token, + queryFn: () => getTrade(stockName), + enabled: !!isAuthenticated, }); const findOrderById = (orderId: string) => diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts new file mode 100644 index 0000000..2218f8e --- /dev/null +++ b/src/hooks/use-api.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; + +import useAuth from "./use-auth"; + +const useApi = () => { + const router = useRouter(); + const { setAuthenticated, clearAuth } = useAuth(); + + const apiCall = useCallback( + async (url: string, options: RequestInit = {}) => { + try { + const response = await fetch(url, { + ...options, + credentials: "include", // 항상 쿠키 포함 + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + + // 인증 실패시 자동 처리 + if (response.status === 401) { + setAuthenticated(false); + clearAuth(); + router.push("/login"); + throw new Error("인증이 만료되었습니다."); + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response; + } catch (error) { + console.error("API 요청 오류:", error); //eslint-disable-line + throw error; + } + }, + [router, setAuthenticated, clearAuth], + ); + + const get = useCallback( + (url: string) => apiCall(url, { method: "GET" }), + [apiCall], + ); + + const post = useCallback( + (url: string, data?: any) => + apiCall(url, { + method: "POST", + body: data ? JSON.stringify(data) : undefined, + }), + [apiCall], + ); + + const put = useCallback( + (url: string, data?: any) => + apiCall(url, { + method: "PUT", + body: data ? JSON.stringify(data) : undefined, + }), + [apiCall], + ); + + const del = useCallback( + (url: string) => apiCall(url, { method: "DELETE" }), + [apiCall], + ); + + return { get, post, put, delete: del, apiCall }; +}; +export default useApi; diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 03d4ba6..4984f29 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -1,220 +1,63 @@ -/*eslint-disable*/ "use client"; import { create } from "zustand"; -import { deleteCookie, getCookie, setCookie } from "@/utils/next-cookies"; -interface LoginResponse { - token: string; - memberId: number; - memberName: string; - memberNickName: string; - annualIncome: number; - deposit: number; +export interface UserInfo { + memberId: string | null; + memberName: string | null; + memberNickName: string | null; + annualIncome: string | null; + deposit: string | null; } interface AuthStore { - token: string | null; - memberId: number | null; - memberName: string | null; - memberNickName: string | null; - annualIncome: number | null; - deposit: number | null; + userInfo: UserInfo; isAuthenticated: boolean; isInitialized: boolean; - isInitializing: boolean; - setAuth: (response: LoginResponse) => Promise; - clearAuth: () => Promise; - initAuth: () => Promise; -} - -/** - * 인증 상태를 관리하는 Zustand 스토어 훅 - * - * @description - * - 로그인/로그아웃 상태 관리 - * - 토큰 및 사용자 정보 관리 - * - 쿠키 기반의 인증 상태 유지 - * - 중복 초기화 방지 로직 포함 - * - * @example - * // 1. 로그인 처리 - * const LoginComponent = () => { - * const { setAuth } = useAuth(); - * - * const handleLogin = async () => { - * const response = await loginAPI(); - * await setAuth(response); - * }; - * }; - * - * @example - * // 2. 인증이 필요한 API 요청 - * const ProtectedComponent = () => { - * const { token, isInitialized } = useAuth(); - * - * useEffect(() => { - * if (!isInitialized) return; - * - * const fetchData = async () => { - * const response = await fetch('/api/protected', { - * headers: { Authorization: `Bearer ${token}` } - * }); - * }; - * - * fetchData(); - * }, [isInitialized, token]); - * }; - * - * @example - * // 3. 앱 초기화시 인증 상태 복원 - * const App = () => { - * const { initAuth } = useAuth(); - * - * useEffect(() => { - * initAuth(); - * }, []); - * }; - * - * @example - * // 4. 로그아웃시 인증 정보 제거 - * function LogoutButton() { - * const { clearAuth } = useAuth(); - * - * const handleLogout = async () => { - * await clearAuth(); - * }; - * } - */ -export const useAuth = create((set, get) => { - let initPromise: Promise | null = null; + setUserInfo: (info: Partial) => void; + setAuthenticated: (authenticated: boolean) => void; + clearAuth: () => void; + initializeAuth: (initialUserInfo: UserInfo, authenticated: boolean) => void; +} - return { - token: null, +const useAuth = create((set) => ({ + userInfo: { memberId: null, memberName: null, memberNickName: null, annualIncome: null, deposit: null, - isAuthenticated: false, - isInitialized: false, - isInitializing: false, - - setAuth: async (response: LoginResponse) => { - const { - token, - memberId, - memberName, - memberNickName, - annualIncome, - deposit, - } = response; + }, + isAuthenticated: false, + isInitialized: false, - // 모든 쿠키 설정을 병렬로 처리 - await Promise.all([ - setCookie("token", token), - setCookie("memberId", memberId.toString()), - setCookie("memberName", memberName), - setCookie("memberNickName", memberNickName), - setCookie("annualIncome", annualIncome.toString()), - setCookie("deposit", deposit.toString()), - ]); + setUserInfo: (info) => + set((state) => ({ + userInfo: { ...state.userInfo, ...info }, + })), - set({ - token, - memberId, - memberName, - memberNickName, - annualIncome, - deposit, - isAuthenticated: true, - isInitialized: true, - }); - }, + setAuthenticated: (authenticated) => set({ isAuthenticated: authenticated }), - clearAuth: async () => { - // 모든 쿠키 삭제를 병렬로 처리 - await Promise.all([ - deleteCookie("token"), - deleteCookie("memberId"), - deleteCookie("memberName"), - deleteCookie("memberNickName"), - deleteCookie("annualIncome"), - deleteCookie("deposit"), - ]); - - set({ - token: null, + clearAuth: () => + set({ + userInfo: { memberId: null, memberName: null, memberNickName: null, annualIncome: null, deposit: null, - isAuthenticated: false, - isInitialized: true, - }); - }, - - initAuth: async () => { - if (get().isInitialized) { - return; - } - - if (initPromise) { - return initPromise; - } - - initPromise = (async () => { - try { - set({ isInitializing: true }); - - // 모든 쿠키 조회를 병렬로 처리 - const [ - token, - memberIdStr, - memberName, - memberNickName, - annualIncomeStr, - depositStr, - ] = await Promise.all([ - getCookie("token"), - getCookie("memberId"), - getCookie("memberName"), - getCookie("memberNickName"), - getCookie("annualIncome"), - getCookie("deposit"), - ]); - - const memberId = memberIdStr ? parseInt(memberIdStr, 10) : null; - const annualIncome = annualIncomeStr - ? parseInt(annualIncomeStr, 10) - : null; - const deposit = depositStr ? parseInt(depositStr, 10) : null; - - set({ - token, - memberId, - memberName, - memberNickName, - annualIncome, - deposit, - isAuthenticated: !!token, - isInitialized: true, - isInitializing: false, - }); - } catch (error) { - set({ - isInitialized: true, - isInitializing: false, - }); - throw error; - } finally { - initPromise = null; - } - })(); - - return initPromise; - }, - }; -}); + }, + isAuthenticated: false, + }), + + initializeAuth: (initialUserInfo, authenticated) => + set({ + userInfo: initialUserInfo, + isAuthenticated: authenticated, + isInitialized: true, + }), +})); + +export default useAuth; +export const getAuthState = () => useAuth.getState(); diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index f002d2a..dd232be 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -1,10 +1,9 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; -import { useAuth } from "@/hooks/use-auth"; -import { setCookie } from "@/utils/next-cookies"; +import { extendSessionAction, logoutAction } from "@/actions/auth"; // 서버 액션만 사용 +import useAuth from "@/hooks/use-auth"; const MAX_REFRESH_COUNT = 4; const SESSION_DURATION = 30 * 60 * 1000; // 30분 @@ -19,31 +18,24 @@ interface UseTokenRefreshReturn { } export default function useTokenRefresh(): UseTokenRefreshReturn { - const { - token, - memberId, - memberName, - memberNickName, - annualIncome, - deposit, - clearAuth, - } = useAuth(); - - const router = useRouter(); + const { isAuthenticated, clearAuth } = useAuth(); const refreshCountRef = useRef(0); const [isModalOpen, setIsModalOpen] = useState(false); const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); const sessionTimerRef = useRef(); const responseTimerRef = useRef(); + const [isPending, startTransition] = useTransition(); const handleLogout = useCallback(async () => { if (responseTimerRef.current) clearTimeout(responseTimerRef.current); if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); setIsModalOpen(false); - await clearAuth(); - router.push("/login"); - router.refresh(); - }, [clearAuth, router]); + + clearAuth(); // 클라이언트 상태 먼저 정리 + startTransition(async () => { + await logoutAction(); // 서버 액션으로 쿠키 정리 및 리다이렉트 + }); + }, [clearAuth]); const startResponseTimer = useCallback(() => { if (responseTimerRef.current) clearTimeout(responseTimerRef.current); @@ -75,40 +67,34 @@ export default function useTokenRefresh(): UseTokenRefreshReturn { return; } - // Refresh all auth information - if (token) { - await setCookie("token", token); - if (memberId) await setCookie("memberId", memberId.toString()); - if (memberName) await setCookie("memberName", memberName); - if (memberNickName) await setCookie("memberNickName", memberNickName); - if (annualIncome) - await setCookie("annualIncome", annualIncome.toString()); - if (deposit) await setCookie("deposit", deposit.toString()); - } - - setIsWaitingForResponse(false); - if (responseTimerRef.current) clearTimeout(responseTimerRef.current); - - refreshCountRef.current += 1; - setIsModalOpen(false); - startSessionTimer(); - }, [ - handleLogout, - startSessionTimer, - token, - memberId, - memberName, - memberNickName, - annualIncome, - deposit, - ]); + // ✅ API Route 없이 서버 액션 직접 사용 + startTransition(async () => { + try { + const result = await extendSessionAction(); // 서버 액션 직접 호출 + + if (result.success) { + setIsWaitingForResponse(false); + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + + refreshCountRef.current += 1; + setIsModalOpen(false); + startSessionTimer(); + } else { + handleLogout(); + } + } catch (error) { + console.error("세션 연장 실패:", error); //eslint-disable-line + handleLogout(); + } + }); + }, [handleLogout, startSessionTimer]); const handleRefreshDecline = useCallback(() => { handleLogout(); }, [handleLogout]); useEffect(() => { - if (token && !isWaitingForResponse) { + if (isAuthenticated && !isWaitingForResponse && !isPending) { refreshCountRef.current = 0; startSessionTimer(); } @@ -117,7 +103,7 @@ export default function useTokenRefresh(): UseTokenRefreshReturn { if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); if (responseTimerRef.current) clearTimeout(responseTimerRef.current); }; - }, [token, isWaitingForResponse, startSessionTimer]); + }, [isAuthenticated, isWaitingForResponse, isPending, startSessionTimer]); return { isModalOpen, diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts new file mode 100644 index 0000000..100b831 --- /dev/null +++ b/src/lib/auth-server.ts @@ -0,0 +1,57 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +// 서버에서 사용자 정보 가져오기 +export async function getServerAuth() { + const cookieStore = cookies(); + + const token = cookieStore.get("token")?.value; + const memberId = cookieStore.get("memberId")?.value; + const memberName = cookieStore.get("memberName")?.value; + const memberNickName = cookieStore.get("memberNickName")?.value; + const annualIncome = cookieStore.get("annualIncome")?.value; + const deposit = cookieStore.get("deposit")?.value; + + const isAuthenticated = Boolean(token); + + return { + isAuthenticated, + userInfo: { + memberId: memberId || null, + memberName: memberName || null, + memberNickName: memberNickName || null, + annualIncome: annualIncome || null, + deposit: deposit || null, + }, + }; +} + +export async function requireAuth() { + const { isAuthenticated } = await getServerAuth(); + + if (!isAuthenticated) { + redirect("/login"); + } +} + +// API 요청을 위한 서버 유틸 +export async function makeAuthenticatedRequest( + url: string, + options: RequestInit = {}, +) { + const cookieStore = cookies(); + const token = cookieStore.get("token")?.value; + + if (!token) { + throw new Error("인증 토큰이 없습니다."); + } + + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); +} diff --git a/src/provider/AuthInitializer.tsx b/src/provider/AuthInitializer.tsx index ee1e060..7f9fe4f 100644 --- a/src/provider/AuthInitializer.tsx +++ b/src/provider/AuthInitializer.tsx @@ -2,14 +2,28 @@ import { useEffect } from "react"; -import { useAuth } from "@/hooks/use-auth"; +import useAuth from "@/hooks/use-auth"; -export default function AuthInitializer() { - const { initAuth } = useAuth(); +interface AuthInitializerProps { + initialUserInfo: { + memberId: string | null; + memberName: string | null; + memberNickName: string | null; + annualIncome: string | null; + deposit: string | null; + }; + isAuthenticated: boolean; +} + +export default function AuthInitializer({ + initialUserInfo, + isAuthenticated, +}: AuthInitializerProps) { + const { initializeAuth } = useAuth(); useEffect(() => { - initAuth(); - }, []); //eslint-disable-line + initializeAuth(initialUserInfo, isAuthenticated); + }, [initialUserInfo, isAuthenticated, initializeAuth]); - return null; // 아무것도 렌더링하지 않음 + return null; } diff --git a/src/types/transaction/index.ts b/src/types/transaction/index.ts index 2a72d5d..a03a310 100644 --- a/src/types/transaction/index.ts +++ b/src/types/transaction/index.ts @@ -1,5 +1,4 @@ export interface CancelData { - token: string | null; orderId: string; } @@ -54,7 +53,6 @@ export interface SellMarketPriceResponse { } export interface TradeAtMarketPriceFormDataType { - token: string | null; data: { stockName: string; quantity: number; @@ -62,7 +60,6 @@ export interface TradeAtMarketPriceFormDataType { } export interface TradeAtLimitPriceFormDataType { - token: string | null; data: { stockName: string; limitPrice: number;