feat: 코드 리뷰 기반 전면 개선 — 보안, 접근성, 테스트, UX
Some checks failed
Client CI/CD / test (push) Failing after 15m27s
Client CI/CD / deploy (push) Has been cancelled

- 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:
2026-03-15 18:07:32 +09:00
parent 254617530c
commit 96f5381d1c
27 changed files with 2154 additions and 401 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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">

View File

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

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

View File

@@ -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">

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

View File

@@ -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' },
};

View File

@@ -0,0 +1,2 @@
export { ToastProvider } from './ToastProvider';
export { useToast } from './useToast';

View File

@@ -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);
}, []);

View File

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

View File

@@ -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 */

View File

@@ -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('');

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

View File

@@ -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('');

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

View File

@@ -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
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -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',
},
})