feat(ui): /orderlist 미인증 시 dbx-main 로그인으로 위임, 프로필 헤더 개선

- read_root() 가 세션 미인증 시 dbx-main /login?next=/orderlist/ 로 302.
  OMS 는 더 이상 자체 로그인 UI 를 가지지 않는다.
- index.html: #login-screen 블록 제거, dashboard 만 렌더.
- 우측 상단 프로필 영역을 메인 페이지 스타일로 개편:
  이름 + 이메일 + 아바타(Google picture URL, 폴백 아이콘) + 로그아웃 텍스트 버튼.
- main.py 가 세션의 user_name/user_email/user_picture 를 HTML placeholder 로 치환.
- app.js checkAuth() 단순화: 화면 토글 제거, 401 시 dbx-main 로그인으로 자동 이동.
- style.css 에 새 프로필 영역 CSS 추가.

Google 아바타가 보이려면 dbx-main 도 같이 user_picture 를 세션 top-level 에
저장하도록 수정됨 (dbx-main 별도 커밋).
This commit is contained in:
2026-05-24 14:20:42 +09:00
parent 1eabd7183c
commit 931ad173ab
4 changed files with 163 additions and 51 deletions
+61 -11
View File
@@ -1,7 +1,9 @@
from fastapi import FastAPI, Depends, Request from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from html import escape
from urllib.parse import quote
import logging import logging
import os import os
@@ -68,18 +70,66 @@ app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
os.makedirs("static", exist_ok=True) os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def read_root(): async def read_root(request: Request):
# Return the main index.html file # 미인증 사용자는 dbx-main 의 로그인 페이지로 위임 (SSO 원칙).
# OMS 는 자체 로그인 UI 를 보여주지 않는다.
user_email = request.session.get("user_email")
if not user_email:
next_path = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/"
sep = "&" if "?" in MAIN_LOGIN_URL else "?"
target = f"{MAIN_LOGIN_URL}{sep}next={quote(next_path, safe='/')}"
return RedirectResponse(url=target, status_code=302)
user_name = request.session.get("user_name") or user_email
user_picture = request.session.get("user_picture", "") or ""
# 아바타: Google picture URL 이 있으면 <img>, 없으면 폴백 아이콘
if user_picture:
avatar_html = (
f'<img src="{escape(user_picture, quote=True)}" '
f'alt="" class="profile-avatar-img" referrerpolicy="no-referrer" '
f'onerror="this.style.display=\'none\';'
f'this.nextElementSibling.style.display=\'flex\'">'
f'<i class="fa-solid fa-user profile-avatar-icon" style="display:none"></i>'
)
else:
avatar_html = '<i class="fa-solid fa-user profile-avatar-icon"></i>'
if os.path.exists("static/index.html"): if os.path.exists("static/index.html"):
with open("static/index.html", "r", encoding="utf-8") as f: with open("static/index.html", "r", encoding="utf-8") as f:
content = f.read() content = f.read()
content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH) content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH)
content = content.replace('src="/static/', f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/') content = content.replace(
content = content.replace('href="/static/', f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/') 'src="/static/',
content = content.replace("window.location.href='/login", f"window.location.href='{APP_ROOT_PATH}/login") f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/',
content = content.replace('window.location.href="/login', f'window.location.href="{APP_ROOT_PATH}/login') )
content = content.replace("window.location.href='/logout", f"window.location.href='{APP_ROOT_PATH}/logout") content = content.replace(
content = content.replace('window.location.href="/logout', f'window.location.href="{APP_ROOT_PATH}/logout') 'href="/static/',
return HTMLResponse(content=content) f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/',
)
content = content.replace(
"window.location.href='/login",
f"window.location.href='{APP_ROOT_PATH}/login",
)
content = content.replace(
'window.location.href="/login',
f'window.location.href="{APP_ROOT_PATH}/login',
)
content = content.replace(
"window.location.href='/logout",
f"window.location.href='{APP_ROOT_PATH}/logout",
)
content = content.replace(
'window.location.href="/logout',
f'window.location.href="{APP_ROOT_PATH}/logout',
)
# 사용자 정보 주입 (HTML 안의 placeholder 치환)
content = content.replace("__USER_NAME__", escape(user_name))
content = content.replace("__USER_EMAIL__", escape(user_email))
content = content.replace("__USER_AVATAR__", avatar_html)
return HTMLResponse(content=content)
return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>") return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>")
+78
View File
@@ -1769,4 +1769,82 @@ input[type="checkbox"] {
} }
} }
/* ── 우측 상단 프로필 영역 (메인 페이지 스타일) ───────────── */
.nav-profile-area .profile-info {
display: flex;
align-items: center;
gap: 10px;
}
.nav-profile-area .profile-name-block {
display: flex;
flex-direction: column;
line-height: 1.2;
text-align: right;
}
.nav-profile-area .profile-name {
font-size: 14px;
font-weight: 700;
color: inherit;
white-space: nowrap;
}
.nav-profile-area .profile-email {
font-size: 11px;
color: rgba(120, 120, 130, 0.75);
white-space: nowrap;
}
.nav-profile-area .profile-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.nav-profile-area .profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nav-profile-area .profile-avatar-icon {
color: rgba(80, 80, 90, 0.7);
font-size: 16px;
}
.nav-profile-area .btn-logout-text {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
color: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
line-height: 1;
}
.nav-profile-area .btn-logout-text:hover {
background: rgba(0, 0, 0, 0.1);
border-color: rgba(0, 0, 0, 0.2);
}
/* 모바일에서 이메일은 숨겨 공간 절약 */
@media (max-width: 720px) {
.nav-profile-area .profile-email { display: none; }
.nav-profile-area .btn-logout-text span { display: none; }
}
/* Force UI Update */ /* Force UI Update */
+14 -26
View File
@@ -20,26 +20,9 @@
<!-- MAIN APP CONTAINER --> <!-- MAIN APP CONTAINER -->
<div class="app-container" id="app"> <div class="app-container" id="app">
<!-- LOGIN SCREEN (Visible when unauthenticated) --> <!-- DASHBOARD SCREEN — 미인증 사용자는 서버 단에서 dbx-main 로그인으로 리다이렉트되므로
<div id="login-screen" class="screen active glass-panel"> OMS 자체 로그인 UI 는 더 이상 존재하지 않는다 (SSO 원칙). -->
<div class="login-box"> <div id="dashboard-screen" class="screen active">
<div class="logo">
<i class="fa-solid fa-boxes-packing logo-icon"></i>
<h2>OMS system</h2>
</div>
<p class="subtitle">Enter the corporate order management center.</p>
<div class="oauth-buttons">
<button class="btn btn-primary" style="width: 100%;"
onclick="window.location.href='/login'">
<i class="fa-solid fa-right-to-bracket"></i> 로그인
</button>
</div>
<p class="auth-hint">Access restricted to approved users.</p>
</div>
</div>
<!-- DASHBOARD SCREEN (Visible when authenticated) -->
<div id="dashboard-screen" class="screen hidden">
<!-- Unified Top Header Bar --> <!-- Unified Top Header Bar -->
<header class="unified-header glass-panel fade-in"> <header class="unified-header glass-panel fade-in">
<!-- 1. Logo Section --> <!-- 1. Logo Section -->
@@ -111,16 +94,21 @@
</div> </div>
</div> </div>
<!-- 3. Profile Section --> <!-- 3. Profile Section — 메인 페이지 스타일 (이름 + 이메일 + 아바타 + 로그아웃 버튼) -->
<div class="nav-profile-area"> <div class="nav-profile-area">
<button id="btn-data-management" class="icon-btn mini-icon-btn nav-tool-btn" title="데이터 관리"><i <button id="btn-data-management" class="icon-btn mini-icon-btn nav-tool-btn" title="데이터 관리"><i
class="fa-solid fa-database"></i></button> class="fa-solid fa-database"></i></button>
<div class="user-profile mini-profile"> <div class="profile-info">
<span id="user-name">Loading...</span> <div class="profile-name-block">
<div class="avatar mini-avatar"><i class="fa-solid fa-user"></i></div> <span class="profile-name" id="user-name">__USER_NAME__</span>
<span class="profile-email">__USER_EMAIL__</span>
</div>
<div class="profile-avatar">__USER_AVATAR__</div>
</div> </div>
<button class="icon-btn mini-icon-btn" onclick="window.location.href='/logout'" title="Logout"><i <button class="btn-logout-text" onclick="window.location.href='/logout'" title="로그아웃">
class="fa-solid fa-right-from-bracket"></i></button> <i class="fa-solid fa-right-from-bracket"></i>
<span>로그아웃</span>
</button>
</div> </div>
</header> </header>
+10 -14
View File
@@ -3,8 +3,7 @@ document.addEventListener('DOMContentLoaded', () => {
const appUrl = (path) => `${APP_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`; const appUrl = (path) => `${APP_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`;
// --- Elements --- // --- Elements ---
const loginScreen = document.getElementById('login-screen'); // loginScreen 은 더 이상 존재하지 않음 (서버에서 미인증 사용자를 dbx-main 으로 리다이렉트)
const dashboardScreen = document.getElementById('dashboard-screen');
const userNameEl = document.getElementById('user-name'); const userNameEl = document.getElementById('user-name');
const searchInputs = { const searchInputs = {
@@ -185,26 +184,23 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// --- Authentication --- // --- Authentication ---
// 미인증 사용자는 서버(read_root) 가 이미 dbx-main 로그인으로 리다이렉트하므로
// 여기서는 is_admin 확인 + 세션 만료 시 다시 dbx-main 으로 보내는 역할만 한다.
async function checkAuth() { async function checkAuth() {
try { try {
const res = await fetch(appUrl('/api/users/me')); const res = await fetch(appUrl('/api/users/me'));
if (res.ok) { if (res.ok) {
currentUser = await res.json(); currentUser = await res.json();
userNameEl.textContent = currentUser.name; if (userNameEl && currentUser.name) {
userNameEl.textContent = currentUser.name;
loginScreen.classList.remove('active'); }
loginScreen.classList.add('hidden');
dashboardScreen.classList.remove('hidden');
dashboardScreen.classList.add('active');
if (btnDataManagement) { if (btnDataManagement) {
btnDataManagement.style.display = currentUser.is_admin ? 'inline-flex' : 'none'; btnDataManagement.style.display = currentUser.is_admin ? 'inline-flex' : 'none';
} }
} else { } else if (res.status === 401) {
loginScreen.classList.remove('hidden'); // 세션 만료 — dbx-main 로그인으로 위임 (next 로 현재 위치 보존)
loginScreen.classList.add('active'); const next = encodeURIComponent((APP_BASE_PATH || '') + '/');
dashboardScreen.classList.add('hidden'); window.location.href = appUrl('/login') + '?next=' + next;
dashboardScreen.classList.remove('active');
} }
} catch (error) { } catch (error) {
console.error('Auth check error', error); console.error('Auth check error', error);