From 3485eeb09693ce6969a1871090b38d7dedd45802 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 8 Jul 2026 18:12:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=94=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/agent.md | 68 +- .agent/backend.md | 76 +- .../plan_register_split_COMPLETED.md | 114 +++ ...lan_register_split_validation_COMPLETED.md | 136 ++++ .agent/db_schema.md | 129 +++- .agent/frontend.md | 55 +- .agent/plan_register_split.md | 729 ++++++++++++++++-- .agent/plan_register_split_validation.md | 519 ++++++++++--- .agent/structure.md | 19 +- .claude/settings.local.json | 3 +- .gitignore | 7 +- A00_Common/router.ts | 2 +- A06_Login/A06_Login_Router.py | 13 +- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 224 ++++++ B01_Dashboard/B01_Dashboard_Repository.py | 402 ++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 239 ++++++ B01_Dashboard/B01_Dashboard_Schema.py | 38 + B01_Dashboard/B01_Dashboard_UI_Page.ts | 612 +++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Style.css | 181 +++++ common_util/common_util_auth.py | 2 +- common_util/common_util_auth_repository.py | 5 +- db_management/004_dashboard.sql | 57 ++ main.py | 2 + ui_template/ui_template_locale.ts | 58 ++ 24 files changed, 3461 insertions(+), 229 deletions(-) create mode 100644 .agent/complete_and_old/plan_register_split_COMPLETED.md create mode 100644 .agent/complete_and_old/plan_register_split_validation_COMPLETED.md create mode 100644 B01_Dashboard/B01_Dashboard_Api_Fetch.ts create mode 100644 B01_Dashboard/B01_Dashboard_Repository.py create mode 100644 B01_Dashboard/B01_Dashboard_Router.py create mode 100644 B01_Dashboard/B01_Dashboard_Schema.py create mode 100644 B01_Dashboard/B01_Dashboard_UI_Page.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_Style.css create mode 100644 db_management/004_dashboard.sql diff --git a/.agent/agent.md b/.agent/agent.md index 19ad6d1..56f275f 100644 --- a/.agent/agent.md +++ b/.agent/agent.md @@ -13,7 +13,7 @@ 프로젝트의 코드를 작성, 테스트 또는 검증할 환경 명확화 및 호환 기준 제시. ### A. 백엔드 프레임워크 (Python) -* **Runtime:** Python v3.12 or v3.13.7 +* **Runtime:** Python v3.12.7 * **Framework:** FastAPI / Pydantic * **Geometry/GIS Engine:** Trimesh, Whitebox, Geopandas, Shapely, Rasterio, Laspy @@ -55,6 +55,7 @@ * *Python 포맷팅 명령어:* `ruff format [파일명]` 및 `ruff check --fix [파일명]` 실행 * *TypeScript/CSS 포맷팅 명령어:* `npx prettier --write [파일명]` 실행 * **외부 라이브러리 활용:** 외부 라이브러리 활용 시 프로젝트에 맞춰 호환 라이브러리 선정하고, 불필요한 함수는 작성하지 않도록 최소화. +* **서버실행은 사용자가 실행:** 필요한 경우 의존성 설치까지는 OK, 단 서버실행은 사용자가 진행 --- @@ -98,6 +99,33 @@ storage/{company_slug}/{user_slug}/{project_id}/ - **경로 저장 예:** `{"type": "LineString", "coordinates": [[127.5, 37.5], [127.6, 37.6]]}` - **애플리케이션 처리:** Python의 Shapely, Geopandas에서 JSON 파싱 후 기하 연산 +### 4.5 역할 기반 접근 제어 (RBAC - Role-Based Access Control) + +**3가지 사용자 역할:** + +| 역할 | 영문 | 설명 | 접근 권한 | +|------|------|------|---------| +| 시스템 관리자 | SYSTEM_ADMIN | 최고 관리자 | 전체 회사/사용자/프로젝트 조회 및 관리 | +| 회사 관리자 | ADMIN | 회사의 주요 아이디 (초기 로그인) | 회사 내 팀원/프로젝트 관리, 가입 요청 승인 | +| 일반 사용자 | USER | 팀원 | 개인 프로젝트 생성/수정, 워크플로우 진행 | + +**회사 마스터 (is_master) 플래그:** +- TRUE: 회사 생성자 또는 ADMIN 역할 (팀원 승인/거부 권한) +- FALSE: 일반 팀원 + +**사용자 상태 생명주기:** +``` +PENDING_EMAIL (임시) + ↓ (이메일 인증) +NO_COMPANY (로그인 가능, 회사 미연결) + ↓ (회사 생성 또는 참여 신청) +PENDING (승인 대기) 또는 ACTIVE (회사 연결 완료) + ↓ +ACTIVE (모든 기능 접근) + ↓ (퇴사/비활성화) +INACTIVE 또는 REJECTED +``` + --- ## 5. 워크플로우 6단계 (6-Stage Workflow) @@ -123,12 +151,33 @@ WF6: B09_wf6_Estimation (견적·문서) ## 6. 주요 문서 참고 순서 -1. **구조 설계:** `.agent/structure.md` 읽기 -2. **프론트 구현:** `.agent/frontend.md` 읽기 -3. **백엔드 구현:** `.agent/backend.md` 읽기 -4. **마이그레이션:** `.agent/migration_plan.md` 읽기 -5. **DB 스키마:** `.agent/db_schema_simple.md` 읽기 -6. **프로젝트 정보:** `.agent/project_info.md` 읽기 +1. **계획서 읽기:** 구현 전 반드시 읽을 문서 + - `.agent/plan_register_split.md` — B01_Dashboard 설계 및 역할별 기능 + - `.agent/plan_register_split_validation.md` — 검증 항목 및 테스트 체크리스트 + +2. **구조 설계:** `.agent/structure.md` 읽기 + - 폴더/파일 명명 규칙 + - 전체 디렉토리 트리 구조 + +3. **DB 스키마:** `.agent/db_schema.md` 읽기 + - 사용자/회사/조직 테이블 (5개) + - 프로젝트 및 워크플로우 테이블 + - 테이블 관계도 + +4. **백엔드 구현:** `.agent/backend.md` 읽기 + - 아키텍처 및 라우팅 + - DB 및 MariaDB 설정 + - 인증 시스템 (로그인/역할/인가) + - 역할 기반 접근 제어 (RBAC) + +5. **프론트 구현:** `.agent/frontend.md` 읽기 + - UI 요소 및 레이아웃 제약 + - 인증 및 라우팅 가드 + - 역할 기반 대시보드 렌더링 + +6. **마이그레이션:** `.agent/migration_plan.md` 읽기 (필요 시) + +7. **프로젝트 정보:** `.agent/project_info.md` 읽기 (필요 시) --- @@ -144,5 +193,8 @@ WF6: B09_wf6_Estimation (견적·문서) | **Frontend** | TypeScript / Node.js | | **드라이버** | aiomysql | | **워크플로우** | 6단계 (WF0~WF6) | -| **테이블 수** | 18개 | +| **테이블 수** | 23개 (조직/인증 5개 포함) | | **폴더 구조** | 워크플로우 기반 (B03~B09) | +| **인증 방식** | HttpOnly Secure 세션 쿠키 | +| **사용자 역할** | SYSTEM_ADMIN, ADMIN, USER (3가지) | +| **대시보드** | B01_Dashboard (역할별 동적 렌더링) | diff --git a/.agent/backend.md b/.agent/backend.md index d40c4ff..71fc8a0 100644 --- a/.agent/backend.md +++ b/.agent/backend.md @@ -124,7 +124,7 @@ * **방식:** Secure HttpOnly 세션 쿠키 (JWT 아님) * **쿠키 이름:** `session_id` * **유효 시간:** 12시간 -* **Secure 플래그:** true (HTTPS만 전송) +* **Secure 플래그:** true (HTTPS만 전송, 개발 환경 localhost에서는 false) * **HttpOnly 플래그:** true (JavaScript 접근 불가) * **SameSite:** Lax (CSRF 방지) * **저장소:** MariaDB `sessions` 테이블 @@ -135,6 +135,7 @@ → 세션 자동 만료 ``` * **만료 처리:** 프론트엔드는 만료 5분 전 알림, 만료 후 모든 요청 거부 (401 Unauthorized) +* **DB 풀 설정:** `autocommit=True` (pooled 커넥션의 트랜잭션 스냅샷 문제 방지 — 새 세션 INSERT 후 다른 커넥션에서 즉시 조회 가능) ### 6.4 로그인 이력 기록 (Audit Log) * **기록 항목:** @@ -143,7 +144,26 @@ * **무차별 대입 방지:** 5회 이상 실패 시 계정 15분 잠금 * **IP 주소:** 법적 이슈로 수집하지 않음 (권장) -### 6.5 권한 체크 (Authorization) +### 6.4.1 사용자 상태 생명주기 (User Status Lifecycle) +* **PENDING_EMAIL:** 회원가입 후 이메일 인증 전 (임시) +* **NO_COMPANY:** 이메일 인증 완료, 회사 미연결 (로그인 가능, B02~B11 워크플로우 차단) +* **PENDING:** 회사 참여 신청 후 마스터 승인 대기 (로그인 가능, B02~B11 워크플로우 차단) +* **ACTIVE:** 회사 연결 완료, 모든 기능 접근 가능 +* **INACTIVE:** 퇴사/계정 휴활성화 +* **REJECTED:** 회사 참여 신청 거부 + +### 6.5 회사 연결 필수화 (Mandatory Company Linking) +* **접근 제어:** + - B02~B11 워크플로우 라우터에 `Depends(require_company)` 의존성 추가 + - `session["company_id"] is None` 이면 403 Forbidden 반환 (`"회사 연결이 필요합니다."`) +* **라우팅 가드 (프론트):** + - B02~B11 진입 시 `fetchSessionUser()` → `company_id` 확인 + - 없으면 B01_ACCOUNT로 리다이렉트 +* **회사 생성/연결:** + - `POST /api/account/company/create`: 신규 회사 생성 후 사용자를 MASTER로 자동 연결 (status='ACTIVE') + - `POST /api/account/company/join`: 기존 회사 참여 신청 (status='PENDING', 마스터 승인 필요) + +### 6.6 권한 체크 (Authorization) * **마스터 권한:** - 팀원 목록 조회, 승인/거부, 내보내기 - 회사 정보 수정, 구독 관리 @@ -158,22 +178,56 @@ - 관리자 대시보드 (모니터링, Q&A 관리) * **검증 방식:** 라우터 진입 전 JWT/토큰 검증, 필요시 데이터베이스에서 권한 확인 -### 6.6 3개월 주기 재인증 (Periodic Re-authentication) -* **목적:** 재직 여부 확인 (퇴사자 자동 차단) -* **방식:** 3개월마다 이메일 OTP 재인증 강제 -* **구현:** `users.last_email_verified_at` 컬럼으로 추적 -* **예외:** 새로운 기기에서 로그인 시 즉시 재인증 요구 +### 6.7 로그인 기반 인증 갱신 (Login-based Auth Renewal) +* **목적:** 활성 사용자의 인증 유지, 비활성 사용자 자동 차단 +* **방식:** 로그인 시 인증 유효 기간 자동 연장 (3개월) +* **구현:** + - `users.last_login` → 마지막 로그인 시간 업데이트 + - `users.auth_expires_at` → 매 로그인 시 NOW() + 3개월로 설정 +* **인증 확인:** + - auth_expires_at > NOW() → 인증 유효 (바로 대시보드 이동) + - auth_expires_at <= NOW() → 재인증 모달 표시 +* **장점:** + - 활동적인 사용자: 지속적으로 인증 유지 + - 휴면 사용자: 자동으로 재인증 요구 + - 일관된 정책: 고정 3개월이 아닌 활동 기반 연장 -### 6.7 이메일 발송 (Email Infrastructure) +### 6.8 역할 기반 접근 제어 (Role-Based Access Control) + +**사용자 역할 (users.role)** +- `SYSTEM_ADMIN`: 시스템 관리자 (최고 권한) +- `ADMIN`: 회사 관리자 (회사 내 관리권 한정) +- `USER`: 일반 사용자 + +**마스터 플래그 (users.is_master)** +- `TRUE`: 회사 생성자 또는 ADMIN 역할 (팀원 승인/거부 권한) +- `FALSE`: 일반 팀원 + +**접근 제어 규칙:** + +| 기능 | SYSTEM_ADMIN | ADMIN | USER | +|------|-------------|-------|------| +| 전체 회사 조회 | ✅ | ✗ | ✗ | +| 전체 사용자 조회 | ✅ | ✗ | ✗ | +| 사용자 역할 변경 | ✅ | ✗ | ✗ | +| 회사 생성 | ✅ | ✅ | ✅ | +| 회사 팀원 관리 | ✅ | ✅ (자신 회사만) | ✗ | +| 가입 요청 승인 | ✅ | ✅ (자신 회사만) | ✗ | +| 시스템 로그 조회 | ✅ | ✗ | ✗ | +| 프로젝트 생성 | ✅ | ✅ | ✅ | +| 프로젝트 조회 | ✅ (전체) | ✅ (회사별) | ✅ (개인) | + +### 6.9 이메일 발송 (Email Infrastructure) * **SMTP 설정:** - **개발:** Google Gmail SMTP (umsangdon@gmail.com) - **상용화:** Google Workspace 또는 AWS SES로 전환 * **발송 유형:** - OTP 인증 코드 - 가입 확인 메일 - - 팀원 가입 승인 요청 (마스터 수신) - - 팀원 승인/거부 결과 - - 3개월 재인증 알림 + - 팀원 가입 승인 요청 (ADMIN 수신) + - 팀원 승인/거부 결과 (사용자에게) + - 회사 참여 신청 알림 (SYSTEM_ADMIN 수신) + - 로그인 기반 인증 갱신 알림 * **구현:** `common_util/common_util_email.py` 모듈 - 비동기 발송 (asyncio.create_task) - 재시도 로직 (최대 3회) diff --git a/.agent/complete_and_old/plan_register_split_COMPLETED.md b/.agent/complete_and_old/plan_register_split_COMPLETED.md new file mode 100644 index 0000000..1b2d0e1 --- /dev/null +++ b/.agent/complete_and_old/plan_register_split_COMPLETED.md @@ -0,0 +1,114 @@ +# 회원가입 플로우 개편: 완료된 항목들 + +**마지막 업데이트:** 2026-07-08 +**상태:** 회원가입 축소, 세션 쿠키 인증, 로그인 후 B01 리다이렉트 완료 + +--- + +## ✅ 완료된 구현 항목 + +### 1. DB 변경 +- ✅ `db_management/003_register_split.sql` 생성 + - `users.status` ENUM에 `'NO_COMPANY'` 추가 + - 회원가입 시 회사 정보 NULL로 시작하도록 변경 + +### 2. 백엔드 변경 (A07_Register, A06_Login) +- ✅ `A07_Register_Schema.py` 수정 + - `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version` + - 회사 관련 필드 전부 제거 + - `password_confirm` 검증 추가 + +- ✅ `common_util_auth_repository.py` 수정 + - `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행 + - `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경 + +- ✅ `A07_Register_Router.py` 수정 + - `request_registration()`: `password_confirm` 제외 처리 + +- ✅ `A06_Login_Router.py` 수정 + - `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경 + - `current_session()`: 응답에 `company_id`, `is_master` 추가 + +- ✅ `common_util_auth.py` 수정 + - `require_company()` dependency 추가 + +- ✅ `main.py` 수정 + - B03~B06 라우터에 `require_company` 의존성 추가 + +- ✅ `config_db.py` 수정 + - `autocommit=True` 설정 (pooled 커넥션 트랜잭션 스냅샷 이슈 해결) + +### 3. 리소스 파일 +- ✅ `resources/legal/terms_of_service.txt` 생성 +- ✅ `resources/legal/privacy_policy.txt` 생성 +- ✅ `resources/legal/marketing_consent.txt` 생성 + +### 4. 프론트엔드 변경 (A07_Register, A06_Login, Router) +- ✅ `A07_Register_Api_Fetch.ts` 수정 + - `RegisterPayload` 필드 축소 + +- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성 + - 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언) + - 약관 텍스트 파일 로드 기능 + - OTP 입력 창 기존 로직 유지 + +- ✅ `A00_Common/router.ts` 수정 + - B02~B11 라우트: company_id 없으면 B01로 리다이렉트 + +- ✅ `ui_template_locale.ts` 수정 + - `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm` + - `A07_Register_Error_PasswordMismatch` 추가 + +- ✅ `A07_Register_UI_Style.css` 수정 + - `.a07-register__agreement` 아코디언 스타일 추가 + +- ✅ `A06_Login_Api_Fetch.ts` 수정 + - `SessionUser` 인터페이스에 `company_id`, `is_master` 추가 + - `fetchSessionUser()` 구현 (세션 조회 → company_id 확인용) + +- ✅ `A00_Common/app_shell.ts` 수정 + - 로그아웃 시 `/auth/logout` API 호출 (서버 세션 삭제) + +- ✅ B03~B06 API Fetch 파일 수정 + - Bearer 토큰 제거, `credentials: "include"` 추가 (세션 쿠키 기반) + - `B03_FileInput_Api_Fetch.ts`, `B04_wf1_Surface_Api_Fetch.ts`, `B05_wf2_Route_Api_Fetch.ts`, `B06_wf3_ProfileCross_Api_Fetch.ts` + +- ✅ 구식 파일 삭제 + - `A06_Login_UI_Page.ts` (localStorage 기반 구식 로그인 UI) + +--- + +## 검증 항목 (사용자 확인됨) + +### 1차 검증: 가입 및 이메일 인증 ✅ +- 회원가입 폼 필드 축소 확인 +- 약관 아코디언 펼쳐서 내용 표시 확인 +- OTP 입력 칸 노출 확인 +- 이메일 인증 완료 후 로그인 페이지 리다이렉트 확인 + +### 2차 검증: DB 상태 확인 ✅ +- `users.status = 'NO_COMPANY'` +- `company_id = NULL` +- `role = 'MEMBER'` +- `is_master = FALSE` + +### 3차 검증: 로그인 및 상태 ✅ +- NO_COMPANY 상태에서도 로그인 성공 (기존: status != ACTIVE로 실패) + +### 4차 검증: 회사 없이 B02+ 접근 차단 ✅ +- B02~B11 진입 시 B01로 리다이렉트 동작 확인 +- 백엔드 require_company 에러 발생 + +--- + +## 최종 상태 + +🟢 **회원가입 플로우 개편 완료 (A~D 단계 중 A~C 완료)** +- DB 마이그레이션 ✅ +- 백엔드 로직 수정 ✅ +- 프론트엔드 UI/라우팅 ✅ +- 세션 쿠키 기반 인증 통일 ✅ +- localStorage 토큰 완전 제거 ✅ +- 회사 연결 필수화 (라우팅 가드) ✅ + +🟡 **B01_AccountDetail 팝업 기능은 별도 작업 (D 단계 — 다음 세션)** diff --git a/.agent/complete_and_old/plan_register_split_validation_COMPLETED.md b/.agent/complete_and_old/plan_register_split_validation_COMPLETED.md new file mode 100644 index 0000000..59a991a --- /dev/null +++ b/.agent/complete_and_old/plan_register_split_validation_COMPLETED.md @@ -0,0 +1,136 @@ +# 회원가입 플로우 개편 검증 보고서 (완료된 항목) + +**마지막 업데이트:** 2026-07-08 +**상태:** 회원가입 축소 및 세션 쿠키 인증 검증 완료 + +--- + +## ✅ 완료된 구현 항목 + +### 1. DB 변경 +- ✅ `db_management/003_register_split.sql` 생성 + - `users.status` ENUM에 `'NO_COMPANY'` 추가 + - 회원가입 시 회사 정보 NULL로 시작하도록 변경 + +### 2. 백엔드 변경 +- ✅ `A07_Register_Schema.py` 수정 + - `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version` + - 회사 관련 필드 전부 제거 + - `password_confirm` 검증 추가 + +- ✅ `common_util_auth_repository.py` 수정 + - `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행 + - `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경 + +- ✅ `A07_Register_Router.py` 수정 + - `request_registration()`: `password_confirm` 제외 처리 + +- ✅ `A06_Login_Router.py` 수정 + - `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경 + - `current_session()`: 응답에 `company_id`, `is_master` 추가 + +- ✅ `common_util_auth.py` 수정 + - `require_company()` dependency 추가 + +- ✅ `main.py` 수정 + - B03~B06 라우터에 `require_company` 의존성 추가 + +### 3. 리소스 파일 +- ✅ `resources/legal/terms_of_service.txt` 생성 +- ✅ `resources/legal/privacy_policy.txt` 생성 +- ✅ `resources/legal/marketing_consent.txt` 생성 + +### 4. 프론트엔드 변경 +- ✅ `A07_Register_Api_Fetch.ts` 수정 + - `RegisterPayload` 필드 축소 + +- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성 + - 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언) + - 약관 텍스트 파일 로드 기능 + - OTP 입력 창 기존 로직 유지 + +- ✅ `A00_Common/router.ts` 수정 + - B02~B11 라우트: company_id 없으면 B01로 리다이렉트 + +- ✅ `ui_template_locale.ts` 수정 + - `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm` + - `A07_Register_Error_PasswordMismatch` 추가 + +- ✅ `A07_Register_UI_Style.css` 수정 + - `.a07-register__agreement` 아코디언 스타일 추가 + +- ✅ `A06_Login_Api_Fetch.ts` 수정 + - `SessionUser` 인터페이스에 `company_id`, `is_master` 추가 + +### 추가 변경 사항 (세션 쿠키 통일) + +#### 프론트엔드 개선 +- ✅ `A06_Login_Api_Fetch.ts`: `fetchSessionUser()` 구현 (세션 조회 → company_id 확인용) +- ✅ 모든 API Fetch 파일: Bearer 토큰 제거, `credentials: "include"` 추가 (세션 쿠키 기반) + - B03_FileInput, B04_wf1_Surface, B05_wf2_Route, B06_wf3_ProfileCross +- ✅ `A00_Common/app_shell.ts`: 로그아웃 시 `/auth/logout` API 호출 (서버 세션 삭제) +- ✅ `A00_Common/router.ts`: B02~B11 진입 시 company_id 확인 후 B01로 리다이렉트 + +#### 백엔드 개선 +- ✅ `config_db.py`: `autocommit=True` 설정 (pooled 커넥션 트랜잭션 스냅샷 이슈 해결) +- ✅ `A06_Login_Router.py`: `/current-session` 응답에 `company_id`, `status` 추가 +- ✅ `common_util_auth.py`: `require_company()` dependency 추가 및 B03~B06에 적용 + +#### DB 마이그레이션 +- ✅ `db_management/003_register_split.sql`: users.status에 NO_COMPANY 추가 + +--- + +## ✅ 완료된 검증 항목 + +### 1차 검증: 가입 및 이메일 인증 ✅ +```bash +python main.py +``` +브라우저에서: +1. `/#/a07-register` 접속 — 축소된 필드 확인 ✅ +2. 필드 확인: name, position, email, phone, password, passwordConfirm, 약관(3개) ✅ +3. 약관 체크박스 옆 라벨 클릭 → `
` 펼쳐서 내용 표시 확인 ✅ +4. 모든 필드 입력 후 "회원가입" 버튼 클릭 ✅ +5. 폼 하단에 OTP 입력 칸 노출 확인 ✅ +6. OTP 입력 후 "이메일 인증 완료" 버튼 클릭 ✅ +7. 로그인 페이지로 리다이렉트 확인 ✅ + +### 2차 검증: DB 상태 확인 ✅ +```sql +SELECT id, email, status, company_id, role, is_master FROM users WHERE email = 'test@example.com'; +``` +예상 결과 (확인됨): +- `status` = 'NO_COMPANY' ✅ +- `company_id` = NULL ✅ +- `role` = 'MEMBER' ✅ +- `is_master` = FALSE ✅ + +### 3차 검증: 로그인 및 상태 ✅ +1. 가입한 계정으로 로그인 시도 → 성공 확인 ✅ + (기존: status != ACTIVE로 실패했을 부분이 이제 NO_COMPANY 상태에서도 로그인 가능) +2. 로그인 후 B02_PROJ_REGISTER (`/#/b02-proj-register`) 접속 시도 → B01_ACCOUNT로 리다이렉트 확인 ✅ + +### 4차 검증: 회사 없이 B02+ 접근 차단 ✅ +- `/#/b03-file-input` 직접 접속 → B01로 리다이렉트 또는 403 응답 확인 ✅ + (백엔드 require_company 에러 발생) + +--- + +## 최종 상태 + +🟢 **회원가입 플로우 개편 완료 (1~3단계)** +- DB 마이그레이션 ✅ +- 백엔드 로직 수정 ✅ +- 프론트엔드 UI/라우팅 ✅ +- 세션 쿠키 기반 인증 통일 ✅ +- localStorage 토큰 완전 제거 ✅ +- 회사 연결 필수화 ✅ + +🟢 **현재 검증 완료 (모든 4차 검증 통과)** +- 회원가입 → 이메일 인증 → NO_COMPANY 상태 확인 ✅ +- 로그인 가능 (NO_COMPANY 상태에서도 가능) ✅ +- B02+ 접근 시 B01로 리다이렉트 동작 확인 ✅ +- 세션 쿠키 기반 인증 정상 작동 ✅ + +🟡 **B01_AccountDetail 팝업 기능은 별도 작업 (4단계 — 다음 세션)** diff --git a/.agent/db_schema.md b/.agent/db_schema.md index c21e018..7be75a1 100644 --- a/.agent/db_schema.md +++ b/.agent/db_schema.md @@ -1,6 +1,6 @@ # DB 스키마 (테이블 및 열 목록) -## 1. 사용자 & 인증 그룹 +## 1. 사용자 & 인증 & 조직 그룹 ### 1-1. users 테이블 ``` @@ -12,12 +12,25 @@ users ├── position (TEXT) — 직급 (예: 과장, 대리, 사원) ├── department (TEXT) — 부서명 (예: 설계팀, 영업팀) ├── phone (TEXT) — 연락처 (예: 010-1234-5678) -├── company_id (INT, FK → companies.id) — 소속 회사 +├── company_id (INT, FK → companies.id, NULL) — 소속 회사 +├── role (ENUM) — 역할: SYSTEM_ADMIN, ADMIN, USER (기본값: USER) +├── is_master (BOOLEAN) — 회사 마스터 여부 (기본값: FALSE) +├── status (ENUM) — 상태: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED +├── last_login (DATETIME, NULL) — 마지막 로그인 시간 +├── auth_expires_at (DATETIME, NULL) — 인증 유효 만료 시간 (3개월 주기) ├── created_at (TIMESTAMP) — 가입일 ├── updated_at (TIMESTAMP) — 수정일 └── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` +**상태 생명주기:** +- PENDING_EMAIL: 회원가입 후 이메일 인증 전 +- NO_COMPANY: 이메일 인증 완료, 회사 미연결 (로그인 가능, B02~B11 차단) +- PENDING: 회사 참여 신청 후 관리자 승인 대기 +- ACTIVE: 회사 연결 완료, 모든 기능 접근 가능 +- INACTIVE: 퇴사/계정 비활성화 +- REJECTED: 회사 참여 신청 거부 + ### 1-2. companies 테이블 ``` companies @@ -26,13 +39,117 @@ companies ├── business_registration_number (TEXT, UNIQUE) — 사업자등록번호 ├── business_address (TEXT) — 사업장 주소 ├── business_owner (TEXT) — 사업주 이름 -├── business_status (TEXT) — 기업 상태 (예: 활동중, 폐업, 휴업) -├── created_by (INT, FK → users.id) — 회사 생성자 +├── business_status (TEXT, ENUM) — 기업 상태: 활동중, 폐업, 휴업 (기본값: 활동중) +├── created_by (INT, FK → users.id) — 회사 생성자 (첫 ADMIN) ├── created_at (TIMESTAMP) — 생성일 ├── updated_at (TIMESTAMP) — 수정일 └── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete) ``` +### 1-3. join_requests 테이블 (신규) +``` +join_requests +├── id (INT, PK) — 요청 고유 번호 +├── user_id (INT, FK → users.id) — 신청한 사용자 +├── company_id (INT, FK → companies.id) — 신청 대상 회사 +├── requested_at (TIMESTAMP) — 신청 시간 +├── status (ENUM) — 상태: PENDING, APPROVED, REJECTED (기본값: PENDING) +├── reviewed_by (INT, FK → users.id, NULL) — 검토한 관리자 +├── reviewed_at (TIMESTAMP, NULL) — 검토 시간 +└── UNIQUE KEY (user_id, company_id) — 중복 신청 방지 +``` + +**동작:** +- 사용자 가입 신청 → status = PENDING 생성 +- 관리자 승인 → status = APPROVED, users.status = ACTIVE, users.company_id 세팅 +- 관리자 거부 → status = REJECTED, 이메일 발송 + +### 1-4. system_audit_logs 테이블 (신규) +``` +system_audit_logs +├── id (INT, PK) — 로그 고유 번호 +├── user_id (INT, FK → users.id) — 행동 수행자 +├── action (VARCHAR(50)) — 행동: LOGIN, LOGOUT, USER_CREATE, USER_UPDATE, COMPANY_CREATE, ROLE_CHANGE, etc +├── resource_type (VARCHAR(50)) — 대상 타입: user, company, project, join_request +├── resource_id (INT, NULL) — 대상 ID +├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간 +├── ip_address (VARCHAR(50), NULL) — IP 주소 (선택사항) +├── user_agent (TEXT, NULL) — 브라우저/OS 정보 (선택사항) +├── INDEX (timestamp) — 조회 성능 최적화 +└── INDEX (user_id) — 사용자별 조회 최적화 +``` + +### 1-5. system_resources 테이블 (신규) +``` +system_resources +├── id (INT, PK) — 기록 고유 번호 +├── timestamp (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) — 기록 시간 +├── cpu_usage_percent (FLOAT) — CPU 사용률 (%) +├── memory_usage_percent (FLOAT) — 메모리 사용률 (%) +├── disk_usage_percent (FLOAT) — 디스크 사용률 (%) +├── active_user_count (INT) — 활성 사용자 수 +├── active_project_count (INT) — 활성 프로젝트 수 +├── total_storage_mb (FLOAT) — 전체 저장소 사용량 (MB) +└── INDEX (timestamp) — 시계열 조회 최적화 +``` + +--- + +## 테이블 관계도 (조직 & 인증 그룹) + +``` +users (사용자) +├── id (PK) +├── email +├── password_hash +├── role (SYSTEM_ADMIN | ADMIN | USER) +├── is_master (마스터 여부) +├── status (생명주기) +├── company_id (FK) ──→ companies.id +├── last_login +└── auth_expires_at + +companies (회사) +├── id (PK) +├── name +├── business_registration_number +├── business_address +├── business_owner +├── business_status +├── created_by (FK) ──→ users.id +├── created_at +└── updated_at + +join_requests (가입 신청) +├── id (PK) +├── user_id (FK) ──→ users.id +├── company_id (FK) ──→ companies.id +├── requested_at +├── status (PENDING | APPROVED | REJECTED) +├── reviewed_by (FK) ──→ users.id +└── reviewed_at + +system_audit_logs (시스템 로그) +├── id (PK) +├── user_id (FK) ──→ users.id +├── action +├── resource_type +├── resource_id +├── timestamp +├── ip_address +└── user_agent + +system_resources (리소스 모니터링) +├── id (PK) +├── timestamp +├── cpu_usage_percent +├── memory_usage_percent +├── disk_usage_percent +├── active_user_count +├── active_project_count +└── total_storage_mb +``` + --- ## 2. 프로젝트 그룹 @@ -586,8 +703,8 @@ ORDER BY timestamp DESC; - `processed_file_path / converted_file_path`: 변환된 파일 위치 - `*_file_path`: 각 워크플로우 단계별 산출물 위치 -### 총 18개 테이블: -1. **사용자/회사**: users, companies (2개) +### 총 23개 테이블: +1. **사용자/회사/조직**: users, companies, join_requests, system_audit_logs, system_resources (5개) 2. **프로젝트**: projects, project_versions (2개) 3. **입력 데이터**: input_files, processed_point_cloud (2개) 4. **지표면 분석**: surface_models, terrain_layers (2개) diff --git a/.agent/frontend.md b/.agent/frontend.md index e8f8c37..c37f0fe 100644 --- a/.agent/frontend.md +++ b/.agent/frontend.md @@ -32,4 +32,57 @@ export const ui_locales = { A01_Home_Title: ["반갑습니다", "Welcome"], B04_wf1_Surface_Btn: ["지표면 분석 실행", "Run Surface Analysis"] -}; \ No newline at end of file +}; +``` + +--- + +## 5. 인증, 인가, 역할 및 라우팅 (Authentication, Authorization, Roles & Routing) + +### 5.1 세션 쿠키 기반 인증 +* **방식:** HttpOnly Secure 세션 쿠키 (localStorage 토큰 미사용) +* **API 통신:** 모든 API 요청에 `credentials: "include"` 설정으로 쿠키 자동 전송 +* **로그아웃:** `/auth/logout` API 호출 → 서버 세션 삭제 → 쿠키 자동 제거 +* **인증 갱신:** 로그인 시 `users.auth_expires_at = NOW() + 3개월` 자동 설정 + - 유효한 인증: `auth_expires_at > NOW()` → 바로 대시보드 이동 + - 만료된 인증: `auth_expires_at <= NOW()` → 재인증 모달 표시 + +### 5.1.1 역할 기반 대시보드 렌더링 +* **USER 역할:** + - 프로젝트 리스트 섹션 + - 회사 정보 섹션 (회사 생성/연결 팝업) + - 기본정보 & 보안 섹션 + +* **ADMIN 역할:** + - 프로젝트 리스트 섹션 (회사 전체 프로젝트) + - 회사 정보 섹션 + - 팀원 관리 섹션 + - 가입 요청 섹션 + - 기본정보 & 보안 섹션 + +* **SYSTEM_ADMIN 역할:** + - 리소스 현황 섹션 (CPU, MEM, DISK, 활성 사용자/프로젝트) + - 회사 관리 섹션 (전체 회사 리스트 + 추가/수정/삭제) + - 사용자 관리 섹션 (전체 사용자 + 역할 변경 + 회사 할당) + - 가입 요청 관리 섹션 (전체 가입 신청 + 승인/거절) + - 시스템 로그 섹션 (필터링 지원) + - 리소스 모니터링 그래프 (시계열 데이터) + - 기본정보 & 보안 섹션 + +### 5.2 라우팅 가드 +* **인증 확인:** `isAuthenticated()` 함수로 세션 유효성 검사 (PROTECTED_ROUTES 진입 전) +* **회사 연결 확인:** B02~B11 워크플로우 진입 시 `fetchDashboardMe()` 호출 → `company_id` + `status` 확인 + - `status` 확인: + - NO_COMPANY: B01_Dashboard로 강제 리다이렉트 (회사 연결 필요) + - PENDING: B01_Dashboard로 강제 리다이렉트 (승인 대기 중) + - ACTIVE: 워크플로우 진입 허용 + - `company_id` 없으면 B01_Dashboard로 리다이렉트 +* **역할 기반 라우팅:** + - SYSTEM_ADMIN: 모든 페이지 접근 가능 + - ADMIN: B01_Dashboard (관리 섹션) + B02~B11 워크플로우 접근 가능 + - USER: B01_Dashboard (사용자 섹션) + B02~B11 워크플로우 접근 가능 +* **상태 전이:** + - 회원가입 후 이메일 인증 완료: `users.status = 'NO_COMPANY'` (로그인 가능, 회사 미연결) + - 회사 생성: `users.status = 'ACTIVE'`, `company_id` 세팅, `role = 'ADMIN'`, `is_master = TRUE` + - 회사 참여 신청: `users.status = 'PENDING'` (관리자 승인 대기) + - 관리자 승인: `users.status = 'ACTIVE'`, `company_id` 세팅 \ No newline at end of file diff --git a/.agent/plan_register_split.md b/.agent/plan_register_split.md index 9d3aff9..5cc7ef6 100644 --- a/.agent/plan_register_split.md +++ b/.agent/plan_register_split.md @@ -1,94 +1,693 @@ -# 회원가입 플로우 개편: 계정 생성과 회사 연결 분리 +# B01_Dashboard 설계 및 구현 계획 -## Context +**상태:** 기본 설계 완료. 역할별 대시보드 구축 및 회사 연결 기능 미구현 +**참고:** 완료된 항목은 `.agent/complete_and_old/plan_register_split_COMPLETED.md` 참조 -현재 `A07_Register` 회원가입 폼은 계정 생성과 동시에 회사 생성/연결(신규 회사명 또는 기존 company_id)을 강제로 요구한다. 사용자는 이 결합이 부적절하다고 판단했다 — 회사 관련 정보(사업자등록번호, 전화번호, 주소 등)는 계정을 만든 사람이 로그인한 이후에 "회사 생성 또는 검색·연결" 단계로 넘겨야 자연스럽다. 또한: +--- -- 회원가입 폼 필드를 이름/직급/이메일/전화번호/비밀번호/비밀번호 확인으로 재정의 (department는 여기서 제외, 회사 연결 이후 설정) -- 약관/개인정보처리방침/마케팅 동의는 필수 항목이지만 실제 내용이 어디에도 없었다 — 체크박스 옆에 이름을 두고 펼치면 내용이 열리는 UI로 바꾸고, 실제 텍스트는 나중에 쉽게 고칠 수 있도록 리소스 텍스트 파일로 분리 -- 이메일 인증은 회원가입 제출 즉시 폼 하단에 OTP 입력 칸이 나타나는 현재 방식을 유지 +## 1. B01_Dashboard 개편 사항 -목표: 계정 생성(A07) → 이메일 인증 → 로그인 가능(company 없이) → 로그인 후 B01_AccountDetail에서 회사 생성/연결 → 연결 완료 후에만 B02~B11 워크플로우 접근 가능. +### 1.1 페이지명 변경 +- **이전:** B01_AccountDetail (계정 상세 페이지) +- **현재:** B01_Dashboard (역할별 대시보드) +- **목적:** 로그인 후 메인 랜딩 페이지로, 역할에 따라 다양한 정보 표시 -## 현재 구조 파악 결과 +### 1.2 역할별 대시보드 구조 -- `users` 테이블: `company_id` nullable, `role` ENUM('SYSTEM_ADMIN','MASTER','MEMBER') 기본 MEMBER, `is_master` BOOLEAN, `status` ENUM('PENDING_EMAIL','PENDING','ACTIVE','INACTIVE','REJECTED') -- `create_registration()` (`common_util/common_util_auth_repository.py:30`): 현재 회원가입 시 계정+회사(or join_request)+user_consents를 한 트랜잭션에서 같이 생성 -- `complete_registration()` (`common_util/common_util_auth_repository.py:134`): 이메일 인증 완료 시 MASTER→ACTIVE, MEMBER→PENDING(마스터 승인 대기)로 status 변경 -- `verify_session()` (`common_util/common_util_auth.py:74`): 세션 조회 시 `company_id`, `role`, `is_master`, `status`를 함께 반환 — 이미 dependency에서 활용 가능한 형태 -- `main.py:236-240`: B03~B06 라우터에 `protected = [Depends(verify_session)]`만 걸려 있음 (company_id 미검사, B07~B11은 라우터 자체가 아직 없음) -- `A00_Common/router.ts`: `PROTECTED_ROUTES` 목록으로 인증 가드만 수행, company 연결 여부는 검사하지 않음 -- `A09_Security_Terms.ts` + `A09_Security_UI_Page.ts`: 이미 "보안정책 안내 페이지" 용도로 존재하지만 세션/보안 정책 설명이지 회원가입 약관 동의가 아님 — 이 구조(약관 텍스트 분리 + 언어별 배열)를 참고해 새 리소스를 만든다 -- `B01_AccountDetail`: 현재 `UI_Page.ts`, `UI_Style.css`만 있고 백엔드/API 없음 — 여기에 계정 상세 정보 표시 + 회사 연결 섹션을 새로 구현해야 함 -- `resources/` 폴더(프로젝트 루트)는 로고/배너 등 에셋용으로 이미 정의돼 있음 (`structure.md:41`) — 약관 텍스트는 이 폴더 하위에 신설 서브폴더로 정리 +#### 🟡 **일반 사용자 (USER) 대시보드** +``` +┌─────────────────────────────────────────────────────┐ +│ AISLO 대시보드 │ +├─────────────────────────────────────────────────────┤ +│ │ +│ 📊 [나의 프로젝트] │ +│ • 프로젝트 리스트 (테이블) │ +│ • 프로젝트명 | 지역 | 진행도 [워크플로우] | 수정일 │ +│ • 클릭 시 해당 단계로 이동 │ +│ • [+ 신규 프로젝트] 버튼 │ +│ │ +│ 🏢 [회사 정보] │ +│ • 현재 소속 회사명 │ +│ • 팀원 수, 진행 중인 프로젝트 수 │ +│ │ +│ 📞 [기본정보 & 보안] (섹션 병합) │ +│ • 사용자명, 직급, 부서, 전화번호 수정 │ +│ • 비밀번호 변경 │ +│ │ +└─────────────────────────────────────────────────────┘ +``` -## DB 변경 +#### 🟢 **회사 관리자 (ADMIN) 대시보드** +``` +┌─────────────────────────────────────────────────────┐ +│ AISLO 회사 관리자 대시보드 │ +├─────────────────────────────────────────────────────┤ +│ │ +│ 🏢 [회사 정보] │ +│ 회사명: 테스트회사 | 사업자: 123-45-67890 │ +│ 주소: 서울시 강남구 | 상태: 활동중 │ +│ │ +│ 👥 [팀원 관리] │ +│ • 소속 사용자 리스트 (테이블) │ +│ • 사용자명 | 직급 | 부서 | 역할 | 상태 │ +│ • [+ 팀원 추가] (기가입 계정 검색/추가) │ +│ • [제거] 버튼 │ +│ │ +│ 📋 [가입 요청] │ +│ • 이 회사에 가입을 신청한 사용자 리스트 │ +│ • [승인] [거절] 버튼 │ +│ │ +│ 📊 [회사 프로젝트] │ +│ • 회사의 모든 프로젝트 리스트 (테이블) │ +│ • 프로젝트명 | 소유자 | 진행도 | 상태 │ +│ │ +│ 📞 [기본정보 & 보안] │ +│ • 사용자 이름, 직급, 전화번호 수정 │ +│ • 비밀번호 변경 │ +│ │ +└─────────────────────────────────────────────────────┘ +``` -`db_management/003_register_split.sql` 신규 마이그레이션: +#### 🔴 **시스템 관리자 (SYSTEM_ADMIN) 대시보드** +``` +┌────────────────────────────────────────────────────┐ +│ AISLO 시스템 관리자 대시보드 │ +├────────────────────────────────────────────────────┤ +│ │ +│ 📊 [리소스 현황] │ +│ CPU: 45% | MEM: 62% | DISK: 78% │ +│ 활성 사용자: 23 | 활성 프로젝트: 15 │ +│ │ +│ 🏢 [회사 관리] │ +│ • 전체 회사 리스트 (테이블) │ +│ • 회사명 | 사업자번호 | 상태 | 사용자 수 │ +│ • [+ 회사 추가] 버튼 │ +│ │ +│ 👤 [사용자 관리] │ +│ • 전체 사용자 리스트 (테이블) │ +│ • 이메일 | 이름 | 역할 | 회사 | 상태 │ +│ • [역할 변경] [회사 할당] [비활성화] 버튼 │ +│ │ +│ 📝 [가입 요청 관리] │ +│ • 회사 참여 대기 중인 요청 (테이블) │ +│ • 신청자 | 회사명 | 신청일 | 상태 │ +│ • [승인] [거절] 버튼 │ +│ │ +│ 📊 [시스템 로그] │ +│ • 사용자 로그인/로그아웃 이력 (테이블) │ +│ • 계정 생성/수정/삭제 이력 │ +│ • 필터링: 기간, 사용자, 행동 유형 │ +│ │ +│ 🖥️ [하드웨어 리소스 모니터링] │ +│ • CPU 사용량 그래프 (시간대별) │ +│ • 메모리 사용량 그래프 │ +│ • 디스크 사용량 그래프 │ +│ • 동시 접속자 수 추이 │ +│ │ +└────────────────────────────────────────────────────┘ +``` -1. `users.status` ENUM에 `'NO_COMPANY'` 추가 (이메일 인증 완료, 회사 미연결 상태) -2. `users.company_id`, `users.role`, `users.is_master` 는 구조 그대로 유지하되 회원가입 시점에는 전부 NULL/기본값으로 시작 -3. 기존 `join_requests` 테이블 그대로 재사용 (MEMBER가 기존 회사 연결 신청 시) -4. `department` 컬럼은 그대로 두되 회원가입 INSERT에서 제외 (NULL 허용이라 문제 없음) +--- -## 백엔드 변경 +## 2. 핵심 기능 상세 설계 -### A07_Register (회원가입 축소) +### 2.1 프로젝트 리스트 섹션 (USER, ADMIN 공통) -- `A07_Register_Schema.py`: `RegisterRequest`에서 `account_type`, `company_id`, `company_name`, `business_number`, `phone_number`(→ 개인 전화번호로 의미 변경), `address`, `representative_name` 제거. 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version`. `password_confirm == password` 검증을 `model_validator`에 추가. -- `A07_Register_Router.py`의 `request_registration()`: `create_registration()` 호출을 단순화된 데이터로 변경. -- `common_util_auth_repository.py`의 `create_registration()`: 회사/join_request 생성 로직 제거. `users` INSERT만 수행 (`role` 기본값 MEMBER, `company_id` NULL, `is_master` FALSE). `user_consents` INSERT는 유지. -- `complete_registration()`: 이메일 인증 완료 시 무조건 `status = 'NO_COMPANY'`로 변경 (MASTER/MEMBER 분기 제거). -- `A06_Login_Router.py`의 `request_login()`: `user["status"] != "ACTIVE"` 체크를 `status not in ("ACTIVE", "NO_COMPANY")`로 완화 — company 미연결 상태에서도 로그인은 허용. +**테이블 구조:** +| 프로젝트명 | 지역 | 진행도 | 워크플로우 | 수정일 | +|----------|------|--------|----------|--------| +| 2025년 산불진화임도 | 울진군 | 60% | [B03●●●○○○] | 2025-07-08 | -### 회사 생성/연결 API (신규, B01_AccountDetail 백엔드) +**진행도 워크플로우:** +``` +[B03 완료] → [B04 진행중 ▶] → [B05 대기] → [B06 대기] → ... + ↑ (클릭하면 이동) (비활성화) +``` -`B01_AccountDetail/B01_AccountDetail_Router.py`, `_Schema.py`, `_Repository.py` 신규 생성 (기존 패턴 준수, `common_util_auth_repository.py`의 `search_companies`/`_company_code` 재사용): +**동작:** +- **완료/진행 중 단계:** 클릭 가능 → 해당 페이지로 이동 +- **미진행 단계:** 비활성화 (회색), 클릭 불가 +- **[+ 신규 프로젝트]:** 새 프로젝트 생성 페이지로 이동 (B02_ProjRegister) -- `GET /api/account/me` — 현재 계정 상세 (session 기반) -- `PATCH /api/account/me` — 이름/직급/전화번호 수정 -- `GET /api/account/companies?q=` — 기존 `search_companies()` 재사용 -- `POST /api/account/company/create` — 신규 회사 생성 + 본인을 MASTER/is_master=TRUE로 연결, `role='MASTER'`, `status='ACTIVE'`로 전환. 회사 필드(사업자등록번호/주소/전화번호/대표자명)를 여기서 받음. -- `POST /api/account/company/join` — 기존 회사에 `join_requests` 생성 + `users.company_id` 세팅(단, status는 `PENDING`으로, 마스터 승인 전까지 유지) +### 2.2 회사 상태 섹션 (USER만 표시) -### 접근 제어 (company 연결 강제) +**상태 표시:** +``` +회사 상태: 테스트회사 (활동중) +[회사 변경] ← 다른 회사 검색 및 신청 +``` -- `common_util/common_util_auth.py`에 `require_company()` dependency 추가: `session["company_id"] is None` 이면 403. -- `main.py`: B03~B06 라우터의 `protected` 리스트에 `Depends(require_company)` 추가. +**동작:** +- `status = 'NO_COMPANY'`: "회사 미연결" 표시 + [회사 생성] [회사 찾기] 버튼 +- `status = 'PENDING'`: "가입 승인 대기 중 (OOO회사)" 표시 +- `status = 'ACTIVE'`: "OOO회사 (ACTIVE)" 표시 + [회사 변경] 버튼 -## 프론트엔드 변경 +### 2.3 팀원 관리 섹션 (ADMIN만 표시) -### 리소스 텍스트 분리 +**팀원 추가 동작:** +1. [+ 팀원 추가] 클릭 → 모달 팝업 노출 +2. 기가입 사용자 검색 (이메일 입력) +3. 검색 결과 선택 → 회사에 추가 +4. 중복 확인 (이미 팀원인 경우 "이미 팀원입니다" 메시지) -`resources/legal/` 신규 폴더: -- `terms_of_service.txt` — 이용약관 (Aislo/임도설계 자동화 서비스에 맞는 내용 초안 작성) -- `privacy_policy.txt` — 개인정보처리방침 -- `marketing_consent.txt` — 마케팅 정보 수신 동의 +**팀원 제거:** +- [제거] 버튼 클릭 → 확인 팝업 → 회사에서 제거 -이 텍스트는 빌드 시 정적 자산으로 fetch하거나, 간단히 `A07_Register`에서 TS 상수 모듈로 다시 export하는 두 방식 중 — Vite가 `?raw` import를 지원하므로 `import termsText from "../../resources/legal/terms_of_service.txt?raw"` 형태로 로드 (외부 자산 폴더를 벗어나지 않고 사용자가 텍스트 파일만 고치면 되도록). +### 2.4 가입 요청 섹션 (ADMIN은 회사별, SYSTEM_ADMIN은 전체) -### A07_Register (폼 축소 + 약관 아코디언 + OTP 유지) +**ADMIN 대시보드:** +``` +신청자 | 신청일 | 상태 +김테스트 | 2025-07-08 | [승인] [거절] +``` -- `A07_Register_UI_Auth_Page.ts`: `accountType`, `company`, `companyId` 필드 및 관련 로직 제거. `position`(직급), `phone`(전화번호), `passwordConfirm` 필드 추가. -- 약관 체크박스 영역: 현재 `check()` 헬퍼(단순 체크박스+라벨)를 확장한 `checkWithAccordion()`로 교체 — 라벨 클릭 시 하단에 `resources/legal/*.txt` 내용이 펼쳐지는 `
/` 또는 토글 버튼 구조. -- OTP 입력 칸: 기존처럼 제출 후 폼 하단에 노출되는 현재 로직 그대로 유지 (`verifyStep` 플래그 방식 재사용). -- `A07_Register_Api_Fetch.ts`의 `RegisterPayload`: 축소된 필드로 갱신, `searchCompanies` export는 B01로 이동(또는 공용 유지 — B01에서 재사용하도록 export 유지). +**SYSTEM_ADMIN 대시보드:** +``` +신청자 | 회사명 | 신청일 | 상태 +김테스트 | 테스트회사 | 2025-07-08 | [승인] [거절] +``` -### B01_AccountDetail (계정 상세 + 회사 연결) +**동작:** +- [승인]: `join_requests.status = 'APPROVED'` → `users.status = 'ACTIVE'` +- [거절]: `join_requests.status = 'REJECTED'` → 이메일 발송 -- `B01_AccountDetail_UI_Page.ts`: 계정 기본정보 표시/수정 폼 + "회사 생성" / "회사 검색·연결" 두 개의 섹션(회사 미연결 시에만 노출, `A07_Register_Api_Fetch.searchCompanies` 재사용 가능). -- `B01_AccountDetail_Api_Fetch.ts` 신규: 위 백엔드 API 클라이언트. +### 2.5 인증 갱신 로직 -### 라우팅 가드 (company 없으면 B02+ 차단) +**회원가입 시:** +``` +1. 회원가입 폼 입력 +2. [가입하기] 클릭 +3. 인증번호 입력 필드 노출 ← 가입 후에만 표시 +4. 인증 완료 → users.auth_expires_at = NOW() + 3개월 설정 +``` -- `A00_Common/router.ts`의 `renderCurrentRoute()`: `PROTECTED_ROUTES` 검사 이후, B02~B11 라우트에 한해 세션의 `company_id` 확인 로직 추가 (session 조회 API에 `company_id` 이미 포함돼 있음 — `A06_Login_Router.py`의 `/session` 응답에 `company_id` 필드만 추가하면 됨). 없으면 `ROUTES.B01_ACCOUNT`로 리다이렉트. -- `A06_Login_Router.py`의 `current_session()`: 응답에 `company_id`, `status` 추가. +**로그인 시:** +``` +1. 이메일/비밀번호 입력 +2. DB 조회 후 auth_expires_at 확인 + - auth_expires_at > NOW() → 바로 대시보드 이동 ✅ + - auth_expires_at <= NOW() → 재인증 모달 표시 ⚠️ +3. 로그인 성공 → last_login = NOW() 업데이트 +4. 다음 로그인 시 auth_expires_at = NOW() + 3개월로 연장 +``` -## 검증 방법 +**인증 만료 기준:** +- **고정 기간 방식 (이전):** 가입일로부터 3개월 후 만료 (자주 사용하면 3개월 후 갑자기 재인증) +- **로그인 기반 갱신 (현재):** 매 로그인 시 `auth_expires_at` 연장 (지속 사용 시 계속 유효) -1. `python main.py` 실행 후 브라우저에서 `/#/a07-register` 접속 → 축소된 필드로 가입, 약관 아코디언 펼쳐서 텍스트 노출 확인, 제출 후 OTP 칸 노출 확인 -2. OTP 인증 완료 → DB에서 `users.status = 'NO_COMPANY'`, `company_id IS NULL` 확인 -3. 해당 계정으로 로그인 성공 확인 (기존엔 `status != ACTIVE`면 403이었음) -4. 로그인 상태에서 `/#/b02-proj-register` 접속 시도 → `/#/b01-account`로 리다이렉트되는지 확인 -5. B01에서 "회사 생성" 실행 → `role='MASTER'`, `status='ACTIVE'`, `company_id` 세팅 확인 후 B02 접근 가능한지 재확인 -6. 두 번째 테스트 계정으로 "회사 검색·연결"(join) 플로우 → `join_requests` 생성 확인, 기존 마스터 승인 API(`A09_Security_Router.py`의 `/master/join-requests`)로 승인 후 status ACTIVE 전환 확인 +--- + +## 3. DB 스키마 확장 + +### 3.1 users 테이블 추가 컬럼 +```sql +ALTER TABLE users ADD COLUMN ( + role ENUM('SYSTEM_ADMIN', 'ADMIN', 'USER') DEFAULT 'USER', + is_master BOOLEAN DEFAULT FALSE, + status ENUM('PENDING_EMAIL', 'NO_COMPANY', 'PENDING', 'ACTIVE', 'INACTIVE', 'REJECTED') DEFAULT 'PENDING_EMAIL', + last_login DATETIME DEFAULT NULL, + auth_expires_at DATETIME DEFAULT NULL +); +``` + +### 3.2 신규 테이블: join_requests +```sql +CREATE TABLE join_requests ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + company_id INT NOT NULL, + requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING', + reviewed_by INT DEFAULT NULL, + reviewed_at TIMESTAMP DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (company_id) REFERENCES companies(id), + FOREIGN KEY (reviewed_by) REFERENCES users(id), + UNIQUE KEY (user_id, company_id) -- 중복 신청 방지 +); +``` + +### 3.3 신규 테이블: system_audit_logs +```sql +CREATE TABLE system_audit_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + action VARCHAR(50), -- LOGIN, LOGOUT, USER_CREATE, USER_UPDATE, COMPANY_CREATE, etc + resource_type VARCHAR(50), -- user, company, project + resource_id INT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id), + INDEX (timestamp), + INDEX (action) +); +``` + +### 3.4 신규 테이블: system_resources +```sql +CREATE TABLE system_resources ( + id INT PRIMARY KEY AUTO_INCREMENT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + cpu_usage_percent FLOAT, + memory_usage_percent FLOAT, + disk_usage_percent FLOAT, + active_user_count INT, + active_project_count INT, + total_storage_mb FLOAT, + INDEX (timestamp) +); +``` + +--- + +## 4. 백엔드 API 설계 + +### 4.1 B01_Dashboard 라우터 (`B01_Dashboard/B01_Dashboard_Router.py`) + +#### 공통 API + +```python +# GET /api/dashboard/me — 현재 사용자 정보 조회 +@router.get("/api/dashboard/me") +async def get_dashboard_me(session: dict = Depends(verify_session)): + """ + 현재 로그인한 사용자의 정보 반환 + 응답: { + id, email, name, position, department, phone, company_id, role, is_master, status, + last_login, auth_expires_at + } + """ + +# PATCH /api/dashboard/me — 사용자 정보 수정 +@router.patch("/api/dashboard/me") +async def patch_dashboard_me( + request: UpdateUserRequest, + session: dict = Depends(verify_session) +): + """이름/직급/부서/전화번호 수정""" + # 응답: 수정된 사용자 정보 +``` + +#### USER 역할 API + +```python +# GET /api/dashboard/user/projects — 사용자의 프로젝트 리스트 +@router.get("/api/dashboard/user/projects") +async def get_user_projects(session: dict = Depends(verify_session)): + """ + 사용자가 생성한 프로젝트 리스트 + 응답: [{id, name, region, status, progress_percent, created_at, updated_at}, ...] + """ + +# GET /api/dashboard/user/company — 사용자의 회사 정보 +@router.get("/api/dashboard/user/company") +async def get_user_company(session: dict = Depends(verify_session)): + """현재 소속 회사 정보""" + # 응답: {id, name, status, user_count, project_count} + +# GET /api/dashboard/user/companies?q= — 회사 검색 +@router.get("/api/dashboard/user/companies") +async def search_companies_for_join( + q: str = Query(...), + session: dict = Depends(verify_session) +): + """회사명 또는 사업자번호로 검색""" + # 응답: [{id, name, business_number, status}, ...] + +# POST /api/dashboard/user/company/create — 신규 회사 생성 +@router.post("/api/dashboard/user/company/create") +async def create_company( + request: CreateCompanyRequest, + session: dict = Depends(verify_session) +): + """ + 신규 회사 생성 + 사용자를 ADMIN(is_master=TRUE)으로 자동 연결 + - companies 테이블에 INSERT + - users.company_id = new_company_id + - users.role = 'ADMIN' + - users.is_master = TRUE + - users.status = 'ACTIVE' + """ + # 응답: {company_id, status='ACTIVE'} + +# POST /api/dashboard/user/company/join — 회사 참여 신청 +@router.post("/api/dashboard/user/company/join") +async def join_company( + request: JoinCompanyRequest, + session: dict = Depends(verify_session) +): + """ + 기존 회사에 참여 신청 + - join_requests 테이블에 INSERT (status='PENDING') + - users.status = 'PENDING' (관리자 승인 대기) + - 관리자에게 이메일 발송 + """ + # 응답: {join_request_id, status='PENDING'} +``` + +#### ADMIN 역할 API + +```python +# GET /api/dashboard/admin/members — 회사 팀원 리스트 +@router.get("/api/dashboard/admin/members") +async def get_company_members(session: dict = Depends(verify_session)): + """회사에 소속된 모든 팀원 조회""" + # 응답: [{id, email, name, position, department, role, status}, ...] + +# POST /api/dashboard/admin/members — 팀원 추가 +@router.post("/api/dashboard/admin/members") +async def add_company_member( + request: AddMemberRequest, + session: dict = Depends(verify_session) +): + """ + 기가입 사용자를 회사에 추가 + - users.company_id = session.company_id + - users.status = 'ACTIVE' + - 중복 확인 필수 + """ + # 응답: {user_id, status='ACTIVE'} + +# DELETE /api/dashboard/admin/members/{user_id} — 팀원 제거 +@router.delete("/api/dashboard/admin/members/{user_id}") +async def remove_company_member( + user_id: int, + session: dict = Depends(verify_session) +): + """팀원을 회사에서 제거 (soft delete)""" + +# GET /api/dashboard/admin/join-requests — 가입 신청 리스트 +@router.get("/api/dashboard/admin/join-requests") +async def get_company_join_requests(session: dict = Depends(verify_session)): + """이 회사에 가입을 신청한 사용자 리스트""" + # 응답: [{id, user_id, user_email, user_name, requested_at, status}, ...] + +# PATCH /api/dashboard/admin/join-requests/{request_id} — 가입 신청 처리 +@router.patch("/api/dashboard/admin/join-requests/{request_id}") +async def process_join_request( + request_id: int, + request: ProcessJoinRequest, # {action: 'APPROVE' | 'REJECT'} + session: dict = Depends(verify_session) +): + """ + 가입 신청 승인/거절 + - APPROVE: join_requests.status='APPROVED', users.status='ACTIVE' + - REJECT: join_requests.status='REJECTED' + - 사용자에게 이메일 발송 + """ + +# GET /api/dashboard/admin/projects — 회사 프로젝트 리스트 +@router.get("/api/dashboard/admin/projects") +async def get_company_projects(session: dict = Depends(verify_session)): + """회사에 속한 모든 프로젝트 조회""" + # 응답: [{id, name, owner, status, progress_percent}, ...] +``` + +#### SYSTEM_ADMIN 역할 API + +```python +# GET /api/dashboard/admin/companies — 전체 회사 리스트 +@router.get("/api/dashboard/admin/companies") +async def get_all_companies(session: dict = Depends(verify_session)): + """시스템 관리자용: 전체 회사 조회""" + # 응답: [{id, name, status, user_count, project_count, created_at}, ...] + +# GET /api/dashboard/admin/users — 전체 사용자 리스트 +@router.get("/api/dashboard/admin/users") +async def get_all_users(session: dict = Depends(verify_session)): + """시스템 관리자용: 전체 사용자 조회""" + # 응답: [{id, email, name, role, company_id, status, last_login}, ...] + +# PATCH /api/dashboard/admin/users/{user_id}/role — 사용자 역할 변경 +@router.patch("/api/dashboard/admin/users/{user_id}/role") +async def change_user_role( + user_id: int, + request: ChangeUserRoleRequest, + session: dict = Depends(verify_session) +): + """사용자 역할 변경 (USER ↔ ADMIN ↔ SYSTEM_ADMIN)""" + +# PATCH /api/dashboard/admin/users/{user_id}/company — 사용자 회사 할당 +@router.patch("/api/dashboard/admin/users/{user_id}/company") +async def assign_user_to_company( + user_id: int, + request: AssignCompanyRequest, + session: dict = Depends(verify_session) +): + """사용자를 다른 회사에 할당""" + +# GET /api/dashboard/admin/join-requests-all — 전체 가입 신청 +@router.get("/api/dashboard/admin/join-requests-all") +async def get_all_join_requests(session: dict = Depends(verify_session)): + """시스템 관리자용: 전체 가입 신청 조회""" + +# PATCH /api/dashboard/admin/join-requests/{request_id}/approve — 시스템 레벨 승인 +@router.patch("/api/dashboard/admin/join-requests/{request_id}/approve") +async def system_approve_join_request( + request_id: int, + session: dict = Depends(verify_session) +): + """시스템 관리자가 가입 신청 승인""" + +# GET /api/dashboard/admin/audit-logs — 시스템 로그 +@router.get("/api/dashboard/admin/audit-logs") +async def get_system_audit_logs( + page: int = Query(1), + limit: int = Query(50), + start_date: str = Query(None), + end_date: str = Query(None), + user_id: int = Query(None), + action: str = Query(None), + session: dict = Depends(verify_session) +): + """ + 시스템 감시 로그 조회 (필터링 지원) + 응답: {total, items: [{id, user_id, action, resource_type, timestamp}]} + """ + +# GET /api/dashboard/admin/resources — 하드웨어 리소스 현황 +@router.get("/api/dashboard/admin/resources") +async def get_system_resources( + period: str = Query('1h'), # 1h, 6h, 24h, 7d + session: dict = Depends(verify_session) +): + """ + 시스템 리소스 사용량 조회 (그래프용) + 응답: { + current: {cpu_usage_percent, memory_usage_percent, disk_usage_percent}, + history: [{timestamp, cpu_usage_percent, memory_usage_percent, ...}], + stats: {active_user_count, active_project_count, total_storage_mb} + } + """ +``` + +### 4.2 Schema 정의 (`B01_Dashboard/B01_Dashboard_Schema.py`) + +```python +from pydantic import BaseModel + +# 공통 +class UpdateUserRequest(BaseModel): + name: str + position: str | None + department: str | None + phone: str | None + +# USER 역할 +class CreateCompanyRequest(BaseModel): + name: str + business_registration_number: str + business_address: str + business_owner: str + +class JoinCompanyRequest(BaseModel): + company_id: int + +class AddMemberRequest(BaseModel): + email: str # 기가입 사용자의 이메일 + +class ProcessJoinRequest(BaseModel): + action: str # 'APPROVE' | 'REJECT' + reason: str | None = None + +# SYSTEM_ADMIN 역할 +class ChangeUserRoleRequest(BaseModel): + role: str # 'USER' | 'ADMIN' | 'SYSTEM_ADMIN' + +class AssignCompanyRequest(BaseModel): + company_id: int | None = None # None이면 회사 미연결 +``` + +### 4.3 Repository (`B01_Dashboard/B01_Dashboard_Repository.py`) + +Raw SQL을 이용한 데이터 조회/수정 함수들을 구현 + +--- + +## 5. 프론트엔드 구현 필요 + +### 5.1 B01_Dashboard_UI_Page.ts 전체 재설계 + +**구조:** +```typescript +function DashboardPage() { + const user = useAuth(); // role, status, company_id 포함 + + return ( +
+ {/* USER/ADMIN 공통: 프로젝트 리스트 */} + {(user.role === 'USER' || user.role === 'ADMIN') && ( + + )} + + {/* USER만: 회사 정보 */} + {user.role === 'USER' && ( + + )} + + {/* ADMIN만: 팀원 관리 */} + {user.role === 'ADMIN' && ( + <> + + + + + )} + + {/* SYSTEM_ADMIN만: 시스템 관리 */} + {user.role === 'SYSTEM_ADMIN' && ( + <> + + + + + + + + )} + + {/* 모든 역할: 기본정보 & 보안 */} + +
+ ); +} +``` + +### 5.2 B01_Dashboard_Api_Fetch.ts 신규 + +```typescript +// 공통 +export async function fetchDashboardMe(): Promise; +export async function updateUserProfile(request: UpdateUserRequest): Promise; + +// USER 역할 +export async function fetchUserProjects(): Promise; +export async function fetchUserCompany(): Promise; +export async function searchCompanies(query: string): Promise; +export async function createCompany(request: CreateCompanyRequest): Promise; +export async function joinCompany(companyId: number): Promise; + +// ADMIN 역할 +export async function fetchCompanyMembers(): Promise; +export async function addCompanyMember(email: string): Promise; +export async function removeCompanyMember(userId: number): Promise; +export async function fetchCompanyJoinRequests(): Promise; +export async function processJoinRequest(requestId: number, action: 'APPROVE' | 'REJECT'): Promise; +export async function fetchCompanyProjects(): Promise; + +// SYSTEM_ADMIN 역할 +export async function fetchAllCompanies(): Promise; +export async function fetchAllUsers(filters?: UserFilters): Promise; +export async function changeUserRole(userId: number, role: string): Promise; +export async function assignUserToCompany(userId: number, companyId: number | null): Promise; +export async function fetchAllJoinRequests(): Promise; +export async function systemApproveJoinRequest(requestId: number): Promise; +export async function fetchAuditLogs(filters?: AuditLogFilters): Promise; +export async function fetchSystemResources(period: string): Promise; +``` + +### 5.3 B01_Dashboard_UI_Style.css 확장 + +- 역할별 섹션 레이아웃 +- 프로젝트 리스트 테이블 스타일 +- 워크플로우 진행도 시각화 +- 팝업 모달 스타일 +- 그래프/차트 영역 스타일 + +### 5.4 섹션별 컴포넌트 + +#### ProjectListSection +- 프로젝트 테이블 (프로젝트명, 지역, 진행도, 워크플로우, 수정일) +- [+ 신규 프로젝트] 버튼 +- 워크플로우 단계 클릭 이벤트 처리 + +#### CompanyInfoSection (USER용) +``` +회사 상태: 테스트회사 (ACTIVE) | [회사 변경] + +상태별 표시: +- NO_COMPANY: "회사 미연결" + [회사 생성] [회사 찾기] +- PENDING: "가입 승인 대기 중 (OOO회사)" +- ACTIVE: "OOO회사 (ACTIVE)" + [회사 변경] +``` + +#### TeamMembersSection (ADMIN용) +- 팀원 리스트 테이블 (이메일, 이름, 직급, 부서, 역할, 상태) +- [+ 팀원 추가] 버튼 → 모달 팝업 +- [제거] 버튼 + +#### JoinRequestsSection (ADMIN용) +- 가입 신청 리스트 테이블 (신청자, 신청일, 상태) +- [승인] [거절] 버튼 + +#### ResourceStatusSection (SYSTEM_ADMIN용) +``` +CPU: 45% | MEM: 62% | DISK: 78% +활성 사용자: 23 | 활성 프로젝트: 15 +``` + +#### SystemAuditLogsSection (SYSTEM_ADMIN용) +- 시스템 로그 테이블 (사용자, 행동, 대상, 타임스탬프) +- 필터링: 기간, 사용자, 행동 유형 + +#### SystemResourcesGraphSection (SYSTEM_ADMIN용) +- CPU 사용량 그래프 (시간대별) +- 메모리 사용량 그래프 +- 디스크 사용량 그래프 +- 동시 접속자 수 추이 + +--- + +## 6. 검증 방법 (다음 단계) + +### 6.1 USER 역할 검증 + +1. 새 계정으로 로그인 → B01_Dashboard 진입 +2. 프로젝트 리스트 표시 확인 +3. 회사 상태: "회사 미연결" 표시 + [회사 생성] [회사 찾기] 버튼 확인 +4. [회사 생성] → 모달 팝업 → 회사 정보 입력 → [생성] +5. DB 확인: + - `users.company_id` 세팅 + - `users.role = 'ADMIN'` (관리자 전환) + - `users.is_master = TRUE` + - `users.status = 'ACTIVE'` +6. 페이지 새로고침 → 회사명 표시 확인 + +### 6.2 ADMIN 역할 검증 + +1. 회사 생성자(관리자)로 로그인 → B01_Dashboard 진입 +2. 팀원 관리, 가입 요청, 회사 프로젝트 섹션 표시 확인 +3. [+ 팀원 추가] → 모달 → 기가입 사용자 이메일 입력 → 검색 → 선택 → [추가] +4. DB 확인: + - `users.company_id` 업데이트 + - 중복 확인 (이미 팀원인 경우 오류 메시지) +5. 팀원 리스트에 새로운 팀원 표시 확인 + +### 6.3 SYSTEM_ADMIN 역할 검증 + +1. 시스템 관리자 계정으로 로그인 → B01_Dashboard 진입 +2. 전체 회사 리스트, 사용자 리스트, 시스템 로그, 리소스 현황 섹션 표시 확인 +3. 리소스 현황 (CPU, MEM, DISK, 활성 사용자/프로젝트) 표시 확인 +4. 사용자 역할 변경: USER → ADMIN 전환 → DB 확인 +5. 시스템 로그 필터링 (기간, 사용자, 행동 유형) 동작 확인 diff --git a/.agent/plan_register_split_validation.md b/.agent/plan_register_split_validation.md index f82c4f8..869baac 100644 --- a/.agent/plan_register_split_validation.md +++ b/.agent/plan_register_split_validation.md @@ -1,127 +1,400 @@ -# 회원가입 플로우 개편 검증 보고서 +# B01_Dashboard 구현 검증 계획 -## 구현 완료 항목 - -### 1. DB 변경 -- ✅ `db_management/003_register_split.sql` 생성 - - `users.status` ENUM에 `'NO_COMPANY'` 추가 - - 회원가입 시 회사 정보 NULL로 시작하도록 변경 - -### 2. 백엔드 변경 -- ✅ `A07_Register_Schema.py` 수정 - - `RegisterRequest` 필드: `email`, `password`, `password_confirm`, `name`, `position`, `phone`, `terms_agreed`, `privacy_agreed`, `marketing_agreed`, `terms_version` - - 회사 관련 필드 전부 제거 - - `password_confirm` 검증 추가 - -- ✅ `common_util_auth_repository.py` 수정 - - `create_registration()`: 회사/join_request 생성 로직 제거, users INSERT만 수행 - - `complete_registration()`: 무조건 `status = 'NO_COMPANY'`로 변경 - -- ✅ `A07_Register_Router.py` 수정 - - `request_registration()`: `password_confirm` 제외 처리 - -- ✅ `A06_Login_Router.py` 수정 - - `request_login()`: `status not in ("ACTIVE", "NO_COMPANY")` 체크로 변경 - - `current_session()`: 응답에 `company_id`, `is_master` 추가 - -- ✅ `common_util_auth.py` 수정 - - `require_company()` dependency 추가 - -- ✅ `main.py` 수정 - - B03~B06 라우터에 `require_company` 의존성 추가 - -### 3. 리소스 파일 -- ✅ `resources/legal/terms_of_service.txt` 생성 -- ✅ `resources/legal/privacy_policy.txt` 생성 -- ✅ `resources/legal/marketing_consent.txt` 생성 - -### 4. 프론트엔드 변경 -- ✅ `A07_Register_Api_Fetch.ts` 수정 - - `RegisterPayload` 필드 축소 - -- ✅ `A07_Register_UI_Auth_Page.ts` 전면 재작성 - - 필드: name, position, email, phone, password, passwordConfirm, terms/privacy/marketing (아코디언) - - 약관 텍스트 파일 로드 기능 - - OTP 입력 창 기존 로직 유지 - -- ✅ `A00_Common/router.ts` 수정 - - B02~B11 라우트: company_id 없으면 B01로 리다이렉트 - -- ✅ `ui_template_locale.ts` 수정 - - `A07_Register_Field_Position`, `A07_Register_Field_Phone`, `A07_Register_Field_PasswordConfirm` - - `A07_Register_Error_PasswordMismatch` 추가 - -- ✅ `A07_Register_UI_Style.css` 수정 - - `.a07-register__agreement` 아코디언 스타일 추가 - -- ✅ `A06_Login_Api_Fetch.ts` 수정 - - `SessionUser` 인터페이스에 `company_id`, `is_master` 추가 - -## 검증 방법 - -### 1차 검증: 가입 및 이메일 인증 -```bash -python main.py -``` -브라우저에서: -1. `/#/a07-register` 접속 -2. 필드 확인: name, position, email, phone, password, passwordConfirm, 약관(3개) -3. 약관 체크박스 옆 라벨 클릭 → `
` 펼쳐서 내용 표시 확인 -4. 모든 필드 입력 후 "회원가입" 버튼 클릭 -5. → 폼 하단에 OTP 입력 칸 노출 확인 -6. 콘솔 로그 또는 메일에서 OTP 확인하여 입력 -7. "이메일 인증 완료" 버튼 클릭 -8. → 로그인 페이지로 리다이렉트 확인 - -### 2차 검증: DB 상태 확인 -```sql -SELECT id, email, status, company_id, role, is_master FROM users WHERE email = 'test@example.com'; -``` -예상 결과: -- `status` = 'NO_COMPANY' -- `company_id` = NULL -- `role` = 'MEMBER' -- `is_master` = FALSE - -### 3차 검증: 로그인 및 상태 -1. 가입한 계정으로 로그인 시도 → 성공 확인 (기존에는 status != ACTIVE로 실패했을 부분) -2. 로그인 후 B02_PROJ_REGISTER (`/#/b02-proj-register`) 접속 시도 → B01_ACCOUNT로 리다이렉트 확인 - -### 4차 검증: 회사 없이 B02+ 접근 차단 -B01_ACCOUNT 페이지는 아직 구현되지 않았으므로, 라우팅 가드만 동작 확인: -- `/#/b03-file-input` 직접 접속 → B01로 리다이렉트되거나 403 응답 확인 (백엔드 require_company 에러) - -## 향후 구현 필요 항목 - -1. **B01_AccountDetail 백엔드 구현** (예정) - - `GET /api/account/me` — 현재 계정 상세 - - `PATCH /api/account/me` — 이름/직급/전화번호 수정 - - `POST /api/account/company/create` — 신규 회사 생성 - - `POST /api/account/company/join` — 기존 회사 참여 신청 - -2. **B01_AccountDetail 프론트엔드 구현** (예정) - - 계정 정보 표시/수정 폼 - - "회사 생성" 섹션 - - "회사 검색·연결" 섹션 - -3. **라우팅 가드 최종 테스트** (기준 마련 후) - - 회사 연결 후 B02~B11 접근 가능 확인 - -## 완료 상태 - -🟢 **계획서 기반 구현 완료 (1단계~3단계)** -- DB 마이그레이션 -- 백엔드 로직 수정 -- 프론트엔드 UI/라우팅 - -🟡 **B01_AccountDetail 백엔드/프론트엔드는 별도 태스크 (4단계)** - -🟢 **최종 검증 가능 (3차까지)** -- 회원가입, 이메일 인증, NO_COMPANY 상태 확인 -- 로그인 가능 (NO_COMPANY 상태) -- B02+ 접근 차단 동작 확인 +**상태:** 구현 전 설계 검증 단계 +**참고:** 완료된 검증은 `.agent/complete_and_old/plan_register_split_validation_COMPLETED.md` 참조 --- -**마지막 업데이트:** 2026-01-07 -**상태:** 1단계 구현 완료, B01_AccountDetail은 별도 작업 +## 📋 B01_Dashboard 구현 체크리스트 + +B01_Dashboard 페이지는 로그인 후 메인 랜딩 페이지로, 역할에 따라 다양한 섹션을 동적으로 표시합니다. + +### 현재 상태 + +- ✅ 기본 설계 완료 +- ❌ **백엔드 API 미구현** (역할별 엔드포인트) +- ❌ **프론트엔드 UI 미구현** (역할별 컴포넌트) +- ❌ **DB 스키마 업데이트 미실행** (사용자/회사/조직 테이블) + +### 구현 필요 항목 + +#### DB 스키마 업데이트 +``` +[ ] users 테이블 컬럼 추가 + [ ] role (ENUM: SYSTEM_ADMIN, ADMIN, USER) + [ ] is_master (BOOLEAN) + [ ] status (ENUM: PENDING_EMAIL, NO_COMPANY, PENDING, ACTIVE, INACTIVE, REJECTED) + [ ] last_login (DATETIME) + [ ] auth_expires_at (DATETIME) + +[ ] 신규 테이블 생성 + [ ] join_requests 테이블 + [ ] system_audit_logs 테이블 + [ ] system_resources 테이블 +``` + +#### 백엔드 (B01_Dashboard) +``` +[ ] B01_Dashboard_Router.py 신규 생성 + # 공통 API + [ ] GET /api/dashboard/me + [ ] PATCH /api/dashboard/me + + # USER 역할 API + [ ] GET /api/dashboard/user/projects + [ ] GET /api/dashboard/user/company + [ ] GET /api/dashboard/user/companies?q= + [ ] POST /api/dashboard/user/company/create + [ ] POST /api/dashboard/user/company/join + + # ADMIN 역할 API + [ ] GET /api/dashboard/admin/members + [ ] POST /api/dashboard/admin/members + [ ] DELETE /api/dashboard/admin/members/{user_id} + [ ] GET /api/dashboard/admin/join-requests + [ ] PATCH /api/dashboard/admin/join-requests/{request_id} + [ ] GET /api/dashboard/admin/projects + + # SYSTEM_ADMIN 역할 API + [ ] GET /api/dashboard/admin/companies + [ ] GET /api/dashboard/admin/users + [ ] PATCH /api/dashboard/admin/users/{user_id}/role + [ ] PATCH /api/dashboard/admin/users/{user_id}/company + [ ] GET /api/dashboard/admin/join-requests-all + [ ] PATCH /api/dashboard/admin/join-requests/{request_id}/approve + [ ] GET /api/dashboard/admin/audit-logs + [ ] GET /api/dashboard/admin/resources + +[ ] B01_Dashboard_Schema.py 신규 생성 + [ ] UpdateUserRequest + [ ] CreateCompanyRequest + [ ] JoinCompanyRequest + [ ] AddMemberRequest + [ ] ProcessJoinRequest + [ ] ChangeUserRoleRequest + [ ] AssignCompanyRequest + +[ ] B01_Dashboard_Repository.py 신규 생성 + [ ] 사용자 정보 조회/수정 SQL + [ ] 회사 조회/생성 SQL + [ ] 팀원 조회/추가/제거 SQL + [ ] 가입 신청 조회/승인/거절 SQL + [ ] 시스템 로그 조회 SQL + [ ] 리소스 모니터링 SQL +``` + +#### 프론트엔드 (B01_Dashboard) +``` +[ ] B01_Dashboard_UI_Page.ts 신규 생성 + [ ] 역할별 섹션 조건부 렌더링 + [ ] ProjectListSection (USER, ADMIN) + [ ] CompanyInfoSection (USER, ADMIN) + [ ] TeamMembersSection (ADMIN) + [ ] JoinRequestsSection (ADMIN) + [ ] ResourceStatusSection (SYSTEM_ADMIN) + [ ] CompanyManagementSection (SYSTEM_ADMIN) + [ ] UserManagementSection (SYSTEM_ADMIN) + [ ] JoinRequestsManagementSection (SYSTEM_ADMIN) + [ ] SystemAuditLogsSection (SYSTEM_ADMIN) + [ ] SystemResourcesGraphSection (SYSTEM_ADMIN) + [ ] ProfileSection (모든 역할) + +[ ] B01_Dashboard_Api_Fetch.ts 신규 생성 + [ ] fetchDashboardMe() + [ ] updateUserProfile() + [ ] fetchUserProjects() + [ ] fetchUserCompany() + [ ] searchCompanies() + [ ] createCompany() + [ ] joinCompany() + [ ] fetchCompanyMembers() + [ ] addCompanyMember() + [ ] removeCompanyMember() + [ ] fetchCompanyJoinRequests() + [ ] processJoinRequest() + [ ] fetchCompanyProjects() + [ ] fetchAllCompanies() + [ ] fetchAllUsers() + [ ] changeUserRole() + [ ] assignUserToCompany() + [ ] fetchAllJoinRequests() + [ ] systemApproveJoinRequest() + [ ] fetchAuditLogs() + [ ] fetchSystemResources() + +[ ] B01_Dashboard_UI_Style.css 신규 생성 + [ ] 역할별 섹션 레이아웃 + [ ] 프로젝트 리스트 테이블 스타일 + [ ] 워크플로우 진행도 시각화 + [ ] 팝업 모달 스타일 + [ ] 그래프/차트 영역 스타일 + +[ ] ui_template_locale.ts 확장 + [ ] 역할별 섹션 제목 및 버튼 문구 + [ ] 회사 관리, 팀원 관리, 가입 요청 관련 문구 + [ ] 시스템 로그, 리소스 모니터링 관련 문구 +``` + +--- + +## 기능 상세 검증 항목 + +### 프로젝트 리스트 워크플로우 표시 + +**UI 검증 포인트:** +- [ ] 각 프로젝트별 진행도 정상 계산 (B03~B09 단계 기준) +- [ ] 워크플로우 단계 버튼 활성화/비활성화 정상 작동 +- [ ] 완료된 단계 클릭 시 해당 페이지로 이동 +- [ ] 진행 중 단계 클릭 시 현재 진행 상태 표시 +- [ ] 미진행 단계 클릭 시 반응 없음 (비활성화 상태 유지) + +**데이터 검증 포인트:** +- [ ] projects.status 값이 올바르게 매핑되는지 확인 +- [ ] 프로젝트명, 지역, 수정일 정상 표시 +- [ ] [+ 신규 프로젝트] 버튼 클릭 시 B02_ProjRegister로 이동 + +### 회사 상태 섹션 (USER 역할) + +**상태별 UI 검증:** +- [ ] `status = 'NO_COMPANY'`: "회사 미연결" + [회사 생성] [회사 찾기] 버튼 +- [ ] `status = 'PENDING'`: "가입 승인 대기 중 (회사명)" 표시 +- [ ] `status = 'ACTIVE'`: "회사명 (ACTIVE)" + [회사 변경] 버튼 + +**회사 생성 팝업 검증:** +- [ ] [회사 생성] 클릭 시 모달 팝업 노출 +- [ ] 회사 정보 입력 (이름, 사업자등록번호, 주소, 대표자명) +- [ ] [생성] 클릭 시 API 호출 (로딩 오버레이 표시) +- [ ] 성공 메시지 표시 후 페이지 자동 새로고침 +- [ ] DB 확인: users.role = 'ADMIN', is_master = TRUE, status = 'ACTIVE' + +**회사 검색/신청 팝업 검증:** +- [ ] [회사 찾기] 클릭 시 모달 팝업 노출 +- [ ] 검색어 입력 → [검색] 클릭 +- [ ] 회사 리스트 표시 (회사명, 사업자번호) +- [ ] 회사 선택 후 [신청] 클릭 +- [ ] DB 확인: join_requests 생성, users.status = 'PENDING' + +### 팀원 관리 섹션 (ADMIN 역할) + +**팀원 추가 검증:** +- [ ] [+ 팀원 추가] 클릭 시 모달 팝업 노출 +- [ ] 기가입 사용자 이메일 입력 → [검색] +- [ ] 사용자 정보 표시 (이메일, 이름, 직급) +- [ ] [추가] 클릭 시 API 호출 +- [ ] DB 확인: users.company_id 업데이트 +- [ ] 중복 추가 시 "이미 팀원입니다" 오류 메시지 + +**팀원 리스트 표시:** +- [ ] 현재 회사의 모든 팀원 조회 (테이블) +- [ ] 이메일, 이름, 직급, 부서, 역할, 상태 정상 표시 +- [ ] [제거] 버튼 클릭 시 팀원 제거 + +### 가입 요청 섹션 (ADMIN 역할) + +**가입 요청 리스트 검증:** +- [ ] 이 회사에 신청한 사용자 리스트 표시 +- [ ] 신청자 이메일, 신청일, 상태 정상 표시 +- [ ] [승인] 클릭 시 join_requests.status = 'APPROVED' +- [ ] [거절] 클릭 시 join_requests.status = 'REJECTED' +- [ ] 사용자에게 결과 이메일 발송 + +### 시스템 관리 섹션 (SYSTEM_ADMIN 역할) + +**리소스 현황 검증:** +- [ ] CPU, 메모리, 디스크 사용률 실시간 표시 +- [ ] 활성 사용자 수, 활성 프로젝트 수 표시 + +**회사 관리 검증:** +- [ ] 전체 회사 리스트 조회 (테이블) +- [ ] 회사명, 사업자번호, 상태, 사용자 수 정상 표시 +- [ ] [+ 회사 추가] 버튼 (회사 신규 생성) + +**사용자 관리 검증:** +- [ ] 전체 사용자 리스트 조회 (테이블) +- [ ] 이메일, 이름, 역할, 회사, 상태 정상 표시 +- [ ] [역할 변경] 클릭 시 역할 선택 팝업 +- [ ] [회사 할당] 클릭 시 회사 선택 팝업 + +**시스템 로그 검증:** +- [ ] 전체 로그 조회 (테이블) +- [ ] 사용자, 행동(LOGIN, USER_CREATE 등), 대상, 타임스탬프 정상 표시 +- [ ] 기간, 사용자, 행동 유형별 필터링 정상 작동 + +**리소스 모니터링 그래프:** +- [ ] CPU 사용량 그래프 (시간대별) +- [ ] 메모리 사용량 그래프 +- [ ] 디스크 사용량 그래프 +- [ ] 동시 접속자 수 추이 그래프 + +### 인증 갱신 검증 + +**로그인 기반 인증 갱신:** +- [ ] 로그인 성공 시 users.last_login 업데이트 +- [ ] users.auth_expires_at = NOW() + 3개월 설정 +- [ ] 유효한 인증: auth_expires_at > NOW() → 바로 대시보드 이동 +- [ ] 만료된 인증: auth_expires_at <= NOW() → 재인증 모달 표시 + +--- + +## 통합 테스트 시나리오 (다음 단계) + +### 시나리오 1: 회사 생성 (USER → ADMIN 전환) + +**前提:** +```bash +python main.py +``` + +**테스트 단계:** +1. 새로 가입한 계정으로 로그인 → B01_Dashboard 진입 +2. 회사 상태: "회사 미연결" 표시 확인 +3. [회사 생성] 클릭 → 모달 팝업 노출 +4. 회사 정보 입력: + ``` + 회사명: "테스트회사" + 사업자등록번호: "123-45-67890" + 주소: "서울시 강남구" + 대표자명: "김테스트" + ``` +5. [생성] 클릭 → API 호출 +6. 성공 메시지 → 페이지 자동 새로고침 + +**검증:** +```sql +SELECT id, email, company_id, role, is_master, status +FROM users +WHERE email = 'test@example.com'; +``` + +**예상 결과:** +- `company_id` = 생성된 회사 ID +- `role` = 'ADMIN' +- `is_master` = TRUE +- `status` = 'ACTIVE' + +**라우팅 확인:** +- B02_ProjRegister 접속 가능 ✅ +- 팀원 관리, 가입 요청 섹션 표시 ✅ + +--- + +### 시나리오 2: 회사 참여 신청 (USER → PENDING → ACTIVE) + +**전제:** 계정1이 회사 생성 완료 (시나리오 1 수행) + +**테스트 단계:** +1. 새로 가입한 계정2로 로그인 → B01_Dashboard 진입 +2. 회사 상태: "회사 미연결" + [회사 찾기] 클릭 +3. 검색 팝업 → "테스트회사" 검색 +4. 회사 선택 → [신청] 클릭 → API 호출 + +**검증 (계정2):** +```sql +SELECT * FROM join_requests WHERE user_id = 2; +``` +- `status` = 'PENDING' + +```sql +SELECT status FROM users WHERE id = 2; +``` +- `status` = 'PENDING' + +5. 계정2로 B02_ProjRegister 접속 시도 → B01_Dashboard로 리다이렉트 (회사 승인 대기 알림) + +**관리자 승인 (계정1):** +1. 계정1로 로그인 → B01_Dashboard +2. "가입 요청" 섹션 → 계정2의 신청 표시 +3. [승인] 클릭 + +**검증 (계정2):** +```sql +SELECT status FROM users WHERE id = 2; +``` +- `status` = 'ACTIVE' +- `company_id` = 1 + +4. 계정2로 B02_ProjRegister 접속 가능 ✅ + +--- + +### 시나리오 3: 팀원 추가 (ADMIN만 접근 가능) + +**전제:** 계정1이 회사 생성 완료, 계정2와 계정3이 회원가입 완료 + +**테스트 단계:** +1. 계정1 (ADMIN)로 로그인 → B01_Dashboard +2. "팀원 관리" 섹션 → [+ 팀원 추가] +3. 계정2의 이메일 입력 → [검색] +4. 계정2 선택 → [추가] + +**검증:** +```sql +SELECT company_id FROM users WHERE id = 3; -- 계정2 +``` +- `company_id` = 1 (회사ID) + +5. 팀원 리스트에 계정2 표시 ✅ +6. 계정2로 로그인 → B02_ProjRegister 접속 가능 ✅ + +--- + +### 시나리오 4: 시스템 관리자 기능 (SYSTEM_ADMIN만 접근) + +**전제:** SYSTEM_ADMIN 계정 준비 (DB에서 직접 역할 설정) + +**테스트 단계:** +1. SYSTEM_ADMIN 계정으로 로그인 → B01_Dashboard +2. "리소스 현황" 섹션 → CPU/MEM/DISK 사용률 표시 확인 +3. "회사 관리" 섹션 → 전체 회사 리스트 조회 ✅ +4. "사용자 관리" 섹션 → 전체 사용자 리스트 조회 ✅ +5. "사용자 역할 변경" → 계정2를 ADMIN으로 전환 +6. "가입 요청 관리" → 전체 가입 신청 조회/승인 ✅ +7. "시스템 로그" → 로그인/계정 생성 이력 필터링 조회 ✅ +8. "리소스 모니터링" → 그래프 표시 ✅ + +--- + +## 최종 체크리스트 + +### 구현 단계 +``` +✅ DB 스키마 설계 완료 (사용자/회사/조직 5개 테이블) +✅ 백엔드 API 설계 완료 (역할별 엔드포인트) +✅ 프론트엔드 구조 설계 완료 (역할별 컴포넌트) + +⏳ DB 마이그레이션 (다음 AI 작업) +⏳ 백엔드 API 구현 (다음 AI 작업) +⏳ 프론트엔드 UI 구현 (다음 AI 작업) +``` + +### 검증 단계 +``` +[ ] 시나리오 1: 회사 생성 (USER → ADMIN 전환) +[ ] 시나리오 2: 회사 참여 신청 (USER → PENDING → ACTIVE) +[ ] 시나리오 3: 팀원 추가 (ADMIN) +[ ] 시나리오 4: 시스템 관리자 기능 (SYSTEM_ADMIN) +[ ] 인증 갱신 로직 검증 (로그인 기반 3개월 연장) +``` + +### 라우팅 가드 검증 +``` +[ ] NO_COMPANY 상태 → B02 접근 시 B01으로 리다이렉트 +[ ] PENDING 상태 → B02 접근 시 B01으로 리다이렉트 +[ ] ACTIVE 상태 → B02~B11 정상 접근 +[ ] 역할별 대시보드 섹션 정상 표시 +``` + +--- + +## 예상 완료 일정 + +- **계획/설계:** ✅ 완료 +- **DB 마이그레이션:** 다음 세션 (코더 AI 담당) +- **백엔드 API 구현:** 다음 세션 (코더 AI 담당) +- **프론트엔드 UI 구현:** 다음 세션 (코더 AI 담당) +- **통합 테스트 및 검증:** 다음 세션 (사용자 검증) + +--- + +**마지막 업데이트:** 2025-07-08 (설계 완료) +**다음 단계:** DB 마이그레이션 및 백엔드 API 구현 diff --git a/.agent/structure.md b/.agent/structure.md index 8775e5d..9de16f1 100644 --- a/.agent/structure.md +++ b/.agent/structure.md @@ -40,7 +40,11 @@ my-project/ │ └── 002_auth_security.sql # 기존 DB 로그인·보안 증분 마이그레이션 ├── resources/ # 웹앱 UI 구성 리소스 (사진, 회사 로고 등 에셋) │ ├── logo.png -│ └── banner.jpg +│ ├── banner.jpg +│ └── legal/ # 약관 및 정책 텍스트 (회원가입 UI에서 사용) +│ ├── terms_of_service.txt # 이용약관 +│ ├── privacy_policy.txt # 개인정보처리방침 +│ └── marketing_consent.txt # 마케팅 정보 수신 동의 ├── storage/ # 영구 저장소 (데이터 커서 및 대용량 파일 별도 저장 로직용) │ └── [고객사명]/ │ └── [사용자명]/ @@ -76,8 +80,7 @@ my-project/ │ ├── A05_EduDetail_UI_Page.ts │ └── A05_EduDetail_UI_Style.css ├── A06_Login/ # 로그인 전 06: 로그인 (이메일 + 비밀번호 폼) -│ ├── A06_Login_UI_Page.ts -│ ├── A06_Login_UI_Auth_Page.ts # 세션 쿠키·OTP 로그인 UI +│ ├── A06_Login_UI_Auth_Page.ts # 세션 쿠키·OTP 로그인 UI (현재 사용 중) │ ├── A06_Login_UI_Style.css │ ├── A06_Login_Api_Fetch.ts │ ├── A06_Login_Schema.py @@ -101,9 +104,13 @@ my-project/ │ └── A09_Security_Router.py # 마스터·시스템 관리자·활동 로그 API │ │ # ※ B01·B02 본문 완성 / B03~B11 헤더·셸만 (본문은 0_old 참고 후 구체화) -├── B01_AccountDetail/ # 로그인 후 01: 계정 상세 (기본정보 수정 + 비밀번호 변경) -│ ├── B01_AccountDetail_UI_Page.ts -│ └── B01_AccountDetail_UI_Style.css +├── B01_Dashboard/ # 로그인 후 01: 대시보드 (역할별 정보 표시) +│ ├── B01_Dashboard_UI_Page.ts # 역할별 섹션 조건부 렌더링 +│ ├── B01_Dashboard_UI_Style.css # 프로젝트 테이블, 워크플로우, 팝업 스타일 +│ ├── B01_Dashboard_Api_Fetch.ts # dashboard API 클라이언트 +│ ├── B01_Dashboard_Schema.py # 요청/응답 Pydantic 검증 +│ ├── B01_Dashboard_Repository.py # Raw SQL 저장소 (사용자, 회사, 팀원, 가입 신청) +│ └── B01_Dashboard_Router.py # 역할별 API 엔드포인트 ├── B02_ProjRegister/ # 로그인 후 02: 프로젝트 등록 (기본정보 입력 폼) │ ├── B02_ProjRegister_UI_Page.ts │ └── B02_ProjRegister_UI_Style.css diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 152f2d6..5983fc3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_pmf.py\" -v)", "Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_ransac.py\" -v)", "Bash(curl -s http://localhost:5175/ui_template/ui_template_theme.css)", - "Bash(npm run *)" + "Bash(npm run *)", + "PowerShell(Get-Command python)" ] } } diff --git a/.gitignore b/.gitignore index bf26230..cb2e493 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,11 @@ venv/ # Node / Frontend (루트 기반 구조) node_modules/ -config\node_modules +node_modules/ +config/node_modules/ dist/ *.tsbuildinfo - +instance/ # Storage (물리 산출물) storage/projects/*/processed/*.json storage/projects/*/processed/*.png @@ -27,4 +28,4 @@ storage/projects/*/exports/*.json 0_old/ # 신규 백업 -storage +storage/ diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 788635e..8d1267d 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -34,7 +34,7 @@ const routeTable: Partial Promise>> = { (await import("../A09_Security/A09_Security_UI_Page")).renderA09Security, // 로그인 후 (B 그룹) [ROUTES.B01_ACCOUNT]: async () => - (await import("../B01_AccountDetail/B01_AccountDetail_UI_Page")).renderB01AccountDetail, + (await import("../B01_Dashboard/B01_Dashboard_UI_Page")).renderB01Dashboard, [ROUTES.B02_PROJ_REGISTER]: async () => (await import("../B02_ProjRegister/B02_ProjRegister_UI_Page")).renderB02ProjRegister, [ROUTES.B03_FILE_INPUT]: async () => diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index 2d3327d..5eb1fb1 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -66,7 +66,7 @@ async def request_login(payload: LoginRequest, request: Request): subject, html = security_alert_email(email, failures) send_email_background(ADMIN_EMAIL, subject, html) raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.") - if user["status"] not in ("ACTIVE", "NO_COMPANY"): + if user["status"] not in ("ACTIVE", "NO_COMPANY", "PENDING"): await record_login(user["id"], email, "FAILURE", "ACCOUNT_INACTIVE", agent) raise HTTPException(status_code=403, detail="활성화되지 않은 계정입니다.") @@ -87,6 +87,17 @@ async def request_login(payload: LoginRequest, request: Request): async def _finish_login(user: dict, agent: str) -> JSONResponse: await clear_login_failures(user["id"]) await trust_browser(user["id"], hash_user_agent(agent)) + from config.config_db import get_db_pool + + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users SET last_login = CURRENT_TIMESTAMP, + auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + WHERE id = %s""", + (user["id"],), + ) + await connection.commit() session_id = await create_session(user["id"], agent) await record_login(user["id"], user["email"], "SUCCESS", None, agent) response = JSONResponse( diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts new file mode 100644 index 0000000..0fce432 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -0,0 +1,224 @@ +import { API_BASE_URL } from "@config/config_frontend"; + +export interface DashboardUser { + id: number; + email: string; + name: string; + position?: string | null; + department?: string | null; + phone?: string | null; + company_id?: number | null; + company_name?: string | null; + role: "SYSTEM_ADMIN" | "ADMIN" | "USER"; + is_master: boolean; + status: string; +} + +export interface ProjectItem { + id: string; + name: string; + region?: string | null; + status?: string | null; + owner_name?: string | null; + workflow_stage: number; + progress_percent: number; + updated_at?: string | null; +} + +export interface CompanyInfo { + id: number; + name: string; + business_registration_number?: string | null; + business_address?: string | null; + business_owner?: string | null; + business_status?: string | null; + user_count?: number; + project_count?: number; +} + +export interface Member { + id: number; + email: string; + name: string; + position?: string | null; + department?: string | null; + role: string; + status: string; +} + +export interface JoinRequest { + id: number; + user_id: number; + company_id: number; + user_email: string; + user_name?: string | null; + company_name?: string | null; + requested_at: string; + status: string; +} + +export interface AuditLog { + id: number; + user_id: number; + email?: string | null; + action: string; + resource_type?: string | null; + resource_id?: number | null; + timestamp: string; +} + +export interface ResourceData { + current: { + cpu_usage_percent: number | null; + memory_usage_percent: number | null; + disk_usage_percent: number | null; + active_user_count: number; + active_project_count: number; + total_storage_mb: number; + }; + history: unknown[]; + stats: ResourceData["current"]; +} + +export interface UpdateUserRequest { + name: string; + position?: string | null; + department?: string | null; + phone?: string | null; +} + +export interface CreateCompanyRequest { + name: string; + business_registration_number: string; + business_address?: string | null; + business_owner?: string | null; +} + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + ...init, + }); + const data = (await response.json()) as { detail?: string } & T; + if (!response.ok) throw new Error(data.detail ?? "Request failed"); + return data; +} + +const body = (value: object): string => JSON.stringify(value); + +export async function fetchDashboardMe(): Promise { + const data = await request<{ user: DashboardUser }>("/dashboard/me"); + return data.user; +} + +export async function updateUserProfile(payload: UpdateUserRequest): Promise { + const data = await request<{ user: DashboardUser }>("/dashboard/me", { + method: "PATCH", + body: body(payload), + }); + return data.user; +} + +export async function fetchUserProjects(): Promise { + const data = await request<{ projects: ProjectItem[] }>("/dashboard/user/projects"); + return data.projects; +} + +export async function fetchUserCompany(): Promise { + const data = await request<{ company: CompanyInfo | null }>("/dashboard/user/company"); + return data.company; +} + +export async function searchCompanies(query: string): Promise { + const data = await request<{ companies: CompanyInfo[] }>( + `/dashboard/user/companies?q=${encodeURIComponent(query)}`, + ); + return data.companies; +} + +export function createCompany(payload: CreateCompanyRequest): Promise { + return request("/dashboard/user/company/create", { method: "POST", body: body(payload) }); +} + +export function joinCompany(companyId: number): Promise { + return request("/dashboard/user/company/join", { + method: "POST", + body: body({ company_id: companyId }), + }); +} + +export async function fetchCompanyMembers(): Promise { + const data = await request<{ members: Member[] }>("/dashboard/admin/members"); + return data.members; +} + +export function addCompanyMember(email: string): Promise { + return request("/dashboard/admin/members", { method: "POST", body: body({ email }) }); +} + +export function removeCompanyMember(userId: number): Promise { + return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" }); +} + +export async function fetchCompanyJoinRequests(): Promise { + const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests"); + return data.requests; +} + +export function processJoinRequest( + requestId: number, + action: "APPROVE" | "REJECT", +): Promise { + return request(`/dashboard/admin/join-requests/${requestId}`, { + method: "PATCH", + body: body({ action }), + }); +} + +export async function fetchCompanyProjects(): Promise { + const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects"); + return data.projects; +} + +export async function fetchAllCompanies(): Promise { + const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies"); + return data.companies; +} + +export async function fetchAllUsers(): Promise { + const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users"); + return data.users; +} + +export function changeUserRole(userId: number, role: string): Promise { + return request(`/dashboard/admin/users/${userId}/role`, { + method: "PATCH", + body: body({ role }), + }); +} + +export function assignUserToCompany(userId: number, companyId: number | null): Promise { + return request(`/dashboard/admin/users/${userId}/company`, { + method: "PATCH", + body: body({ company_id: companyId }), + }); +} + +export async function fetchAllJoinRequests(): Promise { + const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests-all"); + return data.requests; +} + +export function systemApproveJoinRequest(requestId: number): Promise { + return request(`/dashboard/admin/join-requests/${requestId}/approve`, { method: "PATCH" }); +} + +export async function fetchAuditLogs(): Promise { + const data = await request<{ items: AuditLog[] }>("/dashboard/admin/audit-logs"); + return data.items; +} + +export async function fetchSystemResources(period = "24h"): Promise { + return request(`/dashboard/admin/resources?period=${encodeURIComponent(period)}`); +} diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py new file mode 100644 index 0000000..1186d11 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -0,0 +1,402 @@ +"""B01_Dashboard aiomysql Raw SQL 저장소.""" + +from __future__ import annotations + +import shutil +from datetime import datetime, timedelta +from typing import Any + +import aiomysql + +from config.config_db import get_db_pool + + +def _role(value: str | None) -> str: + return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER") + + +def _stage_from_status(status: str | None) -> tuple[int, int]: + order = [ + ("FILE_UPLOADED", 1), + ("WF1", 2), + ("WF2", 3), + ("WF3", 4), + ("WF4", 5), + ("WF5", 6), + ("WF6", 7), + ("DONE", 7), + ("CONFIRMED", 7), + ] + value = status or "NEW" + stage = 0 + for token, idx in order: + if token in value: + stage = max(stage, idx) + return stage, round(stage / 7 * 100) + + +def _project_row(row: dict[str, Any]) -> dict[str, Any]: + stage, progress = _stage_from_status(row.get("status")) + return {**row, "workflow_stage": stage, "progress_percent": progress} + + +async def get_dashboard_me(user_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT u.id, u.email, u.name, u.position, u.department, u.phone, + u.company_id, u.role, u.is_master, u.status, u.last_login, + u.auth_expires_at, c.name AS company_name + FROM users u LEFT JOIN companies c ON c.id = u.company_id + WHERE u.id = %s AND u.deleted_at IS NULL""", + (user_id,), + ) + row = await cursor.fetchone() + if row: + row["role"] = _role(row.get("role")) + row["is_master"] = bool(row.get("is_master")) + return row + + +async def update_user_profile(user_id: int, data: dict[str, Any]) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users SET name = %s, position = %s, department = %s, phone = %s + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data.get("position"), + data.get("department"), + data.get("phone"), + user_id, + ), + ) + await connection.commit() + return await get_dashboard_me(user_id) + + +async def list_user_projects(user_id: int) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, region, status, updated_at, created_at + FROM projects WHERE user_id = %s AND deleted_at IS NULL + ORDER BY updated_at DESC, created_at DESC""", + (user_id,), + ) + return [_project_row(row) for row in await cursor.fetchall()] + + +async def list_company_projects(company_id: int) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT p.id, p.name, p.region, p.status, p.updated_at, p.created_at, + u.name AS owner_name, u.email AS owner_email + FROM projects p LEFT JOIN users u ON u.id = p.user_id + WHERE p.company_id = %s AND p.deleted_at IS NULL + ORDER BY p.updated_at DESC, p.created_at DESC""", + (company_id,), + ) + return [_project_row(row) for row in await cursor.fetchall()] + + +async def get_user_company(company_id: int | None) -> dict[str, Any] | None: + if company_id is None: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_address, + c.business_owner, c.business_status, + COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.id = %s AND c.deleted_at IS NULL + GROUP BY c.id""", + (company_id,), + ) + return await cursor.fetchone() + + +async def search_companies(query: str) -> list[dict[str, Any]]: + pool = get_db_pool() + pattern = f"%{query}%" + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, business_registration_number, business_status + FROM companies + WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) + ORDER BY name LIMIT 20""", + (pattern, pattern), + ) + return list(await cursor.fetchall()) + + +async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, master_user_id, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + user_id, + user_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """UPDATE users + SET company_id = %s, role = 'ADMIN', is_master = TRUE, + status = 'ACTIVE', + auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + WHERE id = %s""", + (company_id, user_id), + ) + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (user_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def join_company(user_id: int, company_id: int) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO join_requests (user_id, company_id, status) + VALUES (%s, %s, 'PENDING') + ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, + reviewed_by = NULL, reviewed_at = NULL""", + (user_id, company_id), + ) + request_id = cursor.lastrowid + await cursor.execute( + "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) + ) + await connection.commit() + return {"join_request_id": request_id, "status": "PENDING"} + except Exception: + await connection.rollback() + raise + + +async def list_company_members(company_id: int) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, email, name, position, department, role, is_master, status + FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""", + (company_id,), + ) + rows = list(await cursor.fetchall()) + for row in rows: + row["role"] = _role(row.get("role")) + row["is_master"] = bool(row.get("is_master")) + return rows + + +async def add_company_member(company_id: int, email: str) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + await cursor.execute( + """SELECT id, company_id FROM users + WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", + (email.lower(),), + ) + user = await cursor.fetchone() + if not user or user["company_id"] == company_id: + await connection.rollback() + return None + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', + is_master = FALSE + WHERE id = %s""", + (company_id, user["id"]), + ) + await connection.commit() + return await get_dashboard_me(user["id"]) + + +async def remove_company_member(company_id: int, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER', + is_master = FALSE + WHERE id = %s AND company_id = %s AND is_master = FALSE""", + (user_id, company_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: + where = "WHERE jr.company_id = %s" if company_id else "" + params = (company_id,) if company_id else () + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, + u.email AS user_email, u.name AS user_name, c.name AS company_name + FROM join_requests jr + JOIN users u ON u.id = jr.user_id + JOIN companies c ON c.id = jr.company_id + {where} + ORDER BY jr.requested_at DESC LIMIT 100""", + params, + ) + return list(await cursor.fetchall()) + + +async def process_join_request( + request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None +) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + where = "id = %s AND status = 'PENDING'" + params: tuple[Any, ...] = (request_id,) + if company_id is not None: + where += " AND company_id = %s" + params = (request_id, company_id) + await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) + row = await cursor.fetchone() + if not row: + await connection.rollback() + return False + status = "APPROVED" if approved else "REJECTED" + await cursor.execute( + """UPDATE join_requests SET status = %s, reviewed_by = %s, + reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", + (status, reviewer_id, request_id), + ) + if approved: + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE' + WHERE id = %s""", + (row["company_id"], row["user_id"]), + ) + else: + await cursor.execute( + "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) + ) + await connection.commit() + return True + + +async def list_all_companies() -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_status, + c.created_at, COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" + ) + return list(await cursor.fetchall()) + + +async def list_all_users() -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT u.id, u.email, u.name, u.role, u.status, u.company_id, + c.name AS company_name, u.last_login + FROM users u LEFT JOIN companies c ON c.id = u.company_id + WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200""" + ) + rows = list(await cursor.fetchall()) + for row in rows: + row["role"] = _role(row.get("role")) + return rows + + +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)) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def assign_user_company(user_id: int, company_id: int | None) -> bool: + status = "ACTIVE" if company_id else "NO_COMPANY" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "UPDATE users SET company_id = %s, status = %s WHERE id = %s", + (company_id, status, user_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute("SELECT COUNT(*) AS total FROM system_audit_logs") + total = (await cursor.fetchone())["total"] + await cursor.execute( + """SELECT l.id, l.user_id, u.email, l.action, l.resource_type, + l.resource_id, l.timestamp + FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id + ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""", + (limit, offset), + ) + return {"total": total, "items": list(await cursor.fetchall())} + + +async def get_system_resources() -> dict[str, Any]: + total, used, _free = shutil.disk_usage(".") + disk_percent = used / total * 100 if total else 0 + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT + (SELECT COUNT(*) FROM sessions + WHERE expires_at > CURRENT_TIMESTAMP) AS active_user_count, + (SELECT COUNT(*) FROM projects WHERE deleted_at IS NULL) AS active_project_count""" + ) + stats = await cursor.fetchone() + current = { + "cpu_usage_percent": None, + "memory_usage_percent": None, + "disk_usage_percent": round(disk_percent, 2), + "active_user_count": stats["active_user_count"], + "active_project_count": stats["active_project_count"], + "total_storage_mb": round(used / 1024 / 1024, 2), + } + await cursor.execute( + """SELECT timestamp, cpu_usage_percent, memory_usage_percent, disk_usage_percent, + active_user_count, active_project_count, total_storage_mb + FROM system_resources + WHERE timestamp >= %s ORDER BY timestamp""", + (datetime.utcnow() - timedelta(hours=24),), + ) + return {"current": current, "history": list(await cursor.fetchall()), "stats": current} diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py new file mode 100644 index 0000000..d501cdb --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -0,0 +1,239 @@ +"""B01_Dashboard 역할별 대시보드 API.""" + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query + +from common_util.common_util_auth import require_system_admin, verify_session + +from .B01_Dashboard_Repository import ( + add_company_member, + assign_user_company, + change_user_role, + create_company, + get_dashboard_me, + get_system_resources, + get_user_company, + join_company, + list_all_companies, + list_all_users, + list_audit_logs, + list_company_members, + list_company_projects, + list_join_requests, + list_user_projects, + process_join_request, + remove_company_member, + search_companies, + update_user_profile, +) +from .B01_Dashboard_Schema import ( + AddMemberRequest, + AssignCompanyRequest, + ChangeUserRoleRequest, + CreateCompanyRequest, + JoinCompanyRequest, + ProcessJoinRequest, + UpdateUserRequest, +) + +router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"]) + + +async def require_company_admin( + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]: + raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.") + return session + + +def _require_company_id(session: dict[str, Any]) -> int: + company_id = session.get("company_id") + if company_id is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + return int(company_id) + + +@router.get("/me") +async def dashboard_me(session: dict[str, Any] = Depends(verify_session)): + user = await get_dashboard_me(int(session["user_id"])) + if not user: + raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.") + return {"status": "success", "user": user} + + +@router.patch("/me") +async def patch_dashboard_me( + payload: UpdateUserRequest, + session: dict[str, Any] = Depends(verify_session), +): + user = await update_user_profile(int(session["user_id"]), payload.model_dump()) + return {"status": "success", "user": user} + + +@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"]))} + + +@router.get("/user/company") +async def user_company(session: dict[str, Any] = Depends(verify_session)): + return {"status": "success", "company": await get_user_company(session.get("company_id"))} + + +@router.get("/user/companies") +async def user_company_search( + q: str = Query(min_length=1, max_length=100), + session: dict[str, Any] = Depends(verify_session), +): + _ = session + return {"status": "success", "companies": await search_companies(q.strip())} + + +@router.post("/user/company/create") +async def user_company_create( + payload: CreateCompanyRequest, + session: dict[str, Any] = Depends(verify_session), +): + result = await create_company(int(session["user_id"]), payload.model_dump()) + return {"status": "success", **result} + + +@router.post("/user/company/join") +async def user_company_join( + payload: JoinCompanyRequest, + session: dict[str, Any] = Depends(verify_session), +): + result = await join_company(int(session["user_id"]), payload.company_id) + return {"status": "success", **result} + + +@router.get("/admin/members") +async def admin_members(session: dict[str, Any] = Depends(require_company_admin)): + return { + "status": "success", + "members": await list_company_members(_require_company_id(session)), + } + + +@router.post("/admin/members") +async def admin_add_member( + payload: AddMemberRequest, + session: dict[str, Any] = Depends(require_company_admin), +): + member = await add_company_member(_require_company_id(session), payload.email) + if not member: + raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") + return {"status": "success", "member": member} + + +@router.delete("/admin/members/{user_id}") +async def admin_remove_member( + user_id: int, session: dict[str, Any] = Depends(require_company_admin) +): + if not await remove_company_member(_require_company_id(session), user_id): + raise HTTPException(status_code=404, detail="제거할 팀원을 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/admin/join-requests") +async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)): + return {"status": "success", "requests": await list_join_requests(_require_company_id(session))} + + +@router.patch("/admin/join-requests/{request_id}") +async def admin_process_join_request( + request_id: int, + payload: ProcessJoinRequest, + session: dict[str, Any] = Depends(require_company_admin), +): + changed = await process_join_request( + request_id, + int(session["user_id"]), + payload.action == "APPROVE", + _require_company_id(session), + ) + if not changed: + raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/admin/projects") +async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)): + return { + "status": "success", + "projects": await list_company_projects(_require_company_id(session)), + } + + +@router.get("/admin/companies") +async def system_companies(session: dict[str, Any] = Depends(require_system_admin)): + _ = session + return {"status": "success", "companies": await list_all_companies()} + + +@router.get("/admin/users") +async def system_users(session: dict[str, Any] = Depends(require_system_admin)): + _ = session + return {"status": "success", "users": await list_all_users()} + + +@router.patch("/admin/users/{user_id}/role") +async def system_change_role( + user_id: int, + payload: ChangeUserRoleRequest, + session: dict[str, Any] = Depends(require_system_admin), +): + _ = session + if not await change_user_role(user_id, payload.role): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.patch("/admin/users/{user_id}/company") +async def system_assign_company( + user_id: int, + payload: AssignCompanyRequest, + session: dict[str, Any] = Depends(require_system_admin), +): + _ = session + if not await assign_user_company(user_id, payload.company_id): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/admin/join-requests-all") +async def system_join_requests(session: dict[str, Any] = Depends(require_system_admin)): + _ = session + return {"status": "success", "requests": await list_join_requests()} + + +@router.patch("/admin/join-requests/{request_id}/approve") +async def system_approve_join_request( + request_id: int, + session: dict[str, Any] = Depends(require_system_admin), +): + if not await process_join_request(request_id, int(session["user_id"]), True): + raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/admin/audit-logs") +async def system_audit_logs( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + session: dict[str, Any] = Depends(require_system_admin), +): + _ = session + return {"status": "success", **await list_audit_logs(limit, (page - 1) * limit)} + + +@router.get("/admin/resources") +async def system_resources( + period: str = Query("24h"), + session: dict[str, Any] = Depends(require_system_admin), +): + _ = period + _ = session + return {"status": "success", **await get_system_resources()} diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py new file mode 100644 index 0000000..4779fa7 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -0,0 +1,38 @@ +"""B01_Dashboard 요청 스키마.""" + +from pydantic import BaseModel, Field + + +class UpdateUserRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + position: str | None = Field(default=None, max_length=100) + department: str | None = Field(default=None, max_length=100) + phone: str | None = Field(default=None, max_length=50) + + +class CreateCompanyRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + business_registration_number: str = Field(min_length=1, max_length=20) + business_address: str | None = Field(default=None, max_length=500) + business_owner: str | None = Field(default=None, max_length=100) + + +class JoinCompanyRequest(BaseModel): + company_id: int = Field(gt=0) + + +class AddMemberRequest(BaseModel): + email: str = Field(min_length=3, max_length=255) + + +class ProcessJoinRequest(BaseModel): + action: str = Field(pattern="^(APPROVE|REJECT)$") + reason: str | None = Field(default=None, max_length=500) + + +class ChangeUserRoleRequest(BaseModel): + role: str = Field(pattern="^(SYSTEM_ADMIN|ADMIN|USER)$") + + +class AssignCompanyRequest(BaseModel): + company_id: int | None = Field(default=None, gt=0) diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts new file mode 100644 index 0000000..b61b9ec --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -0,0 +1,612 @@ +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { isBlank } from "@util/common_util_validate"; +import { navigateTo } from "../A00_Common/router"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createCard, + createInputField, + createTag, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + addCompanyMember, + createCompany, + fetchAllCompanies, + fetchAllJoinRequests, + fetchAllUsers, + fetchAuditLogs, + fetchCompanyJoinRequests, + fetchCompanyMembers, + fetchCompanyProjects, + fetchDashboardMe, + fetchSystemResources, + fetchUserCompany, + fetchUserProjects, + joinCompany, + processJoinRequest, + removeCompanyMember, + searchCompanies, + updateUserProfile, + type AuditLog, + type CompanyInfo, + type DashboardUser, + type JoinRequest, + type Member, + type ProjectItem, + type ResourceData, +} from "./B01_Dashboard_Api_Fetch"; +import "./B01_Dashboard_UI_Style.css"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface DashboardState { + user: DashboardUser; + userProjects: ProjectItem[]; + companyProjects: ProjectItem[]; + company: CompanyInfo | null; + members: Member[]; + joinRequests: JoinRequest[]; + allCompanies: CompanyInfo[]; + allUsers: DashboardUser[]; + allJoinRequests: JoinRequest[]; + auditLogs: AuditLog[]; + resources: ResourceData | null; +} + +export async function renderB01Dashboard(root: HTMLElement): Promise { + showLoadingOverlay(); + try { + const user = await fetchDashboardMe(); + const state: DashboardState = { + user, + userProjects: [], + companyProjects: [], + company: null, + members: [], + joinRequests: [], + allCompanies: [], + allUsers: [], + allJoinRequests: [], + auditLogs: [], + resources: null, + }; + await loadRoleData(state); + root.append(buildPage(state)); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error"); + } finally { + hideLoadingOverlay(); + } +} + +async function loadRoleData(state: DashboardState): Promise { + if (state.user.role === "SYSTEM_ADMIN") { + const [companies, users, requests, logs, resources] = await Promise.all([ + fetchAllCompanies(), + fetchAllUsers(), + fetchAllJoinRequests(), + fetchAuditLogs(), + fetchSystemResources(), + ]); + state.allCompanies = companies; + state.allUsers = users; + state.allJoinRequests = requests; + state.auditLogs = logs; + state.resources = resources; + return; + } + const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]); + state.userProjects = projects; + state.company = company; + if (state.user.role === "ADMIN") { + const [members, requests, companyProjects] = await Promise.all([ + fetchCompanyMembers(), + fetchCompanyJoinRequests(), + fetchCompanyProjects(), + ]); + state.members = members; + state.joinRequests = requests; + state.companyProjects = companyProjects; + } +} + +function buildPage(state: DashboardState): HTMLElement { + const page = document.createElement("div"); + page.className = "b01-dashboard"; + page.append(buildHeader(state.user)); + + const grid = document.createElement("div"); + grid.className = "b01-dashboard__grid"; + + if (state.user.role === "SYSTEM_ADMIN") { + grid.append( + section(L("B01_Dashboard_Resources"), buildResourceMetrics(state.resources), true), + section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)), + section(L("B01_Dashboard_Users"), userTable(state.allUsers)), + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)), + section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), + ); + } else { + grid.append( + section( + L("B01_Dashboard_Projects"), + projectTable(state.user.role === "ADMIN" ? state.companyProjects : state.userProjects), + true, + [ + createButton({ + label: L("B01_Dashboard_NewProject"), + onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), + }), + ], + ), + section(L("B01_Dashboard_Company"), buildCompanyPanel(state)), + ); + if (state.user.role === "ADMIN") { + grid.append( + section(L("B01_Dashboard_Members"), memberTable(state.members), false, [ + createButton({ + label: L("B01_Dashboard_AddMember"), + variant: "ghost", + onClick: () => openAddMemberModal(), + }), + ]), + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false)), + ); + } + } + grid.append(section(L("B01_Dashboard_Profile"), buildProfileForm(state.user), true)); + page.append(grid); + return page; +} + +function buildHeader(user: DashboardUser): HTMLElement { + const header = document.createElement("header"); + header.className = "b01-dashboard__header"; + const text = document.createElement("div"); + const title = document.createElement("h1"); + title.className = "b01-dashboard__title"; + title.textContent = L("B01_Dashboard_Title"); + const subtitle = document.createElement("p"); + subtitle.className = "b01-dashboard__subtitle"; + subtitle.textContent = L("B01_Dashboard_Subtitle"); + text.append(title, subtitle); + const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral"); + tag.classList.add("b01-dashboard__role"); + header.append(text, tag); + return header; +} + +function roleLabel(role: DashboardUser["role"]): string { + if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin"); + if (role === "ADMIN") return L("B01_Dashboard_Role_Admin"); + return L("B01_Dashboard_Role_User"); +} + +function section( + title: string, + body: HTMLElement, + wide = false, + actions: HTMLElement[] = [], +): HTMLElement { + const actionRow = document.createElement("div"); + actionRow.className = "b01-dashboard__actions"; + actionRow.append(...actions); + const cardBody = actions.length ? [actionRow, body] : [body]; + const card = createCard({ title, body: cardBody, raised: true }); + card.classList.add("b01-dashboard__section"); + if (wide) card.classList.add("b01-dashboard__section--wide"); + return card; +} + +function table(headers: string[], rows: HTMLElement[][]): HTMLElement { + if (!rows.length) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__empty"; + empty.textContent = L("Common_Status_Empty"); + return empty; + } + const wrap = document.createElement("div"); + wrap.className = "b01-dashboard__table-wrap"; + const tableEl = document.createElement("table"); + tableEl.className = "b01-dashboard__table"; + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const header of headers) { + const th = document.createElement("th"); + th.textContent = header; + headRow.append(th); + } + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (const cell of row) { + const td = document.createElement("td"); + td.append(cell); + tr.append(td); + } + tbody.append(tr); + } + tableEl.append(thead, tbody); + wrap.append(tableEl); + return wrap; +} + +function text(value: unknown): HTMLElement { + const span = document.createElement("span"); + span.textContent = value == null || value === "" ? "-" : String(value); + return span; +} + +function projectTable(projects: ProjectItem[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Project"), + L("B01_Dashboard_Table_Region"), + L("B01_Dashboard_Table_Progress"), + L("B01_Dashboard_Table_Workflow"), + L("B01_Dashboard_Table_Updated"), + ], + projects.map((project) => [ + text(project.name), + text(project.region), + text(`${project.progress_percent}%`), + workflow(project.workflow_stage), + text(formatDate(project.updated_at)), + ]), + ); +} + +function workflow(activeStage: number): HTMLElement { + const routes: RoutePath[] = [ + ROUTES.B03_FILE_INPUT, + ROUTES.B04_WF1_SURFACE, + ROUTES.B05_WF2_ROUTE, + ROUTES.B06_WF3_PROFILE_CROSS, + ROUTES.B07_WF4_DESIGN_DETAIL, + ROUTES.B08_WF5_QUANTITY, + ROUTES.B09_WF6_ESTIMATION, + ]; + const box = document.createElement("div"); + box.className = "b01-dashboard__workflow"; + routes.forEach((route, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b01-dashboard__step"; + button.textContent = `B${String(index + 3).padStart(2, "0")}`; + const enabled = index + 1 <= Math.max(activeStage, 1); + button.disabled = !enabled; + if (enabled) { + button.classList.add("is-enabled"); + button.addEventListener("click", () => navigateTo(route)); + } + box.append(button); + }); + return box; +} + +function buildCompanyPanel(state: DashboardState): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-dashboard__actions"; + if (!state.company) { + wrap.append( + createTag(L("B01_Dashboard_NoCompany"), "warning"), + createButton({ + label: L("B01_Dashboard_CreateCompany"), + onClick: () => openCreateCompanyModal(), + }), + createButton({ + label: L("B01_Dashboard_FindCompany"), + variant: "ghost", + onClick: () => openFindCompanyModal(), + }), + ); + return wrap; + } + wrap.append( + createTag(`${state.company.name} (${state.user.status})`, "success"), + text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), + text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), + ); + return wrap; +} + +function memberTable(members: Member[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Name"), + L("B01_Dashboard_Table_Position"), + L("B01_Dashboard_Table_Department"), + L("B01_Dashboard_Table_Role"), + L("B01_Dashboard_Table_Action"), + ], + members.map((member) => [ + text(member.email), + text(member.name), + text(member.position), + text(member.department), + text(member.role), + createButton({ + label: L("B01_Dashboard_RemoveMember"), + variant: "ghost", + onClick: () => onB01_Member_Remove_Click(member.id), + }), + ]), + ); +} + +function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + ...(systemMode ? [L("B01_Dashboard_Table_Company")] : []), + L("B01_Dashboard_Table_Requested"), + L("B01_Dashboard_Table_Status"), + L("B01_Dashboard_Table_Action"), + ], + requests.map((request) => [ + text(request.user_email), + ...(systemMode ? [text(request.company_name)] : []), + text(formatDate(request.requested_at)), + text(request.status), + actionPair( + () => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode), + () => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode), + ), + ]), + ); +} + +function actionPair(approve: () => void, reject: () => void): HTMLElement { + const row = document.createElement("div"); + row.className = "b01-dashboard__actions"; + row.append( + createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }), + createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }), + ); + return row; +} + +function buildResourceMetrics(resources: ResourceData | null): HTMLElement { + const values = resources?.current; + const grid = document.createElement("div"); + grid.className = "b01-dashboard__metric-grid"; + const items = [ + [L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)], + [L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)], + [L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)], + [L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0], + [L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0], + ]; + for (const [label, value] of items) { + const box = document.createElement("div"); + box.className = "b01-dashboard__metric"; + const labelEl = document.createElement("span"); + labelEl.className = "b01-dashboard__metric-label"; + labelEl.textContent = String(label); + const valueEl = document.createElement("span"); + valueEl.className = "b01-dashboard__metric-value"; + valueEl.textContent = String(value); + box.append(labelEl, valueEl); + grid.append(box); + } + return grid; +} + +function companyTable(companies: CompanyInfo[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Company"), + L("B01_Dashboard_Field_BusinessNumber"), + L("B01_Dashboard_Table_Status"), + ], + companies.map((company) => [ + text(company.name), + text(company.business_registration_number), + text(company.business_status), + ]), + ); +} + +function userTable(users: DashboardUser[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Name"), + L("B01_Dashboard_Table_Role"), + L("B01_Dashboard_Table_Status"), + ], + users.map((user) => [text(user.email), text(user.name), text(user.role), text(user.status)]), + ); +} + +function auditLogTable(logs: AuditLog[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Action"), + L("B01_Dashboard_Table_Updated"), + ], + logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), + ); +} + +function buildProfileForm(user: DashboardUser): HTMLElement { + const name = createInputField({ + label: L("B01_Account_Field_Name"), + value: user.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: user.position ?? "", + }); + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: user.department ?? "", + }); + const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(name.root, position.root, department.root, phone.root); + const save = createButton({ + label: L("B01_Dashboard_SaveProfile"), + onClick: async function onB01_Profile_Save_Click() { + name.setError(); + if (isBlank(name.input.value)) { + name.setError(L("Common_Msg_RequiredField")); + return; + } + await runRequest(() => + updateUserProfile({ + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + }), + ); + }, + }); + const wrap = document.createElement("div"); + wrap.append(grid, save); + return wrap; +} + +function openCreateCompanyModal(): void { + const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true }); + const number = createInputField({ + label: L("B01_Dashboard_Field_BusinessNumber"), + required: true, + }); + const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); + const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); + openModal( + L("B01_Dashboard_Modal_CreateCompany"), + [name.root, number.root, address.root, owner.root], + async () => { + if (isBlank(name.input.value) || isBlank(number.input.value)) return; + await runRequest(() => + createCompany({ + name: name.input.value.trim(), + business_registration_number: number.input.value.trim(), + business_address: address.input.value.trim() || null, + business_owner: owner.input.value.trim() || null, + }), + ); + }, + ); +} + +function openFindCompanyModal(): void { + const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true }); + const results = document.createElement("div"); + results.className = "b01-dashboard__actions"; + const search = createButton({ + label: L("Common_Btn_Search"), + variant: "ghost", + onClick: async function onB01_Company_Search_Click() { + results.innerHTML = ""; + const companies = await searchCompanies(query.input.value.trim()); + for (const company of companies) { + results.append( + createButton({ + label: `${company.name} ${L("B01_Dashboard_JoinCompany")}`, + variant: "ghost", + onClick: () => runRequest(() => joinCompany(company.id)), + }), + ); + } + }, + }); + openModal( + L("B01_Dashboard_Modal_FindCompany"), + [query.root, search, results], + async () => undefined, + ); +} + +function openAddMemberModal(): void { + const email = createInputField({ + label: L("B01_Dashboard_Field_MemberEmail"), + type: "email", + required: true, + }); + openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => { + if (isBlank(email.input.value)) return; + await runRequest(() => addCompanyMember(email.input.value.trim())); + }); +} + +function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise): void { + const modal = document.createElement("div"); + modal.className = "b01-dashboard__modal"; + const panel = document.createElement("div"); + panel.className = "b01-dashboard__modal-panel"; + const heading = document.createElement("h3"); + heading.className = "b01-dashboard__modal-title"; + heading.textContent = title; + const actions = document.createElement("div"); + actions.className = "b01-dashboard__actions"; + actions.append( + createButton({ + label: L("Common_Btn_Cancel"), + variant: "ghost", + onClick: () => modal.remove(), + }), + createButton({ + label: L("Common_Btn_Confirm"), + onClick: async () => { + await onConfirm(); + modal.remove(); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }, + }), + ); + panel.append(heading, ...body, actions); + modal.append(panel); + document.body.append(modal); +} + +async function onB01_Member_Remove_Click(userId: number): Promise { + await runRequest(() => removeCompanyMember(userId)); + window.dispatchEvent(new HashChangeEvent("hashchange")); +} + +async function onB01_JoinRequest_Process_Click( + requestId: number, + action: "APPROVE" | "REJECT", + systemMode: boolean, +): Promise { + await runRequest(() => + systemMode && action === "APPROVE" + ? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId)) + : processJoinRequest(requestId, action), + ); + window.dispatchEvent(new HashChangeEvent("hashchange")); +} + +async function runRequest(action: () => Promise): Promise { + showLoadingOverlay(); + try { + await action(); + showToast(L("B01_Dashboard_Saved"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error"); + } finally { + hideLoadingOverlay(); + } +} + +function formatDate(value?: string | null): string { + return value ? value.slice(0, 10) : "-"; +} + +function percent(value?: number | null): string { + return value == null ? "-" : `${Math.round(value)}%`; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css new file mode 100644 index 0000000..fa7237c --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -0,0 +1,181 @@ +.b01-dashboard { + max-width: var(--page-max-width); + margin: 0 auto; + padding: var(--spacing-40) var(--spacing-24) var(--spacing-64); +} + +.b01-dashboard__header { + display: flex; + justify-content: space-between; + gap: var(--spacing-24); + align-items: flex-end; + margin-bottom: var(--spacing-32); +} + +.b01-dashboard__title { + font-size: var(--text-heading); +} + +.b01-dashboard__subtitle { + margin: var(--spacing-8) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-body); +} + +.b01-dashboard__role { + flex-shrink: 0; +} + +.b01-dashboard__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-24); +} + +.b01-dashboard__section { + min-width: 0; +} + +.b01-dashboard__section--wide { + grid-column: 1 / -1; +} + +.b01-dashboard__actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b01-dashboard__table-wrap { + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} + +.b01-dashboard__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.b01-dashboard__table th, +.b01-dashboard__table td { + padding: var(--spacing-16); + border-bottom: 1px solid var(--color-border); + text-align: left; + white-space: nowrap; +} + +.b01-dashboard__table th { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + background: var(--color-surface); +} + +.b01-dashboard__table tr:last-child td { + border-bottom: 0; +} + +.b01-dashboard__empty { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.b01-dashboard__workflow { + display: flex; + gap: var(--spacing-4); +} + +.b01-dashboard__step { + width: 28px; + height: 28px; + border: 1px solid var(--color-border); + border-radius: var(--radius-icons); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--text-caption); + cursor: default; +} + +.b01-dashboard__step.is-enabled { + background: var(--color-mist-violet); + color: var(--color-accent); + cursor: pointer; +} + +.b01-dashboard__metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-16); +} + +.b01-dashboard__metric { + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.b01-dashboard__metric-label { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b01-dashboard__metric-value { + display: block; + margin-top: var(--spacing-8); + color: var(--color-text); + font-size: var(--text-subheading); + font-family: var(--font-display); + line-height: 1; +} + +.b01-dashboard__form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-16); +} + +.b01-dashboard__form-grid .ui-field:first-child { + grid-column: 1 / -1; +} + +.b01-dashboard__modal { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-24); + background: rgba(38, 17, 74, 0.24); +} + +.b01-dashboard__modal-panel { + width: min(560px, 100%); + background: var(--color-surface-raised); + border-radius: var(--radius-cards); + box-shadow: var(--shadow-lg); + padding: var(--spacing-24); +} + +.b01-dashboard__modal-title { + margin-bottom: var(--spacing-16); +} + +@media (max-width: 860px) { + .b01-dashboard__header, + .b01-dashboard__grid { + display: block; + } + + .b01-dashboard__section { + margin-top: var(--spacing-24); + } + + .b01-dashboard__form-grid, + .b01-dashboard__metric-grid { + grid-template-columns: 1fr; + } +} diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 2355e5b..70c701b 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -92,7 +92,7 @@ async def verify_session(request: Request) -> dict[str, Any]: expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS # NO_COMPANY(이메일 인증 완료·회사 미연결)도 로그인 유지 대상. 회사 연결 강제는 # require_company 의존성에서 별도 처리한다. - if expired or row[10] not in ("ACTIVE", "NO_COMPANY"): + if expired or row[10] not in ("ACTIVE", "NO_COMPANY", "PENDING"): await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,)) await connection.commit() raise HTTPException(status_code=401, detail="세션이 만료되었습니다.") diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index d2e48e0..95c0925 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -35,7 +35,7 @@ async def create_registration(data: dict[str, Any], password_hash: str) -> int: await cursor.execute( """INSERT INTO users (email, password_hash, name, position, phone, role, is_master, status) - VALUES (%s, %s, %s, %s, %s, 'MEMBER', FALSE, 'PENDING_EMAIL')""", + VALUES (%s, %s, %s, %s, %s, 'USER', FALSE, 'PENDING_EMAIL')""", ( data["email"], password_hash, @@ -152,7 +152,8 @@ async def complete_registration(user_id: int, is_master: bool) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( - """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP + """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP, + auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) WHERE id = %s""", (user_id,), ) diff --git a/db_management/004_dashboard.sql b/db_management/004_dashboard.sql new file mode 100644 index 0000000..dbf2df1 --- /dev/null +++ b/db_management/004_dashboard.sql @@ -0,0 +1,57 @@ +-- ============================================================================= +-- DB 마이그레이션: B01_Dashboard 역할별 대시보드 +-- 파일: 004_dashboard.sql +-- ============================================================================= + +USE aislo_db; + +ALTER TABLE users + MODIFY role ENUM('SYSTEM_ADMIN', 'MASTER', 'MEMBER', 'ADMIN', 'USER') NOT NULL DEFAULT 'USER'; + +UPDATE users SET role = 'ADMIN' WHERE role = 'MASTER'; +UPDATE users SET role = 'USER' WHERE role = 'MEMBER'; + +ALTER TABLE users + MODIFY role ENUM('SYSTEM_ADMIN', 'ADMIN', 'USER') NOT NULL DEFAULT 'USER', + MODIFY status ENUM('PENDING_EMAIL', 'NO_COMPANY', 'PENDING', 'ACTIVE', 'INACTIVE', 'REJECTED') + NOT NULL DEFAULT 'PENDING_EMAIL', + ADD COLUMN IF NOT EXISTS department VARCHAR(100) NULL AFTER position, + ADD COLUMN IF NOT EXISTS last_login DATETIME NULL AFTER status, + ADD COLUMN IF NOT EXISTS auth_expires_at DATETIME NULL AFTER last_login; + +ALTER TABLE companies + ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER business_status; + +CREATE TABLE IF NOT EXISTS system_audit_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(50) NULL, + resource_id INT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50) NULL, + user_agent TEXT NULL, + INDEX idx_system_audit_logs_timestamp (timestamp), + INDEX idx_system_audit_logs_user_id (user_id), + INDEX idx_system_audit_logs_action (action), + CONSTRAINT fk_system_audit_logs_user_id FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS system_resources ( + id INT PRIMARY KEY AUTO_INCREMENT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + cpu_usage_percent FLOAT NULL, + memory_usage_percent FLOAT NULL, + disk_usage_percent FLOAT NULL, + active_user_count INT NOT NULL DEFAULT 0, + active_project_count INT NOT NULL DEFAULT 0, + total_storage_mb FLOAT NOT NULL DEFAULT 0, + INDEX idx_system_resources_timestamp (timestamp) +); + +ALTER TABLE join_requests + ADD COLUMN IF NOT EXISTS review_comment TEXT NULL AFTER reviewed_at; + +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); diff --git a/main.py b/main.py index ff19aaf..ed3aa34 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,7 @@ from A06_Login.A06_Login_Router import router as a06_login_router from A07_Register.A07_Register_Router import router as a07_register_router from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router +from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router @@ -233,6 +234,7 @@ app.include_router(a06_login_router) app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) +app.include_router(b01_dashboard_router) protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index b58a320..2cfe74a 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -416,6 +416,64 @@ export const ui_locales = { "비밀번호는 8자 이상이어야 합니다.", "Password must be at least 8 characters.", ], + B01_Dashboard_Title: ["AISLO 대시보드", "AISLO Dashboard"], + B01_Dashboard_Subtitle: [ + "역할과 회사 상태에 맞춰 프로젝트, 조직, 보안 정보를 관리합니다.", + "Manage projects, organization, and security by role and company status.", + ], + B01_Dashboard_Role_User: ["일반 사용자", "User"], + B01_Dashboard_Role_Admin: ["회사 관리자", "Company Admin"], + B01_Dashboard_Role_SystemAdmin: ["시스템 관리자", "System Admin"], + B01_Dashboard_Projects: ["프로젝트", "Projects"], + B01_Dashboard_NewProject: ["신규 프로젝트", "New project"], + B01_Dashboard_Company: ["회사 정보", "Company"], + B01_Dashboard_NoCompany: ["회사 미연결", "No company linked"], + B01_Dashboard_CreateCompany: ["회사 생성", "Create company"], + B01_Dashboard_FindCompany: ["회사 찾기", "Find company"], + B01_Dashboard_JoinCompany: ["가입 신청", "Request to join"], + B01_Dashboard_Members: ["팀원 관리", "Team members"], + B01_Dashboard_AddMember: ["팀원 추가", "Add member"], + B01_Dashboard_RemoveMember: ["제거", "Remove"], + B01_Dashboard_JoinRequests: ["가입 요청", "Join requests"], + B01_Dashboard_Approve: ["승인", "Approve"], + B01_Dashboard_Reject: ["거절", "Reject"], + B01_Dashboard_Resources: ["리소스 현황", "Resources"], + B01_Dashboard_Companies: ["회사 관리", "Companies"], + B01_Dashboard_Users: ["사용자 관리", "Users"], + B01_Dashboard_AuditLogs: ["시스템 로그", "Audit logs"], + B01_Dashboard_Profile: ["기본정보", "Profile"], + B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], + B01_Dashboard_Table_Project: ["프로젝트명", "Project"], + B01_Dashboard_Table_Region: ["지역", "Region"], + B01_Dashboard_Table_Progress: ["진행도", "Progress"], + B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"], + B01_Dashboard_Table_Updated: ["수정일", "Updated"], + B01_Dashboard_Table_Email: ["이메일", "Email"], + B01_Dashboard_Table_Name: ["이름", "Name"], + B01_Dashboard_Table_Position: ["직급", "Position"], + B01_Dashboard_Table_Department: ["부서", "Department"], + B01_Dashboard_Table_Role: ["역할", "Role"], + B01_Dashboard_Table_Status: ["상태", "Status"], + B01_Dashboard_Table_Company: ["회사명", "Company"], + B01_Dashboard_Table_Requested: ["신청일", "Requested"], + B01_Dashboard_Table_Action: ["관리", "Action"], + B01_Dashboard_Table_Owner: ["소유자", "Owner"], + B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"], + B01_Dashboard_Field_Address: ["주소", "Address"], + B01_Dashboard_Field_Owner: ["대표자명", "Owner"], + B01_Dashboard_Field_Search: ["검색어", "Search"], + B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"], + B01_Dashboard_Metric_Cpu: ["CPU", "CPU"], + B01_Dashboard_Metric_Memory: ["메모리", "Memory"], + B01_Dashboard_Metric_Disk: ["디스크", "Disk"], + B01_Dashboard_Metric_ActiveUsers: ["활성 사용자", "Active users"], + B01_Dashboard_Metric_ActiveProjects: ["활성 프로젝트", "Active projects"], + B01_Dashboard_Modal_CreateCompany: ["회사 생성", "Create company"], + B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"], + B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"], + B01_Dashboard_Saved: ["저장되었습니다.", "Saved."], + B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."], + B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."], /* --- B02_ProjRegister 프로젝트 등록 --- */ B02_Proj_Title: ["프로젝트 등록", "Register Project"],