refactor: 인증 API 모듈화 및 Admin 페이지 적용 완료
- api/admin/auth.js 인증 모듈 생성 (verifyToken, login, logout, hasToken 등) - categories.js reorderCategories 경로 수정 - AdminScheduleCategory 전체 API 모듈 적용 - AdminLogin API 모듈 적용 - AdminDashboard API 모듈 적용 남은 작업: AdminAlbums, AdminAlbumPhotos, AdminMemberEdit, AdminScheduleForm
This commit is contained in:
parent
0722ca10f3
commit
fbb68e66ee
5 changed files with 115 additions and 141 deletions
42
frontend/src/api/admin/auth.js
Normal file
42
frontend/src/api/admin/auth.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* 어드민 인증 API
|
||||||
|
*/
|
||||||
|
import { fetchAdminApi } from "../index";
|
||||||
|
|
||||||
|
// 토큰 검증
|
||||||
|
export async function verifyToken() {
|
||||||
|
return fetchAdminApi("/api/admin/verify");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
export async function login(username, password) {
|
||||||
|
const response = await fetch("/api/admin/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "로그인 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그아웃 (로컬 스토리지 정리)
|
||||||
|
export function logout() {
|
||||||
|
localStorage.removeItem("adminToken");
|
||||||
|
localStorage.removeItem("adminUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 사용자 정보 가져오기
|
||||||
|
export function getCurrentUser() {
|
||||||
|
const userData = localStorage.getItem("adminUser");
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰 존재 여부 확인
|
||||||
|
export function hasToken() {
|
||||||
|
return !!localStorage.getItem("adminToken");
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,9 @@ export async function deleteCategory(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 순서 변경
|
// 카테고리 순서 변경
|
||||||
export async function reorderCategories(orderedIds) {
|
export async function reorderCategories(orders) {
|
||||||
return fetchAdminApi("/api/admin/schedule-categories/reorder", {
|
return fetchAdminApi("/api/admin/schedule-categories-order", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ orderedIds }),
|
body: JSON.stringify({ orders }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import {
|
||||||
Disc3, Calendar, Users, LogOut,
|
Disc3, Calendar, Users, LogOut,
|
||||||
Home, ChevronRight
|
Home, ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import * as authApi from '../../../api/admin/auth';
|
||||||
|
import { getMembers } from '../../../api/public/members';
|
||||||
|
import { getAlbums, getAlbum } from '../../../api/public/albums';
|
||||||
|
import { getSchedules } from '../../../api/public/schedules';
|
||||||
|
|
||||||
// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로)
|
// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로)
|
||||||
function AnimatedNumber({ value }) {
|
function AnimatedNumber({ value }) {
|
||||||
|
|
@ -49,27 +53,17 @@ function AdminDashboard() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 로그인 상태 확인
|
// 로그인 상태 확인
|
||||||
const token = localStorage.getItem('adminToken');
|
if (!authApi.hasToken()) {
|
||||||
const userData = localStorage.getItem('adminUser');
|
|
||||||
|
|
||||||
if (!token || !userData) {
|
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(JSON.parse(userData));
|
setUser(authApi.getCurrentUser());
|
||||||
|
|
||||||
// 토큰 유효성 검증
|
// 토큰 유효성 검증
|
||||||
fetch('/api/admin/verify', {
|
authApi.verifyToken()
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
if (!res.ok) throw new Error('Invalid token');
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('adminToken');
|
authApi.logout();
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -80,26 +74,19 @@ function AdminDashboard() {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
// 각 통계를 개별적으로 가져와서 하나가 실패해도 다른 것은 표시
|
// 각 통계를 개별적으로 가져와서 하나가 실패해도 다른 것은 표시
|
||||||
try {
|
try {
|
||||||
const membersRes = await fetch('/api/members');
|
const members = await getMembers();
|
||||||
if (membersRes.ok) {
|
|
||||||
const members = await membersRes.json();
|
|
||||||
setStats(prev => ({ ...prev, members: members.filter(m => !m.is_former).length }));
|
setStats(prev => ({ ...prev, members: members.filter(m => !m.is_former).length }));
|
||||||
}
|
|
||||||
} catch (e) { console.error('멤버 통계 오류:', e); }
|
} catch (e) { console.error('멤버 통계 오류:', e); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const albumsRes = await fetch('/api/albums');
|
const albums = await getAlbums();
|
||||||
if (albumsRes.ok) {
|
|
||||||
const albums = await albumsRes.json();
|
|
||||||
setStats(prev => ({ ...prev, albums: albums.length }));
|
setStats(prev => ({ ...prev, albums: albums.length }));
|
||||||
|
|
||||||
// 사진 수 계산
|
// 사진 수 계산
|
||||||
let totalPhotos = 0;
|
let totalPhotos = 0;
|
||||||
for (const album of albums) {
|
for (const album of albums) {
|
||||||
try {
|
try {
|
||||||
const detailRes = await fetch(`/api/albums/${album.id}`);
|
const detail = await getAlbum(album.id);
|
||||||
if (detailRes.ok) {
|
|
||||||
const detail = await detailRes.json();
|
|
||||||
if (detail.conceptPhotos) {
|
if (detail.conceptPhotos) {
|
||||||
Object.values(detail.conceptPhotos).forEach(photos => {
|
Object.values(detail.conceptPhotos).forEach(photos => {
|
||||||
totalPhotos += photos.length;
|
totalPhotos += photos.length;
|
||||||
|
|
@ -108,25 +95,20 @@ function AdminDashboard() {
|
||||||
if (detail.teasers) {
|
if (detail.teasers) {
|
||||||
totalPhotos += detail.teasers.length;
|
totalPhotos += detail.teasers.length;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) { /* 개별 앨범 오류 무시 */ }
|
} catch (e) { /* 개별 앨범 오류 무시 */ }
|
||||||
}
|
}
|
||||||
setStats(prev => ({ ...prev, photos: totalPhotos }));
|
setStats(prev => ({ ...prev, photos: totalPhotos }));
|
||||||
}
|
|
||||||
} catch (e) { console.error('앨범 통계 오류:', e); }
|
} catch (e) { console.error('앨범 통계 오류:', e); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const schedulesRes = await fetch('/api/schedules');
|
const today = new Date();
|
||||||
if (schedulesRes.ok) {
|
const schedules = await getSchedules(today.getFullYear(), today.getMonth() + 1);
|
||||||
const schedules = await schedulesRes.json();
|
|
||||||
setStats(prev => ({ ...prev, schedules: Array.isArray(schedules) ? schedules.length : 0 }));
|
setStats(prev => ({ ...prev, schedules: Array.isArray(schedules) ? schedules.length : 0 }));
|
||||||
}
|
|
||||||
} catch (e) { console.error('일정 통계 오류:', e); }
|
} catch (e) { console.error('일정 통계 오류:', e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
authApi.logout();
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import * as authApi from '../../../api/admin/auth';
|
||||||
|
|
||||||
function AdminLogin() {
|
function AdminLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -14,13 +15,13 @@ function AdminLogin() {
|
||||||
|
|
||||||
// 이미 로그인되어 있으면 대시보드로 리다이렉트
|
// 이미 로그인되어 있으면 대시보드로 리다이렉트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('adminToken');
|
if (!authApi.hasToken()) {
|
||||||
if (token) {
|
setCheckingAuth(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 토큰 유효성 검증
|
// 토큰 유효성 검증
|
||||||
fetch('/api/admin/verify', {
|
authApi.verifyToken()
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
navigate('/admin/dashboard');
|
navigate('/admin/dashboard');
|
||||||
|
|
@ -29,9 +30,6 @@ function AdminLogin() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setCheckingAuth(false));
|
.catch(() => setCheckingAuth(false));
|
||||||
} else {
|
|
||||||
setCheckingAuth(false);
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
|
|
@ -40,17 +38,7 @@ function AdminLogin() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/login', {
|
const data = await authApi.login(username, password);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || '로그인에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 토큰 저장
|
// JWT 토큰 저장
|
||||||
localStorage.setItem('adminToken', data.token);
|
localStorage.setItem('adminToken', data.token);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||||
import { LogOut, Home, ChevronRight, Plus, Edit3, Trash2, GripVertical, X, AlertTriangle } from 'lucide-react';
|
import { LogOut, Home, ChevronRight, Plus, Edit3, Trash2, GripVertical, X, AlertTriangle } from 'lucide-react';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
|
import * as authApi from '../../../api/admin/auth';
|
||||||
|
import * as categoriesApi from '../../../api/admin/categories';
|
||||||
|
|
||||||
// 기본 색상 (8개)
|
// 기본 색상 (8개)
|
||||||
const colorOptions = [
|
const colorOptions = [
|
||||||
|
|
@ -58,16 +60,12 @@ function AdminScheduleCategory() {
|
||||||
|
|
||||||
// 사용자 인증 확인
|
// 사용자 인증 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('adminToken');
|
if (!authApi.hasToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/admin/verify', {
|
authApi.verifyToken()
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
|
@ -82,8 +80,7 @@ function AdminScheduleCategory() {
|
||||||
// 카테고리 목록 조회
|
// 카테고리 목록 조회
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/schedule-categories');
|
const data = await categoriesApi.getCategories();
|
||||||
const data = await res.json();
|
|
||||||
setCategories(data);
|
setCategories(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('카테고리 조회 오류:', error);
|
console.error('카테고리 조회 오류:', error);
|
||||||
|
|
@ -95,8 +92,7 @@ function AdminScheduleCategory() {
|
||||||
|
|
||||||
// 로그아웃
|
// 로그아웃
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
authApi.logout();
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -130,33 +126,18 @@ function AdminScheduleCategory() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = editingCategory
|
if (editingCategory) {
|
||||||
? `/api/admin/schedule-categories/${editingCategory.id}`
|
await categoriesApi.updateCategory(editingCategory.id, formData);
|
||||||
: '/api/admin/schedule-categories';
|
} else {
|
||||||
|
await categoriesApi.createCategory(formData);
|
||||||
const res = await fetch(url, {
|
}
|
||||||
method: editingCategory ? 'PUT' : 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('success', editingCategory ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
|
showToast('success', editingCategory ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} else {
|
|
||||||
const error = await res.json();
|
|
||||||
showToast('error', error.error || '저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('저장 오류:', error);
|
console.error('저장 오류:', error);
|
||||||
showToast('error', '저장 중 오류가 발생했습니다.');
|
showToast('error', error.message || '저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -170,26 +151,15 @@ function AdminScheduleCategory() {
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/schedule-categories/${deleteTarget.id}`, {
|
await categoriesApi.deleteCategory(deleteTarget.id);
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('success', '카테고리가 삭제되었습니다.');
|
showToast('success', '카테고리가 삭제되었습니다.');
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} else {
|
|
||||||
const error = await res.json();
|
|
||||||
showToast('error', error.error || '삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('삭제 오류:', error);
|
console.error('삭제 오류:', error);
|
||||||
showToast('error', '삭제 중 오류가 발생했습니다.');
|
showToast('error', error.message || '삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -203,16 +173,8 @@ function AdminScheduleCategory() {
|
||||||
sort_order: idx + 1
|
sort_order: idx + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const token = localStorage.getItem('adminToken');
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/admin/schedule-categories-order', {
|
await categoriesApi.reorderCategories(orders);
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ orders })
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('순서 업데이트 오류:', error);
|
console.error('순서 업데이트 오류:', error);
|
||||||
fetchCategories(); // 실패시 원래 데이터 다시 불러오기
|
fetchCategories(); // 실패시 원래 데이터 다시 불러오기
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue