fix: 코드 리뷰 기반 전체 개선 — 보안, 품질, UX
All checks were successful
Client CI/CD / deploy (push) Successful in 30s

- refreshToken 중복 로직 일원화 (동시 호출 방지 포함)
- 파일 업로드 401 시 토큰 갱신 후 재시도 추가
- XHR JSON.parse 에러 보호
- index.html lang="ko", title "One of the plans" 변경
- Vite 기본 에셋(vite.svg, react.svg) 및 빈 App.css 제거
- 공지 CRUD API 레이어 분리 (AnnouncementAdmin → announcements.js)
- load 함수 useCallback 적용 및 useEffect 의존성 정상화
- 로딩/빈 목록 상태 표시 추가 (AnnouncementBoard, UserAdmin)
- 누락 CSS 정의 추가 (announcement-error, announcement-empty)
- 로그인/회원가입 빈 필드 클라이언트 검증 추가
- 공지 등록 시 빈 제목/내용 에러 피드백 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:37:05 +09:00
parent c2e3be491d
commit 97453b1d81
17 changed files with 162 additions and 77 deletions

View File

@@ -282,6 +282,13 @@
}
/* Role badge */
.admin-list-empty {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.35);
padding: 12px 16px;
margin: 0;
}
.admin-role-badge {
font-size: 0.7rem;
padding: 2px 8px;

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { getAnnouncements } from '../../api/announcements';
import { apiFetch } from '../../api/client';
import { useState, useEffect, useCallback } from 'react';
import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
import './AdminCommon.css';
export default function AnnouncementAdmin() {
@@ -10,25 +9,24 @@ export default function AnnouncementAdmin() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = () => getAnnouncements().then(setList).catch(() => {});
useEffect(() => { load(); }, []);
const load = useCallback(() => {
getAnnouncements().then(setList).catch(() => {});
}, []);
useEffect(() => { load(); }, [load]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title || !form.content) return;
if (!form.title || !form.content) {
setError('제목과 내용을 모두 입력해주세요.');
return;
}
setLoading(true);
setError('');
try {
if (editingId) {
await apiFetch(`/api/announcements/${editingId}`, {
method: 'PUT',
body: JSON.stringify(form),
});
await updateAnnouncement(editingId, form.title, form.content);
} else {
await apiFetch('/api/announcements', {
method: 'POST',
body: JSON.stringify(form),
});
await createAnnouncement(form.title, form.content);
}
setForm({ title: '', content: '' });
setEditingId(null);
@@ -49,7 +47,7 @@ export default function AnnouncementAdmin() {
const handleDelete = async (id) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
await deleteAnnouncement(id);
load();
} catch (err) {
setError(err.message || '삭제에 실패했습니다.');

View File

@@ -1,9 +1,28 @@
import { useState, useEffect } from 'react';
import { getDownloadInfo } from '../../api/download';
import { tryRefresh } from '../../api/client';
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);
@@ -21,43 +40,71 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
if (!file) return;
const token = localStorage.getItem('token');
const xhr = new XMLHttpRequest();
const url = `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
setUploading(true);
setError('');
xhr.onload = () => {
setUploading(false);
const handleDone = (xhr) => {
// 401 시 토큰 갱신 후 재시도
if (xhr.status === 401) {
window.dispatchEvent(new Event('auth:unauthorized'));
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;
}
if (xhr.status >= 200 && xhr.status < 300) {
onSuccess(JSON.parse(xhr.responseText));
setFile(null);
setProgress(0);
} else {
const res = JSON.parse(xhr.responseText || '{}');
setError(res.error || '업로드에 실패했습니다.');
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);
}
};
xhr.onerror = () => {
const handleError = () => {
setUploading(false);
setError('네트워크 오류가 발생했습니다.');
setProgress(0);
};
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);
sendXhr(url, token, file, {
onProgress: (p) => setProgress(p),
onDone: handleDone,
onError: handleError,
});
};
return (

View File

@@ -1,15 +1,21 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { getUsers, updateUserRole, deleteUser } from '../../api/users';
import { useAuth } from '../../context/useAuth';
import './AdminCommon.css';
export default function UserAdmin() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { user: me } = useAuth();
const load = () => getUsers().then(setUsers).catch(() => {});
useEffect(() => { load(); }, []);
const load = useCallback(() => {
getUsers()
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const handleRoleToggle = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
@@ -38,6 +44,8 @@ export default function UserAdmin() {
<div className="admin-section">
<h2 className="admin-section-title">유저 관리</h2>
{error && <p className="admin-error">{error}</p>}
{loading && <p className="admin-list-empty">불러오는 ...</p>}
{!loading && users.length === 0 && <p className="admin-list-empty">등록된 유저가 없습니다.</p>}
<ul className="admin-list">
{users.map((u) => (
<li key={u.id} className="admin-list-item">