From d9d52c8f7cab107ea0f36b4438abe8083bce6f17 Mon Sep 17 00:00:00 2001 From: vibsin9322 Date: Thu, 11 Jun 2026 18:08:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- admin.html | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++-- server.js | 53 ++++++++++++++++---- 2 files changed, 184 insertions(+), 13 deletions(-) diff --git a/admin.html b/admin.html index c32109b..b865ff1 100644 --- a/admin.html +++ b/admin.html @@ -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": <>, "chevron-right": <>, settings: <>, + lock: <>, + "log-out": <>, }; return ( @@ -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 ( +
+
+
+
+
+
자료실 관리자
+
Admin Console
+
+
+ +
+
+ + { setPw(e.target.value); setError(''); }} + placeholder="비밀번호를 입력하세요" + disabled={loading} + /> +
+ {error && ( +
+ {error} +
+ )} + +
+
+
+ ); +} + // ── 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 (