diff --git a/package-lock.json b/package-lock.json index 27131dd..e3dba12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tanstack/react-query": "^5.90.12", + "@tanstack/react-query-devtools": "^5.91.1", "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.11", "axios": "^1.13.2", @@ -1745,11 +1746,22 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-devtools": { + "version": "5.91.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz", + "integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-query": { "version": "5.90.12", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.12" }, @@ -1761,6 +1773,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz", + "integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.91.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.10", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.13", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz", diff --git a/package.json b/package.json index 8138f22..ee8cef8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tanstack/react-query": "^5.90.12", + "@tanstack/react-query-devtools": "^5.91.1", "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.11", "axios": "^1.13.2", diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 1ec05db..4bcda5e 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,40 +1,103 @@ 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState } from 'react'; -// 🎨 UX κ°œμ„ : μ•Œλ¦Ό 라이브러리 μΆ”κ°€ +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useState, useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; +import { useAuthStore } from '@/store/authStore'; +import axios from 'axios'; + +// πŸ› οΈ JWT 토큰 만료 μ—¬λΆ€ 체크 ν•¨μˆ˜ (라이브러리 없이 κ΅¬ν˜„) +function isTokenExpired(token: string) { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + const { exp } = JSON.parse(jsonPayload); + + // ν˜„μž¬ μ‹œκ°„(초)이 만료 μ‹œκ°„(exp)보닀 ν¬κ±°λ‚˜ κ°™μœΌλ©΄ 만료됨 + // μ•ˆμ „ λ§ˆμ§„ 60초 μΆ”κ°€ (만료 1λΆ„ 전이면 미리 κ°±μ‹ ) + return Date.now() / 1000 >= exp - 60; + } catch (e) { + return true; // νŒŒμ‹± μ‹€νŒ¨ μ‹œ 만료된 κ²ƒμœΌλ‘œ κ°„μ£Ό + } +} + +// πŸ›‘οΈ μ•± μ΄ˆκΈ°ν™” μ‹œ 토큰 검증 μ»΄ν¬λ„ŒνŠΈ +function AuthInitializer() { + const { accessToken, refreshToken, login, logout } = useAuthStore(); + + useEffect(() => { + const initializeAuth = async () => { + // 토큰이 μ—†μœΌλ©΄ 검사할 ν•„μš” μ—†μŒ + if (!accessToken || !refreshToken) return; + + // 토큰이 λ§Œλ£Œλ˜μ—ˆλŠ”μ§€ 확인 + if (isTokenExpired(accessToken)) { + console.log('πŸ”„ AccessToken expired on init, refreshing...'); + + try { + const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + + // κ°±μ‹  μš”μ²­ (http 인터셉터 κ±°μΉ˜μ§€ μ•Šκ³  직접 axios μ‚¬μš©) + const { data } = await axios.post( + `${BASE_URL}/api/auth/reissue`, + { accessToken, refreshToken }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true + } + ); + + if (data.code === 'SUCCESS' && data.data) { + // κ°±μ‹  성곡: μŠ€ν† μ–΄ μ—…λ°μ΄νŠΈ + login(data.data.accessToken, data.data.refreshToken); + console.log('βœ… Token refreshed successfully on init'); + } else { + throw new Error('Token refresh response invalid'); + } + } catch (error) { + console.error('❌ Failed to refresh token on init:', error); + // κ°±μ‹  μ‹€νŒ¨ μ‹œ κΉ”λ”ν•˜κ²Œ λ‘œκ·Έμ•„μ›ƒ μ²˜λ¦¬ν•˜μ—¬ κΌ¬μž„ λ°©μ§€ + logout(); + // ν•„μš” μ‹œ 둜그인 νŽ˜μ΄μ§€λ‘œ 이동 (선택 사항) + // window.location.href = '/login'; + } + } + }; + + initializeAuth(); + }, [accessToken, refreshToken, login, logout]); + + return null; // UIλ₯Ό λ Œλ”λ§ν•˜μ§€ μ•ŠμŒ +} export default function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - }, - }, - })); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // μœˆλ„μš° 포컀슀 μ‹œ μžλ™ 리페치 λ°©μ§€ (λΆˆν•„μš”ν•œ μš”μ²­ μ€„μž„) + refetchOnWindowFocus: false, + retry: 1, + staleTime: 1000 * 60 * 5, // 5λΆ„κ°„ 데이터 캐싱 + }, + }, + }) + ); return ( + {/* πŸ‘ˆ μ•± μ‹€ν–‰ μ‹œ 토큰 μžλ™ 검사 */} {children} - {/* 🎨 μ „μ—­ μ•Œλ¦Ό μ»΄ν¬λ„ŒνŠΈ 배치 (상단 쀑앙) */} - + + ); } \ No newline at end of file diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx index 0e14b8e..b1caf00 100644 --- a/src/app/write/page.tsx +++ b/src/app/write/page.tsx @@ -11,29 +11,50 @@ import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud } fro // 🎨 UX κ°œμ„ : ν† μŠ€νŠΈ μ•Œλ¦Ό μ‚¬μš© import toast from 'react-hot-toast'; import dynamic from 'next/dynamic'; +import axios from 'axios'; // πŸ†• 직접 κ°±μ‹  μš”μ²­μ„ μœ„ν•΄ μΆ”κ°€ const MDEditor = dynamic( () => import('@uiw/react-md-editor').then((mod) => mod.default), { ssr: false } ); +// πŸ› οΈ 1. 토큰 만료 체크 μœ ν‹Έλ¦¬ν‹° (JWT λ””μ½”λ”©) +function isTokenExpired(token: string) { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + const { exp } = JSON.parse(jsonPayload); + // ν˜„μž¬ μ‹œκ°„λ³΄λ‹€ 만료 μ‹œκ°„μ΄ 적게 λ‚¨μ•˜κ±°λ‚˜ μ§€λ‚¬μœΌλ©΄ true (μ—¬μœ μ‹œκ°„ 30초) + return Date.now() / 1000 >= exp - 30; + } catch (e) { + return true; // νŒŒμ‹± μ‹€νŒ¨ μ‹œ 만료된 κ²ƒμœΌλ‘œ κ°„μ£Ό + } +} + function WritePageContent() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { role, _hasHydrated } = useAuthStore(); + const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); // πŸ†• 토큰 κ΄€λ ¨ μƒνƒœ κ°€μ Έμ˜€κΈ° const editSlug = searchParams.get('slug'); const isEditMode = !!editSlug; const [title, setTitle] = useState(''); - const [content, setContent] = useState('**Hello world!**'); + const [content, setContent] = useState(''); const [categoryId, setCategoryId] = useState(''); const [isUploading, setIsUploading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); // πŸ†• 제좜 쀑 μƒνƒœ 관리 useEffect(() => { if (_hasHydrated && (!role || !role.includes('ADMIN'))) { - toast.error('κ΄€λ¦¬μž κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€.'); // 🎨 Alert λŒ€μ²΄ + toast.error('κ΄€λ¦¬μž κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€.'); router.push('/'); } }, [role, _hasHydrated, router]); @@ -51,7 +72,8 @@ function WritePageContent() { useEffect(() => { if (existingPost) { - setTitle(existingPost.title); + // πŸ› οΈ undefined λ°©μ§€ 처리 μΆ”κ°€ + setTitle(existingPost.title || ''); setContent(existingPost.content || ''); if (categories && existingPost.categoryName) { const found = findCategoryByName(categories, existingPost.categoryName); @@ -78,16 +100,55 @@ function WritePageContent() { if (isEditMode) { queryClient.invalidateQueries({ queryKey: ['post', editSlug] }); } - // 🎨 UX κ°œμ„ : 성곡 λ©”μ‹œμ§€ ν† μŠ€νŠΈ toast.success(isEditMode ? 'κ²Œμ‹œκΈ€μ΄ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.' : 'κ²Œμ‹œκΈ€μ΄ λ°œν–‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€!'); router.push(isEditMode ? `/posts/${editSlug}` : '/'); }, onError: (err: any) => { - // 🎨 UX κ°œμ„ : μ—λŸ¬ λ©”μ‹œμ§€ ν† μŠ€νŠΈ toast.error('μ €μž₯ μ‹€νŒ¨: ' + (err.response?.data?.message || err.message)); }, + onSettled: () => { + setIsSubmitting(false); // πŸ†• μ™„λ£Œ μ‹œ λ‘œλ”© ν•΄μ œ + } }); + // πŸ›‘οΈ 2. [핡심 둜직] 토큰 검사 및 κ°±μ‹  보μž₯ ν•¨μˆ˜ + const ensureAuthToken = async (): Promise => { + // 1. 토큰이 μ•„μ˜ˆ μ—†μœΌλ©΄ μ‹€νŒ¨ + if (!accessToken || !refreshToken) { + toast.error('둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.'); + return false; + } + + // 2. 토큰이 아직 μ‹±μ‹±ν•˜λ©΄ λ°”λ‘œ 톡과 + if (!isTokenExpired(accessToken)) { + return true; + } + + // 3. λ§Œλ£Œλ˜μ—ˆλ‹€λ©΄ κ°±μ‹  μ‹œλ„ + try { + console.log('πŸ”„ Access token expired during write. Refreshing...'); + const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + + const { data } = await axios.post( + `${BASE_URL}/api/auth/reissue`, + { accessToken, refreshToken }, + { headers: { 'Content-Type': 'application/json' }, withCredentials: true } + ); + + if (data.code === 'SUCCESS' && data.data) { + login(data.data.accessToken, data.data.refreshToken); + console.log('βœ… Token refreshed successfully before save.'); + return true; + } + return false; + } catch (error) { + console.error('❌ Failed to refresh token before save:', error); + // κ°±μ‹  μ‹€νŒ¨: μ‚¬μš©μžκ°€ λ‚΄μš©μ„ λ°±μ—…ν•  수 μžˆλ„λ‘ κ²½κ³  + toast.error('μ„Έμ…˜μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.\nμž‘μ„± 쀑인 글을 볡사해두고 λ‹€μ‹œ λ‘œκ·ΈμΈν•΄μ£Όμ„Έμš”!', { duration: 5000 }); + return false; + } + }; + const handleSubmit = async () => { if (!title.trim() || !content.trim()) { toast.error('제λͺ©κ³Ό λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.'); @@ -98,10 +159,20 @@ function WritePageContent() { return; } + setIsSubmitting(true); // λ²„νŠΌ λΉ„ν™œμ„±ν™” + + // πŸ›‘οΈ μ €μž₯ μ „ 토큰 체크! + const isTokenValid = await ensureAuthToken(); + if (!isTokenValid) { + setIsSubmitting(false); + return; // 토큰 κ°±μ‹  μ‹€νŒ¨ μ‹œ 쀑단 + } + mutation.mutate({ title, content, categoryId: Number(categoryId), + tags: [], // πŸ†• νƒ€μž… 였λ₯˜ λ°©μ§€μš© 빈 λ°°μ—΄ (ν•„μš” μ‹œ νƒœκ·Έ μž…λ ₯ κΈ°λŠ₯ μΆ”κ°€) }); }; @@ -110,15 +181,18 @@ function WritePageContent() { if (!file) return; setIsUploading(true); - const uploadToast = toast.loading('이미지 μ—…λ‘œλ“œ 쀑...'); // 🎨 μ—…λ‘œλ“œ λ‘œλ”© ν‘œμ‹œ + const uploadToast = toast.loading('이미지 μ—…λ‘œλ“œ 쀑...'); try { + // 이미지 μ—…λ‘œλ“œ 전에도 토큰 체크 (선택 μ‚¬ν•­μ΄μ§€λ§Œ μ•ˆμ „ν•¨) + await ensureAuthToken(); + const res = await uploadImage(file); if (res.code === 'SUCCESS' && res.data) { const imageUrl = res.data; const markdownImage = `![image](${imageUrl})`; setContent((prev) => prev + '\n' + markdownImage); - toast.success('이미지가 μ—…λ‘œλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', { id: uploadToast }); // λ‘œλ”© ν† μŠ€νŠΈλ₯Ό μ„±κ³΅μœΌλ‘œ λ³€κ²½ + toast.success('이미지가 μ—…λ‘œλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', { id: uploadToast }); } } catch (error) { toast.error('이미지 μ—…λ‘œλ“œ μ‹€νŒ¨', { id: uploadToast }); @@ -142,6 +216,8 @@ function WritePageContent() { const uploadToast = toast.loading('이미지 μ—…λ‘œλ“œ 쀑...'); try { + await ensureAuthToken(); // 토큰 체크 + const res = await uploadImage(file); if (res.code === 'SUCCESS' && res.data) { const imageUrl = res.data; @@ -198,10 +274,10 @@ function WritePageContent() { diff --git a/src/types/index.ts b/src/types/index.ts index 5a6ac4a..503840d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,7 @@ export interface Post { viewCount: number; createdAt: string; content?: string; + tags: string[]; // πŸ†• νƒœκ·Έ λ°°μ—΄ 속성 μΆ”κ°€ } // 3. κ²Œμ‹œκΈ€ λͺ©λ‘ νŽ˜μ΄μ§• 응닡 @@ -36,6 +37,7 @@ export interface AuthResponse { export interface Category { id: number; name: string; + parentId?: number | null; // πŸ†• λΆ€λͺ¨ μΉ΄ν…Œκ³ λ¦¬ ID μΆ”κ°€ children: Category[]; } diff --git a/yarn.lock b/yarn.lock index aa1694e..41a452b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -541,7 +541,19 @@ resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz" integrity sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg== -"@tanstack/react-query@^5.90.12": +"@tanstack/query-devtools@5.91.1": + version "5.91.1" + resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz" + integrity sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg== + +"@tanstack/react-query-devtools@^5.91.1": + version "5.91.1" + resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz" + integrity sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ== + dependencies: + "@tanstack/query-devtools" "5.91.1" + +"@tanstack/react-query@^5.90.10", "@tanstack/react-query@^5.90.12": version "5.90.12" resolved "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz" integrity sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==