feat: 대시보드에 유저의 정보를 불러오기
This commit is contained in:
53
src/app/(app)/dashboard/page.tsx
Normal file
53
src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -92,10 +92,6 @@ const SocialLoginButtons = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그인 화면 UI (View)
|
||||
* GoogleOAuthProvider로 감싸서 내부에서 useGoogleLogin 훅을 사용할 수 있게 합니다.
|
||||
*/
|
||||
export const SocialLoginGroup = () => {
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
11
src/features/user/api/userApi.ts
Normal file
11
src/features/user/api/userApi.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
60
src/features/user/components/UserProfileCard.tsx
Normal file
60
src/features/user/components/UserProfileCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/features/user/hooks/useUserProfile.ts
Normal file
32
src/features/user/hooks/useUserProfile.ts
Normal 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 };
|
||||
};
|
||||
4
src/features/user/index.ts
Normal file
4
src/features/user/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./hooks/useUserProfile";
|
||||
export * from "./components/UserProfileCard";
|
||||
export * from "./api/userApi";
|
||||
6
src/features/user/types/index.ts
Normal file
6
src/features/user/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
grade: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user