feat(erp): ERP 대시보드 + 관리자 권한 시스템 도입
- 로그인: dbxcorp.co.kr 도메인 화이트리스트만 사용. ALLOWED_EMAILS 게이트 제거 - king@dbxcorp.co.kr 슈퍼관리자 자동 부여 (강등/삭제 불가) - 신규 로그인 사용자는 JSON 저장소에 자동 등록 (기본 모듈 권한 0) - 관리자 페이지(/admin): 역할/모듈별 토글로 직원 권한 수정 - 메인 대시보드를 ERP 시스템(monochromatic shadcn)으로 재설계 · 모듈: CORM, Order, 개인경비(준비중), 휴가(준비중) · 권한 없는 모듈은 '권한 없음' 배지 + 클릭 불가 - DESIGN.md 토큰 기반 styles → app/static/erp.css - docker-compose: dbx-main-data 볼륨 마운트 (DATA_DIR=/data) 으로 권한 JSON 영구 보관 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+184
-42
@@ -5,12 +5,22 @@ from typing import Any
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .store import (
|
||||
MODULE_KEYS,
|
||||
SUPER_ADMIN_EMAIL,
|
||||
UserStore,
|
||||
allowed_modules,
|
||||
has_module,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||
SESSION_COOKIE_DEFAULT = "session"
|
||||
|
||||
@@ -20,14 +30,17 @@ load_dotenv()
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
||||
|
||||
# ALLOWED_EMAILS 는 운영 시 .env 의 ALLOWED_EMAILS (쉼표 구분)로 덮어쓸 수 있다.
|
||||
# env 가 비어 있으면 아래 기본 목록을 그대로 사용 — 기존 운영 호환.
|
||||
DEFAULT_ALLOWED_EMAILS = (
|
||||
"king@dbxcorp.co.kr",
|
||||
"julie@dbxcorp.co.kr",
|
||||
"ellen@dbxcorp.co.kr",
|
||||
"bj@dbxcorp.co.kr",
|
||||
)
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""사용자/권한 JSON 저장소 위치. 컨테이너 재배포에도 살아남도록
|
||||
DATA_DIR 환경변수로 마운트된 볼륨을 가리킬 수 있다."""
|
||||
override = os.getenv("DATA_DIR", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
return BASE_DIR / "data"
|
||||
|
||||
|
||||
DATA_DIR = _data_dir()
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
@@ -35,14 +48,6 @@ def env(name: str, default: str = "") -> str:
|
||||
return value if value else default
|
||||
|
||||
|
||||
def _parse_email_list(raw: str) -> set[str]:
|
||||
return {e.strip().lower() for e in raw.split(",") if e.strip()}
|
||||
|
||||
|
||||
_env_emails = _parse_email_list(env("ALLOWED_EMAILS", ""))
|
||||
ALLOWED_EMAILS: set[str] = _env_emails or _parse_email_list(",".join(DEFAULT_ALLOWED_EMAILS))
|
||||
|
||||
|
||||
def _require_session_secret() -> str:
|
||||
secret = env("SESSION_SECRET_KEY", "")
|
||||
if not secret:
|
||||
@@ -80,6 +85,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
||||
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
oauth = build_google_oauth()
|
||||
user_store = UserStore(DATA_DIR / "users.json")
|
||||
|
||||
|
||||
def public_url_for(request: Request, route_name: str) -> str:
|
||||
@@ -89,11 +95,11 @@ def public_url_for(request: Request, route_name: str) -> str:
|
||||
return str(request.url_for(route_name))
|
||||
|
||||
|
||||
def get_user(request: Request) -> dict[str, Any] | None:
|
||||
def get_session_user(request: Request) -> dict[str, Any] | None:
|
||||
"""세션에서 로그인 사용자(이메일/이름/사진) 추출. OMS SSO 호환."""
|
||||
user = request.session.get("user")
|
||||
if isinstance(user, dict):
|
||||
return user
|
||||
# OMS 가 top-level user_email 만 채워놓은 SSO 세션도 인정
|
||||
email = request.session.get("user_email")
|
||||
if email:
|
||||
return {
|
||||
@@ -104,6 +110,27 @@ def get_user(request: Request) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user_record(request: Request) -> dict[str, Any] | None:
|
||||
"""세션 + 저장소를 합쳐 권한이 포함된 사용자 레코드 반환."""
|
||||
sess = get_session_user(request)
|
||||
if not sess:
|
||||
return None
|
||||
rec = user_store.get(sess["email"])
|
||||
if rec is None:
|
||||
# 세션은 살아있지만 저장소에 없음 — 세션 무효화
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict[str, Any]:
|
||||
rec = get_current_user_record(request)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not is_admin(rec):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
return rec
|
||||
|
||||
|
||||
def safe_next(raw: str | None) -> str:
|
||||
"""Open redirect 방지: 같은 호스트의 절대 경로만 허용."""
|
||||
if not raw:
|
||||
@@ -144,35 +171,79 @@ def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
|
||||
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
||||
if domain != ALLOWED_DOMAIN:
|
||||
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
||||
if email not in ALLOWED_EMAILS:
|
||||
return False, "접속 허용 목록에 없는 계정입니다."
|
||||
return True, ""
|
||||
|
||||
|
||||
# ── ERP 메뉴 정의 ──────────────────────────────────────────────
|
||||
# 각 항목: key(권한키), title, description, url(env override), status(ready|preparing), category
|
||||
def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items = [
|
||||
{
|
||||
"key": "corm",
|
||||
"title": "CORM",
|
||||
"subtitle": "CS · 발주 · 반품 · 코드관리",
|
||||
"description": "고객 응대와 발주/반품, 코드 관리 업무를 한 곳에서 처리합니다.",
|
||||
"url": env("CS_ORDER_URL", "/corm/"),
|
||||
"health_url": "/corm/health/db",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "order",
|
||||
"title": "Order",
|
||||
"subtitle": "고객 주문 데이터베이스",
|
||||
"description": "고객 주문 내역을 조회·검색하고 관련 데이터를 관리합니다.",
|
||||
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
||||
"health_url": "/orderlist/health/db",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "expense",
|
||||
"title": "개인경비",
|
||||
"subtitle": "Personal Expense",
|
||||
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
|
||||
"url": "#",
|
||||
"health_url": None,
|
||||
"status": "preparing",
|
||||
"category": "관리",
|
||||
},
|
||||
{
|
||||
"key": "vacation",
|
||||
"title": "휴가",
|
||||
"subtitle": "Vacation",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
|
||||
"url": "#",
|
||||
"health_url": None,
|
||||
"status": "preparing",
|
||||
"category": "관리",
|
||||
},
|
||||
]
|
||||
allowed = allowed_modules(user_rec)
|
||||
for item in items:
|
||||
item["allowed"] = item["key"] in allowed
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
user = get_user(request)
|
||||
if not user:
|
||||
sess = get_session_user(request)
|
||||
if not sess:
|
||||
return render_template(request, "login.html")
|
||||
|
||||
menu_items = [
|
||||
{
|
||||
"title": "CS, 발주, 반품, 코드관리",
|
||||
"description": "CS, 발주, 반품, 코드관리 페이지로 이동",
|
||||
"url": env("CS_ORDER_URL", "/corm/"),
|
||||
"health_url": "/corm/health/db",
|
||||
},
|
||||
{
|
||||
"title": "고객 주문 데이터베이스",
|
||||
"description": "고객 주문 데이터베이스 페이지로 이동",
|
||||
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
||||
"health_url": "/orderlist/health/db",
|
||||
},
|
||||
]
|
||||
user_rec = user_store.get(sess["email"])
|
||||
if user_rec is None:
|
||||
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
||||
request.session.clear()
|
||||
return render_template(request, "login.html")
|
||||
menu_items = _menu_items_for(user_rec)
|
||||
return render_template(
|
||||
request,
|
||||
"main.html",
|
||||
{"user": user, "menu_items": menu_items},
|
||||
{
|
||||
"user": user_rec,
|
||||
"menu_items": menu_items,
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +257,6 @@ async def login(request: Request):
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# SSO: ?next= 로 들어온 목적지를 OAuth 콜백 뒤에 사용하기 위해 세션에 임시 보관
|
||||
request.session["_post_login_next"] = safe_next(request.query_params.get("next"))
|
||||
|
||||
redirect_uri = public_url_for(request, "auth_google")
|
||||
@@ -226,11 +296,14 @@ async def auth_google(request: Request):
|
||||
email = str(userinfo.get("email", "")).lower().strip()
|
||||
name = userinfo.get("name") or email
|
||||
picture = userinfo.get("picture", "") or ""
|
||||
|
||||
# 저장소에 사용자 등록/갱신 (신규는 권한 0 — 관리자가 부여)
|
||||
user_store.upsert_login(email=email, name=name, picture=picture)
|
||||
|
||||
# OMS 와 공유하는 top-level 키 (SSO 계약)
|
||||
request.session["user_email"] = email
|
||||
request.session["user_name"] = name
|
||||
request.session["user_picture"] = picture
|
||||
# 기존 코드/템플릿 호환용 dict
|
||||
request.session["user"] = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
@@ -249,3 +322,72 @@ async def logout(request: Request) -> RedirectResponse:
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── 관리자 페이지 ──────────────────────────────────────────────
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_page(request: Request) -> HTMLResponse:
|
||||
rec = get_current_user_record(request)
|
||||
if rec is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not is_admin(rec):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "관리자만 접근할 수 있는 페이지입니다."},
|
||||
status_code=403,
|
||||
)
|
||||
users = sorted(user_store.list_all(), key=lambda u: (u["email"] != SUPER_ADMIN_EMAIL, u["email"]))
|
||||
return render_template(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"user": rec,
|
||||
"users": users,
|
||||
"module_keys": list(MODULE_KEYS),
|
||||
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||
"is_admin": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 관리자 API ──────────────────────────────────────────────
|
||||
class UpdatePermissionsBody(BaseModel):
|
||||
role: str | None = None
|
||||
modules: dict[str, bool] | None = None
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||
return JSONResponse({"users": user_store.list_all()})
|
||||
|
||||
|
||||
@app.put("/api/users/{email}")
|
||||
async def api_update_user(
|
||||
email: str,
|
||||
body: UpdatePermissionsBody,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
rec = user_store.update_permissions(
|
||||
email=email, role=body.role, modules=body.modules
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"user": rec})
|
||||
|
||||
|
||||
@app.delete("/api/users/{email}")
|
||||
async def api_delete_user(
|
||||
email: str,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
user_store.delete(email)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
ERP 대시보드 — DESIGN.md 기반 (Monochromatic / shadcn)
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
/* Geist 폰트 — CDN. 실패시 Inter / 시스템 폰트 fallback */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono&family=Inter:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-canvas-white: #ffffff;
|
||||
--color-ghost-gray: #f2f2f2;
|
||||
--color-subtle-ash: #e5e5e5;
|
||||
--color-midtone-gray: #737373;
|
||||
--color-rich-black: #0a0a0a;
|
||||
--color-deep-black: #000000;
|
||||
--color-callout-red: #c22b10;
|
||||
--color-success-green:#10c22b;
|
||||
|
||||
/* Typography */
|
||||
--font-geist: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo,
|
||||
Monaco, Consolas, monospace;
|
||||
|
||||
--text-caption: 12px;
|
||||
--text-body: 14px;
|
||||
--text-heading: 18px;
|
||||
--text-display: 48px;
|
||||
--leading-body: 1.43;
|
||||
|
||||
--tracking-heading: -0.45px;
|
||||
--tracking-display: -2.4px;
|
||||
|
||||
/* Spacing */
|
||||
--sp-4: 4px; --sp-6: 6px; --sp-8: 8px; --sp-10: 10px;
|
||||
--sp-12: 12px; --sp-16: 16px; --sp-20: 20px; --sp-24: 24px;
|
||||
--sp-32: 32px; --sp-40: 40px; --sp-80: 80px;
|
||||
|
||||
/* Radii */
|
||||
--r-pill: 9999px;
|
||||
--r-badge: 26px;
|
||||
--r-card: 14px;
|
||||
--r-input: 10px;
|
||||
--r-btn: 10px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||
--shadow-focus: lab(100 0 0) 0px 0px 0px 2px;
|
||||
}
|
||||
|
||||
/* ── 리셋 ── (auth pages 와 충돌 방지 위해 erp-shell 안에서만 적용) */
|
||||
.erp-shell, .erp-shell * { box-sizing: border-box; }
|
||||
.erp-shell { margin: 0; padding: 0; }
|
||||
|
||||
body.erp-body {
|
||||
margin: 0;
|
||||
font-family: var(--font-geist);
|
||||
font-size: var(--text-body);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.erp-body a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ── 상단 네비게이션 ── */
|
||||
.erp-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: var(--color-canvas-white);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-24);
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.erp-nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.erp-brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.erp-brand-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-subtle-ash);
|
||||
}
|
||||
|
||||
.erp-brand-system {
|
||||
font-size: var(--text-body);
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
}
|
||||
|
||||
.erp-user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
padding: 4px var(--sp-12) 4px 4px;
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--color-canvas-white);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-rich-black);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.erp-user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.erp-user-avatar-fallback {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.erp-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.erp-user-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.erp-user-email {
|
||||
color: var(--color-midtone-gray);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── 버튼 ── */
|
||||
.erp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-6);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-btn);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.erp-btn-primary {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.erp-btn-primary:hover {
|
||||
background: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-rich-black);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.erp-btn-ghost:hover {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-btn-outline {
|
||||
background: var(--color-canvas-white);
|
||||
color: var(--color-rich-black);
|
||||
border-color: var(--color-subtle-ash);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.erp-btn-outline:hover {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-btn-danger {
|
||||
background: transparent;
|
||||
color: var(--color-callout-red);
|
||||
border-color: var(--color-subtle-ash);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.erp-btn-danger:hover {
|
||||
border-color: var(--color-callout-red);
|
||||
background: rgba(194, 43, 16, 0.04);
|
||||
}
|
||||
|
||||
/* ── 본문 컨테이너 ── */
|
||||
.erp-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-40) var(--sp-24);
|
||||
}
|
||||
|
||||
.erp-greeting {
|
||||
margin: 0 0 var(--sp-8);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: -0.6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-greeting-sub {
|
||||
margin: 0 0 var(--sp-32);
|
||||
color: var(--color-midtone-gray);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.erp-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-section-title {
|
||||
font-size: var(--text-heading);
|
||||
font-weight: 500;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
.erp-section-meta {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
|
||||
/* ── 카드 그리드 ── */
|
||||
.erp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-12);
|
||||
background: var(--color-canvas-white);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--sp-20);
|
||||
min-height: 180px;
|
||||
transition: box-shadow 120ms, transform 120ms;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="true"]:hover {
|
||||
box-shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.18) 0 0 0 1px,
|
||||
oklab(0.145 -0.00000143796 0.00000340492 / 0.06) 0 8px 24px;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="false"] {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-8);
|
||||
}
|
||||
|
||||
.erp-card-title {
|
||||
font-size: var(--text-heading);
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-card-subtitle {
|
||||
font-family: var(--font-geist-mono);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-midtone-gray);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.erp-card-desc {
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-midtone-gray);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.erp-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-8);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-12);
|
||||
border-top: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
|
||||
.erp-card-link {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-deep-black);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.erp-card-link svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="true"]:hover .erp-card-link svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* ── 배지 ── */
|
||||
.erp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--r-badge);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.erp-badge-inverse {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
}
|
||||
|
||||
.erp-badge-neutral {
|
||||
background: var(--color-ghost-gray);
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-badge-outline {
|
||||
background: transparent;
|
||||
color: var(--color-rich-black);
|
||||
border: 1px solid #a1a1a1;
|
||||
}
|
||||
|
||||
.erp-badge-success { background: #e6f8e9; color: var(--color-success-green); border: 1px solid #c2efc8; }
|
||||
.erp-badge-danger { background: #fbe9e6; color: var(--color-callout-red); border: 1px solid #efc6bf; }
|
||||
|
||||
.erp-badge-dot::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* ── 잠긴 카드 표시 ── */
|
||||
.erp-locked-overlay {
|
||||
position: absolute;
|
||||
top: var(--sp-12);
|
||||
right: var(--sp-12);
|
||||
font-size: 11px;
|
||||
color: var(--color-midtone-gray);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── 관리자 페이지 ── */
|
||||
.erp-admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-12);
|
||||
margin-bottom: var(--sp-16);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.erp-input {
|
||||
font-family: inherit;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px var(--sp-10);
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.erp-input:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
border-color: var(--color-deep-black);
|
||||
}
|
||||
|
||||
.erp-table-wrap {
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--color-canvas-white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.erp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.erp-table thead th {
|
||||
text-align: left;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-midtone-gray);
|
||||
background: var(--color-ghost-gray);
|
||||
padding: var(--sp-10) var(--sp-12);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.erp-table tbody td {
|
||||
padding: var(--sp-12);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.erp-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.erp-table tbody tr[data-dirty="true"] {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.erp-user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-10);
|
||||
}
|
||||
|
||||
.erp-user-cell .erp-user-avatar,
|
||||
.erp-user-cell .erp-user-avatar-fallback {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.erp-user-cell-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-user-cell-email {
|
||||
font-size: 12px;
|
||||
color: var(--color-midtone-gray);
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* shadcn-style 토글 (checkbox 기반) */
|
||||
.erp-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
.erp-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.erp-switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-subtle-ash);
|
||||
border-radius: var(--r-pill);
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.erp-switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px; height: 16px;
|
||||
left: 2px; top: 2px;
|
||||
background: var(--color-canvas-white);
|
||||
border-radius: 50%;
|
||||
transition: transform 120ms;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2);
|
||||
}
|
||||
.erp-switch input:checked + .erp-switch-slider { background: var(--color-deep-black); }
|
||||
.erp-switch input:checked + .erp-switch-slider::before { transform: translateX(16px); }
|
||||
.erp-switch input:disabled + .erp-switch-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* shadcn-style select */
|
||||
.erp-select {
|
||||
font-family: inherit;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 4px var(--sp-10);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.erp-select:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
border-color: var(--color-deep-black);
|
||||
}
|
||||
|
||||
.erp-select:disabled {
|
||||
background: var(--color-ghost-gray);
|
||||
cursor: not-allowed;
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
|
||||
/* 토스트 */
|
||||
.erp-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
padding: var(--sp-12) var(--sp-16);
|
||||
border-radius: var(--r-input);
|
||||
font-size: var(--text-body);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 200ms, transform 200ms;
|
||||
pointer-events: none;
|
||||
max-width: 360px;
|
||||
}
|
||||
.erp-toast[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.erp-toast[data-type="error"] {
|
||||
background: var(--color-callout-red);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 640px) {
|
||||
.erp-nav { padding: 0 var(--sp-16); height: 52px; }
|
||||
.erp-brand-system { display: none; }
|
||||
.erp-user-info { display: none; }
|
||||
.erp-main { padding: var(--sp-24) var(--sp-16); }
|
||||
.erp-greeting { font-size: 22px; }
|
||||
.erp-table thead { display: none; }
|
||||
.erp-table tbody td { display: block; border: none; padding: 4px var(--sp-12); }
|
||||
.erp-table tbody tr { display: block; padding: var(--sp-12) 0; border-bottom: 1px solid var(--color-subtle-ash); }
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""JSON 기반 사용자/권한 저장소.
|
||||
|
||||
- app/data/users.json 에 사용자 레코드 저장
|
||||
- 동시성: 파일락 대신 process-내 threading.Lock + 원자적 쓰기(temp → rename)
|
||||
- 운영시 컨테이너 한 대 가정. 다중 인스턴스 필요해지면 DB 로 교체.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
# 슈퍼 관리자 — 강등/삭제 불가
|
||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||
|
||||
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
|
||||
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class UserStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"users": {}})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {"users": {}}
|
||||
if "users" not in data or not isinstance(data["users"], dict):
|
||||
data["users"] = {}
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".users.", suffix=".json.tmp", dir=str(self._path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _default_modules(is_admin: bool) -> dict[str, bool]:
|
||||
return {key: is_admin for key in MODULE_KEYS}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
|
||||
base = UserStore._default_modules(is_admin)
|
||||
if isinstance(modules, dict):
|
||||
for key in MODULE_KEYS:
|
||||
if key in modules:
|
||||
base[key] = bool(modules[key])
|
||||
if is_admin:
|
||||
for key in MODULE_KEYS:
|
||||
base[key] = True
|
||||
return base
|
||||
|
||||
def get(self, email: str) -> dict[str, Any] | None:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
user = data["users"].get(email)
|
||||
if user is None:
|
||||
return None
|
||||
return self._enrich(email, user)
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||
|
||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||
email = email.lower().strip()
|
||||
is_super = email == SUPER_ADMIN_EMAIL
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
now = _now_iso()
|
||||
existing = data["users"].get(email)
|
||||
if existing is None:
|
||||
rec = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
"picture": picture,
|
||||
"role": "admin" if is_super else "user",
|
||||
"modules": self._default_modules(is_admin=is_super),
|
||||
"created_at": now,
|
||||
"last_login": now,
|
||||
}
|
||||
else:
|
||||
rec = dict(existing)
|
||||
rec["name"] = name or rec.get("name", email)
|
||||
rec["picture"] = picture or rec.get("picture", "")
|
||||
rec["last_login"] = now
|
||||
# 슈퍼 관리자는 항상 admin + 모든 모듈
|
||||
if is_super:
|
||||
rec["role"] = "admin"
|
||||
rec["modules"] = self._default_modules(is_admin=True)
|
||||
else:
|
||||
rec["modules"] = self._normalize_modules(
|
||||
rec.get("modules"), is_admin=(rec.get("role") == "admin")
|
||||
)
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def update_permissions(
|
||||
self, *, email: str, role: str | None, modules: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""관리자가 다른 사용자의 권한을 수정."""
|
||||
email = email.lower().strip()
|
||||
if email == SUPER_ADMIN_EMAIL:
|
||||
raise PermissionError("슈퍼 관리자 계정은 수정할 수 없습니다.")
|
||||
if role is not None and role not in ("admin", "user"):
|
||||
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
rec = data["users"].get(email)
|
||||
if rec is None:
|
||||
raise KeyError(f"사용자를 찾을 수 없습니다: {email}")
|
||||
if role is not None:
|
||||
rec["role"] = role
|
||||
new_is_admin = rec.get("role") == "admin"
|
||||
rec["modules"] = self._normalize_modules(
|
||||
modules if modules is not None else rec.get("modules"),
|
||||
is_admin=new_is_admin,
|
||||
)
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def delete(self, email: str) -> None:
|
||||
email = email.lower().strip()
|
||||
if email == SUPER_ADMIN_EMAIL:
|
||||
raise PermissionError("슈퍼 관리자 계정은 삭제할 수 없습니다.")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if email in data["users"]:
|
||||
del data["users"][email]
|
||||
self._write_atomic(data)
|
||||
|
||||
@staticmethod
|
||||
def _enrich(email: str, rec: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(rec)
|
||||
out["email"] = email
|
||||
out["is_super_admin"] = email == SUPER_ADMIN_EMAIL
|
||||
out["modules"] = UserStore._normalize_modules(
|
||||
out.get("modules"), is_admin=(out.get("role") == "admin")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def is_admin(user_rec: dict[str, Any] | None) -> bool:
|
||||
return bool(user_rec and user_rec.get("role") == "admin")
|
||||
|
||||
|
||||
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
||||
if not user_rec:
|
||||
return False
|
||||
if is_admin(user_rec):
|
||||
return True
|
||||
mods = user_rec.get("modules") or {}
|
||||
return bool(mods.get(module))
|
||||
|
||||
|
||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
|
||||
@@ -0,0 +1,232 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>사용자/권한 관리 — DBX ERP</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
</head>
|
||||
<body class="erp-body">
|
||||
|
||||
<div class="erp-shell">
|
||||
|
||||
<!-- ── 상단 네비게이션 ── -->
|
||||
<nav class="erp-nav">
|
||||
<div class="erp-nav-left">
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">ERP 시스템</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-nav-right">
|
||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||
<div class="erp-user-chip">
|
||||
{% if user.picture %}
|
||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div class="erp-user-info">
|
||||
<span class="erp-user-name">{{ user.name }}</span>
|
||||
<span class="erp-user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="erp-main" style="max-width: 1100px;">
|
||||
|
||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||
<p class="erp-greeting-sub">
|
||||
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
|
||||
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
|
||||
</p>
|
||||
|
||||
<div class="erp-admin-toolbar">
|
||||
<input type="search"
|
||||
id="user-search"
|
||||
class="erp-input"
|
||||
placeholder="이메일 또는 이름으로 검색…" />
|
||||
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28%;">사용자</th>
|
||||
<th style="width: 11%;">역할</th>
|
||||
{% for key in module_keys %}
|
||||
<th style="text-align:center; width: 9%;">
|
||||
{% if key == 'corm' %}CORM
|
||||
{% elif key == 'order' %}Order
|
||||
{% elif key == 'expense' %}개인경비
|
||||
{% elif key == 'vacation' %}휴가
|
||||
{% else %}{{ key }}{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="width: 13%;">최근 로그인</th>
|
||||
<th style="width: 12%; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-email="{{ u.email }}" data-dirty="false"
|
||||
data-super="{{ 'true' if u.is_super_admin else 'false' }}">
|
||||
<td>
|
||||
<div class="erp-user-cell">
|
||||
{% if u.picture %}
|
||||
<img class="erp-user-avatar" src="{{ u.picture }}" alt="{{ u.name }}" />
|
||||
{% else %}
|
||||
<span class="erp-user-avatar-fallback">{{ (u.name or u.email)[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="erp-user-cell-name">
|
||||
{{ u.name or u.email }}
|
||||
{% if u.is_super_admin %}
|
||||
<span class="erp-badge erp-badge-inverse" style="margin-left:6px;">슈퍼관리자</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="erp-user-cell-email">{{ u.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="erp-select" data-field="role"
|
||||
{% if u.is_super_admin %}disabled{% endif %}>
|
||||
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
|
||||
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
||||
</select>
|
||||
</td>
|
||||
{% for key in module_keys %}
|
||||
<td style="text-align:center;">
|
||||
<label class="erp-switch">
|
||||
<input type="checkbox"
|
||||
data-field="module"
|
||||
data-module="{{ key }}"
|
||||
{% if u.modules[key] %}checked{% endif %}
|
||||
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
|
||||
<span class="erp-switch-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||
{{ (u.last_login or '')[:16].replace('T', ' ') }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-primary" data-action="save"
|
||||
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
|
||||
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="erp-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
const tbody = document.querySelector("#users-table tbody");
|
||||
const searchInput = document.getElementById("user-search");
|
||||
const userCountEl = document.getElementById("user-count");
|
||||
const toastEl = document.getElementById("toast");
|
||||
|
||||
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
||||
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
||||
|
||||
function showToast(msg, type) {
|
||||
toastEl.textContent = msg;
|
||||
toastEl.dataset.type = type || "info";
|
||||
toastEl.dataset.visible = "true";
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => {
|
||||
toastEl.dataset.visible = "false";
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
// 역할 변경시 모듈 토글 활성/비활성 갱신
|
||||
function syncModuleSwitches(tr) {
|
||||
const isSuper = tr.dataset.super === "true";
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const adminRole = role === "admin";
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
if (isSuper || adminRole) {
|
||||
cb.checked = true;
|
||||
cb.disabled = true;
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
const tr = e.target.closest("tr");
|
||||
if (!tr) return;
|
||||
if (e.target.dataset.field === "role") {
|
||||
syncModuleSwitches(tr);
|
||||
}
|
||||
markDirty(tr);
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button[data-action='save']");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const email = tr.dataset.email;
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const modules = {};
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
modules[cb.dataset.module] = cb.checked;
|
||||
});
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "저장 중…";
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ role, modules }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
clearDirty(tr);
|
||||
showToast(`${email} 저장 완료`, "info");
|
||||
} catch (err) {
|
||||
showToast(`저장 실패: ${err.message}`, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "저장";
|
||||
}
|
||||
});
|
||||
|
||||
// 검색
|
||||
searchInput.addEventListener("input", () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||||
const text = tr.textContent.toLowerCase();
|
||||
const match = !q || text.includes(q);
|
||||
tr.style.display = match ? "" : "none";
|
||||
if (match) visible++;
|
||||
});
|
||||
userCountEl.textContent = q
|
||||
? `검색 결과 ${visible}명`
|
||||
: `총 ${tbody.querySelectorAll("tr").length}명`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+139
-123
@@ -3,153 +3,169 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>업무 포털 — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<title>ERP 시스템 — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="erp-body">
|
||||
|
||||
<!-- ── 헤더 ── -->
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX Corporation" class="header-logo-img" />
|
||||
<span class="header-portal">업무 포털</span>
|
||||
</div>
|
||||
<div class="erp-shell">
|
||||
|
||||
<div class="header-user">
|
||||
<div class="header-user-info">
|
||||
<span class="header-user-name">{{ user.name }}</span>
|
||||
<span class="header-user-email">{{ user.email }}</span>
|
||||
<!-- ── 상단 네비게이션 ── -->
|
||||
<nav class="erp-nav">
|
||||
<div class="erp-nav-left">
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">ERP 시스템</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if user.picture %}
|
||||
<img class="header-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<div class="header-avatar-placeholder">
|
||||
{{ user.name[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="btn-logout" href="/logout">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── 히어로 배너 ── -->
|
||||
<section class="hero">
|
||||
<p class="hero-greeting">안녕하세요, {{ user.name }}님 👋</p>
|
||||
<p class="hero-sub">오늘도 좋은 하루 되세요.</p>
|
||||
<p class="hero-date" id="today-date"></p>
|
||||
</section>
|
||||
<div class="erp-nav-right">
|
||||
{% if is_admin %}
|
||||
<a class="erp-btn erp-btn-ghost" href="/admin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
|
||||
</svg>
|
||||
관리자
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 메뉴 콘텐츠 ── -->
|
||||
<main class="content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">업무 바로가기</h2>
|
||||
<span class="section-count">{{ menu_items | length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for item in menu_items %}
|
||||
<a class="menu-card" href="{{ item.url }}" target="_blank" rel="noopener noreferrer"
|
||||
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
|
||||
<div class="card-icon">
|
||||
{% if loop.index == 1 %}
|
||||
<!-- 클립보드 / 발주 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="14" y2="17"/>
|
||||
</svg>
|
||||
{% elif loop.index == 2 %}
|
||||
<!-- 리스트 / 주문 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
{% elif loop.index == 3 %}
|
||||
<!-- 차트 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<line x1="2" y1="20" x2="22" y2="20"/>
|
||||
</svg>
|
||||
<div class="erp-user-chip">
|
||||
{% if user.picture %}
|
||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<!-- 기본 링크 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div class="erp-user-info">
|
||||
<span class="erp-user-name">{{ user.name }}</span>
|
||||
<span class="erp-user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<p class="card-title">
|
||||
{{ item.title }}
|
||||
{% if item.health_url %}
|
||||
<span class="status-badge" data-status="checking" aria-live="polite">확인 중</span>
|
||||
<a class="erp-btn erp-btn-outline" href="/logout">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── 본문 ── -->
|
||||
<main class="erp-main">
|
||||
|
||||
<h1 class="erp-greeting">안녕하세요, {{ user.name }}님</h1>
|
||||
<p class="erp-greeting-sub" id="erp-greeting-sub"></p>
|
||||
|
||||
<div class="erp-section-head">
|
||||
<h2 class="erp-section-title">업무 모듈</h2>
|
||||
<span class="erp-section-meta">{{ menu_items | length }}개 모듈</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-grid">
|
||||
{% for item in menu_items %}
|
||||
{% set clickable = item.allowed and item.status == 'ready' %}
|
||||
{% if clickable %}
|
||||
<a class="erp-card" href="{{ item.url }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-clickable="true"
|
||||
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
|
||||
{% else %}
|
||||
<div class="erp-card" data-clickable="false">
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-card-head">
|
||||
<div>
|
||||
<div class="erp-card-title">{{ item.title }}</div>
|
||||
<div class="erp-card-subtitle">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
{% if item.status == 'preparing' %}
|
||||
<span class="erp-badge erp-badge-neutral">준비중</span>
|
||||
{% elif not item.allowed %}
|
||||
<span class="erp-badge erp-badge-outline">권한 없음</span>
|
||||
{% elif item.health_url %}
|
||||
<span class="erp-badge erp-badge-neutral erp-badge-dot"
|
||||
data-health-url="{{ item.health_url }}" data-status="checking">확인 중</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-inverse">활성</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="card-desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="card-link">
|
||||
바로가기
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</span>
|
||||
<p class="erp-card-desc">{{ item.description }}</p>
|
||||
|
||||
<div class="erp-card-foot">
|
||||
<span class="erp-badge erp-badge-outline">{{ item.category }}</span>
|
||||
{% if clickable %}
|
||||
<span class="erp-card-link">
|
||||
바로가기
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% elif item.status == 'preparing' %}
|
||||
<span class="erp-card-link" style="color: var(--color-midtone-gray);">출시 예정</span>
|
||||
{% else %}
|
||||
<span class="erp-card-link" style="color: var(--color-midtone-gray);">접근 불가</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if clickable %}
|
||||
</a>
|
||||
{% else %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 오늘 날짜 표시
|
||||
const d = new Date();
|
||||
const days = ["일요일","월요일","화요일","수요일","목요일","금요일","토요일"];
|
||||
document.getElementById("today-date").textContent =
|
||||
d.getFullYear() + "년 " + (d.getMonth()+1) + "월 " + d.getDate() + "일 " + days[d.getDay()];
|
||||
// 인사말 — 시간대별
|
||||
(function() {
|
||||
const d = new Date();
|
||||
const h = d.getHours();
|
||||
let greet = "오늘도 좋은 하루 되세요.";
|
||||
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
|
||||
else if (h < 12) greet = "활기찬 아침입니다.";
|
||||
else if (h < 18) greet = "오후 업무 화이팅입니다.";
|
||||
else greet = "오늘도 고생 많으셨습니다.";
|
||||
const days = ["일","월","화","수","목","금","토"];
|
||||
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
|
||||
document.getElementById("erp-greeting-sub").textContent = `${dateStr} · ${greet}`;
|
||||
})();
|
||||
|
||||
// 헬스 배지 — data-health-url 이 있는 카드만 갱신
|
||||
async function refreshHealthBadges() {
|
||||
const cards = document.querySelectorAll(".menu-card[data-health-url]");
|
||||
await Promise.all(Array.from(cards).map(async (card) => {
|
||||
const url = card.getAttribute("data-health-url");
|
||||
const badge = card.querySelector(".status-badge");
|
||||
if (!badge) return;
|
||||
// 헬스 배지 — data-health-url 이 있는 배지만 갱신
|
||||
async function refreshHealth() {
|
||||
const badges = document.querySelectorAll(".erp-badge[data-health-url]");
|
||||
await Promise.all(Array.from(badges).map(async (badge) => {
|
||||
const url = badge.getAttribute("data-health-url");
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store", credentials: "omit" });
|
||||
const ok = res.ok;
|
||||
badge.dataset.status = ok ? "online" : "offline";
|
||||
badge.textContent = ok ? "온라인" : "오프라인";
|
||||
if (res.ok) {
|
||||
badge.dataset.status = "online";
|
||||
badge.className = "erp-badge erp-badge-success erp-badge-dot";
|
||||
badge.textContent = "온라인";
|
||||
} else {
|
||||
badge.dataset.status = "offline";
|
||||
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
|
||||
badge.textContent = "오프라인";
|
||||
}
|
||||
} catch {
|
||||
badge.dataset.status = "offline";
|
||||
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
|
||||
badge.textContent = "오프라인";
|
||||
}
|
||||
}));
|
||||
}
|
||||
refreshHealthBadges();
|
||||
setInterval(refreshHealthBadges, 30000);
|
||||
refreshHealth();
|
||||
setInterval(refreshHealth, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,3 +8,10 @@ services:
|
||||
- "8080:8000"
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- dbx-main-data:/data
|
||||
|
||||
volumes:
|
||||
dbx-main-data:
|
||||
|
||||
@@ -9,3 +9,11 @@ services:
|
||||
- "8080:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 사용자/권한 JSON 저장소 — 볼륨에 영구 보관
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- dbx-main-data:/data
|
||||
|
||||
volumes:
|
||||
dbx-main-data:
|
||||
|
||||
Reference in New Issue
Block a user