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
+
+
+
+
+
+
+
+
+
+
+
+ 사용자 / 권한 관리
+
+ 회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
+ 토글을 변경한 뒤 저장 버튼을 누르세요.
+
+
+
+
+ 총 {{ users | length }}명
+
+
+
+
+
+
+ | 사용자 |
+ 역할 |
+ {% for key in module_keys %}
+
+ {% if key == 'corm' %}CORM
+ {% elif key == 'order' %}Order
+ {% elif key == 'expense' %}개인경비
+ {% elif key == 'vacation' %}휴가
+ {% else %}{{ key }}{% endif %}
+ |
+ {% endfor %}
+ 최근 로그인 |
+ 동작 |
+
+
+
+ {% for u in users %}
+
+
+
+ {% if u.picture %}
+ 
+ {% else %}
+ {{ (u.name or u.email)[0] | upper }}
+ {% endif %}
+
+
+ {{ u.name or u.email }}
+ {% if u.is_super_admin %}
+ 슈퍼관리자
+ {% endif %}
+
+ {{ u.email }}
+
+
+ |
+
+
+ |
+ {% for key in module_keys %}
+
+
+ |
+ {% endfor %}
+
+ {{ (u.last_login or '')[:16].replace('T', ' ') }}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ ※ {{ super_admin_email }} 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
+ 관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
+
+
+
+
+
+
+
+
+
+
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
+
-
+
-
-