From c92e270716ec8ec2123940e90c1952266f976778 Mon Sep 17 00:00:00 2001 From: corpi Date: Thu, 26 Feb 2026 20:49:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=9C=A0=EC=A0=80=EC=9D=98=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(app)/dashboard/page.tsx | 53 ++++++++++++++++ .../auth/components/SocialLoginGroup.tsx | 4 -- src/features/auth/hooks/useSocialLogin.ts | 3 +- src/features/auth/types/index.ts | 10 +++- src/features/user/api/userApi.ts | 11 ++++ .../user/components/UserProfileCard.tsx | 60 +++++++++++++++++++ src/features/user/hooks/useUserProfile.ts | 32 ++++++++++ src/features/user/index.ts | 4 ++ src/features/user/types/index.ts | 6 ++ src/shared/lib/apiClient.ts | 18 +++++- src/store/useAuthStore.ts | 34 +++++++---- 11 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 src/app/(app)/dashboard/page.tsx create mode 100644 src/features/user/api/userApi.ts create mode 100644 src/features/user/components/UserProfileCard.tsx create mode 100644 src/features/user/hooks/useUserProfile.ts create mode 100644 src/features/user/index.ts create mode 100644 src/features/user/types/index.ts diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..ef88708 --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuthStore } from "@/store/useAuthStore"; +import { UserProfileCard, useUserProfile } from "@/features/user"; + +export default function DashboardPage() { + const router = useRouter(); + const { logout, isAuthenticated } = useAuthStore(); + + // Feature 모듈에서 로직과 데이터를 가져옴 + const { user, isLoading, error } = useUserProfile(); + + useEffect(() => { + if (!isAuthenticated) { + router.push("/login"); + } + }, [isAuthenticated, router]); + + const handleLogout = () => { + logout(); + router.push("/login"); + }; + + return ( +
+
+

VibeRoom Dashboard

+ +
+ +
+ {/* Feature 컴포넌트에 데이터와 상태만 전달 */} + + +
+
+

나의 스페이스 관리 (준비 중)

+
+
+

타이머 통계 (준비 중)

+
+
+
+
+ ); +} diff --git a/src/features/auth/components/SocialLoginGroup.tsx b/src/features/auth/components/SocialLoginGroup.tsx index a693688..b9ede44 100644 --- a/src/features/auth/components/SocialLoginGroup.tsx +++ b/src/features/auth/components/SocialLoginGroup.tsx @@ -92,10 +92,6 @@ const SocialLoginButtons = () => { ); }; -/** - * 로그인 화면 UI (View) - * GoogleOAuthProvider로 감싸서 내부에서 useGoogleLogin 훅을 사용할 수 있게 합니다. - */ export const SocialLoginGroup = () => { return ( diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts index 547806c..2af0f7e 100644 --- a/src/features/auth/hooks/useSocialLogin.ts +++ b/src/features/auth/hooks/useSocialLogin.ts @@ -21,7 +21,7 @@ export const useSocialLogin = () => { setIsLoading(true); setError(null); - console.log("token:" + socialToken); + console.log(`[${provider}] token:`, socialToken); try { // 1. 백엔드로 소셜 토큰 전송 (토큰 교환) const response = await authApi.loginWithSocial({ @@ -50,6 +50,7 @@ export const useSocialLogin = () => { */ const loginWithGoogle = useGoogleLogin({ onSuccess: (tokenResponse) => { + console.log(tokenResponse); handleSocialLogin("google", tokenResponse.access_token); }, onError: () => { diff --git a/src/features/auth/types/index.ts b/src/features/auth/types/index.ts index 1ec81ef..d2a1c32 100644 --- a/src/features/auth/types/index.ts +++ b/src/features/auth/types/index.ts @@ -1,9 +1,17 @@ export interface SocialLoginRequest { - provider: "GOOGLE" | "apple" | "FACEBOOK"; + provider: "google" | "apple" | "facebook"; token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token } export interface AuthResponse { accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용) refreshToken: string; // 토큰 갱신용 + user?: UserMeResponse; // 선택적으로 유저 정보를 포함할 수 있음 +} + +export interface UserMeResponse { + id: number; + name: string; + email: string; + grade: string; } diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts new file mode 100644 index 0000000..6389797 --- /dev/null +++ b/src/features/user/api/userApi.ts @@ -0,0 +1,11 @@ +import { apiClient } from "@/shared/lib/apiClient"; +import { UserProfile } from "../types"; + +export const userApi = { + /** + * 내 정보 조회 + */ + getMe: async (): Promise => { + return apiClient("api/v1/user/me"); + }, +}; diff --git a/src/features/user/components/UserProfileCard.tsx b/src/features/user/components/UserProfileCard.tsx new file mode 100644 index 0000000..f234f6d --- /dev/null +++ b/src/features/user/components/UserProfileCard.tsx @@ -0,0 +1,60 @@ +import { UserProfile } from "../types"; + +interface UserProfileCardProps { + user: UserProfile | null; + isLoading: boolean; + error: string | null; +} + +export const UserProfileCard = ({ + user, + isLoading, + error, +}: UserProfileCardProps) => { + if (isLoading) { + return
; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!user) return null; + + return ( +
+
+
+ {user.name[0]} +
+
+

{user.name}

+

{user.email}

+
+
+ +
+
+

+ User ID +

+

{user.id}

+
+
+

+ Grade +

+

+ + {user.grade} + +

+
+
+
+ ); +}; diff --git a/src/features/user/hooks/useUserProfile.ts b/src/features/user/hooks/useUserProfile.ts new file mode 100644 index 0000000..7c1fbf3 --- /dev/null +++ b/src/features/user/hooks/useUserProfile.ts @@ -0,0 +1,32 @@ +import { useAuthStore } from "@/store/useAuthStore"; +import { useEffect, useState } from "react"; +import { userApi } from "../api/userApi"; +import { UserProfile } from "../types"; + +export const useUserProfile = () => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { isAuthenticated } = useAuthStore(); + + useEffect(() => { + if (!isAuthenticated) return; + + const loadProfile = async () => { + try { + setIsLoading(true); + const data = await userApi.getMe(); + console.log("data: " + data); + setUser(data); + } catch (err) { + setError("프로필을 불러오는 데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadProfile(); + }, [isAuthenticated]); + + return { user, isLoading, error }; +}; diff --git a/src/features/user/index.ts b/src/features/user/index.ts new file mode 100644 index 0000000..fd0b66a --- /dev/null +++ b/src/features/user/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./hooks/useUserProfile"; +export * from "./components/UserProfileCard"; +export * from "./api/userApi"; diff --git a/src/features/user/types/index.ts b/src/features/user/types/index.ts new file mode 100644 index 0000000..8280527 --- /dev/null +++ b/src/features/user/types/index.ts @@ -0,0 +1,6 @@ +export interface UserProfile { + id: number; + name: string; + email: string; + grade: string; +} diff --git a/src/shared/lib/apiClient.ts b/src/shared/lib/apiClient.ts index 664398e..f11c68d 100644 --- a/src/shared/lib/apiClient.ts +++ b/src/shared/lib/apiClient.ts @@ -6,20 +6,31 @@ * - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production) */ +import Cookies from "js-cookie"; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080"; +const TOKEN_COOKIE_KEY = "vr_access_token"; + export const apiClient = async ( endpoint: string, options: RequestInit = {}, ): Promise => { - // 엔드포인트 앞의 슬래시(/) 중복 방지 const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`; - const defaultHeaders = { + // 쿠키에서 토큰 가져오기 + const token = Cookies.get(TOKEN_COOKIE_KEY); + + const defaultHeaders: Record = { "Content-Type": "application/json", }; + // 토큰이 있으면 Authorization 헤더 추가 + if (token) { + defaultHeaders["Authorization"] = `Bearer ${token}`; + } + const response = await fetch(url, { ...options, headers: { @@ -34,5 +45,6 @@ export const apiClient = async ( throw new Error(`API 요청 실패: ${response.status}`); } - return response.json() as Promise; + const result = await response.json(); + return result.data as T; }; diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index 8d1393a..7801c06 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -12,34 +12,43 @@ interface AuthState { logout: () => void; } -// 쿠키 키 상수 정의 (Access Token 보관용) +// 쿠키 키 상수 정의 const TOKEN_COOKIE_KEY = 'vr_access_token'; +const REFRESH_TOKEN_COOKIE_KEY = 'vr_refresh_token'; /** * VibeRoom 전역 인증(Auth) 상태 저장소 - * - * Zustand를 활용하여 메모리에 상태를 쥐고 있으면서, - * 쿠키(js-cookie)를 통해 브라우저 종료 후에도 세션이 유지되도록 동기화합니다. */ export const useAuthStore = create((set) => { - // 브라우저 환경에서만 쿠키 접근 const isClient = typeof window !== 'undefined'; const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null; return { accessToken: savedToken || null, - user: null, // 새로고침 시 이 토큰을 기반으로 유저 정보(me API)를 다시 가져와야 합니다(Hydrate). + user: null, isAuthenticated: !!savedToken, setAuth: (data: AuthResponse) => { - // 1. 브라우저 쿠키에 저장 (보안 옵션 설정) + const cookieOptions = { + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' as const + }; + + // 1. Access Token 저장 (7일) Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, { - expires: 7, // 7일 후 만료 - secure: process.env.NODE_ENV === 'production', // HTTPS 환경에서만 전송 - sameSite: 'strict' // CSRF 공격 방어 + ...cookieOptions, + expires: 7 }); - // 2. Zustand 메모리 상태 업데이트 (연결된 UI 전체 리렌더링) + // 2. Refresh Token 저장 (30일) + if (data.refreshToken) { + Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, { + ...cookieOptions, + expires: 30 + }); + } + + // 3. 상태 업데이트 set({ accessToken: data.accessToken, user: data.user, @@ -48,10 +57,9 @@ export const useAuthStore = create((set) => { }, logout: () => { - // 1. 쿠키에서 토큰 삭제 Cookies.remove(TOKEN_COOKIE_KEY); + Cookies.remove(REFRESH_TOKEN_COOKIE_KEY); - // 2. Zustand 상태 초기화 set({ accessToken: null, user: null,