feat: 관리자 페이지 로그인 인증 추가
- server.js: 세션 토큰 기반 인증 미들웨어 추가 - POST /api/admin/login, POST /api/admin/logout, GET /api/admin/verify 엔드포인트 추가 - 관리자 전용 쓰기 라우트에 authMiddleware 적용 - admin.html: 로그인 화면 컴포넌트 및 토큰 관리(sessionStorage) 추가 - 사이드바에 로그아웃 버튼 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+141
-3
@@ -137,10 +137,24 @@
|
||||
|
||||
const { useState, useEffect, useRef, useCallback } = React;
|
||||
|
||||
// ── 인증 토큰 유틸 ────────────────────────────────────────────
|
||||
|
||||
const getToken = () => sessionStorage.getItem('admin_token');
|
||||
const setToken = (t) => sessionStorage.setItem('admin_token', t);
|
||||
const clearToken = () => sessionStorage.removeItem('admin_token');
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
async function apiFetch(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.__onUnauthorized?.();
|
||||
throw new Error('인증이 필요합니다');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `API 오류 (${res.status})`);
|
||||
return data;
|
||||
@@ -170,6 +184,8 @@ const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
||||
"chevron-left": <><path d="m15 18-6-6 6-6"/></>,
|
||||
"chevron-right": <><path d="m9 18 6-6-6-6"/></>,
|
||||
settings: <><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
lock: <><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></>,
|
||||
"log-out": <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></>,
|
||||
};
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
||||
@@ -178,6 +194,82 @@ const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── 로그인 화면 ───────────────────────────────────────────────
|
||||
|
||||
function LoginScreen({ onLogin }) {
|
||||
const [pw, setPw] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '로그인 실패');
|
||||
onLogin(data.token);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setPw('');
|
||||
inputRef.current?.focus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display:'grid', placeItems:'center', height:'100vh', background:'var(--bg)' }}>
|
||||
<div style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', padding:'32px 28px', width:360, maxWidth:'92vw', boxShadow:'var(--shadow-lg)' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:28 }}>
|
||||
<div style={{ width:34, height:34, borderRadius:9, background:'var(--ink)', display:'grid', placeItems:'center', color:'#fff', fontWeight:700, fontSize:14, flexShrink:0 }}>자</div>
|
||||
<div>
|
||||
<div style={{ fontWeight:600, fontSize:15 }}>자료실 관리자</div>
|
||||
<div style={{ fontSize:11, color:'var(--muted)', fontFamily:'var(--font-mono)' }}>Admin Console</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="field" style={{ marginBottom: error ? 8 : 16 }}>
|
||||
<label style={{ display:'flex', alignItems:'center', gap:5 }}>
|
||||
<Icon name="lock" size={11} />관리자 비밀번호
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
value={pw}
|
||||
onChange={e => { setPw(e.target.value); setError(''); }}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ fontSize:12, color:'var(--warn)', marginBottom:12, display:'flex', alignItems:'center', gap:5 }}>
|
||||
<Icon name="x" size={12} />{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-accent"
|
||||
style={{ width:'100%', justifyContent:'center', padding:'9px 16px', fontSize:14 }}
|
||||
disabled={loading || !pw}
|
||||
>
|
||||
{loading ? '확인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────
|
||||
|
||||
const Toast = ({ msg, on }) => (
|
||||
@@ -666,7 +758,7 @@ const NAV = [
|
||||
{ id: 'users', label: '사용자 관리', icon: 'user' },
|
||||
];
|
||||
|
||||
function AdminApp() {
|
||||
function AdminContent({ onLogout }) {
|
||||
const [active, setActive] = useState('categories');
|
||||
const [toast, setToast] = useState({ on: false, msg: '' });
|
||||
|
||||
@@ -675,6 +767,17 @@ function AdminApp() {
|
||||
setTimeout(() => setToast(s => ({ ...s, on: false })), 2200);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/admin/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
} catch (_) {}
|
||||
clearToken();
|
||||
onLogout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sb">
|
||||
@@ -696,6 +799,9 @@ function AdminApp() {
|
||||
))}
|
||||
|
||||
<div className="sb-divider" style={{ marginTop: 'auto' }} />
|
||||
<div className="sb-item" onClick={handleLogout} style={{ cursor:'default', color:'var(--warn)' }}>
|
||||
<Icon name="log-out" size={14} />로그아웃
|
||||
</div>
|
||||
<a href="/index.html" className="sb-back">
|
||||
<Icon name="arrow-left" size={13} />자료실로 돌아가기
|
||||
</a>
|
||||
@@ -714,6 +820,38 @@ function AdminApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function AdminApp() {
|
||||
const [authed, setAuthed] = useState(null); // null=로딩중, true=인증됨, false=미인증
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) { setAuthed(false); return; }
|
||||
fetch('/api/admin/verify', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => r.json())
|
||||
.then(d => setAuthed(d.ok === true))
|
||||
.catch(() => setAuthed(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.__onUnauthorized = () => setAuthed(false);
|
||||
return () => { window.__onUnauthorized = null; };
|
||||
}, []);
|
||||
|
||||
if (authed === null) {
|
||||
return (
|
||||
<div style={{ display:'grid', placeItems:'center', height:'100vh', background:'var(--bg)' }}>
|
||||
<span style={{ color:'var(--faint)', fontSize:13 }}>로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return <LoginScreen onLogin={token => { setToken(token); setAuthed(true); }} />;
|
||||
}
|
||||
|
||||
return <AdminContent onLogout={() => setAuthed(false)} />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -11,6 +11,8 @@ const { randomUUID } = require('crypto');
|
||||
const PORT = 3000;
|
||||
const DB_PATH = path.join(__dirname, 'jaryo.db');
|
||||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin1234';
|
||||
const sessions = new Set();
|
||||
|
||||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR);
|
||||
|
||||
@@ -186,6 +188,12 @@ function rowToFile(row) {
|
||||
return { ...row, starred: row.starred === 1 };
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
||||
if (!token || !sessions.has(token)) return res.status(401).json({ error: '인증이 필요합니다' });
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Express ───────────────────────────────────────────────────
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -322,7 +330,7 @@ app.get('/api/files/:id/download', (req, res) => {
|
||||
});
|
||||
|
||||
// ── API: 삭제 ─────────────────────────────────────────────────
|
||||
app.delete('/api/files', (req, res) => {
|
||||
app.delete('/api/files', authMiddleware, (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return res.status(400).json({ error: 'ids required' });
|
||||
|
||||
@@ -344,8 +352,28 @@ app.delete('/api/files', (req, res) => {
|
||||
res.json({ deleted: ids.length });
|
||||
});
|
||||
|
||||
// ── API: 관리자 인증 ──────────────────────────────────────────
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (password !== ADMIN_PASSWORD) return res.status(401).json({ error: '비밀번호가 올바르지 않습니다' });
|
||||
const token = randomUUID();
|
||||
sessions.add(token);
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', (req, res) => {
|
||||
const token = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
||||
sessions.delete(token);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/verify', (req, res) => {
|
||||
const token = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
||||
res.json({ ok: sessions.has(token) });
|
||||
});
|
||||
|
||||
// ── API: 관리자 파일 목록 (검색·페이지네이션) ─────────────────
|
||||
app.get('/api/admin/files', (req, res) => {
|
||||
app.get('/api/admin/files', authMiddleware, (req, res) => {
|
||||
const { search, category, page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const where = [];
|
||||
@@ -370,7 +398,7 @@ app.get('/api/admin/files', (req, res) => {
|
||||
});
|
||||
|
||||
// ── API: 파일 메타데이터 수정 ─────────────────────────────────
|
||||
app.patch('/api/files/:id', (req, res) => {
|
||||
app.patch('/api/files/:id', authMiddleware, (req, res) => {
|
||||
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const { name, category, author, visibility, modified_at } = req.body;
|
||||
@@ -396,7 +424,7 @@ app.get('/api/categories', (req, res) => {
|
||||
res.json(queryAll('SELECT * FROM categories ORDER BY sort_order ASC, id ASC'));
|
||||
});
|
||||
|
||||
app.post('/api/categories', (req, res) => {
|
||||
app.post('/api/categories', authMiddleware, (req, res) => {
|
||||
const { id, label, icon = 'folder', sort_order = 0 } = req.body;
|
||||
if (!id || !label) return res.status(400).json({ error: 'id와 label은 필수입니다' });
|
||||
const exists = queryGet('SELECT id FROM categories WHERE id = :id', { ':id': id });
|
||||
@@ -409,7 +437,7 @@ app.post('/api/categories', (req, res) => {
|
||||
res.status(201).json(queryGet('SELECT * FROM categories WHERE id = :id', { ':id': id }));
|
||||
});
|
||||
|
||||
app.patch('/api/categories/:id', (req, res) => {
|
||||
app.patch('/api/categories/:id', authMiddleware, (req, res) => {
|
||||
const row = queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const { label, icon, sort_order } = req.body;
|
||||
@@ -424,7 +452,7 @@ app.patch('/api/categories/:id', (req, res) => {
|
||||
res.json(queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id }));
|
||||
});
|
||||
|
||||
app.delete('/api/categories/:id', (req, res) => {
|
||||
app.delete('/api/categories/:id', authMiddleware, (req, res) => {
|
||||
const row = queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const used = queryGet('SELECT COUNT(*) as c FROM files WHERE category = :id', { ':id': req.params.id });
|
||||
@@ -443,7 +471,7 @@ app.get('/api/users', (req, res) => {
|
||||
res.json(withCount);
|
||||
});
|
||||
|
||||
app.post('/api/users', (req, res) => {
|
||||
app.post('/api/users', authMiddleware, (req, res) => {
|
||||
const { name, email = null, role = 'member' } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name은 필수입니다' });
|
||||
const exists = queryGet('SELECT id FROM users WHERE name = :name', { ':name': name });
|
||||
@@ -457,7 +485,7 @@ app.post('/api/users', (req, res) => {
|
||||
res.status(201).json(queryGet('SELECT * FROM users WHERE id = :id', { ':id': id }));
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', (req, res) => {
|
||||
app.patch('/api/users/:id', authMiddleware, (req, res) => {
|
||||
const row = queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const { name, email, role } = req.body;
|
||||
@@ -477,7 +505,7 @@ app.patch('/api/users/:id', (req, res) => {
|
||||
res.json(queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id }));
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', (req, res) => {
|
||||
app.delete('/api/users/:id', authMiddleware, (req, res) => {
|
||||
const row = queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const fc = queryGet('SELECT COUNT(*) as c FROM files WHERE author = :name', { ':name': row.name });
|
||||
@@ -487,5 +515,10 @@ app.delete('/api/users/:id', (req, res) => {
|
||||
|
||||
// ── 시작 ──────────────────────────────────────────────────────
|
||||
initDb().then(() => {
|
||||
app.listen(PORT, () => console.log(`자료실 서버 실행 중 → http://localhost:${PORT}`));
|
||||
app.listen(PORT, () => {
|
||||
console.log(`자료실 서버 실행 중 → http://localhost:${PORT}`);
|
||||
if (!process.env.ADMIN_PASSWORD) {
|
||||
console.warn('⚠️ 관리자 비밀번호가 기본값(admin1234)입니다. ADMIN_PASSWORD 환경변수로 변경하세요.');
|
||||
}
|
||||
});
|
||||
}).catch(err => { console.error('DB 초기화 실패:', err); process.exit(1); });
|
||||
|
||||
Reference in New Issue
Block a user