diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 5f284ae0..7219638e 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -11,13 +11,37 @@ import aiomysql import psutil from config.config_db import get_db_pool -from config.config_system import EMAIL_REVERIFY_DAYS +from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS def _role(value: str | None) -> str: return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER") +async def get_system_company_id() -> int | None: + """시스템 관리 회사 = `.env` 관리자 계정이 속한 회사 (2026-09-06 사용자 확정). + + 개발사 자기 회사 한 곳뿐이라 따로 표시 칸을 두지 않고 이 한 줄로 판정한다. + """ + if not ADMIN_EMAIL: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT company_id FROM users WHERE email = %s AND deleted_at IS NULL", + (ADMIN_EMAIL.lower(),), + ) + row = await cursor.fetchone() + return int(row["company_id"]) if row and row["company_id"] else None + + +async def role_for_company(company_id: int | None) -> str: + """회사에 들어갈 때 받는 역할 — 시스템 회사면 시스템관리자, 아니면 일반사용자.""" + if company_id is not None and int(company_id) == (await get_system_company_id() or 0): + return "SYSTEM_ADMIN" + return "USER" + + def _stage_from_status(status: str | None) -> tuple[int, int]: value = status or "NEW" if value in {"WF1_ANALYZING", "WF1_FAILED"}: @@ -392,11 +416,21 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str raise -async def join_company(user_id: int, company_id: int) -> dict[str, Any]: +async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: try: await connection.begin() + # 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) — + # 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다. + await cursor.execute( + "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE", + (user_id,), + ) + current = await cursor.fetchone() + if current and current[0]: + await connection.rollback() + return None await cursor.execute( """INSERT INTO join_requests (user_id, company_id, status) VALUES (%s, %s, 'PENDING') @@ -456,10 +490,12 @@ async def process_join_request( (status, reviewer_id, request_id), ) if approved: + # 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정). + role = await role_for_company(int(row["company_id"])) await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE' + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s WHERE id = %s""", - (row["company_id"], row["user_id"]), + (row["company_id"], role, row["user_id"]), ) else: await cursor.execute( @@ -503,7 +539,11 @@ async def list_all_users() -> list[dict[str, Any]]: async def change_user_role(user_id: int, role: str) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id)) + # is_master 도 권한 판정에 쓰이므로 역할과 어긋나지 않게 함께 맞춘다. + await cursor.execute( + "UPDATE users SET role = %s, is_master = %s WHERE id = %s", + (role, role in ("ADMIN", "SYSTEM_ADMIN"), user_id), + ) changed = cursor.rowcount > 0 await connection.commit() return changed diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 0a7d9c1d..394e3797 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -15,7 +15,7 @@ from fastapi import HTTPException from common_util.common_util_auth import hash_password from config.config_db import get_db_pool -from .B01_Dashboard_Repository import _role, get_dashboard_me +from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company from .B01_Dashboard_Repository_Assets import list_company_assets @@ -58,7 +58,7 @@ async def add_company_member( await cursor.execute( """INSERT INTO users (email, password_hash, name, position, department, company_id, role, status) - VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""", + VALUES (%s, %s, %s, %s, %s, %s, %s, 'PENDING')""", ( email.lower(), hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호 @@ -66,6 +66,7 @@ async def add_company_member( profile.get("position"), profile.get("department"), company_id, + await role_for_company(company_id), ), ) await connection.commit() @@ -74,10 +75,10 @@ async def add_company_member( await connection.rollback() return None await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s, is_master = FALSE WHERE id = %s""", - (company_id, user["id"]), + (company_id, await role_for_company(company_id), user["id"]), ) await connection.commit() return await get_dashboard_me(user["id"]) diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index a0227f68..8021f8ad 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -14,6 +14,7 @@ from config.config_system import PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( assign_user_company, change_user_role, + count_company_admins, create_company, get_dashboard_me, get_project, @@ -144,7 +145,14 @@ async def patch_dashboard_me( @router.get("/user/projects") async def user_projects(session: dict[str, Any] = Depends(verify_session)): - return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))} + # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. + company_id = session.get("company_id") + projects = ( + await list_company_projects(int(company_id)) + if company_id + else await list_user_projects(int(session["user_id"])) + ) + return {"status": "success", "projects": projects} @router.get("/user/company") @@ -176,6 +184,11 @@ async def user_company_join( session: dict[str, Any] = Depends(verify_session), ): result = await join_company(int(session["user_id"]), payload.company_id) + if result is None: + raise HTTPException( + status_code=409, + detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.", + ) return {"status": "success", **result} @@ -286,14 +299,10 @@ async def dashboard_delete_project( if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - can_del = False - if session["role"] == "SYSTEM_ADMIN": - can_del = True - # 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리 - # elif session["role"] == "ADMIN": - # if int(project.get("user_id") or 0) == int(session["user_id"]): - # can_del = True - + # 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). + can_del = session["role"] == "SYSTEM_ADMIN" or ( + session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) + ) if not can_del: raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") @@ -329,14 +338,29 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)): async def system_change_role( user_id: int, payload: ChangeUserRoleRequest, - session: dict[str, Any] = Depends(require_system_admin), + session: dict[str, Any] = Depends(require_company_admin), ): target = await get_user_admin_target(user_id) if not target: raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + # 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다. + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.") if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN": raise HTTPException( - status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다." + status_code=403, + detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.", + ) + # 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함). + if ( + target["role"] == "ADMIN" + and payload.role != "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", ) if not await change_user_role(user_id, payload.role): raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts index 261f491b..d4c988f1 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Helper.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -7,9 +7,8 @@ export function canEditProject(user: DashboardUser, _project: ProjectItem): bool } export function canDeleteProject(user: DashboardUser): boolean { - // SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리) - return user.role === "SYSTEM_ADMIN"; - // return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 회사 관리자도 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). 범위는 백엔드가 다시 본다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canAddUser(user: DashboardUser): boolean { @@ -17,8 +16,9 @@ export function canAddUser(user: DashboardUser): boolean { } export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { - // 역할 변경은 오직 SYSTEM_ADMIN만 가능 - return user.role === "SYSTEM_ADMIN"; + // 회사 관리자도 자기 회사 안에서 역할을 바꾼다 (2026-09-06 사용자 확정). + // 마지막 관리자 이탈·시스템 관리자 변경은 백엔드가 막는다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 66009c67..6e821eea 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -151,7 +151,6 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [ createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), - section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), ); } else if (state.user.role === "ADMIN") { grid.append( @@ -181,6 +180,10 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); + // 시스템 로그는 맨 아래 (2026-09-06 사용자 지시). + if (state.user.role === "SYSTEM_ADMIN") { + grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); + } page.append(grid); return page; } diff --git a/main.py b/main.py index af402284..0c9dce8a 100644 --- a/main.py +++ b/main.py @@ -278,6 +278,16 @@ async def lifespan(app: FastAPI): await cursor.execute( "UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),) ) + # 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다 + # (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다. + await cursor.execute( + """UPDATE users u + JOIN users a ON a.email = %s AND a.deleted_at IS NULL + AND a.company_id IS NOT NULL + SET u.role = 'SYSTEM_ADMIN' + WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""", + (ADMIN_EMAIL.lower(),), + ) await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop())