feat: 관리자 페이지 추가

- 관리자 로그인 시스템 (JWT, 30일 만료)
- admin_users 테이블 및 bcrypt 암호화
- 로그인 페이지 (/admin)
- 대시보드 (/admin/dashboard)
- 메뉴: 멤버, 앨범, 일정 관리
This commit is contained in:
caadiq 2026-01-01 18:01:42 +09:00
parent ae898d01ad
commit 009c428d37
8 changed files with 618 additions and 11 deletions

1
.env
View file

@ -8,3 +8,4 @@ DB_NAME=fromis9
# Server # Server
PORT=80 PORT=80
NODE_ENV=production NODE_ENV=production
JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w

View file

@ -8,7 +8,9 @@
"name": "fromis9-backend", "name": "fromis9-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.11.0" "mysql2": "^3.11.0"
} }
}, },
@ -40,6 +42,20 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -64,6 +80,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -189,6 +211,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -475,6 +506,97 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@ -628,6 +750,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -745,6 +887,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",

View file

@ -7,7 +7,9 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.11.0" "mysql2": "^3.11.0"
} }
} }

123
backend/routes/admin.js Normal file
View file

@ -0,0 +1,123 @@
import express from "express";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import pool from "../lib/db.js";
const router = express.Router();
// JWT 비밀키 (실제 운영에서는 환경변수로 관리)
const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026";
const JWT_EXPIRES_IN = "30d"; // 30일
// 관리자 로그인
router.post("/login", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res
.status(400)
.json({ error: "아이디와 비밀번호를 입력해주세요." });
}
// 사용자 조회
const [users] = await pool.query(
"SELECT * FROM admin_users WHERE username = ?",
[username]
);
if (users.length === 0) {
return res
.status(401)
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
}
const user = users[0];
// 비밀번호 검증
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res
.status(401)
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
}
// JWT 토큰 발급
const token = jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({
message: "로그인 성공",
token,
user: {
id: user.id,
username: user.username,
},
});
} catch (error) {
console.error("로그인 오류:", error);
res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." });
}
});
// 토큰 검증 미들웨어
export const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: "인증이 필요합니다." });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "유효하지 않은 토큰입니다." });
}
req.user = user;
next();
});
};
// 토큰 검증 엔드포인트
router.get("/verify", authenticateToken, (req, res) => {
res.json({
valid: true,
user: req.user,
});
});
// 초기 관리자 계정 생성 (한 번만 실행)
router.post("/init", async (req, res) => {
try {
// 이미 계정이 있는지 확인
const [existing] = await pool.query(
"SELECT COUNT(*) as count FROM admin_users"
);
if (existing[0].count > 0) {
return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." });
}
// 비밀번호 해시 생성
const password = "auddnek0403!";
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// 계정 생성
await pool.query(
"INSERT INTO admin_users (username, password_hash) VALUES (?, ?)",
["admin", passwordHash]
);
res.json({ message: "관리자 계정이 생성되었습니다." });
} catch (error) {
console.error("계정 생성 오류:", error);
res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." });
}
});
export default router;

View file

