From 56a93eb35e4ff3c071d962339bb73ccf8c52da64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EB=B9=88?= Date: Wed, 7 Oct 2026 16:21:17 +0900 Subject: [PATCH 1/2] feat: split user and admin UI routes Add route-aware login, signup, app, and admin entry points with static server fallback while preserving the existing proxy UI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/demo_runbook.md | 4 +-- frontend/demo.html | 64 +++++++++++++++++++++++++++++++++--------- frontend/dev_server.py | 22 +++++++++++++++ 4 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 frontend/dev_server.py diff --git a/README.md b/README.md index d1648ec..4ba0265 100644 --- a/README.md +++ b/README.md @@ -783,7 +783,7 @@ python -m uvicorn tools.mock_llm:app --host 127.0.0.1 --port 8001 --app-dir . ```bash cd frontend -python -m http.server 5500 +python dev_server.py ``` 브라우저에서 `http://127.0.0.1:5500/demo.html`로 접속합니다. 로그인 전에 API Base URL을 설정하고 회원가입·로그인을 진행합니다. 사용자 Bearer 토큰은 인증에 성공한 API origin에만 전송됩니다. `frontend/demo.html`은 발표용 정적 데모 페이지이며 운영용 관리자 콘솔이 아닙니다. 공개 기본 관리자 토큰은 없으며, `ADMIN_API_TOKEN`이 미설정이면 관리자 API는 안전하게 접근을 거부합니다. 관리자 토큰은 브라우저 저장소에 저장하지 않습니다. 전체 절차는 `docs/demo_runbook.md`를 참고합니다. diff --git a/docs/demo_runbook.md b/docs/demo_runbook.md index 720d3cf..f95cd9b 100644 --- a/docs/demo_runbook.md +++ b/docs/demo_runbook.md @@ -37,10 +37,10 @@ python -m uvicorn backend.app.api.proxy:app --host 127.0.0.1 --port 8000 터미널 3에서 정적 UI를 실행한다. ```powershell -python -m http.server 5500 --bind 127.0.0.1 --directory frontend +python frontend/dev_server.py ``` -브라우저에서 `http://127.0.0.1:5500/demo.html`을 연다. 다른 UI origin을 사용하면 그 origin을 `UI_ALLOWED_ORIGINS`에 쉼표로 구분해 추가하고 프록시를 다시 시작한다. +브라우저에서 `http://127.0.0.1:5500/login`을 연다. 사용자 화면은 `/login`, `/signup`, `/app`, 관리자 화면은 `/admin` 경로를 사용한다. 다른 UI origin을 사용하면 그 origin을 `UI_ALLOWED_ORIGINS`에 쉼표로 구분해 추가하고 프록시를 다시 시작한다. ## 3. 회원가입과 로그인 diff --git a/frontend/demo.html b/frontend/demo.html index 9b30fec..cdac27f 100644 --- a/frontend/demo.html +++ b/frontend/demo.html @@ -827,6 +827,7 @@

다시 오신 것을 환영합니다

+

LLM Security Proxy Demo

백엔드 보안 프록시의 사용자 입력 흐름과 관리자 요약을 한 화면에서 보여주는 발표용 정적 데모 페이지입니다. FastAPI 프록시가 http://127.0.0.1:8000에서 실행 중일 때 사용할 수 있으며, API 호출까지 함께 확인하려면 http://127.0.0.1:5500/demo.html로 서빙하는 구성을 권장합니다.

@@ -957,7 +958,9 @@

마스킹된 입력

-
+
+ +

관리자 요약

별도 관리자 토큰으로 전체 통계와 최근 차단 이력을 조회합니다. 계정별 RBAC 화면은 아닙니다.

@@ -1029,7 +1032,9 @@

최근 차단 이력

-
+
+ + @@ -1051,6 +1056,9 @@

사용 주의

