feat: 대시보드에 유저의 정보를 불러오기

This commit is contained in:
2026-02-26 20:49:12 +09:00
parent d56303cec4
commit c92e270716
11 changed files with 213 additions and 22 deletions

View File

@@ -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 (
<div className="max-w-4xl mx-auto p-6">
<header className="flex justify-between items-center mb-12">
<h1 className="text-2xl font-bold text-brand-dark">VibeRoom Dashboard</h1>
<button
onClick={handleLogout}
className="text-sm text-slate-500 hover:text-red-500 transition-colors"
>
</button>
</header>
<main className="grid gap-6">
{/* Feature 컴포넌트에 데이터와 상태만 전달 */}
<UserProfileCard user={user} isLoading={isLoading} error={error} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 p-6 rounded-xl border border-dashed border-slate-200 flex flex-col items-center justify-center h-40 text-slate-400">
<p> ( )</p>
</div>
<div className="bg-slate-50 p-6 rounded-xl border border-dashed border-slate-200 flex flex-col items-center justify-center h-40 text-slate-400">
<p> ( )</p>
</div>
</div>
</main>
</div>
);
}

View File

@@ -92,10 +92,6 @@ const SocialLoginButtons = () => {
);
};
/**
* 로그인 화면 UI (View)
* GoogleOAuthProvider로 감싸서 내부에서 useGoogleLogin 훅을 사용할 수 있게 합니다.
*/
export const SocialLoginGroup = () => {
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>

View File

@@ -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: () => {

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
import { apiClient } from "@/shared/lib/apiClient";
import { UserProfile } from "../types";
export const userApi = {
/**
* 내 정보 조회
*/
getMe: async (): Promise<UserProfile> => {
return apiClient<UserProfile>("api/v1/user/me");
},
};

View File

@@ -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 <div className="animate-pulse h-48 bg-slate-100 rounded-2xl" />;
}
if (error) {
return (
<div className="p-8 text-center text-red-500 bg-red-50 rounded-2xl border border-red-100">
{error}
</div>
);
}
if (!user) return null;
return (
<section className="bg-white p-8 rounded-2xl shadow-sm border border-slate-100">
<div className="flex items-center gap-6 mb-8">
<div className="w-20 h-20 bg-brand-dark/10 rounded-full flex items-center justify-center text-3xl font-bold text-brand-dark">
{user.name[0]}
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">{user.name}</h2>
<p className="text-slate-500">{user.email}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-slate-50 pt-8">
<div className="space-y-1">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
User ID
</p>
<p className="text-lg font-semibold text-slate-700">{user.id}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Grade
</p>
<p className="text-lg font-semibold text-brand-dark">
<span className="bg-brand-dark/5 px-3 py-1 rounded-full text-sm">
{user.grade}
</span>
</p>
</div>
</div>
</section>
);
};

View File

@@ -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<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
};

View File

@@ -0,0 +1,4 @@
export * from "./types";
export * from "./hooks/useUserProfile";
export * from "./components/UserProfileCard";
export * from "./api/userApi";

View File

@@ -0,0 +1,6 @@
export interface UserProfile {
id: number;
name: string;
email: string;
grade: string;
}

View File

@@ -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 <T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> => {
// 엔드포인트 앞의 슬래시(/) 중복 방지
const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`;
const defaultHeaders = {
// 쿠키에서 토큰 가져오기
const token = Cookies.get(TOKEN_COOKIE_KEY);
const defaultHeaders: Record<string, string> = {
"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 <T>(
throw new Error(`API 요청 실패: ${response.status}`);
}
return response.json() as Promise<T>;
const result = await response.json();
return result.data as T;
};

View File

@@ -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<AuthState>((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<AuthState>((set) => {
},
logout: () => {
// 1. 쿠키에서 토큰 삭제
Cookies.remove(TOKEN_COOKIE_KEY);
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
// 2. Zustand 상태 초기화
set({
accessToken: null,
user: null,