From 316e3fe8d1b61e0ff50a6d0c39525f8263ab965a Mon Sep 17 00:00:00 2001 From: king Date: Thu, 28 May 2026 23:36:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(erp):=20ERP=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20+=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인: 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 --- app/main.py | 226 ++++++++++++--- app/static/erp.css | 595 +++++++++++++++++++++++++++++++++++++++ app/store.py | 186 ++++++++++++ app/templates/admin.html | 232 +++++++++++++++ app/templates/main.html | 262 +++++++++-------- docker-compose.local.yml | 7 + docker-compose.yml | 8 + 7 files changed, 1351 insertions(+), 165 deletions(-) create mode 100644 app/static/erp.css create mode 100644 app/store.py create mode 100644 app/templates/admin.html diff --git a/app/main.py b/app/main.py index fc7d062..f1784ed 100644 --- a/app/main.py +++ b/app/main.py @@ -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}) diff --git a/app/static/erp.css b/app/static/erp.css new file mode 100644 index 0000000..4770cd2 --- /dev/null +++ b/app/static/erp.css @@ -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); } +} diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..bb9ceaf --- /dev/null +++ b/app/store.py @@ -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)} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..d98d68a --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,232 @@ + + + + + + 사용자/권한 관리 — DBX ERP + + + + +
+ + + + +
+ +

사용자 / 권한 관리

+

+ 회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다. + 토글을 변경한 뒤 저장 버튼을 누르세요. +

+ +
+ + +
+ +
+ + + + + + {% for key in module_keys %} + + {% endfor %} + + + + + + {% for u in users %} + + + + {% for key in module_keys %} + + {% endfor %} + + + + {% endfor %} + +
사용자역할 + {% if key == 'corm' %}CORM + {% elif key == 'order' %}Order + {% elif key == 'expense' %}개인경비 + {% elif key == 'vacation' %}휴가 + {% else %}{{ key }}{% endif %} + 최근 로그인동작
+
+ {% if u.picture %} + {{ u.name }} + {% else %} + {{ (u.name or u.email)[0] | upper }} + {% endif %} +
+
+ {{ u.name or u.email }} + {% if u.is_super_admin %} + 슈퍼관리자 + {% endif %} +
+ +
+
+
+ + + + + {{ (u.last_login or '')[:16].replace('T', ' ') }} + + +
+
+ + + +
+
+ +
+ + + + diff --git a/app/templates/main.html b/app/templates/main.html index e25ffb3..4bdc30c 100644 --- a/app/templates/main.html +++ b/app/templates/main.html @@ -3,153 +3,169 @@ - 업무 포털 — DBX Corporation - + ERP 시스템 — DBX Corporation + - + - -
-
- DBX Corporation - 업무 포털 -
+
-
- -
- -
-

안녕하세요, {{ user.name }}님 👋

-

오늘도 좋은 하루 되세요.

-

-
+ diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0fc7dd7..6c348e2 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -8,3 +8,10 @@ services: - "8080:8000" env_file: - .env.local + environment: + DATA_DIR: /data + volumes: + - dbx-main-data:/data + +volumes: + dbx-main-data: diff --git a/docker-compose.yml b/docker-compose.yml index a5b5113..786ccc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,11 @@ services: - "8080:8000" env_file: - .env + environment: + # 사용자/권한 JSON 저장소 — 볼륨에 영구 보관 + DATA_DIR: /data + volumes: + - dbx-main-data:/data + +volumes: + dbx-main-data: