fix: 보안 강화, 리프레시 토큰 도입, 연계 오류 수정

- api/client: 리프레시 토큰 자동 갱신 (401 시 재시도, 동시 요청 dedup)
- api/client: 204 No Content 처리 추가 (res.json() 크래시 방지)
- api/client: 서버 에러 메시지 body에서 파싱하여 전달
- api/auth: logout 함수 추가 (서버 세션 삭제), 미사용 refreshToken 함수 제거
- AuthContext: 로그인 시 refreshToken 저장, 로그아웃 시 서버 호출 분리
- AuthContext: 401 이벤트는 로컬 세션만 정리 (clearSession 분리)
- DownloadSection: 게임 시작 토큰을 localStorage에서 직접 읽기 (스테일 방지)
- DownloadAdmin: XHR 401 처리, Content-Type 헤더 추가
- AnnouncementAdmin: 등록/수정/삭제 에러 상태 표시 추가
- AnnouncementBoard: API 실패 시 에러 메시지 표시
- UserAdmin: 권한 변경/삭제 에러 처리 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:51:35 +09:00
parent f85e261366
commit 6fb7e2cbc5
8 changed files with 143 additions and 26 deletions

View File

@@ -8,14 +8,16 @@ export default function AnnouncementAdmin() {
const [form, setForm] = useState({ title: '', content: '' });
const [editingId, setEditingId] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = () => getAnnouncements().then(setList);
const load = () => getAnnouncements().then(setList).catch(() => {});
useEffect(() => { load(); }, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title || !form.content) return;
setLoading(true);
setError('');
try {
if (editingId) {
await apiFetch(`/api/announcements/${editingId}`, {
@@ -31,6 +33,8 @@ export default function AnnouncementAdmin() {
setForm({ title: '', content: '' });
setEditingId(null);
load();
} catch (err) {
setError(err.message || '처리에 실패했습니다.');
} finally {
setLoading(false);
}
@@ -39,17 +43,23 @@ export default function AnnouncementAdmin() {
const handleEdit = (item) => {
setEditingId(item.id);
setForm({ title: item.title, content: item.content });
setError('');
};
const handleDelete = async (id) => {
if (!confirm('삭제하시겠습니까?')) return;
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
load();
try {
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
load();
} catch (err) {
setError(err.message || '삭제에 실패했습니다.');
}
};
const handleCancel = () => {
setEditingId(null);
setForm({ title: '', content: '' });
setError('');
};
return (
@@ -70,6 +80,7 @@ export default function AnnouncementAdmin() {
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
/>
{error && <p className="admin-error">{error}</p>}
<div className="admin-form-actions">
<button className="btn-admin-primary" type="submit" disabled={loading}>
{editingId ? '수정 완료' : '공지 등록'}

View File

@@ -31,6 +31,10 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
xhr.onload = () => {
setUploading(false);
if (xhr.status === 401) {
window.dispatchEvent(new Event('auth:unauthorized'));
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
onSuccess(JSON.parse(xhr.responseText));
setFile(null);
@@ -50,6 +54,7 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
setUploading(true);
setError('');
xhr.send(file);

View File

@@ -5,27 +5,39 @@ import './AdminCommon.css';
export default function UserAdmin() {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
const { user: me } = useAuth();
const load = () => getUsers().then(setUsers);
const load = () => getUsers().then(setUsers).catch(() => {});
useEffect(() => { load(); }, []);
const handleRoleToggle = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
await updateUserRole(u.id, newRole);
load();
setError('');
try {
await updateUserRole(u.id, newRole);
load();
} catch (err) {
setError(err.message || '권한 변경에 실패했습니다.');
}
};
const handleDelete = async (u) => {
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
await deleteUser(u.id);
load();
setError('');
try {
await deleteUser(u.id);
load();
} catch (err) {
setError(err.message || '삭제에 실패했습니다.');
}
};
return (
<div className="admin-section">
<h2 className="admin-section-title">유저 관리</h2>
{error && <p className="admin-error">{error}</p>}
<ul className="admin-list">
{users.map((u) => (
<li key={u.id} className="admin-list-item">