feat: 관리자 페이지 추가
- 관리자 로그인 시스템 (JWT, 30일 만료) - admin_users 테이블 및 bcrypt 암호화 - 로그인 페이지 (/admin) - 대시보드 (/admin/dashboard) - 메뉴: 멤버, 앨범, 일정 관리
This commit is contained in:
parent
ae898d01ad
commit
009c428d37
8 changed files with 618 additions and 11 deletions
1
.env
1
.env
|
|
@ -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
|
||||||
|
|
|
||||||
154
backend/package-lock.json
generated
154
backend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
123
backend/routes/admin.js
Normal 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;
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{/* 모바일 버전은 추후 구현 */}
|
{/* 모바일 버전은 추후 구현 */}
|
||||||
|
|
|
||||||
164
frontend/src/pages/pc/admin/AdminDashboard.jsx
Normal file
164
frontend/src/pages/pc/admin/AdminDashboard.jsx
Normal 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;
|
||||||
148
frontend/src/pages/pc/admin/AdminLogin.jsx
Normal file
148
frontend/src/pages/pc/admin/AdminLogin.jsx
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue