diff --git a/.env b/.env
index 6177fbb..686a7e7 100644
--- a/.env
+++ b/.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
diff --git a/Dockerfile b/Dockerfile
index 43ade58..f7ae506 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/backend/lib/db.js b/backend/lib/db.js
index 2ccca8d..f4eb18e 100644
--- a/backend/lib/db.js
+++ b/backend/lib/db.js
@@ -80,6 +80,7 @@ const setIconCache = (key, value) => {
export {
dbPool,
+ dbPool as pool,
loadTranslations,
getTranslations,
getIcons,
diff --git a/backend/package.json b/backend/package.json
index eaa974b..9686e77 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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"
}
}
\ No newline at end of file
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
new file mode 100644
index 0000000..e79d9ed
--- /dev/null
+++ b/backend/routes/auth.js
@@ -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;
diff --git a/backend/routes/link.js b/backend/routes/link.js
new file mode 100644
index 0000000..b46bb0b
--- /dev/null
+++ b/backend/routes/link.js
@@ -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;
diff --git a/backend/server.js b/backend/server.js
index 374b54a..4aba6c6 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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);
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
new file mode 100644
index 0000000..be7e775
--- /dev/null
+++ b/backend/services/emailService.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+ 🎮 마인크래프트 서버
+
+
이메일 인증
+
안녕하세요${name ? `, ${name}님` : ""}!
+
마인크래프트 서버 대시보드에 가입해주셔서 감사합니다.
아래 버튼을 클릭하여 이메일 인증을 완료해주세요.
+
+ 이메일 인증하기
+
+
버튼이 작동하지 않으면 아래 링크를 복사해서 브라우저에 붙여넣으세요:
+
${verifyUrl}
+
+
+
+
+ `;
+
+ 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 };
diff --git a/docker-compose.yml b/docker-compose.yml
index b291645..275afb7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8cc40fb..dfd8706 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 (
+
+
+
+
+
+ } />
+ } />
+ } />
+
+
+
+
+ );
+ }
+
+ // 관리자/프로필 페이지는 별도 레이아웃
+ if (isStandalonePage) {
+ return (
+
+
+
+
+
+ } />
+ } />
+
+
+
+
+ );
+ }
+
return (
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 3e5ce1d..a81958e 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -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 = () => (
+
+ {showProfileMenu && (
+
+ {/* 프로필 정보 */}
+
+
+

{ e.target.src = 'https://via.placeholder.com/48'; }}
+ />
+
+
+
{user?.name}
+ {isAdmin && (
+
관리자
+ )}
+
+
{user?.email}
+
+
+
+
+ {/* 메뉴 항목 */}
+
+
+
+ {minecraftLink && (
+
+ )}
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+
+ // 로그아웃 확인 다이얼로그
+ const LogoutDialog = () => (
+
+ {showLogoutDialog && (
+ <>
+ {/* 오버레이 */}
+ setShowLogoutDialog(false)}
+ />
+ {/* 다이얼로그 */}
+
+
+
+
+
+
로그아웃
+
정말 로그아웃 하시겠습니까?
+
+
+
+
+
+
+ >
+ )}
+
+ );
+
+ // 프로필 영역 (클릭 가능)
+ const ProfileSection = () => (
+
+
+
+
+ );
+
// 모바일: 상단 툴바 + 햄버거 메뉴 사이드바
if (isMobile) {
return (
<>
+
+
+ {/* 토스트 알림 */}
+
+ {toast && (
+
+ {toast}
+
+ )}
+
{/* 상단 툴바 */}
@@ -117,7 +451,19 @@ const Sidebar = ({ isMobile = false }) => {
})}
-
+ {/* 하단: 로그인 버튼 (비로그인 시에만) */}
+ {!isLoggedIn && (
+
+ 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"
+ >
+
+ 로그인
+
+
+ )}
)}
@@ -128,9 +474,71 @@ const Sidebar = ({ isMobile = false }) => {
// PC: 기존 사이드바
return (
<>
+
+
+ {/* 토스트 알림 */}
+
+ {toast && (
+
+ {toast}
+
+ )}
+
{/* 데스크탑 사이드바 (항상 표시) */}
{/* 데스크톱용 사이드바 spacer */}
@@ -139,46 +547,4 @@ const Sidebar = ({ isMobile = false }) => {
);
};
-// 사이드바 내용 컴포넌트 (PC 전용)
-const SidebarContent = ({ menuItems, isMenuActive, onClose }) => (
- <>
- {/* 로고 */}
-
-
- {/* 메뉴 */}
-
-
-
- >
-);
-
export default Sidebar;
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
new file mode 100644
index 0000000..23c5979
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth는 AuthProvider 내에서 사용해야 합니다');
+ }
+ return context;
+}
+
+export default AuthContext;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 8f4b7a1..a8299f7 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -19,6 +19,7 @@ body.mobile-layout {
min-width: unset;
width: 100%;
overflow-x: hidden;
+ min-height: auto;
}
@layer utilities {
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index cb32e73..33b29aa 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -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(
-
+
+
+
,
)
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
new file mode 100644
index 0000000..618a1f7
--- /dev/null
+++ b/frontend/src/pages/Admin.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (!isLoggedIn || !isAdmin) {
+ return null;
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {/* 사용자 정보 */}
+
+
로그인 정보
+
+
+
이름
+
{user?.name || '-'}
+
+
+
+
+
+ {/* 관리 기능 카드 */}
+
+ {/* 서버 상태 */}
+
+
+
+
서버 상태
+
+
+ 마인크래프트 서버 상태 모니터링 및 관리
+
+
+ 정상 작동 중
+
+
+
+ {/* 플레이어 관리 */}
+
+
+
+
플레이어 관리
+
+
+ 접속 중인 플레이어 목록 및 관리 기능
+
+
+ 추후 업데이트 예정
+
+
+
+ {/* 설정 */}
+
+
+
+
설정
+
+
+ 대시보드 설정 및 구성 관리
+
+
+ 추후 업데이트 예정
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..1199988
--- /dev/null
+++ b/frontend/src/pages/LoginPage.jsx
@@ -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 (
+
+
+ {/* 로고 */}
+
+
+
+
+
로그인
+
마인크래프트 서버 대시보드
+
+
+ {/* 로그인 폼 */}
+
+
+ {/* 안내 문구 */}
+
+ 5회 로그인 실패 시 15분간 잠금됩니다
+
+
+
+ );
+}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx
new file mode 100644
index 0000000..e7bece3
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.jsx
@@ -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 (
+
+ {/* 헤더 */}
+
+
+
+
+
+
+
+
프로필
+
계정 정보 및 마인크래프트 연동
+
+
+
+
+
+
+ {/* 프로필 정보 카드 */}
+
+
+
+ 기본 정보
+
+
+
+

{ e.target.src = 'https://via.placeholder.com/96'; }}
+ />
+
+
+
+
+
이메일
+
+
+ {user.email}
+
+
+
+
+
+
+ {/* 마인크래프트 연동 카드 */}
+
+
+
+ 마인크래프트 계정 연동
+
+
+ {linkStatus?.linked ? (
+ // 연동 완료 상태
+
+
+
+
+
+
+
연동 완료
+
+ 마인크래프트 계정: {linkStatus.minecraftName}
+
+
+
+
+
+
+ ) : linkToken ? (
+ // 연동 대기 상태
+
+
+
마인크래프트에서 아래 명령어를 입력하세요:
+
+
+ {command}
+
+
+
+
+
+
+
+ 연동 대기 중... (10분 후 만료)
+
+
+
+
+ ) : (
+ // 연동 안됨 상태
+
+
+ 마인크래프트 계정을 연동하면 게임 내 닉네임과 스킨이 프로필에 적용됩니다.
+
+
+
+
+ )}
+
+
+
+ {/* 연동 해제 다이얼로그 */}
+
+ {showUnlinkDialog && (
+ <>
+ setShowUnlinkDialog(false)}
+ />
+
+ 연동 해제
+
+ 마인크래프트 계정 연동을 해제하시겠습니까?
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx
new file mode 100644
index 0000000..d6c978c
--- /dev/null
+++ b/frontend/src/pages/RegisterPage.jsx
@@ -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 (
+
+
+
+
+
+
가입 완료!
+
+ {email}으로
+ 인증 메일을 발송했습니다.
+
+
+ 메일함을 확인하여 이메일 인증을 완료해주세요.
+ 인증 링크는 24시간 동안 유효합니다.
+
+
+
+ 로그인 페이지로
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* 로고 */}
+
+
+
+
+
회원가입
+
마인크래프트 서버 대시보드
+
+
+ {/* 회원가입 폼 */}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx
new file mode 100644
index 0000000..f284f9e
--- /dev/null
+++ b/frontend/src/pages/VerifyEmailPage.jsx
@@ -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 (
+
+
+ {status === 'loading' && (
+ <>
+
+
+
+
인증 중...
+
이메일을 확인하고 있습니다.
+ >
+ )}
+
+ {status === 'success' && (
+ <>
+
+
+
+
인증 완료!
+
{message}
+
+ 로그인하기
+
+
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+
+
+
인증 실패
+
{message}
+
+ 다시 가입하기
+
+
+ >
+ )}
+
+
+ );
+}