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}님` : ""}!

+

마인크래프트 서버 대시보드에 가입해주셔서 감사합니다.
아래 버튼을 클릭하여 이메일 인증을 완료해주세요.

+

+ 이메일 인증하기 +

+

버튼이 작동하지 않으면 아래 링크를 복사해서 브라우저에 붙여넣으세요:

+ + +
+ + + `; + + 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} + + )} + {/* 상단 툴바 */}
@@ -44,8 +293,93 @@ const Sidebar = ({ isMobile = false }) => { 마인크래프트
- {/* 우측 공간 (균형용) */} -
+ {/* 로그인/유저 */} + {isLoggedIn ? ( +
+ + + {/* 드롭다운 메뉴 */} + + {showProfileMenu && ( + + {/* 프로필 정보 */} +
+
+ 프로필 { e.target.src = 'https://via.placeholder.com/40'; }} + /> +
+

{user?.name}

+

{user?.email}

+
+
+
+ + {/* 메뉴 항목 */} +
+ + + {minecraftLink && ( + + )} + + {isAdmin && ( + + )} + +
+ + +
+ + )} + +
+ ) : ( + + + + )}
@@ -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 || '-'}

+
+
+ 이메일 +

{user?.email}

+
+
+
+ + {/* 관리 기능 카드 */} +
+ {/* 서버 상태 */} +
+
+ +

서버 상태

+
+

+ 마인크래프트 서버 상태 모니터링 및 관리 +

+
+ 정상 작동 중 +
+
+ + {/* 플레이어 관리 */} +
+
+ +

플레이어 관리

+
+

+ 접속 중인 플레이어 목록 및 관리 기능 +

+
+ 추후 업데이트 예정 +
+
+ + {/* 설정 */} +
+
+ +

설정

+
+

+ 대시보드 설정 및 구성 관리 +

+
+ 추후 업데이트 예정 +
+
+
+
+ ); +} 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 ( +
+
+ {/* 로고 */} +
+
+ +
+

로그인

+

마인크래프트 서버 대시보드

+
+ + {/* 로그인 폼 */} +
+ {/* 에러 메시지 */} + {error && ( +
+ + {error} +
+ )} + + {/* 이메일 */} +
+ +
+ + 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" + /> +
+
+ + {/* 비밀번호 */} +
+ +
+ + 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" + /> +
+
+ + {/* 로그인 버튼 */} + + + {/* 회원가입 링크 */} +
+ + + 계정이 없으신가요? + +
+
+ + {/* 안내 문구 */} +

+ 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.name}

+
+
+

이메일

+

+ + {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 ( +
+
+ {/* 로고 */} +
+
+ +
+

회원가입

+

마인크래프트 서버 대시보드

+
+ + {/* 회원가입 폼 */} +
+ {/* 에러 메시지 */} + {error && ( +
+ + {error} +
+ )} + + {/* 안내 */} +
+ 닉네임은 가입 시 자동으로 생성됩니다! +
+ + {/* 이메일 */} +
+ +
+ + 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" + /> +
+
+ + {/* 비밀번호 */} +
+ +
+ + 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" + /> +
+
+ + {/* 비밀번호 확인 */} +
+ +
+ + 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" + /> +
+
+ + {/* 가입 버튼 */} + + + {/* 로그인 링크 */} +
+ + + 이미 계정이 있으신가요? + +
+
+
+
+ ); +} 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}

+ + 다시 가입하기 + + + + )} +
+
+ ); +}