diff --git a/package.json b/package.json index 6711564..c266ffe 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,17 @@ "lint": "eslint" }, "dependencies": { + "js-cookie": "^3.0.5", "next": "16.0.1", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "react-hot-toast": "^2.6.0", + "zustand": "^5.0.10" }, "devDependencies": { "@tailwindcss/cli": "^4.1.17", "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29b0c64..ca8ee0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 next: specifier: 16.0.1 version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -17,6 +20,12 @@ importers: react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + zustand: + specifier: ^5.0.10 + version: 5.0.10(@types/react@19.2.2)(react@19.2.0) devDependencies: '@tailwindcss/cli': specifier: ^4.1.17 @@ -24,6 +33,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.17 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^20 version: 20.19.24 @@ -603,6 +615,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1261,6 +1276,11 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1441,6 +1461,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1757,6 +1781,13 @@ packages: peerDependencies: react: ^19.2.0 + react-hot-toast@2.6.0: + resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2041,6 +2072,24 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@5.0.10: + resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2537,6 +2586,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/js-cookie@3.0.6': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -3381,6 +3432,10 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + goober@2.1.18(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -3559,6 +3614,8 @@ snapshots: jiti@2.6.1: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -3841,6 +3898,13 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-hot-toast@2.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + csstype: 3.1.3 + goober: 2.1.18(csstype@3.1.3) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-is@16.13.1: {} react@19.2.0: {} @@ -4251,3 +4315,8 @@ snapshots: zod: 4.1.12 zod@4.1.12: {} + + zustand@5.0.10(@types/react@19.2.2)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.2.0 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d8b5396..219383c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,22 @@ -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; -import './globals.css'; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { Toaster } from "react-hot-toast"; +import { AuthProvider } from "@/components/auth/AuthProvider"; +import "@/app/globals.css"; + const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], + variable: "--font-geist-sans", + subsets: ["latin"], }); const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], + variable: "--font-geist-mono", + subsets: ["latin"], }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: "Create Next App", + description: "Generated by create next app", }; export default function RootLayout({ @@ -26,7 +29,10 @@ export default function RootLayout({ - {children} + + {children} + + ); diff --git a/src/components/auth/AuthProvider.tsx b/src/components/auth/AuthProvider.tsx new file mode 100644 index 0000000..3553202 --- /dev/null +++ b/src/components/auth/AuthProvider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import Cookies from "js-cookie"; +import { useAuthStore } from "@/store/useAuthStore"; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const login = useAuthStore((state) => state.login); + + useEffect(() => { + // Hydration Logic: Check for token on app load + const token = Cookies.get("accessToken"); + if (token) { + // If token exists, restore session state. + // Note: Since we don't store user details in the cookie, + // we pass a placeholder or partial data to set isLoggedIn to true. + // TODO: Call /api/auth/me here to fetch full user profile and validate token. + // If fetch fails (401), the interceptor in apiClient will handle logout. + login({ email: "" }); + } + }, [login]); + + return <>{children}; +} diff --git a/src/components/base-ui/Login/LoginModal.tsx b/src/components/base-ui/Login/LoginModal.tsx index 7921ac1..c7dbe99 100644 --- a/src/components/base-ui/Login/LoginModal.tsx +++ b/src/components/base-ui/Login/LoginModal.tsx @@ -2,9 +2,10 @@ import Image from "next/image"; import React, { useEffect } from "react"; -import LoginLogo from "./LoginLogo"; -import styles from "./LoginModal.module.css"; -import useLoginForm from "./useLoginForm"; +import LoginLogo from "@/components/base-ui/Login/LoginLogo"; +import styles from "@/components/base-ui/Login/LoginModal.module.css"; +import useLoginForm from "@/components/base-ui/Login/useLoginForm"; +import { SOCIAL_LOGINS } from "@/constants/auth"; type Props = { onClose: () => void; @@ -12,12 +13,6 @@ type Props = { onSignUp?: () => void; }; -const SOCIAL_LOGINS = [ - { name: "google", icon: "/googleLogo.svg", alt: "구글 로그인" }, - { name: "naver", icon: "/naverLogo.svg", alt: "네이버 로그인" }, - { name: "kakao", icon: "/kakaoLogo.svg", alt: "카카오 로그인" }, -]; - export default function LoginModal({ onClose, onFindAccount, @@ -31,7 +26,7 @@ export default function LoginModal({ handleLogin, handleSocialLogin, handleKeyDown, - } = useLoginForm(); + } = useLoginForm(onClose); // Body scroll lock useEffect(() => { @@ -61,19 +56,19 @@ export default function LoginModal({ {/* 인풋 필드 */}
- {errors?.id && ( - {errors.id} + {errors?.email && ( + {errors.email} )} void) { + const router = useRouter(); + const login = useAuthStore((state) => state.login); + const [form, setForm] = useState({ email: "", password: "" }); -export default function useLoginForm() { - const [form, setForm] = useState({ id: "", password: "" }); const [errors, setErrors] = useState>({}); const [isLoading, setIsLoading] = useState(false); @@ -23,7 +28,7 @@ export default function useLoginForm() { const handleLogin = async () => { // 1. Validation (Inline Error) const newErrors: Partial = {}; - if (!form.id) newErrors.id = "아이디를 입력해주세요."; + if (!form.email) newErrors.email = "이메일을 입력해주세요."; if (!form.password) newErrors.password = "비밀번호를 입력해주세요."; if (Object.keys(newErrors).length > 0) { @@ -35,20 +40,36 @@ export default function useLoginForm() { // 2. Submission Logic setIsLoading(true); + setErrors({}); + try { - console.log("로그인 시도:", form); - // TODO: API Call here - // await api.login(form); + // Service Layer 호출 + const data = await authService.login(form); + + console.log("로그인 성공:", data); + // 1. Token Storage (Secure Cookie) + if (data.isSuccess && data.result?.accessToken) { + Cookies.set("accessToken", data.result.accessToken, { + secure: true, + sameSite: "strict", + }); + } - // Simulate delay - await new Promise((resolve) => setTimeout(resolve, 1000)); + // 2. Global State Update + login({ email: form.email }); - // Success handling (e.g., close modal, redirect) - // onClose(); + // 3. Navigation & UI Feedback + toast.success("로그인에 성공했습니다!"); + if (onSuccess) onSuccess(); + router.push("/"); } catch (error) { - console.error("로그인 실패:", error); - // 로그인 실패 시 전체 에러 처리 등을 추가할 수 있습니다. - alert("로그인에 실패했습니다."); + if (error instanceof ApiError) { + // 비즈니스 에러 (예: 비밀번호 불일치)는 인라인 에러로 처리 + setErrors({ email: error.message }); + } else { + console.error("로그인 실패:", error); + toast.error("로그인 요청 중 오류가 발생했습니다."); + } } finally { setIsLoading(false); } diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..e8bbc28 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,5 @@ +export const SOCIAL_LOGINS = [ + { name: "google", icon: "/googleLogo.svg", alt: "구글 로그인" }, + { name: "naver", icon: "/naverLogo.svg", alt: "네이버 로그인" }, + { name: "kakao", icon: "/kakaoLogo.svg", alt: "카카오 로그인" }, +]; diff --git a/src/lib/api/ApiError.ts b/src/lib/api/ApiError.ts new file mode 100644 index 0000000..c2397d5 --- /dev/null +++ b/src/lib/api/ApiError.ts @@ -0,0 +1,11 @@ +export class ApiError extends Error { + code: string; + response?: any; + + constructor(message: string, code: string = "UNKNOWN_ERROR", response?: any) { + super(message); + this.name = "ApiError"; + this.code = code; + this.response = response; + } +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..065b32a --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,118 @@ +import { API_BASE_URL } from "@/lib/api/endpoints"; +import Cookies from "js-cookie"; +import { useAuthStore } from "@/store/useAuthStore"; +import toast from "react-hot-toast"; +import { getErrorMessage } from "./errorMapper"; + +interface RequestOptions extends RequestInit { + headers?: Record; + params?: Record; + timeout?: number; // Timeout in ms (default: 10000) +} + +async function request( + url: string, + options: RequestOptions = {} +): Promise { + const { params, timeout = 10000, ...fetchOptions } = options; + + const defaultHeaders: Record = { + "Content-Type": "application/json", + }; + + // [Security] Token Auto-Injection + const token = Cookies.get("accessToken"); + if (token) { + defaultHeaders["Authorization"] = `Bearer ${token}`; + } + + // [Utility] Query String Builder + let requestUrl = url; + if (params) { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + requestUrl += `?${searchParams.toString()}`; + } + + // [Resilience] Timeout Controller + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + const config: RequestInit = { + ...fetchOptions, + headers: { + ...defaultHeaders, + ...options.headers, + }, + signal: controller.signal, + }; + + try { + const response = await fetch(requestUrl, config); + clearTimeout(id); + + // [Resilience] Interceptor: 401 Unauthorized Handling + if (response.status === 401) { + console.warn("Session expired. Logging out..."); + useAuthStore.getState().logout(); + toast.error("세션이 만료되었습니다. 다시 로그인해주세요."); + // 여기서 throw를 해서 흐름을 끊어주는 것이 안전할 수 있음 + } + // [Resilience] Safe JSON Parsing + let data: any; + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + data = await response.json(); + } else { + // JSON이 아닌 경우 (예: 500 HTML 에러 페이지 등) + data = { + isSuccess: false, + message: "서버 응답 형식이 올바르지 않습니다.", + }; + } + + // [Standardization] Response Normalization + // HTTP Status가 200~299가 아니거나, 백엔드 로직상 실패(isSuccess: false)인 경우 + if (!response.ok || (data && data.isSuccess === false)) { + const errorCode = data?.code || `HTTP${response.status}`; + const errorMessage = + data?.message || + getErrorMessage(errorCode) || + "요청 처리 중 오류가 발생했습니다."; + + // 에러 객체를 확장하여 throw + const error: any = new Error(errorMessage); + error.code = errorCode; + error.response = data; + // 비즈니스 로직에서 catch 할 수 있도록 그대로 반환하거나 throw + // 현재 구조상 useLoginForm 등에서 data.isSuccess를 체크하므로 data를 반환 + return data as T; + } + + return data; + } catch (error) { + clearTimeout(id); + console.error("API Request Error:", error); + // Timeout Error Handling + if (error instanceof DOMException && error.name === "AbortError") { + toast.error("요청 시간이 초과되었습니다."); + throw new Error("Request timeout"); + } + throw error; + } +} + +export const apiClient = { + get: (url: string, options?: RequestOptions) => + request(url, { ...options, method: "GET" }), + post: (url: string, body: any, options?: RequestOptions) => + request(url, { ...options, method: "POST", body: JSON.stringify(body) }), + put: (url: string, body: any, options?: RequestOptions) => + request(url, { ...options, method: "PUT", body: JSON.stringify(body) }), + delete: (url: string, options?: RequestOptions) => + request(url, { ...options, method: "DELETE" }), +}; diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts new file mode 100644 index 0000000..51418e3 --- /dev/null +++ b/src/lib/api/endpoints.ts @@ -0,0 +1,3 @@ +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "https://api.checkmo.co.kr/api"; +export const LOGIN_URL = `${API_BASE_URL}/auth/login`; diff --git a/src/lib/api/errorMapper.ts b/src/lib/api/errorMapper.ts new file mode 100644 index 0000000..5f24bc6 --- /dev/null +++ b/src/lib/api/errorMapper.ts @@ -0,0 +1,19 @@ +export const ERROR_MESSAGES: Record = { + // Common Errors + COMMON400: "잘못된 요청입니다.", + COMMON401: "인증이 필요합니다.", + COMMON403: "접근 권한이 없습니다.", + COMMON404: "요청한 리소스를 찾을 수 없습니다.", + COMMON500: "서버 내부 오류가 발생했습니다.", + + // Auth Errors + USER_NOT_FOUND: "존재하지 않는 사용자입니다.", + WRONG_PASSWORD: "비밀번호가 일치하지 않습니다.", + DUPLICATE_EMAIL: "이미 사용 중인 이메일입니다.", + INVALID_TOKEN: "유효하지 않은 토큰입니다.", + EXPIRED_TOKEN: "만료된 토큰입니다.", +}; + +export function getErrorMessage(code: string): string { + return ERROR_MESSAGES[code] || "알 수 없는 오류가 발생했습니다."; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..84476cf --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,47 @@ +// Next.js 미들웨어를 사용하여 인증 상태에 따른 라우트 접근 제어를 구현합니다. +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// 보호된 경로 (로그인 필요) +const protectedRoutes = ["/mypage"]; +// 인증된 사용자가 접근할 수 없는 경로 (로그인 불필요) +const authRoutes = ["/login", "/signup"]; + +export function middleware(request: NextRequest) { + const token = request.cookies.get("accessToken")?.value; + const { pathname } = request.nextUrl; + + // 1. 비로그인 사용자가 보호된 경로에 접근 시 + if (protectedRoutes.some((route) => pathname.startsWith(route))) { + if (!token) { + const url = request.nextUrl.clone(); + url.pathname = "/"; // 또는 로그인 모달을 띄우기 위한 쿼리 파라미터 추가 + return NextResponse.redirect(url); + } + } + + // 2. 로그인 사용자가 인증 경로(로그인 등)에 접근 시 + if (authRoutes.some((route) => pathname.startsWith(route))) { + if (token) { + const url = request.nextUrl.clone(); + url.pathname = "/"; + return NextResponse.redirect(url); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder files (svg, png, etc.) + */ + "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..0e437b4 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,9 @@ +import { apiClient } from "@/lib/api/client"; +import { LOGIN_URL } from "@/lib/api/endpoints"; +import { LoginForm, LoginResponse } from "@/types/auth"; + +export const authService = { + login: async (data: LoginForm): Promise => { + return await apiClient.post(LOGIN_URL, data); + }, +}; diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts new file mode 100644 index 0000000..8b75e71 --- /dev/null +++ b/src/store/useAuthStore.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; +import Cookies from "js-cookie"; +import { User } from "@/types/auth"; + +interface AuthState { + user: User | null; + isLoggedIn: boolean; + login: (user: User) => void; + logout: () => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + isLoggedIn: false, + login: (user) => set({ user, isLoggedIn: true }), + logout: () => { + Cookies.remove("accessToken"); + set({ user: null, isLoggedIn: false }); + }, +})); diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..47ff70e --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,17 @@ +export interface User { + email: string; +} + +export interface LoginForm { + email: string; + password: string; +} + +export interface LoginResponse { + isSuccess: boolean; + code: string; + message: string; + result?: { + accessToken: string; + }; +}