const confirmPassword = document.getElementById("confirmPassword"); const authApiBase = document.getElementById("authApiBase"); const apiBase = document.getElementById("apiBase"); + const userConsole = document.getElementById("userConsole"); + const adminView = document.getElementById("adminView"); + const appFooter = document.getElementById("appFooter"); const apiBaseError = document.getElementById("apiBaseError"); const emailError = document.getElementById("emailError"); const passwordError = document.getElementById("passwordError"); @@ -1062,6 +1070,28 @@

사용 주의

let authMode = "login"; let authToken = sessionStorage.getItem(TOKEN_STORAGE_KEY); let authOrigin = sessionStorage.getItem(ORIGIN_STORAGE_KEY); + const routePath = ["/login", "/signup", "/app", "/admin"].includes(window.location.pathname) + ? window.location.pathname + : "/login"; + const protectedRoute = routePath === "/app" || routePath === "/admin"; + const adminRoute = routePath === "/admin"; + + function navigateTo(path) { + if (window.location.pathname === path) return; + window.location.assign(path); + } + + function applyRoutePresentation() { + const authRoute = routePath === "/login" || routePath === "/signup"; + authView.hidden = !authRoute; + appView.hidden = authRoute; + userConsole.hidden = !authRoute ? adminRoute : true; + adminView.hidden = !adminRoute; + appFooter.hidden = authRoute; + document.getElementById("loginTab").hidden = routePath === "/signup"; + document.getElementById("signupTab").hidden = routePath === "/login"; + setAuthMode(routePath === "/signup" ? "signup" : "login"); + } class ApiRequestError extends Error { constructor(messageText, { status = 0, kind = "request", payload = null } = {}) { @@ -1210,6 +1240,9 @@

사용 주의

function showApp(email) { authView.hidden = true; appView.hidden = false; + userConsole.hidden = adminRoute; + adminView.hidden = !adminRoute; + appFooter.hidden = false; sessionUser.textContent = `${email}로 로그인됨`; window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -1217,6 +1250,9 @@

사용 주의

function showAuth({ resetForm = true } = {}) { appView.hidden = true; authView.hidden = false; + userConsole.hidden = true; + adminView.hidden = true; + appFooter.hidden = true; if (resetForm) authForm.reset(); authApiBase.value = authOrigin || apiBase.value || "http://127.0.0.1:8000"; setAuthMode("login"); @@ -1234,6 +1270,7 @@

사용 주의

authMessage.textContent = message; authMessage.className = "auth-message error"; } + if (protectedRoute) navigateTo("/login"); } async function submitAuth() { @@ -1286,11 +1323,11 @@

사용 주의

return isValid; } - document.getElementById("loginTab").addEventListener("click", () => setAuthMode("login")); - document.getElementById("signupTab").addEventListener("click", () => setAuthMode("signup")); + document.getElementById("loginTab").addEventListener("click", () => navigateTo("/login")); + document.getElementById("signupTab").addEventListener("click", () => navigateTo("/signup")); authNote.addEventListener("click", (event) => { const switchButton = event.target.closest("[data-switch]"); - if (switchButton) setAuthMode(switchButton.dataset.switch); + if (switchButton) navigateTo(switchButton.dataset.switch === "signup" ? "/signup" : "/login"); }); document.querySelectorAll(".toggle-password").forEach((button) => { button.addEventListener("click", () => { @@ -1316,12 +1353,7 @@

사용 주의

try { const { payload, apiOrigin } = await submitAuth(); if (authMode === "signup") { - setAuthMode("login"); - authApiBase.value = apiOrigin; - authEmail.value = payload.email; - authPassword.value = ""; - authMessage.textContent = "가입이 완료되었습니다. 로그인해 주세요."; - authMessage.className = "auth-message success"; + navigateTo("/login"); } else { authToken = payload.access_token; authOrigin = apiOrigin; @@ -1330,7 +1362,7 @@

사용 주의

sessionStorage.setItem(ORIGIN_STORAGE_KEY, authOrigin); authMessage.textContent = "로그인되었습니다. 콘솔로 이동합니다."; authMessage.className = "auth-message success"; - window.setTimeout(() => showApp(payload.email), 350); + window.setTimeout(() => navigateTo("/app"), 350); } } catch (error) { authMessage.textContent = requestErrorMessage(error, "인증 요청을 처리하지 못했습니다."); @@ -1417,12 +1449,14 @@

사용 주의

const { payload } = await requestJson(`${authOrigin}/auth/me`, { headers: { Authorization: `Bearer ${authToken}` } }); - showApp(payload.email); + if (routePath === "/login" || routePath === "/signup") navigateTo("/app"); + else showApp(payload.email); } catch (error) { if (error.kind === "unauthorized") { clearStoredAuth("세션이 만료되었거나 무효입니다. 다시 로그인해 주세요."); return; } + if (protectedRoute) showAuth({ resetForm: false }); showAuth({ resetForm: false }); authMessage.textContent = "서버에 연결하지 못해 세션을 확인할 수 없습니다. 로컬 토큰은 유지되며 새로고침하여 다시 확인할 수 있습니다."; authMessage.className = "auth-message error"; @@ -2038,10 +2072,12 @@

사용 주의

resetResultView(); updateMessageCount(); }); + applyRoutePresentation(); updateMessageCount(); clearAdminData(); syncProxyControls(); - restoreSession(); + if (protectedRoute && (!authToken || !authOrigin)) navigateTo("/login"); + else restoreSession(); diff --git a/frontend/dev_server.py b/frontend/dev_server.py new file mode 100644 index 0000000..2ee30bf --- /dev/null +++ b/frontend/dev_server.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +ROUTE_TARGETS = {"/login", "/signup", "/app", "/admin"} + + +class RouteFallbackHandler(SimpleHTTPRequestHandler): + def translate_path(self, path: str) -> str: + route = path.split("?", 1)[0].rstrip("/") or "/" + if route in ROUTE_TARGETS: + return str(ROOT / "demo.html") + return super().translate_path(path) + + +if __name__ == "__main__": + server = ThreadingHTTPServer(("127.0.0.1", 5500), RouteFallbackHandler) + print("TEAM ORCA UI: http://127.0.0.1:5500/login") + server.serve_forever() From bce22d6f562b38d1453c2a29d52125eb3082e653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EB=B9=88?= Date: Wed, 7 Oct 2026 16:32:24 +0900 Subject: [PATCH 2/2] fix: preserve signup success message Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/demo.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/demo.html b/frontend/demo.html index cdac27f..b06f9b4 100644 --- a/frontend/demo.html +++ b/frontend/demo.html @@ -1066,6 +1066,7 @@

사용 주의

const sessionUser = document.getElementById("sessionUser"); const TOKEN_STORAGE_KEY = "orca_auth_token"; const ORIGIN_STORAGE_KEY = "orca_auth_origin"; + const FLASH_MESSAGE_KEY = "orca_auth_flash"; const REQUEST_TIMEOUT_MS = 15000; let authMode = "login"; let authToken = sessionStorage.getItem(TOKEN_STORAGE_KEY); @@ -1353,6 +1354,7 @@

사용 주의

try { const { payload, apiOrigin } = await submitAuth(); if (authMode === "signup") { + sessionStorage.setItem(FLASH_MESSAGE_KEY, "가입이 완료되었습니다. 로그인해 주세요."); navigateTo("/login"); } else { authToken = payload.access_token; @@ -2073,6 +2075,12 @@

사용 주의

updateMessageCount(); }); applyRoutePresentation(); + const authFlashMessage = sessionStorage.getItem(FLASH_MESSAGE_KEY); + if (authFlashMessage && routePath === "/login") { + sessionStorage.removeItem(FLASH_MESSAGE_KEY); + authMessage.textContent = authFlashMessage; + authMessage.className = "auth-message success"; + } updateMessageCount(); clearAdminData(); syncProxyControls();