회원가입/로그인, 프로필 추가
This commit is contained in:
parent
17d23519d7
commit
fd6a583fcc
19 changed files with 2327 additions and 51 deletions
13
.env
13
.env
|
|
@ -11,3 +11,16 @@ DB_NAME=minecraft
|
|||
S3_ENDPOINT=http://rustfs:9000
|
||||
S3_ACCESS_KEY=lFMQ5ncyAvXRgzHrUpua
|
||||
S3_SECRET_KEY=e67PK9zt4fnFSd21sIkxlW3gLqrNmGDHwouciOvE
|
||||
|
||||
# Resend API (이메일 발송)
|
||||
RESEND_API_KEY=re_HoPkCo6p_4Qj1ENYJLHEEgcfcTN9ddQ8r
|
||||
MAIL_FROM=no-reply@caadiq.co.kr
|
||||
|
||||
# JWT 설정
|
||||
JWT_SECRET=mc-status-jwt-7f8e9a2b1c4d5e6f7a8b9c0d1e2f3a4b
|
||||
|
||||
# 사이트 URL (인증 링크용)
|
||||
SITE_URL=https://minecraft.caadiq.co.kr
|
||||
|
||||
# 세션 비밀 키
|
||||
SESSION_SECRET=mc-status-session-d7f8e9a2b1c4d5e6f
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ RUN npm run build
|
|||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# CA 인증서 및 필수 패키지 설치
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# undici 연결 타임아웃 늘리기
|
||||
ENV NODE_OPTIONS="--dns-result-order=ipv4first"
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --production
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ const setIconCache = (key, value) => {
|
|||
|
||||
export {
|
||||
dbPool,
|
||||
dbPool as pool,
|
||||
loadTranslations,
|
||||
getTranslations,
|
||||
getIcons,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,15 @@
|
|||
"sync-icons": "node scripts/sync-icons.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.400.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minecraft-server-util": "^5.3.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"socket.io": "^4.8.1"
|
||||
"resend": "^2.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
366
backend/routes/auth.js
Normal file
366
backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
/**
|
||||
* 인증 라우트
|
||||
* 회원가입, 이메일 인증, 로그인, 로그아웃
|
||||
*
|
||||
* 보안 기능:
|
||||
* - bcrypt 비밀번호 해시
|
||||
* - JWT 토큰 인증
|
||||
* - 로그인 실패 횟수 제한 (IP 기반)
|
||||
* - 이메일 인증 필수
|
||||
* - 랜덤 닉네임 생성
|
||||
* - 랜덤 프로필 사진
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { pool } from "../lib/db.js";
|
||||
import { sendVerificationEmail } from "../services/emailService.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
|
||||
const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://rustfs:9000";
|
||||
|
||||
// 로그인 실패 추적 (IP 기반 rate limiting)
|
||||
const loginAttempts = new Map();
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_TIME = 15 * 60 * 1000; // 15분
|
||||
|
||||
/**
|
||||
* 클라이언트 IP 추출
|
||||
*/
|
||||
function getClientIp(req) {
|
||||
return (
|
||||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||||
req.headers["x-real-ip"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 시도 횟수 확인
|
||||
*/
|
||||
function checkLoginAttempts(ip) {
|
||||
const attempts = loginAttempts.get(ip);
|
||||
if (!attempts) return { allowed: true, remaining: MAX_ATTEMPTS };
|
||||
|
||||
if (Date.now() - attempts.lastAttempt > LOCKOUT_TIME) {
|
||||
loginAttempts.delete(ip);
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS };
|
||||
}
|
||||
|
||||
if (attempts.count >= MAX_ATTEMPTS) {
|
||||
const remainingTime = Math.ceil(
|
||||
(LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60
|
||||
);
|
||||
return { allowed: false, remaining: 0, lockoutMinutes: remainingTime };
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - attempts.count };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(ip) {
|
||||
const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
|
||||
attempts.count += 1;
|
||||
attempts.lastAttempt = Date.now();
|
||||
loginAttempts.set(ip, attempts);
|
||||
}
|
||||
|
||||
function clearLoginAttempts(ip) {
|
||||
loginAttempts.delete(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 닉네임 생성 (형용사 + 명사)
|
||||
*/
|
||||
async function generateRandomNickname() {
|
||||
try {
|
||||
// 랜덤 형용사
|
||||
const [adjectives] = await pool.query(
|
||||
"SELECT word FROM nickname_words WHERE type = 'adjective' ORDER BY RAND() LIMIT 1"
|
||||
);
|
||||
// 랜덤 명사
|
||||
const [nouns] = await pool.query(
|
||||
"SELECT word FROM nickname_words WHERE type = 'noun' ORDER BY RAND() LIMIT 1"
|
||||
);
|
||||
|
||||
if (adjectives.length > 0 && nouns.length > 0) {
|
||||
return `${adjectives[0].word} ${nouns[0].word}`;
|
||||
}
|
||||
|
||||
// 폴백: 기본 닉네임
|
||||
return `유저${Math.floor(Math.random() * 10000)}`;
|
||||
} catch (error) {
|
||||
console.error("[Auth] 닉네임 생성 실패:", error);
|
||||
return `유저${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 프로필 사진 URL 생성
|
||||
* RustFS minecraft 버킷의 profile 폴더에서 랜덤 선택
|
||||
*/
|
||||
function getRandomProfileUrl() {
|
||||
// 기본 프로필 사진 개수 (1~16까지)
|
||||
const profileCount = 16;
|
||||
const randomIndex = Math.floor(Math.random() * profileCount) + 1;
|
||||
// 외부 접근 가능한 S3 URL 사용
|
||||
return `https://s3.caadiq.co.kr/minecraft/profile/default_profile_${randomIndex}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/register - 회원가입
|
||||
*/
|
||||
router.post("/register", async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// 입력 검증
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
||||
// 이메일 형식 검증
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({ error: "올바른 이메일 형식이 아닙니다." });
|
||||
}
|
||||
|
||||
// 비밀번호 길이 검증
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: "비밀번호는 8자 이상이어야 합니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
// 이메일 중복 확인
|
||||
const [existing] = await pool.query(
|
||||
"SELECT id FROM users WHERE email = ?",
|
||||
[email]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "이미 등록된 이메일입니다." });
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 인증 토큰 생성
|
||||
const verifyToken = uuidv4();
|
||||
const verifyExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24시간
|
||||
|
||||
// 랜덤 닉네임 생성
|
||||
const randomName = await generateRandomNickname();
|
||||
|
||||
// 랜덤 프로필 사진 URL
|
||||
const profileUrl = getRandomProfileUrl();
|
||||
|
||||
// 사용자 저장
|
||||
await pool.query(
|
||||
`INSERT INTO users (email, password, name, profile_url, verify_token, verify_expires) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
email,
|
||||
hashedPassword,
|
||||
randomName,
|
||||
profileUrl,
|
||||
verifyToken,
|
||||
verifyExpires,
|
||||
]
|
||||
);
|
||||
|
||||
// 인증 이메일 발송
|
||||
try {
|
||||
await sendVerificationEmail(email, verifyToken, randomName);
|
||||
} catch (emailError) {
|
||||
console.error("[Auth] 인증 메일 발송 실패:", emailError);
|
||||
// 이메일 발송 실패해도 가입은 완료 (재발송 가능)
|
||||
}
|
||||
|
||||
console.log(`[Auth] 회원가입: ${email} (닉네임: ${randomName})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다. 이메일을 확인해주세요.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] 회원가입 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/verify/:token - 이메일 인증
|
||||
*/
|
||||
router.get("/verify/:token", async (req, res) => {
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
const [users] = await pool.query(
|
||||
"SELECT id, email, email_verified, verify_expires FROM users WHERE verify_token = ?",
|
||||
[token]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(400).json({ error: "유효하지 않은 인증 링크입니다." });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
if (user.email_verified) {
|
||||
return res.json({ success: true, message: "이미 인증된 이메일입니다." });
|
||||
}
|
||||
|
||||
if (new Date() > new Date(user.verify_expires)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "인증 링크가 만료되었습니다. 다시 가입해주세요." });
|
||||
}
|
||||
|
||||
// 인증 완료 처리
|
||||
await pool.query(
|
||||
"UPDATE users SET email_verified = TRUE, verify_token = NULL, verify_expires = NULL WHERE id = ?",
|
||||
[user.id]
|
||||
);
|
||||
|
||||
console.log(`[Auth] 이메일 인증 완료: ${user.email}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "이메일 인증이 완료되었습니다. 로그인해주세요.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] 이메일 인증 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/login - 로그인
|
||||
*/
|
||||
router.post("/login", async (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
|
||||
// 로그인 시도 횟수 확인
|
||||
const attemptCheck = checkLoginAttempts(ip);
|
||||
if (!attemptCheck.allowed) {
|
||||
console.log(`[Auth] 로그인 잠금 - IP: ${ip}`);
|
||||
return res.status(429).json({
|
||||
error: `너무 많은 로그인 시도. ${attemptCheck.lockoutMinutes}분 후에 다시 시도해주세요.`,
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
||||
try {
|
||||
const [users] = await pool.query(
|
||||
"SELECT id, email, password, name, profile_url, email_verified, is_admin FROM users WHERE email = ?",
|
||||
[email]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
recordFailedAttempt(ip);
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "이메일 또는 비밀번호가 올바르지 않습니다." });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// 비밀번호 확인
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
recordFailedAttempt(ip);
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "이메일 또는 비밀번호가 올바르지 않습니다." });
|
||||
}
|
||||
|
||||
// 이메일 인증 확인
|
||||
if (!user.email_verified) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "이메일 인증이 필요합니다. 메일함을 확인해주세요." });
|
||||
}
|
||||
|
||||
// 로그인 성공
|
||||
clearLoginAttempts(ip);
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email, isAdmin: user.is_admin },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
|
||||
console.log(`[Auth] 로그인 성공: ${user.email} - IP: ${ip}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
profileUrl: user.profile_url,
|
||||
isAdmin: user.is_admin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] 로그인 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/me - 현재 사용자 정보
|
||||
*/
|
||||
router.get("/me", async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.json({ loggedIn: false });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const [users] = await pool.query(
|
||||
"SELECT id, email, name, profile_url, is_admin FROM users WHERE id = ?",
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.json({ loggedIn: false });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
res.json({
|
||||
loggedIn: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
profileUrl: user.profile_url,
|
||||
isAdmin: user.is_admin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return res.json({ loggedIn: false });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제)
|
||||
*/
|
||||
router.post("/logout", (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
387
backend/routes/link.js
Normal file
387
backend/routes/link.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
/**
|
||||
* 마인크래프트 계정 연동 API
|
||||
*
|
||||
* POST /link/request - 연동 토큰 생성
|
||||
* GET /link/status - 연동 상태 확인
|
||||
* POST /link/verify - 마인크래프트에서 연동 완료 (모드에서 호출)
|
||||
* POST /link/unlink - 연동 해제
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { pool } from "../lib/db.js";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
|
||||
const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://rustfs:9000";
|
||||
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
|
||||
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
|
||||
|
||||
// S3 클라이언트 설정
|
||||
const s3Client = new S3Client({
|
||||
endpoint: S3_ENDPOINT,
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY || "",
|
||||
secretAccessKey: S3_SECRET_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 추출
|
||||
*/
|
||||
function getUserFromToken(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.split(" ")[1];
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 6자리 토큰 생성
|
||||
*/
|
||||
function generateLinkToken() {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 혼동되는 문자 제외
|
||||
let token = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 닉네임 생성 (연동 해제용)
|
||||
*/
|
||||
async function generateRandomNickname() {
|
||||
try {
|
||||
const [adjectives] = await pool.query(
|
||||
"SELECT word FROM nickname_words WHERE type = 'adjective' ORDER BY RAND() LIMIT 1"
|
||||
);
|
||||
const [nouns] = await pool.query(
|
||||
"SELECT word FROM nickname_words WHERE type = 'noun' ORDER BY RAND() LIMIT 1"
|
||||
);
|
||||
|
||||
if (adjectives.length > 0 && nouns.length > 0) {
|
||||
return `${adjectives[0].word} ${nouns[0].word}`;
|
||||
}
|
||||
return `사용자${Math.floor(Math.random() * 10000)}`;
|
||||
} catch {
|
||||
return `사용자${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 프로필 URL 생성 (연동 해제용)
|
||||
*/
|
||||
function getRandomProfileUrl() {
|
||||
const num = Math.floor(Math.random() * 16) + 1;
|
||||
return `https://s3.caadiq.co.kr/minecraft/profile/default_profile_${num}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 스킨 다운로드 후 S3 업로드
|
||||
*/
|
||||
async function downloadAndUploadSkin(uuid, minecraftName) {
|
||||
try {
|
||||
console.log(`[Link] 스킨 다운로드 시작: ${minecraftName} (${uuid})`);
|
||||
|
||||
// mc-heads.net에서 스킨 헤드 다운로드
|
||||
const skinUrl = `https://mc-heads.net/avatar/${uuid}/128`;
|
||||
console.log(`[Link] 스킨 URL: ${skinUrl}`);
|
||||
|
||||
const response = await fetch(skinUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[Link] 스킨 다운로드 실패: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
console.log(`[Link] 스킨 다운로드 완료: ${imageBuffer.byteLength} bytes`);
|
||||
|
||||
// S3에 업로드
|
||||
const key = `profile/${uuid}.png`;
|
||||
console.log(`[Link] S3 업로드 시작: bucket=minecraft, key=${key}`);
|
||||
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "minecraft",
|
||||
Key: key,
|
||||
Body: Buffer.from(imageBuffer),
|
||||
ContentType: "image/png",
|
||||
})
|
||||
);
|
||||
|
||||
const profileUrl = `https://s3.caadiq.co.kr/minecraft/${key}`;
|
||||
console.log(`[Link] 스킨 업로드 완료: ${profileUrl}`);
|
||||
return profileUrl;
|
||||
} catch (error) {
|
||||
console.error("[Link] 스킨 처리 오류:", error.message, error.stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /link/request - 연동 토큰 생성
|
||||
*/
|
||||
router.post("/request", async (req, res) => {
|
||||
const user = getUserFromToken(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "로그인이 필요합니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
// 기존 토큰 확인/갱신
|
||||
const [existing] = await pool.query(
|
||||
"SELECT * FROM minecraft_links WHERE user_id = ?",
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// 이미 연동된 경우
|
||||
if (existing.length > 0 && existing[0].minecraft_uuid) {
|
||||
return res.json({
|
||||
linked: true,
|
||||
minecraftName: existing[0].minecraft_name,
|
||||
minecraftUuid: existing[0].minecraft_uuid,
|
||||
});
|
||||
}
|
||||
|
||||
// 새 토큰 생성
|
||||
const linkToken = generateLinkToken();
|
||||
const tokenExpires = new Date(Date.now() + 10 * 60 * 1000); // 10분
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 기존 레코드 업데이트
|
||||
await pool.query(
|
||||
"UPDATE minecraft_links SET link_token = ?, token_expires = ? WHERE user_id = ?",
|
||||
[linkToken, tokenExpires, user.id]
|
||||
);
|
||||
} else {
|
||||
// 새 레코드 생성
|
||||
await pool.query(
|
||||
"INSERT INTO minecraft_links (user_id, link_token, token_expires) VALUES (?, ?, ?)",
|
||||
[user.id, linkToken, tokenExpires]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Link] 토큰 생성: ${user.email} -> ${linkToken}`);
|
||||
|
||||
res.json({
|
||||
linked: false,
|
||||
token: linkToken,
|
||||
command: `/인증 ${linkToken}`,
|
||||
expiresIn: 600, // 초
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Link] 토큰 생성 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /link/status - 연동 상태 확인
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
const user = getUserFromToken(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "로그인이 필요합니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
const [links] = await pool.query(
|
||||
"SELECT minecraft_uuid, minecraft_name, linked_at FROM minecraft_links WHERE user_id = ?",
|
||||
[user.id]
|
||||
);
|
||||
|
||||
if (links.length === 0 || !links[0].minecraft_uuid) {
|
||||
return res.json({ linked: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
linked: true,
|
||||
minecraftName: links[0].minecraft_name,
|
||||
minecraftUuid: links[0].minecraft_uuid,
|
||||
linkedAt: links[0].linked_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Link] 상태 확인 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /link/verify - 마인크래프트에서 연동 완료 (ServerStatus 모드에서 호출)
|
||||
*/
|
||||
router.post("/verify", async (req, res) => {
|
||||
const { token, uuid, name } = req.body;
|
||||
|
||||
if (!token || !uuid || !name) {
|
||||
return res.status(400).json({ error: "필수 파라미터가 누락되었습니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
// 이미 연동된 UUID인지 확인 (중복 연동 방지)
|
||||
const [existingUuid] = await pool.query(
|
||||
"SELECT user_id FROM minecraft_links WHERE minecraft_uuid = ?",
|
||||
[uuid]
|
||||
);
|
||||
|
||||
if (existingUuid.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "이미 다른 계정에 연동된 마인크래프트 계정입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 토큰 검증
|
||||
const [links] = await pool.query(
|
||||
"SELECT * FROM minecraft_links WHERE link_token = ? AND token_expires > NOW()",
|
||||
[token.toUpperCase()]
|
||||
);
|
||||
|
||||
if (links.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "유효하지 않거나 만료된 토큰입니다." });
|
||||
}
|
||||
|
||||
const link = links[0];
|
||||
|
||||
// 스킨 다운로드 및 업로드
|
||||
const profileUrl = await downloadAndUploadSkin(uuid, name);
|
||||
console.log(`[Link] 프로필 URL 결과: ${profileUrl}`);
|
||||
|
||||
// 연동 정보 업데이트
|
||||
await pool.query(
|
||||
`UPDATE minecraft_links
|
||||
SET minecraft_uuid = ?, minecraft_name = ?, linked_at = NOW(), link_token = NULL, token_expires = NULL
|
||||
WHERE id = ?`,
|
||||
[uuid, name, link.id]
|
||||
);
|
||||
|
||||
// users 테이블 업데이트 (닉네임, 프로필 사진)
|
||||
if (profileUrl) {
|
||||
await pool.query(
|
||||
"UPDATE users SET name = ?, profile_url = ? WHERE id = ?",
|
||||
[name, profileUrl, link.user_id]
|
||||
);
|
||||
console.log(
|
||||
`[Link] 사용자 업데이트 완료: name=${name}, profile_url=${profileUrl}`
|
||||
);
|
||||
} else {
|
||||
await pool.query("UPDATE users SET name = ? WHERE id = ?", [
|
||||
name,
|
||||
link.user_id,
|
||||
]);
|
||||
console.log(
|
||||
`[Link] 사용자 업데이트 완료: name=${name} (프로필 사진 없음)`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Link] 연동 완료: user_id=${link.user_id}, minecraft=${name} (${uuid})`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${name}님, 계정 연동이 완료되었습니다!`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Link] 연동 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /link/unlink - 연동 해제 (닉네임, 프로필 초기화)
|
||||
*/
|
||||
router.post("/unlink", async (req, res) => {
|
||||
const user = getUserFromToken(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "로그인이 필요합니다." });
|
||||
}
|
||||
|
||||
try {
|
||||
// 연동 해제
|
||||
await pool.query("DELETE FROM minecraft_links WHERE user_id = ?", [
|
||||
user.id,
|
||||
]);
|
||||
|
||||
// 닉네임, 프로필 초기화
|
||||
const newNickname = await generateRandomNickname();
|
||||
const newProfileUrl = getRandomProfileUrl();
|
||||
|
||||
await pool.query(
|
||||
"UPDATE users SET name = ?, profile_url = ? WHERE id = ?",
|
||||
[newNickname, newProfileUrl, user.id]
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Link] 연동 해제: user_id=${user.id}, 새 닉네임=${newNickname}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
newName: newNickname,
|
||||
newProfileUrl: newProfileUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Link] 연동 해제 오류:", error);
|
||||
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /link/cancel - 대기 중인 토큰 취소 (새로고침/페이지 이탈 시)
|
||||
*/
|
||||
router.post("/cancel", express.text({ type: "*/*" }), async (req, res) => {
|
||||
try {
|
||||
// sendBeacon은 JSON을 text로 보내므로 파싱
|
||||
let authToken;
|
||||
try {
|
||||
const body =
|
||||
typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
authToken = body.authToken;
|
||||
} catch {
|
||||
authToken = null;
|
||||
}
|
||||
|
||||
if (!authToken) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
// JWT에서 사용자 ID 추출
|
||||
let userId;
|
||||
try {
|
||||
const decoded = jwt.verify(authToken, JWT_SECRET);
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
// 대기 중인 토큰 삭제 (연동 완료되지 않은 것만)
|
||||
await pool.query(
|
||||
"UPDATE minecraft_links SET link_token = NULL, token_expires = NULL WHERE user_id = ? AND minecraft_uuid IS NULL",
|
||||
[userId]
|
||||
);
|
||||
|
||||
console.log(`[Link] 토큰 취소: user_id=${userId}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[Link] 토큰 취소 오류:", error);
|
||||
res.status(200).json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -3,6 +3,7 @@ import { createServer } from "http";
|
|||
import { Server } from "socket.io";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import session from "express-session";
|
||||
|
||||
// 모듈 import
|
||||
import { loadTranslations } from "./lib/db.js";
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
getCachedPlayers,
|
||||
} from "./lib/minecraft.js";
|
||||
import apiRoutes from "./routes/api.js";
|
||||
import authRoutes from "./routes/auth.js";
|
||||
import linkRoutes from "./routes/link.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -28,6 +31,28 @@ const io = new Server(httpServer, {
|
|||
});
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || "minecraft-status-secret";
|
||||
|
||||
// JSON 파싱 미들웨어
|
||||
app.use(express.json());
|
||||
|
||||
// 프록시 신뢰 (X-Forwarded-For 헤더 사용)
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// 세션 설정
|
||||
app.use(
|
||||
session({
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // HTTPS가 아닌 환경에서도 작동 (프록시 뒤에서는 Caddy가 HTTPS 처리)
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24시간
|
||||
sameSite: "lax",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// dist 디렉토리에서 정적 파일 제공
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
|
|
@ -35,6 +60,12 @@ app.use(express.static(path.join(__dirname, "dist")));
|
|||
// API 라우트
|
||||
app.use("/api", apiRoutes);
|
||||
|
||||
// 인증 라우트
|
||||
app.use("/auth", authRoutes);
|
||||
|
||||
// 마인크래프트 연동 라우트
|
||||
app.use("/link", linkRoutes);
|
||||
|
||||
// Socket.IO 연결 처리
|
||||
io.on("connection", (socket) => {
|
||||
console.log("클라이언트 연결됨:", socket.id);
|
||||
|
|
|
|||
82
backend/services/emailService.js
Normal file
82
backend/services/emailService.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* 이메일 발송 서비스 (Resend API)
|
||||
* 인증 이메일 발송
|
||||
*/
|
||||
|
||||
import { Resend } from "resend";
|
||||
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const MAIL_FROM = process.env.MAIL_FROM || "no-reply@caadiq.co.kr";
|
||||
const SITE_URL = process.env.SITE_URL || "https://minecraft.caadiq.co.kr";
|
||||
|
||||
// Resend 클라이언트 생성
|
||||
const resend = new Resend(RESEND_API_KEY);
|
||||
|
||||
/**
|
||||
* 이메일 인증 메일 발송
|
||||
*/
|
||||
export async function sendVerificationEmail(to, token, name) {
|
||||
const verifyUrl = `${SITE_URL}/verify/${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #ffffff; color: #333333; margin: 0; padding: 40px 20px; }
|
||||
.container { max-width: 500px; margin: 0 auto; background: #1a1a1a; border-radius: 16px; padding: 40px; border: 1px solid #2a2a2a; }
|
||||
.logo { text-align: center; margin-bottom: 30px; }
|
||||
.logo-text { font-size: 24px; font-weight: bold; color: #22c55e; }
|
||||
h1 { font-size: 20px; margin: 0 0 20px 0; color: #fff; }
|
||||
p { line-height: 1.6; margin: 0 0 20px 0; color: #a3a3a3; }
|
||||
.button { display: inline-block; background: #22c55e; color: #fff; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-weight: 600; }
|
||||
.button:hover { background: #16a34a; }
|
||||
.footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; }
|
||||
.link { color: #22c55e; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<span class="logo-text">🎮 마인크래프트 서버</span>
|
||||
</div>
|
||||
<h1>이메일 인증</h1>
|
||||
<p>안녕하세요${name ? `, ${name}님` : ""}!</p>
|
||||
<p>마인크래프트 서버 대시보드에 가입해주셔서 감사합니다.<br>아래 버튼을 클릭하여 이메일 인증을 완료해주세요.</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="${verifyUrl}" class="button">이메일 인증하기</a>
|
||||
</p>
|
||||
<p style="font-size: 13px;">버튼이 작동하지 않으면 아래 링크를 복사해서 브라우저에 붙여넣으세요:</p>
|
||||
<p class="link" style="font-size: 12px;">${verifyUrl}</p>
|
||||
<div class="footer">
|
||||
<p>이 링크는 24시간 동안 유효합니다.</p>
|
||||
<p>본인이 가입하지 않았다면 이 메일을 무시해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await resend.emails.send({
|
||||
from: MAIL_FROM,
|
||||
to: to,
|
||||
subject: "[마인크래프트 서버] 이메일 인증",
|
||||
html: html,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[Email] 발송 오류:", result.error);
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
console.log(`[Email] 인증 메일 발송 완료: ${to}`);
|
||||
return { success: true, messageId: result.data?.id };
|
||||
} catch (error) {
|
||||
console.error("[Email] 발송 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default { sendVerificationEmail };
|
||||
|
|
@ -6,6 +6,9 @@ services:
|
|||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
env_file:
|
||||
- .env
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
networks:
|
||||
- minecraft
|
||||
- db
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import WorldsPage from './pages/WorldsPage';
|
|||
import PlayersPage from './pages/PlayersPage';
|
||||
import PlayerStatsPage from './pages/PlayerStatsPage';
|
||||
import WorldMapPage from './pages/WorldMapPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
||||
import Admin from './pages/Admin';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
// 페이지 전환 애니메이션 래퍼 컴포넌트
|
||||
const PageWrapper = ({ children }) => (
|
||||
|
|
@ -25,11 +30,53 @@ function App() {
|
|||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
// 인증 페이지 여부 확인 (사이드바 없이 렌더링)
|
||||
const isAuthPage = ['/login', '/register'].includes(location.pathname) ||
|
||||
location.pathname.startsWith('/verify/');
|
||||
|
||||
// 별도 레이아웃 페이지 (관리자, 프로필)
|
||||
const isStandalonePage = ['/admin', '/profile'].includes(location.pathname);
|
||||
|
||||
// 라우트 전환 시 스크롤 맨 위로
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 인증 페이지는 사이드바 없이 렌더링
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<DeviceLayout>
|
||||
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/login" element={<PageWrapper><LoginPage /></PageWrapper>} />
|
||||
<Route path="/register" element={<PageWrapper><RegisterPage /></PageWrapper>} />
|
||||
<Route path="/verify/:token" element={<PageWrapper><VerifyEmailPage /></PageWrapper>} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자/프로필 페이지는 별도 레이아웃
|
||||
if (isStandalonePage) {
|
||||
return (
|
||||
<DeviceLayout>
|
||||
<div className="bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/admin" element={<PageWrapper><Admin /></PageWrapper>} />
|
||||
<Route path="/profile" element={<PageWrapper><ProfilePage /></PageWrapper>} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DeviceLayout>
|
||||
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import React, { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Home, Globe, Users, Menu, X, Gamepad2, Map } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
|
||||
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// 사이드바 네비게이션 컴포넌트
|
||||
const Sidebar = ({ isMobile = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [minecraftLink, setMinecraftLink] = useState(null);
|
||||
const [serverOnline, setServerOnline] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isLoggedIn, isAdmin, user, logout } = useAuth();
|
||||
const profileMenuRef = useRef(null);
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/', icon: Home, label: '홈' },
|
||||
|
|
@ -23,10 +33,249 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
return location.pathname === item.path;
|
||||
};
|
||||
|
||||
// 프로필 메뉴 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target)) {
|
||||
setShowProfileMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 연동 상태 확인
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
setMinecraftLink(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchLinkStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/link/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.linked) {
|
||||
setMinecraftLink(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('연동 상태 확인 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLinkStatus();
|
||||
}, [isLoggedIn, user]);
|
||||
|
||||
// 서버 상태 확인 (socket.io)
|
||||
useEffect(() => {
|
||||
const socket = io(window.location.origin, { path: '/socket.io' });
|
||||
|
||||
socket.on('status', (status) => {
|
||||
setServerOnline(status?.online || false);
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, []);
|
||||
|
||||
// 토스트 자동 숨기기
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// 통계 페이지 이동 핸들러
|
||||
const handleStatsClick = () => {
|
||||
if (!serverOnline) {
|
||||
setToast('서버가 오프라인입니다.');
|
||||
setShowProfileMenu(false);
|
||||
return;
|
||||
}
|
||||
setShowProfileMenu(false);
|
||||
navigate(`/player/${minecraftLink.minecraftUuid}/stats`);
|
||||
};
|
||||
|
||||
// 로그아웃 핸들러
|
||||
const handleLogout = () => {
|
||||
setShowProfileMenu(false);
|
||||
setShowLogoutDialog(true);
|
||||
};
|
||||
|
||||
const confirmLogout = () => {
|
||||
logout();
|
||||
setShowLogoutDialog(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// 프로필 메뉴 컴포넌트
|
||||
const ProfileMenu = () => (
|
||||
<AnimatePresence>
|
||||
{showProfileMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-xl overflow-hidden z-50"
|
||||
>
|
||||
{/* 프로필 정보 */}
|
||||
<div className="p-4 bg-zinc-800/80 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={user?.profileUrl || '/default-profile.png'}
|
||||
alt="프로필"
|
||||
className="w-12 h-12 rounded-full object-cover border-2 border-mc-green"
|
||||
onError={(e) => { e.target.src = 'https://via.placeholder.com/48'; }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-white truncate">{user?.name}</p>
|
||||
{isAdmin && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/20 text-yellow-400 rounded">관리자</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 항목 */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); navigate('/profile'); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
||||
>
|
||||
<User size={16} />
|
||||
프로필 수정
|
||||
</button>
|
||||
|
||||
{minecraftLink && (
|
||||
<button
|
||||
onClick={handleStatsClick}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-mc-green hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<BarChart2 size={16} />
|
||||
내 통계
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); navigate('/admin'); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-yellow-400 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Shield size={16} />
|
||||
관리자
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-t border-zinc-700 my-1" />
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 로그아웃 확인 다이얼로그
|
||||
const LogoutDialog = () => (
|
||||
<AnimatePresence>
|
||||
{showLogoutDialog && (
|
||||
<>
|
||||
{/* 오버레이 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
||||
onClick={() => setShowLogoutDialog(false)}
|
||||
/>
|
||||
{/* 다이얼로그 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101] shadow-2xl"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
||||
<LogOut className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">로그아웃</h3>
|
||||
<p className="text-sm text-zinc-400 mb-6">정말 로그아웃 하시겠습니까?</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLogoutDialog(false)}
|
||||
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmLogout}
|
||||
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 프로필 영역 (클릭 가능)
|
||||
const ProfileSection = () => (
|
||||
<div className="relative p-4 border-t border-white/10" ref={profileMenuRef}>
|
||||
<ProfileMenu />
|
||||
<button
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={user?.profileUrl || '/default-profile.png'}
|
||||
alt="프로필"
|
||||
className="w-10 h-10 rounded-full object-cover border border-zinc-700"
|
||||
onError={(e) => { e.target.src = 'https://via.placeholder.com/40'; }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-white font-medium truncate">{user?.name}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 모바일: 상단 툴바 + 햄버거 메뉴 사이드바
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<LogoutDialog />
|
||||
|
||||
{/* 토스트 알림 */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
className="fixed bottom-20 left-4 right-4 z-[200] bg-red-500/90 backdrop-blur-sm text-white px-4 py-3 rounded-xl text-center font-medium shadow-lg"
|
||||
>
|
||||
{toast}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* 상단 툴바 */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-mc-dark/95 backdrop-blur-xl border-b border-white/10 safe-area-top">
|
||||
<div className="flex items-center justify-between h-14 px-4">
|
||||
|
|
@ -44,8 +293,93 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
<span className="font-bold text-white">마인크래프트</span>
|
||||
</div>
|
||||
|
||||
{/* 우측 공간 (균형용) */}
|
||||
<div className="w-10" />
|
||||
{/* 로그인/유저 */}
|
||||
{isLoggedIn ? (
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button onClick={() => setShowProfileMenu(!showProfileMenu)}>
|
||||
<img
|
||||
src={user?.profileUrl || '/default-profile.png'}
|
||||
alt="프로필"
|
||||
className="w-8 h-8 rounded-full object-cover border border-zinc-700"
|
||||
onError={(e) => { e.target.src = 'https://via.placeholder.com/32'; }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 드롭다운 메뉴 */}
|
||||
<AnimatePresence>
|
||||
{showProfileMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full right-0 mt-2 w-56 bg-zinc-800 border border-zinc-700 rounded-xl shadow-xl overflow-hidden z-[60]"
|
||||
>
|
||||
{/* 프로필 정보 */}
|
||||
<div className="p-3 bg-zinc-800/80 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={user?.profileUrl || '/default-profile.png'}
|
||||
alt="프로필"
|
||||
className="w-10 h-10 rounded-full object-cover border-2 border-mc-green"
|
||||
onError={(e) => { e.target.src = 'https://via.placeholder.com/40'; }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">{user?.name}</p>
|
||||
<p className="text-xs text-zinc-400 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 항목 */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); navigate('/profile'); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
||||
>
|
||||
<User size={16} />
|
||||
프로필 수정
|
||||
</button>
|
||||
|
||||
{minecraftLink && (
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); handleStatsClick(); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-mc-green hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<BarChart2 size={16} />
|
||||
내 통계
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); navigate('/admin'); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-yellow-400 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Shield size={16} />
|
||||
관리자
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-t border-zinc-700 my-1" />
|
||||
|
||||
<button
|
||||
onClick={() => { setShowProfileMenu(false); setShowLogoutDialog(true); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="text-mc-green">
|
||||
<LogIn size={20} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -117,7 +451,19 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
|
||||
{/* 하단: 로그인 버튼 (비로그인 시에만) */}
|
||||
{!isLoggedIn && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -128,9 +474,71 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
// PC: 기존 사이드바
|
||||
return (
|
||||
<>
|
||||
<LogoutDialog />
|
||||
|
||||
{/* 토스트 알림 */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg"
|
||||
>
|
||||
{toast}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* 데스크탑 사이드바 (항상 표시) */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-64 bg-mc-dark/95 backdrop-blur-xl border-r border-white/10 z-30 flex-col">
|
||||
<SidebarContent menuItems={menuItems} isMenuActive={isMenuActive} />
|
||||
{/* 로고 */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
||||
<Gamepad2 className="text-mc-green" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">마인크래프트</h1>
|
||||
<p className="text-xs text-gray-500">서버 대시보드</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const active = isMenuActive(item);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
||||
active
|
||||
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 하단: 로그인/유저 정보 */}
|
||||
{isLoggedIn ? (
|
||||
<ProfileSection />
|
||||
) : (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 데스크톱용 사이드바 spacer */}
|
||||
|
|
@ -139,46 +547,4 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
);
|
||||
};
|
||||
|
||||
// 사이드바 내용 컴포넌트 (PC 전용)
|
||||
const SidebarContent = ({ menuItems, isMenuActive, onClose }) => (
|
||||
<>
|
||||
{/* 로고 */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
||||
<Gamepad2 className="text-mc-green" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">마인크래프트</h1>
|
||||
<p className="text-xs text-gray-500">서버 대시보드</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const active = isMenuActive(item);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
||||
active
|
||||
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
|
|
|
|||
126
frontend/src/contexts/AuthContext.jsx
Normal file
126
frontend/src/contexts/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* 인증 상태 관리 Context
|
||||
* JWT 토큰 기반 인증
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 컴포넌트 마운트 시 로그인 상태 확인
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// 로그인 상태 확인
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.loggedIn) {
|
||||
setUser(data.user);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('인증 상태 확인 실패:', error);
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 로그인
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
localStorage.setItem('token', data.token);
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
return { success: false, error: '로그인 중 오류가 발생했습니다.' };
|
||||
}
|
||||
};
|
||||
|
||||
// 회원가입 (name 파라미터 제거)
|
||||
const register = async (email, password) => {
|
||||
try {
|
||||
const response = await fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
return { success: true, message: data.message };
|
||||
} else {
|
||||
return { success: false, error: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('회원가입 실패:', error);
|
||||
return { success: false, error: '회원가입 중 오류가 발생했습니다.' };
|
||||
}
|
||||
};
|
||||
|
||||
// 로그아웃
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
isLoggedIn: !!user,
|
||||
isAdmin: user?.isAdmin || false,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
checkAuth
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth는 AuthProvider 내에서 사용해야 합니다');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
|
|
@ -19,6 +19,7 @@ body.mobile-layout {
|
|||
min-width: unset;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'
|
|||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
|
|||
126
frontend/src/pages/Admin.jsx
Normal file
126
frontend/src/pages/Admin.jsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* 관리자 페이지
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Shield, LogOut, Settings, Server, Users, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Admin() {
|
||||
const { isLoggedIn, isAdmin, user, loading, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 권한 확인
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (!isLoggedIn) {
|
||||
navigate('/login');
|
||||
} else if (!isAdmin) {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn, isAdmin, loading, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-8 h-8 text-mc-green animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn || !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30">
|
||||
<Shield className="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">관리자 페이지</h1>
|
||||
<p className="text-sm text-zinc-400">서버 관리 및 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">로그인 정보</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-zinc-500">이름</span>
|
||||
<p className="text-white mt-1">{user?.name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500">이메일</span>
|
||||
<p className="text-white mt-1">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 관리 기능 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 서버 상태 */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Server className="w-5 h-5 text-mc-green" />
|
||||
<h2 className="text-lg font-semibold text-white">서버 상태</h2>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
마인크래프트 서버 상태 모니터링 및 관리
|
||||
</p>
|
||||
<div className="mt-4 py-2 px-3 bg-mc-green/10 border border-mc-green/20 rounded-lg text-mc-green text-sm inline-block">
|
||||
정상 작동 중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플레이어 관리 */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-white">플레이어 관리</h2>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
접속 중인 플레이어 목록 및 관리 기능
|
||||
</p>
|
||||
<div className="mt-4 text-zinc-500 text-sm">
|
||||
추후 업데이트 예정
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 md:col-span-2">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Settings className="w-5 h-5 text-zinc-400" />
|
||||
<h2 className="text-lg font-semibold text-white">설정</h2>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
대시보드 설정 및 구성 관리
|
||||
</p>
|
||||
<div className="mt-4 text-zinc-500 text-sm">
|
||||
추후 업데이트 예정
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/LoginPage.jsx
Normal file
128
frontend/src/pages/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* 로그인 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Lock, Mail, AlertCircle, Loader2, Shield, UserPlus } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(email, password);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* 로고 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex p-4 rounded-2xl bg-mc-green/10 border border-mc-green/20 mb-4">
|
||||
<Shield className="w-10 h-10 text-mc-green" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">로그인</h1>
|
||||
<p className="text-zinc-400">마인크래프트 서버 대시보드</p>
|
||||
</div>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 space-y-5">
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">이메일</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-mc-green/50 focus:ring-1 focus:ring-mc-green/50 transition-colors"
|
||||
placeholder="이메일을 입력하세요"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-mc-green/50 focus:ring-1 focus:ring-mc-green/50 transition-colors"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-mc-green hover:bg-mc-green/80 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-5 h-5" />
|
||||
로그인
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 회원가입 링크 */}
|
||||
<div className="text-center pt-2">
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center gap-2 text-mc-green hover:text-mc-green/80 text-sm transition-colors"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
계정이 없으신가요?
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-center text-zinc-500 text-sm mt-6">
|
||||
5회 로그인 실패 시 15분간 잠금됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
frontend/src/pages/ProfilePage.jsx
Normal file
311
frontend/src/pages/ProfilePage.jsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* 프로필 페이지
|
||||
* 프로필 정보 및 마인크래프트 계정 연동
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { ArrowLeft, Copy, Check, Gamepad2, Link as LinkIcon, Unlink, RefreshCw, User, Mail } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isLoggedIn, loading, checkAuth } = useAuth();
|
||||
|
||||
const [linkStatus, setLinkStatus] = useState(null);
|
||||
const [linkToken, setLinkToken] = useState(null);
|
||||
const [command, setCommand] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [showUnlinkDialog, setShowUnlinkDialog] = useState(false);
|
||||
|
||||
// 로그인 체크 (loading 완료 후에만)
|
||||
useEffect(() => {
|
||||
if (!loading && !isLoggedIn) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [loading, isLoggedIn, navigate]);
|
||||
|
||||
// 연동 상태 확인
|
||||
useEffect(() => {
|
||||
fetchLinkStatus();
|
||||
}, []);
|
||||
|
||||
// 폴링 (연동 대기 중일 때)
|
||||
useEffect(() => {
|
||||
if (!polling) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const status = await fetchLinkStatus();
|
||||
if (status?.linked) {
|
||||
setPolling(false);
|
||||
setLinkToken(null);
|
||||
setCommand('');
|
||||
// 유저 정보 새로고침
|
||||
await checkAuth();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [polling]);
|
||||
|
||||
// 연동 대기 중 새로고침 방지 + 토큰 무효화
|
||||
useEffect(() => {
|
||||
if (!linkToken) return;
|
||||
|
||||
const handleBeforeUnload = (e) => {
|
||||
// 토큰 무효화 API 호출 (sendBeacon으로 페이지 이탈 전 전송)
|
||||
const token = localStorage.getItem('token');
|
||||
navigator.sendBeacon('/link/cancel', JSON.stringify({ authToken: token }));
|
||||
|
||||
e.preventDefault();
|
||||
e.returnValue = '연동이 진행 중입니다. 페이지를 떠나시겠습니까?';
|
||||
return e.returnValue;
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [linkToken]);
|
||||
|
||||
const fetchLinkStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/link/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
setLinkStatus(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('연동 상태 확인 실패:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestLink = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/link/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.linked) {
|
||||
setLinkStatus({ linked: true, minecraftName: data.minecraftName });
|
||||
} else {
|
||||
setLinkToken(data.token);
|
||||
setCommand(data.command);
|
||||
setPolling(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('연동 요청 실패:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCommand = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleUnlink = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await fetch('/link/unlink', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
setLinkStatus({ linked: false });
|
||||
setShowUnlinkDialog(false);
|
||||
await checkAuth();
|
||||
} catch (error) {
|
||||
console.error('연동 해제 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-mc-bg pb-8">
|
||||
{/* 헤더 */}
|
||||
<header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">프로필</h1>
|
||||
<p className="text-sm text-zinc-500">계정 정보 및 마인크래프트 연동</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-4 sm:space-y-6">
|
||||
{/* 프로필 정보 카드 */}
|
||||
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<User size={20} className="text-mc-green" />
|
||||
기본 정보
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<img
|
||||
src={user.profileUrl || '/default-profile.png'}
|
||||
alt="프로필"
|
||||
className="w-24 h-24 rounded-2xl object-cover border-2 border-zinc-700"
|
||||
onError={(e) => { e.target.src = 'https://via.placeholder.com/96'; }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-zinc-500">닉네임</p>
|
||||
<p className="text-lg font-medium text-white">{user.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-zinc-500">이메일</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
<Mail size={14} className="text-zinc-500" />
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 마인크래프트 연동 카드 */}
|
||||
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Gamepad2 size={20} className="text-mc-green" />
|
||||
마인크래프트 계정 연동
|
||||
</h2>
|
||||
|
||||
{linkStatus?.linked ? (
|
||||
// 연동 완료 상태
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-mc-green/10 border border-mc-green/20 rounded-xl">
|
||||
<div className="w-12 h-12 rounded-lg bg-mc-green/20 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-mc-green" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">연동 완료</p>
|
||||
<p className="text-sm text-zinc-400">
|
||||
마인크래프트 계정: <span className="text-mc-green font-medium">{linkStatus.minecraftName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUnlinkDialog(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Unlink size={16} />
|
||||
연동 해제
|
||||
</button>
|
||||
</div>
|
||||
) : linkToken ? (
|
||||
// 연동 대기 상태
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-xl">
|
||||
<p className="text-sm text-yellow-400 mb-2">마인크래프트에서 아래 명령어를 입력하세요:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-4 py-3 bg-zinc-800 rounded-lg text-lg font-mono text-white">
|
||||
{command}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="p-3 bg-mc-green hover:bg-mc-green/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{copied ? <Check size={20} /> : <Copy size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-zinc-400">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
연동 대기 중... (10분 후 만료)
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setLinkToken(null); setCommand(''); setPolling(false); }}
|
||||
className="text-sm text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// 연동 안됨 상태
|
||||
<div className="space-y-6">
|
||||
<p className="text-zinc-400">
|
||||
마인크래프트 계정을 연동하면 게임 내 닉네임과 스킨이 프로필에 적용됩니다.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleRequestLink}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-mc-green hover:bg-mc-green/80 disabled:bg-zinc-700 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw size={18} className="animate-spin" />
|
||||
) : (
|
||||
<LinkIcon size={18} />
|
||||
)}
|
||||
마인크래프트 계정 연동하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 연동 해제 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
{showUnlinkDialog && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
||||
onClick={() => setShowUnlinkDialog(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101]"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-white mb-2">연동 해제</h3>
|
||||
<p className="text-sm text-zinc-400 mb-6">
|
||||
마인크래프트 계정 연동을 해제하시겠습니까?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUnlinkDialog(false)}
|
||||
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnlink}
|
||||
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
연동 해제
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/RegisterPage.jsx
Normal file
188
frontend/src/pages/RegisterPage.jsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* 회원가입 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Lock, Mail, AlertCircle, Loader2, UserPlus, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 비밀번호 확인
|
||||
if (password !== confirmPassword) {
|
||||
setError('비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 길이 확인
|
||||
if (password.length < 8) {
|
||||
setError('비밀번호는 8자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await register(email, password);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
// 성공 화면
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex p-4 rounded-2xl bg-mc-green/10 border border-mc-green/20 mb-6">
|
||||
<CheckCircle className="w-12 h-12 text-mc-green" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">가입 완료!</h1>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
<span className="text-white font-medium">{email}</span>으로<br />
|
||||
인증 메일을 발송했습니다.
|
||||
</p>
|
||||
<p className="text-zinc-500 text-sm mb-8">
|
||||
메일함을 확인하여 이메일 인증을 완료해주세요.<br />
|
||||
인증 링크는 24시간 동안 유효합니다.
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
로그인 페이지로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* 로고 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex p-4 rounded-2xl bg-blue-500/10 border border-blue-500/20 mb-4">
|
||||
<UserPlus className="w-10 h-10 text-blue-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">회원가입</h1>
|
||||
<p className="text-zinc-400">마인크래프트 서버 대시보드</p>
|
||||
</div>
|
||||
|
||||
{/* 회원가입 폼 */}
|
||||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 space-y-5">
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400 text-sm">
|
||||
닉네임은 가입 시 자동으로 생성됩니다!
|
||||
</div>
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">이메일</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 transition-colors"
|
||||
placeholder="이메일을 입력하세요"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 transition-colors"
|
||||
placeholder="8자 이상"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-2">비밀번호 확인</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 transition-colors"
|
||||
placeholder="비밀번호 다시 입력"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가입 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
가입 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-5 h-5" />
|
||||
회원가입
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 로그인 링크 */}
|
||||
<div className="text-center pt-2">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
이미 계정이 있으신가요?
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/pages/VerifyEmailPage.jsx
Normal file
85
frontend/src/pages/VerifyEmailPage.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* 이메일 인증 결과 페이지
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { CheckCircle, XCircle, Loader2, ArrowRight } from 'lucide-react';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { token } = useParams();
|
||||
const [status, setStatus] = useState('loading'); // loading, success, error
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
verifyEmail();
|
||||
}, [token]);
|
||||
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
const response = await fetch(`/auth/verify/${token}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setStatus('success');
|
||||
setMessage(data.message);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage(data.error || '인증에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setMessage('서버 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<div className="inline-flex p-4 rounded-2xl bg-zinc-800 border border-zinc-700 mb-6">
|
||||
<Loader2 className="w-12 h-12 text-zinc-400 animate-spin" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">인증 중...</h1>
|
||||
<p className="text-zinc-400">이메일을 확인하고 있습니다.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="inline-flex p-4 rounded-2xl bg-mc-green/10 border border-mc-green/20 mb-6">
|
||||
<CheckCircle className="w-12 h-12 text-mc-green" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">인증 완료!</h1>
|
||||
<p className="text-zinc-400 mb-8">{message}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
로그인하기
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="inline-flex p-4 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
||||
<XCircle className="w-12 h-12 text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-4">인증 실패</h1>
|
||||
<p className="text-zinc-400 mb-8">{message}</p>
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
다시 가입하기
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue