fix: 코드 리뷰 기반 전체 개선 — 보안, 품질, UX
All checks were successful
Client CI/CD / deploy (push) Successful in 30s
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:
@@ -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;
|
||||
|
||||
@@ -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 || '삭제에 실패했습니다.');
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user