@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
import membersRouter from "./routes/members.js"; import membersRouter from "./routes/members.js";
import albumsRouter from "./routes/albums.js"; import albumsRouter from "./routes/albums.js";
import statsRouter from "./routes/stats.js"; import statsRouter from "./routes/stats.js";
import adminRouter from "./routes/admin.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -25,6 +26,7 @@ app.get("/api/health", (req, res) => {
app.use("/api/members", membersRouter); app.use("/api/members", membersRouter);
app.use("/api/albums", albumsRouter); app.use("/api/albums", albumsRouter);
app.use("/api/stats", statsRouter); app.use("/api/stats", statsRouter);
app.use("/api/admin", adminRouter);
// SPA 폴백 - 모든 요청을 index.html로 // SPA 폴백 - 모든 요청을 index.html로
app.get("*", (req, res) => { app.get("*", (req, res) => {

View file

@ -9,6 +9,10 @@ import PCAlbumDetail from './pages/pc/AlbumDetail';
import PCAlbumGallery from './pages/pc/AlbumGallery'; import PCAlbumGallery from './pages/pc/AlbumGallery';
import PCSchedule from './pages/pc/Schedule'; import PCSchedule from './pages/pc/Schedule';
//
import AdminLogin from './pages/pc/admin/AdminLogin';
import AdminDashboard from './pages/pc/admin/AdminDashboard';
// PC // PC
import PCLayout from './components/pc/Layout'; import PCLayout from './components/pc/Layout';
@ -16,16 +20,25 @@ function App() {
return ( return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<BrowserView> <BrowserView>
<PCLayout> <Routes>
<Routes> {/* 관리자 페이지 (레이아웃 없음) */}
<Route path="/" element={<PCHome />} /> <Route path="/admin" element={<AdminLogin />} />
<Route path="/members" element={<PCMembers />} /> <Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/album" element={<PCDiscography />} />
<Route path="/album/:name" element={<PCAlbumDetail />} /> {/* 일반 페이지 (레이아웃 포함) */}
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} /> <Route path="/*" element={
<Route path="/schedule" element={<PCSchedule />} /> <PCLayout>
</Routes> <Routes>
</PCLayout> <Route path="/" element={<PCHome />} />
<Route path="/members" element={<PCMembers />} />
<Route path="/album" element={<PCDiscography />} />
<Route path="/album/:name" element={<PCAlbumDetail />} />
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
<Route path="/schedule" element={<PCSchedule />} />
</Routes>
</PCLayout>
} />
</Routes>
</BrowserView> </BrowserView>
<MobileView> <MobileView>
{/* 모바일 버전은 추후 구현 */} {/* 모바일 버전은 추후 구현 */}

View file

@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Disc3, Calendar, Users, LogOut,
Home, ChevronRight
} from 'lucide-react';
function AdminDashboard() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
useEffect(() => {
//
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
//
fetch('/api/admin/verify', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => {
if (!res.ok) throw new Error('Invalid token');
return res.json();
})
.catch(() => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
});
}, [navigate]);
const handleLogout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
};
//
const menuItems = [
{
icon: Users,
label: '멤버 관리',
description: '멤버 정보 및 프로필 관리',
path: '/admin/members',
color: 'bg-primary'
},
{
icon: Disc3,
label: '앨범 관리',
description: '앨범, 트랙, 사진 업로드 및 관리',
path: '/admin/albums',
color: 'bg-purple-500'
},
{
icon: Calendar,
label: '일정 관리',
description: '스케줄 추가 및 관리',
path: '/admin/schedule',
color: 'bg-blue-500'
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
fromis_9
</Link>
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 text-sm">
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>
</span>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut size={18} />
<span>로그아웃</span>
</button>
</div>
</div>
</header>
{/* 메인 콘텐츠 */}
<main className="max-w-7xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<Home size={16} />
<ChevronRight size={14} />
<span className="text-gray-700">관리자 대시보드</span>
</div>
{/* 타이틀 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">관리자 대시보드</h1>
<p className="text-gray-500">fromis_9 팬사이트를 관리하세요</p>
</div>
{/* 메뉴 그리드 */}
<div className="grid grid-cols-3 gap-6">
{menuItems.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Link
to={item.path}
className="block bg-white rounded-2xl p-6 border border-gray-100 shadow-sm hover:shadow-lg hover:border-gray-200 transition-all group"
>
<div className={`w-12 h-12 ${item.color} rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<item.icon size={24} className="text-white" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">{item.label}</h3>
<p className="text-gray-500 text-sm">{item.description}</p>
</Link>
</motion.div>
))}
</div>
{/* 빠른 통계 */}
<div className="mt-12">
<h2 className="text-xl font-bold text-gray-900 mb-6">빠른 통계</h2>
<div className="grid grid-cols-4 gap-6">
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
<p className="text-3xl font-bold text-primary mb-1">-</p>
<p className="text-gray-500 text-sm"> 앨범</p>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
<p className="text-3xl font-bold text-primary mb-1">-</p>
<p className="text-gray-500 text-sm"> 사진</p>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
<p className="text-3xl font-bold text-primary mb-1">-</p>
<p className="text-gray-500 text-sm"> 일정</p>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
<p className="text-3xl font-bold text-primary mb-1">5</p>
<p className="text-gray-500 text-sm">멤버</p>
</div>
</div>
</div>
</main>
</div>
);
}
export default AdminDashboard;

View file

@ -0,0 +1,148 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
function AdminLogin() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/admin/login', {
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
localStorage.setItem('adminToken', data.token);
localStorage.setItem('adminUser', JSON.stringify(data.user));
//
navigate('/admin/dashboard');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md"
>
{/* 로고 */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">fromis_9</h1>
<p className="text-gray-500">관리자 페이지</p>
</div>
{/* 로그인 카드 */}
<div className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100">
<h2 className="text-xl font-bold text-gray-900 text-center mb-6">로그인</h2>
{/* 에러 메시지 */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg mb-6"
>
<AlertCircle size={18} />
<span className="text-sm">{error}</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* 아이디 입력 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
아이디
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
placeholder="아이디를 입력하세요"
required
/>
</div>
</div>
{/* 비밀번호 입력 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
비밀번호
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-12 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
placeholder="비밀번호를 입력하세요"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-3 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
로그인 ...
</span>
) : (
'로그인'
)}
</button>
</form>
</div>
{/* 하단 링크 */}
<div className="text-center mt-6">
<a href="/" className="text-gray-500 hover:text-primary text-sm transition-colors">
메인 사이트로 돌아가기
</a>
</div>
</motion.div>
</div>
);
}
export default AdminLogin;