diff --git a/.env.example b/.env.example index a694bed..c7255dd 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ VITE_API_URL=https://api.example.com -VITE_KAKAO_JS_KEY= \ No newline at end of file +VITE_KAKAO_JS_KEY= + +VITE_GOOGLE_CLIENT_ID= + diff --git a/index.html b/index.html index 65da26e..46715f6 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@ content="자리 자체를 예약하는 새로운 식문화 플랫폼 웹 애플리케이션 서비스" /> +
diff --git a/package.json b/package.json index c421afd..25a70d2 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.16", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "immer": "^11.1.3", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-day-picker": "^9.13.0", @@ -30,7 +32,8 @@ "react-hook-form": "^7.69.0", "react-router-dom": "^7.11.0", "tailwind-merge": "^3.4.0", - "zod": "^4.3.4" + "zod": "^4.3.4", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96f27f6..7837c20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.7)(react@19.2.3) + '@react-oauth/google': + specifier: ^0.13.4 + version: 0.13.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.90.16 version: 5.90.16(react@19.2.3) @@ -44,6 +47,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + immer: + specifier: ^11.1.3 + version: 11.1.3 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -68,6 +74,9 @@ importers: zod: specifier: ^4.3.4 version: 4.3.4 + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.7)(immer@11.1.3)(react@19.2.3) devDependencies: '@eslint/js': specifier: ^9.39.1 @@ -766,6 +775,12 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-oauth/google@0.13.4': + resolution: {integrity: sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -1445,6 +1460,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.3: + resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1936,6 +1954,24 @@ packages: zod@4.3.4: resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + 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: '@babel/code-frame@7.27.1': @@ -2495,6 +2531,11 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-oauth/google@0.13.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/rollup-android-arm-eabi@4.54.0': @@ -3158,6 +3199,8 @@ snapshots: ignore@7.0.5: {} + immer@11.1.3: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3547,3 +3590,9 @@ snapshots: zod: 4.3.4 zod@4.3.4: {} + + zustand@5.0.11(@types/react@19.2.7)(immer@11.1.3)(react@19.2.3): + optionalDependencies: + '@types/react': 19.2.7 + immer: 11.1.3 + react: 19.2.3 diff --git a/src/App.tsx b/src/App.tsx index 5fd941b..0141ad9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,11 +19,11 @@ import SubscriptionPage from "./pages/myPage/subscriptionPage"; import ReservationPage from "./pages/myPage/reservationPage"; import StorePage from "./pages/myPage/storePage"; import OwnerPage from "./pages/ownerPage"; +import { useEffect } from "react"; const routes: RouteObject[] = [ { - //TODO: 로그아웃처리 필요 - element: {}} />, + element: , errorElement: , children: [ { path: "/search", element: }, @@ -72,6 +72,13 @@ const routes: RouteObject[] = [ const router = createBrowserRouter(routes); export default function App() { + // 카카오 sdk 초기화 + useEffect(() => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(import.meta.env.VITE_KAKAO_JS_KEY); + } + }, []); + return ( <> diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..b32155c --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,69 @@ +import type { ApiResponse } from "@/types/api"; +import type { + RequestLoginDto, + RequestSignupDto, + RequestSocialLoginDto, + ResponseLoginDto, + ResponseLogoutDto, + ResponseRefreshDto, + ResponseSignupDto, +} from "@/types/auth"; +import { api } from "./axios"; + +export const postSignup = async ( + body: RequestSignupDto, +): Promise> => { + const { data } = await api.post>( + "/auth/signup", + body, + ); + return data; +}; + +export const postLogin = async ( + body: RequestLoginDto, +): Promise> => { + const { data } = await api.post>( + "/auth/login", + body, + ); + return data; +}; + +export const postSocialLogin = async ( + provider: "kakao" | "google", + body: RequestSocialLoginDto, +): Promise> => { + const { data } = await api.post>( + `/auth/social/${provider}`, + body, + ); + return data; +}; + +export const postLogout = async () => { + const { data } = + await api.post>("/auth/logout"); + return data; +}; + +export const clearAuth = () => { + localStorage.removeItem("accessToken"); + window.location.href = "/"; +}; + +export const logout = async () => { + try { + await postLogout(); + } catch (e) { + console.error(e); + } finally { + clearAuth(); + } +}; + +export const postRefresh = async () => { + const { data } = + await api.post>("/auth/refresh"); + return data; +}; diff --git a/src/api/axios.ts b/src/api/axios.ts index 210166a..95dca09 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,6 +1,7 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; import { isApiResponse, normalizeApiError } from "./api.error"; import type { ApiError } from "@/types/api"; +import { clearAuth, postRefresh } from "./auth"; export const api = axios.create({ baseURL: import.meta.env.VITE_API_URL as string | undefined, @@ -35,7 +36,10 @@ api.interceptors.response.use( return res; }, - (err: AxiosError) => { + async (err: AxiosError) => { + const originalRequest = err.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; const apiError: ApiError = normalizeApiError(err); if (import.meta.env.DEV) { @@ -48,8 +52,40 @@ api.interceptors.response.use( }); } - if (apiError.status === 401) { - // TODO: refresh or logout 정책 확정 후 구현 + if (apiError.status === 401 && originalRequest) { + // 이미 재시도한 요청이거나, 재발급 요청 자체가 실패인 경우 -> 로그아웃 + if ( + originalRequest._retry || + originalRequest.url?.includes("/auth/refresh") + ) { + clearAuth(); + return Promise.reject(apiError); + } + + originalRequest._retry = true; // 재시도 플래그 설정 + + try { + //토큰 재발급 요청 + const response = await postRefresh(); + + if (response.success) { + const newAccessToken = response.data.accessToken; + + // 새 토큰 저장 + localStorage.setItem("accessToken", newAccessToken); + + // 실패했던 요청의 헤더를 새 토큰으로 교체 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // 재시도 + return api(originalRequest); + } + } catch (refreshError) { + // 재발급 실패 시 로그아웃 처리 + console.error("토큰 재발급 실패:", refreshError); + clearAuth(); + return Promise.reject(refreshError); + } } return Promise.reject(apiError); diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index fc01d7d..0fc9540 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -13,6 +13,9 @@ import { Input } from "@/components/ui/input"; import { useForm } from "react-hook-form"; import { type LoginFormValues, loginSchema } from "./login.schema"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useGoogleLogin } from "@react-oauth/google"; +import { useEmailLogin, useSocialLogin } from "@/hooks/queries/useAuth"; +import type { ApiError } from "@/types/api"; interface LoginDialogProps { isOpen: boolean; @@ -20,18 +23,25 @@ interface LoginDialogProps { onSwitchToSignup: () => void; } +type KakaoAuthSuccessResponse = { + access_token: string; +}; + export function LoginDialog({ isOpen, onClose, onSwitchToSignup, }: LoginDialogProps) { + const emailLoginMutation = useEmailLogin(); + const socialLoginMutation = useSocialLogin(); + const [showEmailLogin, setShowEmailLogin] = useState(false); const { register, handleSubmit, reset, - formState: { errors, isSubmitting }, + formState: { errors }, } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { @@ -48,23 +58,51 @@ export function LoginDialog({ } }, [isOpen, reset]); - const handleSocialLogin = (provider: string) => { - console.log(`Login with ${provider}`); - alert("로그인 완료되었습니다."); - onClose(); + const handleSocialLogin = (provider: "google" | "kakao", token: string) => { + socialLoginMutation.mutate( + { provider, token }, + { + onSuccess: () => onClose(), + onError: (error) => + alert( + (error as ApiError).message || "로그인 중 문제가 발생했습니다.", + ), + }, + ); }; - const handleEmailLogin = async (data: LoginFormValues) => { - try { - console.log("Email login:", data); - //await API - - alert("로그인 완료되었습니다."); + const handleGoogleLogin = useGoogleLogin({ + onSuccess: (response) => { + handleSocialLogin("google", response.access_token); + }, + onError: (errorResponse) => { + console.error("구글 로그인 실패:", errorResponse); + }, + }); - onClose(); - } catch (e) { - console.error("Login error:", e); + const handleKakaoLogin = () => { + if (!window.Kakao) { + return alert("카카오 스크립트가 아직 로드되지 않았습니다."); + } + if (!window.Kakao.isInitialized()) { + window.Kakao.init(import.meta.env.VITE_KAKAO_JS_KEY); } + window.Kakao.Auth.login({ + success: (authObj: KakaoAuthSuccessResponse) => { + handleSocialLogin("kakao", authObj.access_token); + }, + fail: (error: unknown) => { + console.error("카카오 로그인 실패:", error); + }, + }); + }; + + const handleEmailLogin = (data: LoginFormValues) => { + emailLoginMutation.mutate(data, { + onSuccess: () => onClose(), + onError: (error) => + alert((error as ApiError).message || "로그인 중 문제가 발생했습니다."), + }); }; return ( @@ -89,7 +127,7 @@ export function LoginDialog({ type="button" variant="outline" className="w-full h-12 text-base cursor-pointer" - onClick={() => handleSocialLogin("google")} + onClick={() => handleGoogleLogin()} > handleSocialLogin("kakao")} + onClick={handleKakaoLogin} > - {isSubmitting ? "로그인 중..." : "로그인"} + {emailLoginMutation.isPending ? "로그인 중..." : "로그인"} + + + )} + /> + {/* 소셜 회원가입 */}
+ +

+ 소셜 계정으로 가입 시 이용약관,{" "} + 개인정보처리방침 에 동의하는 + 것으로 간주합니다. +

@@ -158,12 +240,22 @@ export function SignupDialog({
- ( + { + const formatted = phoneNumber(e.target.value); + field.onChange(formatted); + }} + /> + )} /> {errors.phone && (

{errors.phone.message}

@@ -271,10 +363,10 @@ export function SignupDialog({
diff --git a/src/components/auth/signup.schema.ts b/src/components/auth/signup.schema.ts index 1d49110..c30dd46 100644 --- a/src/components/auth/signup.schema.ts +++ b/src/components/auth/signup.schema.ts @@ -2,6 +2,8 @@ import z from "zod"; export const signupSchema = z .object({ + role: z.enum(["customer", "owner"]).default("customer"), + name: z.string().min(1, "이름을 입력하세요."), email: z @@ -12,7 +14,7 @@ export const signupSchema = z phone: z .string() .min(1, { message: "휴대폰 번호를 입력해주세요." }) - .regex(/^010\d{8}$/, { + .regex(/^\d{2,3}-\d{3,4}-\d{4}$/, { message: "올바른 휴대폰 번호 형식이 아닙니다.", }), diff --git a/src/components/main/Header.tsx b/src/components/main/Header.tsx index 8e2cdbe..c9eb67d 100644 --- a/src/components/main/Header.tsx +++ b/src/components/main/Header.tsx @@ -5,6 +5,7 @@ import { Button } from "../ui/button"; import { Link, useNavigate } from "react-router-dom"; import { Menu, X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useAuthActions, useIsAuthenticated } from "@/stores/useAuthStore"; type NavItem = { label: string; @@ -18,6 +19,10 @@ export default function Header() { const [loginOpen, setLoginOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); + const isAuthenticated = useIsAuthenticated(); + + const { logout } = useAuthActions(); + const nav = useNavigate(); const navItems: NavItem[] = useMemo( @@ -57,6 +62,16 @@ export default function Header() { }; }, [mobileOpen]); + const handleLogout = () => { + if (!confirm("로그아웃 하시겠습니까?")) return; + + logout(); + setMobileOpen(false); + + alert("로그아웃 되었습니다."); + nav("/", { replace: true }); + }; + const go = (to: string) => { setMobileOpen(false); nav(to); @@ -90,24 +105,13 @@ export default function Header() { >
- setMobileOpen(false)} - aria-label="홈으로 이동" +
- -
- - -
+ {isAuthenticated ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
diff --git a/src/hooks/queries/useAuth.ts b/src/hooks/queries/useAuth.ts new file mode 100644 index 0000000..f9ee9f5 --- /dev/null +++ b/src/hooks/queries/useAuth.ts @@ -0,0 +1,61 @@ +import { postLogin, postSignup, postSocialLogin } from "@/api/auth"; +import type { LoginFormValues } from "@/components/auth/login.schema"; +import type { SignupFormValues } from "@/components/auth/signup.schema"; +import { useAuthActions } from "@/stores/useAuthStore"; +import type { ApiError } from "@/types/api"; +import { useMutation } from "@tanstack/react-query"; + +// 이메일 회원가입 훅 +export const useEmailSignup = () => { + return useMutation({ + mutationFn: (data: SignupFormValues) => { + const requestBody = { + role: data.role!, + name: data.name, + email: data.email, + phone: data.phone, + password: data.password, + }; + return postSignup(requestBody); + }, + onError: (error: ApiError) => { + console.error("회원가입 실패:", error); + }, + }); +}; + +// 이메일 로그인 훅 +export const useEmailLogin = () => { + const { login } = useAuthActions(); + + return useMutation({ + mutationFn: (data: LoginFormValues) => postLogin(data), + onSuccess: (response) => { + login(response.data.accessToken); + }, + onError: (error: ApiError) => { + console.error("이메일 로그인 실패:", error); + }, + }); +}; + +// 소셜 로그인 훅 +export const useSocialLogin = () => { + const { login } = useAuthActions(); + + return useMutation({ + mutationFn: ({ + provider, + token, + }: { + provider: "google" | "kakao"; + token: string; + }) => postSocialLogin(provider, { accessToken: token }), + onSuccess: (response) => { + login(response.data.accessToken); + }, + onError: (error: ApiError) => { + console.error("소셜 로그인 실패:", error); + }, + }); +}; diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 760df30..9ac6223 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,10 +1,19 @@ -import { Link, Outlet } from "react-router-dom"; +import { useAuthActions, useIsAuthenticated } from "@/stores/useAuthStore"; +import { Link, Outlet, useNavigate } from "react-router-dom"; -type PublicLayoutProps = { - onLogOut?: () => void; -}; +export default function PublicLayout() { + const nav = useNavigate(); + const { logout } = useAuthActions(); + const isAuthenticated = useIsAuthenticated(); + + const handleLogout = () => { + if (!confirm("로그아웃 하시겠습니까?")) return; + + logout(); + alert("로그아웃 되었습니다."); + nav("/", { replace: true }); + }; -export default function PublicLayout({ onLogOut }: PublicLayoutProps) { return (
@@ -20,13 +29,15 @@ export default function PublicLayout({ onLogOut }: PublicLayoutProps) {

원하는 자리를 선택하는 스마트 식당 예약

- + {isAuthenticated && ( + + )}
diff --git a/src/main.tsx b/src/main.tsx index e05250d..4ae0c83 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,11 +4,14 @@ import "./index.css"; import App from "./App.tsx"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@/query/queryClient.ts"; +import { GoogleOAuthProvider } from "@react-oauth/google"; createRoot(document.getElementById("root")!).render( - - - - - , + + + + + + + , ); diff --git a/src/stores/useAuthStore.ts b/src/stores/useAuthStore.ts new file mode 100644 index 0000000..399e1ae --- /dev/null +++ b/src/stores/useAuthStore.ts @@ -0,0 +1,50 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +interface AuthState { + accessToken: string | null; + isAuthenticated: boolean; + + actions: { + login: (token: string) => void; + logout: () => void; + }; +} + +export const useAuthStore = create()( + // persist: 내용물이 바뀌면 무조건 저장소에 기록 + persist( + immer((set) => ({ + accessToken: null, + isAuthenticated: false, + + actions: { + login: (token) => + set((state) => { + state.accessToken = token; + state.isAuthenticated = true; + }), + logout: () => + set((state) => { + state.accessToken = null; + state.isAuthenticated = false; + }), + }, + })), + { + name: "auth-storage", + storage: createJSONStorage(() => localStorage), + // persist가 저장할 때 state만 저장하도록 설정 + partialize: (state) => ({ + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, + }), + }, + ), +); + +export const useAuthActions = () => useAuthStore((state) => state.actions); +export const useAuthToken = () => useAuthStore((state) => state.accessToken); +export const useIsAuthenticated = () => + useAuthStore((state) => state.isAuthenticated); diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..4ad1fd2 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,39 @@ +export type RequestSignupDto = { + email: string; + password: string; + name: string; + phone: string; + role: "customer" | "owner"; +}; + +export type ResponseSignupDto = { + userId: string; + email: string; + name: string; + role: "customer" | "owner"; +}; + +export type RequestLoginDto = { + email: string; + password: string; +}; + +export type ResponseLoginDto = { + accessToken: string; + user: { + userId: string; + email: string; + name: string; + role: "customer" | "owner"; + }; +}; + +export type RequestSocialLoginDto = { + accessToken: string; +}; + +export type ResponseLogoutDto = null; + +export type ResponseRefreshDto = { + accessToken: string; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 565c35d..b430ab5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -10,3 +10,8 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Kakao: any; +}