회원가입/로그인, 프로필 추가

This commit is contained in:
caadiq 2025-12-22 09:36:23 +09:00
parent 17d23519d7
commit fd6a583fcc
19 changed files with 2327 additions and 51 deletions

13
.env
View file

@ -11,3 +11,16 @@ DB_NAME=minecraft
S3_ENDPOINT=http://rustfs:9000 S3_ENDPOINT=http://rustfs:9000
S3_ACCESS_KEY=lFMQ5ncyAvXRgzHrUpua S3_ACCESS_KEY=lFMQ5ncyAvXRgzHrUpua
S3_SECRET_KEY=e67PK9zt4fnFSd21sIkxlW3gLqrNmGDHwouciOvE 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

View file

@ -10,6 +10,12 @@ RUN npm run build
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# CA 인증서 및 필수 패키지 설치
RUN apk add --no-cache ca-certificates
# undici 연결 타임아웃 늘리기
ENV NODE_OPTIONS="--dns-result-order=ipv4first"
# 백엔드 의존성 설치 # 백엔드 의존성 설치
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm install --production RUN npm install --production

View file

@ -80,6 +80,7 @@ const setIconCache = (key, value) => {
export { export {
dbPool, dbPool,
dbPool as pool,
loadTranslations, loadTranslations,
getTranslations, getTranslations,
getIcons, getIcons,

View file

@ -8,9 +8,15 @@
"sync-icons": "node scripts/sync-icons.cjs" "sync-icons": "node scripts/sync-icons.cjs"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.400.0",
"bcryptjs": "^2.4.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3",
"jsonwebtoken": "^9.0.2",
"minecraft-server-util": "^5.3.0", "minecraft-server-util": "^5.3.0",
"mysql2": "^3.11.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
View 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
View 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;

View file

@ -3,6 +3,7 @@ import { createServer } from "http";
import { Server } from "socket.io"; import { Server } from "socket.io";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import session from "express-session";
// 모듈 import // 모듈 import
import { loadTranslations } from "./lib/db.js"; import { loadTranslations } from "./lib/db.js";
@ -14,6 +15,8 @@ import {
getCachedPlayers, getCachedPlayers,
} from "./lib/minecraft.js"; } from "./lib/minecraft.js";
import apiRoutes from "./routes/api.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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -28,6 +31,28 @@ const io = new Server(httpServer, {
}); });
const PORT = process.env.PORT || 80; 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 디렉토리에서 정적 파일 제공 // dist 디렉토리에서 정적 파일 제공
app.use(express.static(path.join(__dirname, "dist"))); app.use(express.static(path.join(__dirname, "dist")));
@ -35,6 +60,12 @@ app.use(express.static(path.join(__dirname, "dist")));
// API 라우트 // API 라우트
app.use("/api", apiRoutes); app.use("/api", apiRoutes);
// 인증 라우트
app.use("/auth", authRoutes);
// 마인크래프트 연동 라우트
app.use("/link", linkRoutes);
// Socket.IO 연결 처리 // Socket.IO 연결 처리
io.on("connection", (socket) => { io.on("connection", (socket) => {
console.log("클라이언트 연결됨:", socket.id); console.log("클라이언트 연결됨:", socket.id);

View 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 };

View file

@ -6,6 +6,9 @@ services:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"
env_file: env_file:
- .env - .env
dns:
- 8.8.8.8
- 8.8.4.4
networks: networks:
- minecraft - minecraft
- db - db

View file

@ -8,6 +8,11 @@ import WorldsPage from './pages/WorldsPage';
import PlayersPage from './pages/PlayersPage'; import PlayersPage from './pages/PlayersPage';
import PlayerStatsPage from './pages/PlayerStatsPage'; import PlayerStatsPage from './pages/PlayerStatsPage';
import WorldMapPage from './pages/WorldMapPage'; 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 }) => ( const PageWrapper = ({ children }) => (
@ -25,11 +30,53 @@ function App() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const location = useLocation(); const location = useLocation();
// ( )
const isAuthPage = ['/login', '/register'].includes(location.pathname) ||
location.pathname.startsWith('/verify/');
// (, )
const isStandalonePage = ['/admin', '/profile'].includes(location.pathname);
// //
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, [location.pathname]); }, [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 ( return (
<DeviceLayout> <DeviceLayout>
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30"> <div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">

View file

@ -1,12 +1,22 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
import { Home, Globe, Users, Menu, X, Gamepad2, Map } from 'lucide-react'; import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../contexts/AuthContext';
import { io } from 'socket.io-client';
// //
const Sidebar = ({ isMobile = false }) => { const Sidebar = ({ isMobile = false }) => {
const [isOpen, setIsOpen] = useState(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 location = useLocation();
const navigate = useNavigate();
const { isLoggedIn, isAdmin, user, logout } = useAuth();
const profileMenuRef = useRef(null);
const menuItems = [ const menuItems = [
{ path: '/', icon: Home, label: '홈' }, { path: '/', icon: Home, label: '홈' },
@ -23,10 +33,249 @@ const Sidebar = ({ isMobile = false }) => {
return location.pathname === item.path; 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) { if (isMobile) {
return ( 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"> <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"> <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> <span className="font-bold text-white">마인크래프트</span>
</div> </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> </div>
</header> </header>
@ -117,7 +451,19 @@ const Sidebar = ({ isMobile = false }) => {
})} })}
</nav> </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> </motion.aside>
)} )}
</AnimatePresence> </AnimatePresence>
@ -128,9 +474,71 @@ const Sidebar = ({ isMobile = false }) => {
// PC: // PC:
return ( 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"> <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> </aside>
{/* 데스크톱용 사이드바 spacer */} {/* 데스크톱용 사이드바 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; export default Sidebar;

View 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;

View file

@ -19,6 +19,7 @@ body.mobile-layout {
min-width: unset; min-width: unset;
width: 100%; width: 100%;
overflow-x: hidden; overflow-x: hidden;
min-height: auto;
} }
@layer utilities { @layer utilities {

View file

@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import './index.css' import './index.css'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext.jsx'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <AuthProvider>
<App />
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}