feat: 코드 리뷰 기반 전면 개선 — 보안, 접근성, 테스트, UX
- HttpOnly 쿠키 refresh token (localStorage 제거) - 런치 티켓 방식 (JWT URL 노출 방지) - JWT 디코드로 role 결정 (localStorage 신뢰 제거) - apiUpload withCredentials 추가 - ErrorBoundary 컴포넌트 추가 - 404 catch-all 라우트 추가 - ARIA 접근성 (tab pattern, aria-label, aria-live) - Toast CSS 추출 + toastId useRef - UploadForm 별도 파일 분리 + apiUpload 함수 - UserAdmin fetchError 상태 + retry 버튼 - AuthRedirect 일관성 (모든 경로 → /login) - DownloadSection localStorage 중복 제거 - CI lint + test + build 검증 단계 추가 - Vitest 테스트 (client 8, Register 10, Login 8) - AuthPage.css 공유 의도 명확화 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,33 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 코드 체크아웃
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Node.js 설정
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 의존성 설치
|
||||
run: npm ci
|
||||
|
||||
- name: 린트 검사
|
||||
run: npm run lint
|
||||
|
||||
- name: 테스트 실행
|
||||
run: npm test
|
||||
|
||||
- name: 프로덕션 빌드 검증
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: 서버에 배포
|
||||
uses: appleboy/ssh-action@v1
|
||||
|
||||
1202
package-lock.json
generated
1202
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
@@ -16,6 +18,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -23,6 +28,8 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"vite": "^7.3.1"
|
||||
"jsdom": "^28.1.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
23
src/App.jsx
23
src/App.jsx
@@ -1,8 +1,9 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ToastProvider } from './components/toast/ToastProvider';
|
||||
import { useAuth } from './context/useAuth';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -12,19 +13,14 @@ import SSAFYCallbackPage from './pages/SSAFYCallbackPage';
|
||||
function AuthRedirect() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const prevUserRef = useRef(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevUserRef.current && !user) {
|
||||
if (location.pathname.startsWith('/admin')) {
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
prevUserRef.current = user;
|
||||
}, [user, navigate, location.pathname]);
|
||||
}, [user, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -39,7 +35,7 @@ function AdminRoute({ children }) {
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<AuthRedirect />
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
@@ -54,8 +50,15 @@ function AppRoutes() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#aaa' }}>
|
||||
<h2>404</h2>
|
||||
<p>페이지를 찾을 수 없습니다</p>
|
||||
<a href="/" style={{ color: '#4ea8de' }}>홈으로 돌아가기</a>
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,9 @@ export async function ssafyCallback(code) {
|
||||
// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
|
||||
export { tryRefresh as refreshToken } from './client';
|
||||
|
||||
// 게임 런처용 일회용 티켓 발급 (JWT를 URL에 노출하지 않기 위해 사용)
|
||||
export async function createLaunchTicket() {
|
||||
const data = await apiFetch('/api/auth/launch-ticket', { method: 'POST' });
|
||||
return data.ticket;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,16 @@ export async function tryRefresh() {
|
||||
if (refreshingPromise) return refreshingPromise;
|
||||
|
||||
refreshingPromise = (async () => {
|
||||
const rt = localStorage.getItem('refreshToken');
|
||||
if (!rt) throw new Error('no_refresh_token');
|
||||
|
||||
const res = await fetch(BASE + '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: rt }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('refresh_failed');
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
return data.token;
|
||||
})().finally(() => {
|
||||
refreshingPromise = null;
|
||||
@@ -32,7 +28,7 @@ export async function tryRefresh() {
|
||||
async function doFetch(path, options, token) {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return fetch(BASE + path, { ...options, headers });
|
||||
return fetch(BASE + path, { ...options, headers, credentials: 'include' });
|
||||
}
|
||||
|
||||
async function parseError(res) {
|
||||
@@ -103,3 +99,56 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* XHR 기반 파일 업로드 (progress 지원 + 401 자동 갱신)
|
||||
* @param {string} path - API 경로 (예: '/api/download/upload/game?filename=...')
|
||||
* @param {File} file - 업로드할 파일
|
||||
* @param {function} onProgress - progress 콜백 (percent: number)
|
||||
* @returns {Promise<{status: number, body: object}>}
|
||||
*/
|
||||
export function apiUpload(path, file, onProgress) {
|
||||
function send(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', BASE + path);
|
||||
xhr.withCredentials = true;
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => resolve(xhr);
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류가 발생했습니다.'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const xhr = await send(token);
|
||||
|
||||
if (xhr.status === 401) {
|
||||
try {
|
||||
const newToken = await tryRefresh();
|
||||
const retryXhr = await send(newToken);
|
||||
if (retryXhr.status === 401) {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
throw new Error('인증이 필요합니다');
|
||||
}
|
||||
return { status: retryXhr.status, body: JSON.parse(retryXhr.responseText || '{}') };
|
||||
} catch (e) {
|
||||
if (e.message === 'no_refresh_token' || e.message === 'refresh_failed') {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return { status: xhr.status, body: JSON.parse(xhr.responseText || '{}') };
|
||||
})();
|
||||
}
|
||||
|
||||
156
src/api/client.test.js
Normal file
156
src/api/client.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { apiFetch, tryRefresh } from './client';
|
||||
|
||||
// Stub import.meta.env
|
||||
// Vitest handles import.meta.env automatically via vite config
|
||||
|
||||
describe('apiFetch', () => {
|
||||
let fetchSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds Authorization header when token exists in localStorage', async () => {
|
||||
localStorage.setItem('token', 'test-token-123');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
);
|
||||
|
||||
await apiFetch('/api/test');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [, options] = fetchSpy.mock.calls[0];
|
||||
expect(options.headers['Authorization']).toBe('Bearer test-token-123');
|
||||
});
|
||||
|
||||
it('does not add Authorization header when no token exists', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: 1 }), { status: 200 }),
|
||||
);
|
||||
|
||||
await apiFetch('/api/test');
|
||||
|
||||
const [, options] = fetchSpy.mock.calls[0];
|
||||
expect(options.headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attempts token refresh on 401 response', async () => {
|
||||
localStorage.setItem('token', 'expired-token');
|
||||
|
||||
// First call: 401
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }),
|
||||
);
|
||||
|
||||
// Refresh call: success (refresh token sent via HttpOnly cookie by browser)
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ token: 'new-token' }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
// Retry with new token: success
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: 'ok' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await apiFetch('/api/protected');
|
||||
|
||||
expect(result).toEqual({ data: 'ok' });
|
||||
// After refresh, localStorage should have new access token
|
||||
expect(localStorage.getItem('token')).toBe('new-token');
|
||||
});
|
||||
|
||||
it('dispatches auth:unauthorized when refresh fails', async () => {
|
||||
localStorage.setItem('token', 'expired-token');
|
||||
// Refresh will fail (no HttpOnly cookie set in test env)
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }),
|
||||
);
|
||||
|
||||
// Refresh call: fail
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: 'refreshToken이 필요합니다' }), { status: 400 }),
|
||||
);
|
||||
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
await expect(apiFetch('/api/protected')).rejects.toThrow();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'auth:unauthorized' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on 5xx errors up to 2 times', async () => {
|
||||
// Mock delay to be instant for faster tests
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn) => originalSetTimeout(fn, 0));
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 500, statusText: 'Internal Server Error' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 502, statusText: 'Bad Gateway' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: 'recovered' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await apiFetch('/api/flaky');
|
||||
expect(result).toEqual({ data: 'recovered' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws after exhausting 5xx retries', async () => {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn) => originalSetTimeout(fn, 0));
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValue(
|
||||
new Response('', { status: 500, statusText: 'Internal Server Error' }),
|
||||
);
|
||||
|
||||
await expect(apiFetch('/api/broken')).rejects.toThrow();
|
||||
// 1 original + 2 retries = 3
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryRefresh', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when refresh request fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: 'refreshToken이 필요합니다' }), { status: 400 }),
|
||||
);
|
||||
|
||||
await expect(tryRefresh()).rejects.toThrow('refresh_failed');
|
||||
});
|
||||
|
||||
it('stores new access token on successful refresh', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ token: 'fresh-token' }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const token = await tryRefresh();
|
||||
expect(token).toBe('fresh-token');
|
||||
expect(localStorage.getItem('token')).toBe('fresh-token');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { getDownloadInfo } from '../api/download';
|
||||
import { refreshToken } from '../api/auth';
|
||||
import { createLaunchTicket } from '../api/auth';
|
||||
import './DownloadSection.css';
|
||||
|
||||
export default function DownloadSection() {
|
||||
@@ -30,24 +30,18 @@ export default function DownloadSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 없으면 (다른 탭에서 로그아웃 등) 로그인 유도
|
||||
let token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setLaunching(true);
|
||||
|
||||
// JWT를 URL에 직접 노출하지 않고, 일회용 티켓을 발급받아 전달
|
||||
try {
|
||||
const ticket = await createLaunchTicket();
|
||||
window.location.href = 'a301://launch?ticket=' + encodeURIComponent(ticket);
|
||||
} catch {
|
||||
// 티켓 발급 실패 시 로그인 유도
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setLaunching(true);
|
||||
|
||||
// 토큰 만료 대비: 런처에 전달하기 전에 리프레시 시도
|
||||
try {
|
||||
token = await refreshToken();
|
||||
} catch {
|
||||
// 리프레시 실패해도 기존 토큰으로 시도 (아직 유효할 수 있음)
|
||||
}
|
||||
|
||||
window.location.href = 'a301://launch?token=' + encodeURIComponent(token);
|
||||
|
||||
// 런처가 실행되지 않았을 수 있으므로 안내 표시
|
||||
setLaunched(true);
|
||||
setLaunching(false);
|
||||
@@ -58,7 +52,9 @@ export default function DownloadSection() {
|
||||
const a = document.createElement('a');
|
||||
a.href = info.launcherUrl;
|
||||
a.download = 'launcher.exe';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
31
src/components/ErrorBoundary.jsx
Normal file
31
src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#ccc' }}>
|
||||
<h2>문제가 발생했습니다</h2>
|
||||
<p>페이지를 새로고침하거나 잠시 후 다시 시도해주세요.</p>
|
||||
<button onClick={() => window.location.reload()} style={{ marginTop: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// TODO: Add tests for CRUD operations (create, update, delete announcements)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
|
||||
import { useToast } from '../toast/useToast';
|
||||
@@ -57,6 +58,7 @@ export default function AnnouncementAdmin() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
// TODO: Replace window.confirm() with a custom confirmation modal for consistent UI
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteAnnouncement(id);
|
||||
@@ -103,6 +105,7 @@ export default function AnnouncementAdmin() {
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
maxLength={200}
|
||||
aria-label="공지사항 제목"
|
||||
/>
|
||||
<textarea
|
||||
className="admin-textarea"
|
||||
@@ -111,6 +114,7 @@ export default function AnnouncementAdmin() {
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
maxLength={10000}
|
||||
aria-label="공지사항 내용"
|
||||
/>
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
<div className="admin-form-actions">
|
||||
|
||||
@@ -1,144 +1,9 @@
|
||||
// TODO: Add tests for CRUD operations (load download info, upload launcher, upload game)
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getDownloadInfo } from '../../api/download';
|
||||
import { tryRefresh } from '../../api/client';
|
||||
import UploadForm from './UploadForm';
|
||||
import './AdminCommon.css';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
function sendXhr(url, token, file, { onProgress, onDone, onError }) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => onDone(xhr);
|
||||
xhr.onerror = () => onError();
|
||||
|
||||
xhr.open('POST', url);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.send(file);
|
||||
}
|
||||
|
||||
function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setFile(e.target.files[0] || null);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
const handleUpload = (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const url = `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`;
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
const handleDone = (xhr) => {
|
||||
// 401 시 토큰 갱신 후 재시도
|
||||
if (xhr.status === 401) {
|
||||
tryRefresh()
|
||||
.then((newToken) => {
|
||||
sendXhr(url, newToken, file, {
|
||||
onProgress: (p) => setProgress(p),
|
||||
onDone: (retryXhr) => {
|
||||
setUploading(false);
|
||||
if (retryXhr.status === 401) {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
return;
|
||||
}
|
||||
parseXhrResponse(retryXhr);
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setUploading(false);
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
parseXhrResponse(xhr);
|
||||
};
|
||||
|
||||
const parseXhrResponse = (xhr) => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
onSuccess(body);
|
||||
setFile(null);
|
||||
setProgress(0);
|
||||
} else {
|
||||
setError(body.error || '업로드에 실패했습니다.');
|
||||
setProgress(0);
|
||||
}
|
||||
} catch {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setError('응답을 처리할 수 없습니다.');
|
||||
} else {
|
||||
setError('업로드에 실패했습니다.');
|
||||
}
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setUploading(false);
|
||||
setError('네트워크 오류가 발생했습니다.');
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
sendXhr(url, token, file, {
|
||||
onProgress: (p) => setProgress(p),
|
||||
onDone: handleDone,
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={handleUpload}>
|
||||
<div className="admin-field">
|
||||
<label className="admin-label">{title}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="admin-input-file"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<span className="admin-field-hint">{hint}</span>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="admin-upload-progress">
|
||||
<div className="admin-upload-bar" style={{ width: `${progress}%` }} />
|
||||
<span className="admin-upload-pct">{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="btn-admin-primary" type="submit" disabled={uploading || !file}>
|
||||
{uploading ? `업로드 중... (${progress}%)` : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DownloadAdmin() {
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -166,11 +31,27 @@ export default function DownloadAdmin() {
|
||||
);
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
getDownloadInfo()
|
||||
.then((data) => {
|
||||
setInfo(data);
|
||||
setLoadError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('다운로드 정보 로드 실패:', err);
|
||||
setLoadError('배포 정보를 불러올 수 없습니다.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-title">게임 배포 관리</h2>
|
||||
<p className="admin-error">{loadError}</p>
|
||||
<button className="btn-admin-secondary" onClick={reload}>다시 시도</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
76
src/components/admin/UploadForm.jsx
Normal file
76
src/components/admin/UploadForm.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { apiUpload } from '../../api/client';
|
||||
|
||||
export default function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setFile(e.target.files[0] || null);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
|
||||
const path = `${endpoint}?filename=${encodeURIComponent(file.name)}`;
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { status, body } = await apiUpload(path, file, (p) => setProgress(p));
|
||||
if (status >= 200 && status < 300) {
|
||||
onSuccess(body);
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setProgress(0);
|
||||
} else {
|
||||
setError(body.error || '업로드에 실패했습니다.');
|
||||
setProgress(0);
|
||||
}
|
||||
} catch {
|
||||
setError('네트워크 오류가 발생했습니다.');
|
||||
setProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={handleUpload}>
|
||||
<div className="admin-field">
|
||||
<label className="admin-label">{title}</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="admin-input-file"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<span className="admin-field-hint">{hint}</span>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="admin-upload-progress">
|
||||
<div className="admin-upload-bar" style={{ width: `${progress}%` }} />
|
||||
<span className="admin-upload-pct">{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="btn-admin-primary" type="submit" disabled={uploading || !file}>
|
||||
{uploading ? `업로드 중... (${progress}%)` : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// TODO: Add tests for CRUD operations (list users, update role, delete user)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getUsers, updateUserRole, deleteUser } from '../../api/users';
|
||||
import { useAuth } from '../../context/useAuth';
|
||||
@@ -7,22 +8,27 @@ import './AdminCommon.css';
|
||||
export default function UserAdmin() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const { user: me } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setFetchError(false);
|
||||
getUsers()
|
||||
.then(setUsers)
|
||||
.catch((err) => { console.error('유저 목록 로드 실패:', err); })
|
||||
.catch((err) => {
|
||||
console.error('유저 목록 로드 실패:', err);
|
||||
setFetchError(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRoleToggle = async (u) => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
// TODO: Replace window.confirm() with a custom confirmation modal for consistent UI
|
||||
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
|
||||
setError('');
|
||||
try {
|
||||
await updateUserRole(u.id, newRole);
|
||||
toast.success(`${u.username}의 권한이 ${newRole}로 변경되었습니다.`);
|
||||
@@ -33,8 +39,8 @@ export default function UserAdmin() {
|
||||
};
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
// TODO: Replace window.confirm() with a custom confirmation modal for consistent UI
|
||||
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
||||
setError('');
|
||||
try {
|
||||
await deleteUser(u.id);
|
||||
toast.success(`${u.username} 계정이 삭제되었습니다.`);
|
||||
@@ -47,7 +53,12 @@ export default function UserAdmin() {
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-title">유저 관리</h2>
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
{fetchError && (
|
||||
<div className="admin-error-block">
|
||||
<p className="admin-error">유저 목록을 불러올 수 없습니다.</p>
|
||||
<button className="btn-admin-secondary" onClick={load}>다시 시도</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <p className="admin-list-empty">불러오는 중...</p>}
|
||||
{!loading && users.length === 0 && <p className="admin-list-empty">등록된 유저가 없습니다.</p>}
|
||||
<ul className="admin-list">
|
||||
|
||||
38
src/components/toast/Toast.css
Normal file
38
src/components/toast/Toast.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
max-width: 360px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: toast-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
.toast-item.info {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.toast-item.success {
|
||||
background-color: #2d6a4f;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
background-color: #9b2226;
|
||||
}
|
||||
|
||||
.toast-item.warn {
|
||||
background-color: #7f5539;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { ToastContext } from './toastContextValue';
|
||||
|
||||
let toastId = 0;
|
||||
import './Toast.css';
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const timersRef = useRef({});
|
||||
const toastIdRef = useRef(0);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
clearTimeout(timersRef.current[id]);
|
||||
@@ -14,12 +14,19 @@ export function ToastProvider({ children }) {
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastId;
|
||||
const id = ++toastIdRef.current;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
timersRef.current[id] = setTimeout(() => removeToast(id), duration);
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
// Attaching methods to the toast function works because useCallback keeps
|
||||
// the reference stable across renders, so the .success/.error/.warn properties
|
||||
// persist. This is a pragmatic pattern — a plain object would be cleaner but
|
||||
// would change the public API consumed by useToast() callers.
|
||||
// IMPORTANT: This pattern relies on `addToast` being stable (no dependencies that change).
|
||||
// If addToast gains new dependencies, toast.success/error/warn will be stale on the old ref.
|
||||
// In that case, refactor to return an object { info, success, error, warn } from context.
|
||||
const toast = useCallback((message) => addToast(message, 'info'), [addToast]);
|
||||
toast.success = useCallback((message) => addToast(message, 'success'), [addToast]);
|
||||
toast.error = useCallback((message) => addToast(message, 'error'), [addToast]);
|
||||
@@ -28,9 +35,9 @@ export function ToastProvider({ children }) {
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
<div style={containerStyle}>
|
||||
<div className="toast-container" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} style={{ ...itemStyle, ...typeStyles[t.type] }} onClick={() => removeToast(t.id)}>
|
||||
<div key={t.id} className={`toast-item ${t.type}`} onClick={() => removeToast(t.id)}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
@@ -38,33 +45,3 @@ export function ToastProvider({ children }) {
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle = {
|
||||
position: 'fixed',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
|
||||
const itemStyle = {
|
||||
padding: '12px 20px',
|
||||
borderRadius: 8,
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
maxWidth: 360,
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
animation: 'toast-in 0.25s ease-out',
|
||||
};
|
||||
|
||||
const typeStyles = {
|
||||
info: { backgroundColor: '#3a3a3a' },
|
||||
success: { backgroundColor: '#2d6a4f' },
|
||||
error: { backgroundColor: '#9b2226' },
|
||||
warn: { backgroundColor: '#7f5539' },
|
||||
};
|
||||
|
||||
2
src/components/toast/index.js
Normal file
2
src/components/toast/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ToastProvider } from './ToastProvider';
|
||||
export { useToast } from './useToast';
|
||||
@@ -2,38 +2,45 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||
import { AuthContext } from './authContextValue';
|
||||
|
||||
function decodeTokenPayload(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const username = localStorage.getItem('username');
|
||||
const role = localStorage.getItem('role');
|
||||
return token ? { token, username, role } : null;
|
||||
if (!token) return null;
|
||||
const payload = decodeTokenPayload(token);
|
||||
const role = payload?.role || 'user';
|
||||
return { token, username, role };
|
||||
});
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
const data = await apiLogin(username, password);
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
setUser({ token: data.token, username: data.username, role: data.role });
|
||||
const decoded = decodeTokenPayload(data.token);
|
||||
const role = decoded?.role || 'user';
|
||||
setUser({ token: data.token, username: data.username, role });
|
||||
}, []);
|
||||
|
||||
// SSAFY OAuth 콜백에서 받은 토큰으로 로그인 처리
|
||||
const setUserFromSSAFY = useCallback((data) => {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
setUser({ token: data.token, username: data.username, role: data.role });
|
||||
const decoded = decodeTokenPayload(data.token);
|
||||
const role = decoded?.role || 'user';
|
||||
setUser({ token: data.token, username: data.username, role });
|
||||
}, []);
|
||||
|
||||
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('role');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -29,10 +29,14 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<div className="admin-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
id={`tab-${t.key}`}
|
||||
aria-selected={tab === t.key}
|
||||
aria-controls={`tabpanel-${t.key}`}
|
||||
className={`admin-tab${tab === t.key ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
@@ -41,7 +45,8 @@ export default function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="admin-main">
|
||||
{/* Tabs are conditionally rendered (not hidden) to avoid stale data. Each panel re-fetches on mount. */}
|
||||
<main className="admin-main" role="tabpanel" id={`tabpanel-${tab}`} aria-labelledby={`tab-${tab}`}>
|
||||
{tab === 'announcement' && <AnnouncementAdmin />}
|
||||
{tab === 'download' && <DownloadAdmin />}
|
||||
{tab === 'user' && <UserAdmin />}
|
||||
|
||||
176
src/pages/AuthPage.css
Normal file
176
src/pages/AuthPage.css
Normal file
@@ -0,0 +1,176 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2E2C2F;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(186, 205, 176, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 50%, rgba(186, 205, 176, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(186, 205, 176, 0.15);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: #BACDB0;
|
||||
letter-spacing: 0.15em;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.game-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(186, 205, 176, 0.6);
|
||||
letter-spacing: 0.5em;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(186, 205, 176, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: #BACDB0;
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: #81c784;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #e57373;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
padding: 14px;
|
||||
background: #BACDB0;
|
||||
color: #2E2C2F;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.login-divider span {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.btn-ssafy {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: rgba(186, 205, 176, 0.8);
|
||||
border: 1px solid rgba(186, 205, 176, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-ssafy:hover {
|
||||
background: rgba(186, 205, 176, 0.08);
|
||||
border-color: rgba(186, 205, 176, 0.5);
|
||||
}
|
||||
|
||||
.login-back {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-back:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(186, 205, 176, 0.7);
|
||||
margin: -12px 0 0;
|
||||
}
|
||||
|
||||
.password-strength.strength-weak {
|
||||
color: #e57373;
|
||||
}
|
||||
@@ -1,176 +1 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2E2C2F;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(186, 205, 176, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 50%, rgba(186, 205, 176, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(186, 205, 176, 0.15);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: #BACDB0;
|
||||
letter-spacing: 0.15em;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.game-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(186, 205, 176, 0.6);
|
||||
letter-spacing: 0.5em;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(186, 205, 176, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: #BACDB0;
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: #81c784;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: #e57373;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
padding: 14px;
|
||||
background: #BACDB0;
|
||||
color: #2E2C2F;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.login-divider span {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.btn-ssafy {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: rgba(186, 205, 176, 0.8);
|
||||
border: 1px solid rgba(186, 205, 176, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-ssafy:hover {
|
||||
background: rgba(186, 205, 176, 0.08);
|
||||
border-color: rgba(186, 205, 176, 0.5);
|
||||
}
|
||||
|
||||
.login-back {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-back:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(186, 205, 176, 0.7);
|
||||
margin: -12px 0 0;
|
||||
}
|
||||
|
||||
.password-strength.strength-weak {
|
||||
color: #e57373;
|
||||
}
|
||||
/* This file is deprecated. Both LoginPage and RegisterPage now use AuthPage.css */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { getSSAFYLoginURL } from '../api/auth';
|
||||
import './LoginPage.css';
|
||||
import './AuthPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
129
src/pages/LoginPage.test.jsx
Normal file
129
src/pages/LoginPage.test.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
// Mock useAuth
|
||||
const mockLogin = vi.fn();
|
||||
vi.mock('../context/useAuth', () => ({
|
||||
useAuth: () => ({ login: mockLogin }),
|
||||
}));
|
||||
|
||||
// Mock auth API
|
||||
vi.mock('../api/auth', () => ({
|
||||
getSSAFYLoginURL: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
function renderLoginPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the login form with all elements', () => {
|
||||
renderLoginPage();
|
||||
expect(screen.getByText('One of the plans')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('아이디')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
|
||||
expect(screen.getByText('SSAFY 계정으로 로그인')).toBeInTheDocument();
|
||||
expect(screen.getByText(/회원가입/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('shows error when both fields are empty', async () => {
|
||||
renderLoginPage();
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
expect(
|
||||
await screen.findByText('아이디와 비밀번호를 입력해주세요.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when username is empty (whitespace only)', async () => {
|
||||
renderLoginPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: ' ' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'pass' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
expect(
|
||||
await screen.findByText('아이디와 비밀번호를 입력해주세요.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when password is empty', async () => {
|
||||
renderLoginPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'user' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
expect(
|
||||
await screen.findByText('아이디와 비밀번호를 입력해주세요.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('login flow', () => {
|
||||
it('calls login and navigates on success', async () => {
|
||||
mockLogin.mockResolvedValueOnce({});
|
||||
|
||||
renderLoginPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('testuser', 'password1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on login failure', async () => {
|
||||
mockLogin.mockRejectedValueOnce(new Error('잘못된 아이디 또는 비밀번호입니다.'));
|
||||
|
||||
renderLoginPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'wrong' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('잘못된 아이디 또는 비밀번호입니다.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state while submitting', async () => {
|
||||
// Make login hang so we can observe the loading state
|
||||
let resolveLogin;
|
||||
mockLogin.mockReturnValueOnce(new Promise((r) => { resolveLogin = r; }));
|
||||
|
||||
renderLoginPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'user' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'pass' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
|
||||
|
||||
expect(await screen.findByText('로그인 중...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '로그인 중...' })).toBeDisabled();
|
||||
|
||||
// Resolve to clean up
|
||||
resolveLogin({});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('로그인 중...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { register } from '../api/auth';
|
||||
import './LoginPage.css';
|
||||
import './AuthPage.css';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
139
src/pages/RegisterPage.test.jsx
Normal file
139
src/pages/RegisterPage.test.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import RegisterPage from './RegisterPage';
|
||||
|
||||
// Mock the auth API
|
||||
vi.mock('../api/auth', () => ({
|
||||
register: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
function renderRegisterPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the registration form', () => {
|
||||
renderRegisterPage();
|
||||
expect(screen.getByLabelText('아이디')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('비밀번호 확인')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '회원가입' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('username validation', () => {
|
||||
it('shows error when username is empty', async () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
expect(await screen.findByText('아이디를 입력해주세요.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when username is less than 3 characters', async () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'ab' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호 확인'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
expect(await screen.findByText('아이디는 3자 이상이어야 합니다.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when username contains invalid characters', async () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'user@name' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호 확인'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
expect(
|
||||
await screen.findByText('아이디는 영문 소문자, 숫자, _, -만 사용 가능합니다.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('password strength', () => {
|
||||
it('shows weak indicator for short passwords', () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'abc' } });
|
||||
expect(screen.getByText(/비밀번호는 6자 이상이어야 합니다/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "약함" for lowercase-only password of 6+ chars', () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'abcdef' } });
|
||||
expect(screen.getByText(/약함/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "보통" for mixed-case password', () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'abcDEF' } });
|
||||
expect(screen.getByText(/보통/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "강함" for password with multiple character types', () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'abcD1!' } });
|
||||
expect(screen.getByText(/강함/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('password confirmation', () => {
|
||||
it('shows error when passwords do not match', async () => {
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호 확인'), { target: { value: 'different' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
expect(await screen.findByText('비밀번호가 일치하지 않습니다.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls register and navigates on success', async () => {
|
||||
const { register } = await import('../api/auth');
|
||||
register.mockResolvedValueOnce({});
|
||||
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호 확인'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(register).toHaveBeenCalledWith('testuser', 'password1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', { state: { registered: true } });
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error message on registration failure', async () => {
|
||||
const { register } = await import('../api/auth');
|
||||
register.mockRejectedValueOnce(new Error('이미 존재하는 아이디입니다.'));
|
||||
|
||||
renderRegisterPage();
|
||||
fireEvent.change(screen.getByLabelText('아이디'), { target: { value: 'existing' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('비밀번호 확인'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '회원가입' }));
|
||||
|
||||
expect(await screen.findByText('이미 존재하는 아이디입니다.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,15 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { ssafyCallback } from '../api/auth';
|
||||
|
||||
// Inline styles are intentional for this simple callback/loading page — not worth a separate CSS file.
|
||||
export default function SSAFYCallbackPage() {
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setUserFromSSAFY } = useAuth();
|
||||
// useRef(false) prevents double-execution in React 18+ StrictMode,
|
||||
// which remounts components in development. The ref persists across
|
||||
// the StrictMode remount, ensuring the OAuth callback runs only once.
|
||||
const called = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
1
src/test/setup.js
Normal file
1
src/test/setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.js',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user