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
|
- main
|
||||||
|
|
||||||
jobs:
|
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:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
steps:
|
steps:
|
||||||
- name: 서버에 배포
|
- name: 서버에 배포
|
||||||
uses: appleboy/ssh-action@v1
|
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",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@@ -16,6 +18,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@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": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
@@ -23,6 +28,8 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"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 { useEffect, useRef } from 'react';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import { ToastProvider } from './components/toast/ToastProvider';
|
import { ToastProvider } from './components/toast/ToastProvider';
|
||||||
import { useAuth } from './context/useAuth';
|
import { useAuth } from './context/useAuth';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
@@ -12,19 +13,14 @@ import SSAFYCallbackPage from './pages/SSAFYCallbackPage';
|
|||||||
function AuthRedirect() {
|
function AuthRedirect() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const prevUserRef = useRef(user);
|
const prevUserRef = useRef(user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevUserRef.current && !user) {
|
if (prevUserRef.current && !user) {
|
||||||
if (location.pathname.startsWith('/admin')) {
|
navigate('/login', { replace: true });
|
||||||
navigate('/', { replace: true });
|
|
||||||
} else {
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
prevUserRef.current = user;
|
prevUserRef.current = user;
|
||||||
}, [user, navigate, location.pathname]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -39,7 +35,7 @@ function AdminRoute({ children }) {
|
|||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
return (
|
return (
|
||||||
<>
|
<ErrorBoundary>
|
||||||
<AuthRedirect />
|
<AuthRedirect />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||||
@@ -54,8 +50,15 @@ function AppRoutes() {
|
|||||||
</AdminRoute>
|
</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>
|
</Routes>
|
||||||
</>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,9 @@ export async function ssafyCallback(code) {
|
|||||||
// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
|
// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
|
||||||
export { tryRefresh as refreshToken } from './client';
|
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;
|
if (refreshingPromise) return refreshingPromise;
|
||||||
|
|
||||||
refreshingPromise = (async () => {
|
refreshingPromise = (async () => {
|
||||||
const rt = localStorage.getItem('refreshToken');
|
|
||||||
if (!rt) throw new Error('no_refresh_token');
|
|
||||||
|
|
||||||
const res = await fetch(BASE + '/api/auth/refresh', {
|
const res = await fetch(BASE + '/api/auth/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken: rt }),
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('refresh_failed');
|
if (!res.ok) throw new Error('refresh_failed');
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
localStorage.setItem('refreshToken', data.refreshToken);
|
|
||||||
return data.token;
|
return data.token;
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
refreshingPromise = null;
|
refreshingPromise = null;
|
||||||
@@ -32,7 +28,7 @@ export async function tryRefresh() {
|
|||||||
async function doFetch(path, options, token) {
|
async function doFetch(path, options, token) {
|
||||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
return fetch(BASE + path, { ...options, headers });
|
return fetch(BASE + path, { ...options, headers, credentials: 'include' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseError(res) {
|
async function parseError(res) {
|
||||||
@@ -103,3 +99,56 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
|
|||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, 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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { getDownloadInfo } from '../api/download';
|
import { getDownloadInfo } from '../api/download';
|
||||||
import { refreshToken } from '../api/auth';
|
import { createLaunchTicket } from '../api/auth';
|
||||||
import './DownloadSection.css';
|
import './DownloadSection.css';
|
||||||
|
|
||||||
export default function DownloadSection() {
|
export default function DownloadSection() {
|
||||||
@@ -30,24 +30,18 @@ export default function DownloadSection() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰이 없으면 (다른 탭에서 로그아웃 등) 로그인 유도
|
setLaunching(true);
|
||||||
let token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
// JWT를 URL에 직접 노출하지 않고, 일회용 티켓을 발급받아 전달
|
||||||
|
try {
|
||||||
|
const ticket = await createLaunchTicket();
|
||||||
|
window.location.href = 'a301://launch?ticket=' + encodeURIComponent(ticket);
|
||||||
|
} catch {
|
||||||
|
// 티켓 발급 실패 시 로그인 유도
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLaunching(true);
|
|
||||||
|
|
||||||
// 토큰 만료 대비: 런처에 전달하기 전에 리프레시 시도
|
|
||||||
try {
|
|
||||||
token = await refreshToken();
|
|
||||||
} catch {
|
|
||||||
// 리프레시 실패해도 기존 토큰으로 시도 (아직 유효할 수 있음)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = 'a301://launch?token=' + encodeURIComponent(token);
|
|
||||||
|
|
||||||
// 런처가 실행되지 않았을 수 있으므로 안내 표시
|
// 런처가 실행되지 않았을 수 있으므로 안내 표시
|
||||||
setLaunched(true);
|
setLaunched(true);
|
||||||
setLaunching(false);
|
setLaunching(false);
|
||||||
@@ -58,7 +52,9 @@ export default function DownloadSection() {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = info.launcherUrl;
|
a.href = info.launcherUrl;
|
||||||
a.download = 'launcher.exe';
|
a.download = 'launcher.exe';
|
||||||
|
document.body.appendChild(a);
|
||||||
a.click();
|
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
|
import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
|
||||||
import { useToast } from '../toast/useToast';
|
import { useToast } from '../toast/useToast';
|
||||||
@@ -57,6 +58,7 @@ export default function AnnouncementAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
|
// TODO: Replace window.confirm() with a custom confirmation modal for consistent UI
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
try {
|
try {
|
||||||
await deleteAnnouncement(id);
|
await deleteAnnouncement(id);
|
||||||
@@ -103,6 +105,7 @@ export default function AnnouncementAdmin() {
|
|||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
|
aria-label="공지사항 제목"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
className="admin-textarea"
|
className="admin-textarea"
|
||||||
@@ -111,6 +114,7 @@ export default function AnnouncementAdmin() {
|
|||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||||
maxLength={10000}
|
maxLength={10000}
|
||||||
|
aria-label="공지사항 내용"
|
||||||
/>
|
/>
|
||||||
{error && <p className="admin-error">{error}</p>}
|
{error && <p className="admin-error">{error}</p>}
|
||||||
<div className="admin-form-actions">
|
<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 { useState, useEffect } from 'react';
|
||||||
import { getDownloadInfo } from '../../api/download';
|
import { getDownloadInfo } from '../../api/download';
|
||||||
import { tryRefresh } from '../../api/client';
|
import UploadForm from './UploadForm';
|
||||||
import './AdminCommon.css';
|
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() {
|
export default function DownloadAdmin() {
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<h2 className="admin-section-title">게임 배포 관리</h2>
|
<h2 className="admin-section-title">게임 배포 관리</h2>
|
||||||
<p className="admin-error">{loadError}</p>
|
<p className="admin-error">{loadError}</p>
|
||||||
|
<button className="btn-admin-secondary" onClick={reload}>다시 시도</button>
|
||||||
</div>
|
</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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { getUsers, updateUserRole, deleteUser } from '../../api/users';
|
import { getUsers, updateUserRole, deleteUser } from '../../api/users';
|
||||||
import { useAuth } from '../../context/useAuth';
|
import { useAuth } from '../../context/useAuth';
|
||||||
@@ -7,22 +8,27 @@ import './AdminCommon.css';
|
|||||||
export default function UserAdmin() {
|
export default function UserAdmin() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [fetchError, setFetchError] = useState(false);
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError(false);
|
||||||
getUsers()
|
getUsers()
|
||||||
.then(setUsers)
|
.then(setUsers)
|
||||||
.catch((err) => { console.error('유저 목록 로드 실패:', err); })
|
.catch((err) => {
|
||||||
|
console.error('유저 목록 로드 실패:', err);
|
||||||
|
setFetchError(true);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const handleRoleToggle = async (u) => {
|
const handleRoleToggle = async (u) => {
|
||||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
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;
|
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
await updateUserRole(u.id, newRole);
|
await updateUserRole(u.id, newRole);
|
||||||
toast.success(`${u.username}의 권한이 ${newRole}로 변경되었습니다.`);
|
toast.success(`${u.username}의 권한이 ${newRole}로 변경되었습니다.`);
|
||||||
@@ -33,8 +39,8 @@ export default function UserAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (u) => {
|
const handleDelete = async (u) => {
|
||||||
|
// TODO: Replace window.confirm() with a custom confirmation modal for consistent UI
|
||||||
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
await deleteUser(u.id);
|
await deleteUser(u.id);
|
||||||
toast.success(`${u.username} 계정이 삭제되었습니다.`);
|
toast.success(`${u.username} 계정이 삭제되었습니다.`);
|
||||||
@@ -47,7 +53,12 @@ export default function UserAdmin() {
|
|||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<h2 className="admin-section-title">유저 관리</h2>
|
<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 && <p className="admin-list-empty">불러오는 중...</p>}
|
||||||
{!loading && users.length === 0 && <p className="admin-list-empty">등록된 유저가 없습니다.</p>}
|
{!loading && users.length === 0 && <p className="admin-list-empty">등록된 유저가 없습니다.</p>}
|
||||||
<ul className="admin-list">
|
<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 { useState, useCallback, useRef } from 'react';
|
||||||
import { ToastContext } from './toastContextValue';
|
import { ToastContext } from './toastContextValue';
|
||||||
|
import './Toast.css';
|
||||||
let toastId = 0;
|
|
||||||
|
|
||||||
export function ToastProvider({ children }) {
|
export function ToastProvider({ children }) {
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const timersRef = useRef({});
|
const timersRef = useRef({});
|
||||||
|
const toastIdRef = useRef(0);
|
||||||
|
|
||||||
const removeToast = useCallback((id) => {
|
const removeToast = useCallback((id) => {
|
||||||
clearTimeout(timersRef.current[id]);
|
clearTimeout(timersRef.current[id]);
|
||||||
@@ -14,12 +14,19 @@ export function ToastProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||||
const id = ++toastId;
|
const id = ++toastIdRef.current;
|
||||||
setToasts((prev) => [...prev, { id, message, type }]);
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
timersRef.current[id] = setTimeout(() => removeToast(id), duration);
|
timersRef.current[id] = setTimeout(() => removeToast(id), duration);
|
||||||
return id;
|
return id;
|
||||||
}, [removeToast]);
|
}, [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]);
|
const toast = useCallback((message) => addToast(message, 'info'), [addToast]);
|
||||||
toast.success = useCallback((message) => addToast(message, 'success'), [addToast]);
|
toast.success = useCallback((message) => addToast(message, 'success'), [addToast]);
|
||||||
toast.error = useCallback((message) => addToast(message, 'error'), [addToast]);
|
toast.error = useCallback((message) => addToast(message, 'error'), [addToast]);
|
||||||
@@ -28,9 +35,9 @@ export function ToastProvider({ children }) {
|
|||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={toast}>
|
<ToastContext.Provider value={toast}>
|
||||||
{children}
|
{children}
|
||||||
<div style={containerStyle}>
|
<div className="toast-container" role="status" aria-live="polite">
|
||||||
{toasts.map((t) => (
|
{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}
|
{t.message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -38,33 +45,3 @@ export function ToastProvider({ children }) {
|
|||||||
</ToastContext.Provider>
|
</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 { login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||||
import { AuthContext } from './authContextValue';
|
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 }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(() => {
|
const [user, setUser] = useState(() => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const username = localStorage.getItem('username');
|
const username = localStorage.getItem('username');
|
||||||
const role = localStorage.getItem('role');
|
if (!token) return null;
|
||||||
return token ? { token, username, role } : null;
|
const payload = decodeTokenPayload(token);
|
||||||
|
const role = payload?.role || 'user';
|
||||||
|
return { token, username, role };
|
||||||
});
|
});
|
||||||
|
|
||||||
const login = useCallback(async (username, password) => {
|
const login = useCallback(async (username, password) => {
|
||||||
const data = await apiLogin(username, password);
|
const data = await apiLogin(username, password);
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
localStorage.setItem('refreshToken', data.refreshToken);
|
|
||||||
localStorage.setItem('username', data.username);
|
localStorage.setItem('username', data.username);
|
||||||
localStorage.setItem('role', data.role);
|
const decoded = decodeTokenPayload(data.token);
|
||||||
setUser({ token: data.token, username: data.username, role: data.role });
|
const role = decoded?.role || 'user';
|
||||||
|
setUser({ token: data.token, username: data.username, role });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// SSAFY OAuth 콜백에서 받은 토큰으로 로그인 처리
|
// SSAFY OAuth 콜백에서 받은 토큰으로 로그인 처리
|
||||||
const setUserFromSSAFY = useCallback((data) => {
|
const setUserFromSSAFY = useCallback((data) => {
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
localStorage.setItem('refreshToken', data.refreshToken);
|
|
||||||
localStorage.setItem('username', data.username);
|
localStorage.setItem('username', data.username);
|
||||||
localStorage.setItem('role', data.role);
|
const decoded = decodeTokenPayload(data.token);
|
||||||
setUser({ token: data.token, username: data.username, role: data.role });
|
const role = decoded?.role || 'user';
|
||||||
|
setUser({ token: data.token, username: data.username, role });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
|
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
|
||||||
const clearSession = useCallback(() => {
|
const clearSession = useCallback(() => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('refreshToken');
|
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('role');
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="admin-tabs">
|
<div className="admin-tabs" role="tablist">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
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' : ''}`}
|
className={`admin-tab${tab === t.key ? ' active' : ''}`}
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => setTab(t.key)}
|
||||||
>
|
>
|
||||||
@@ -41,7 +45,8 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 === 'announcement' && <AnnouncementAdmin />}
|
||||||
{tab === 'download' && <DownloadAdmin />}
|
{tab === 'download' && <DownloadAdmin />}
|
||||||
{tab === 'user' && <UserAdmin />}
|
{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 {
|
/* This file is deprecated. Both LoginPage and RegisterPage now use AuthPage.css */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { getSSAFYLoginURL } from '../api/auth';
|
import { getSSAFYLoginURL } from '../api/auth';
|
||||||
import './LoginPage.css';
|
import './AuthPage.css';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
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 { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { register } from '../api/auth';
|
import { register } from '../api/auth';
|
||||||
import './LoginPage.css';
|
import './AuthPage.css';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState('');
|
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 { useAuth } from '../context/useAuth';
|
||||||
import { ssafyCallback } from '../api/auth';
|
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() {
|
export default function SSAFYCallbackPage() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { setUserFromSSAFY } = useAuth();
|
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);
|
const called = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.js',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user