From 68f85f3736fa58070afa4472809e8ce2a28ec112 Mon Sep 17 00:00:00 2001 From: gbpark Date: Sat, 25 Apr 2026 00:36:05 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=99=95=EC=9E=A5=20+=20=ED=85=8C?= =?UTF-8?q?=EB=84=8C=ED=8A=B8/=EB=B9=84=EB=B2=88=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EB=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼, ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지 - 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code) 동시 반환 - 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼): CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 + CompanyMgmtController + SuperAdminGuard - 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종 (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport - 프로비저닝 마법사: force_password_change 토글 반영 - 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED) 전역 리다이렉트 - 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 - CLAUDE.md | 323 ++++++ .../com/erp/controller/AuthController.java | 42 + .../erp/migration/StartupSchemaMigrator.java | 96 ++ .../erp/provisioning/AdminAccountCreator.java | 22 +- .../erp/provisioning/CompanyAdminService.java | 118 ++ .../provisioning/CompanyAuditLogService.java | 150 +++ .../provisioning/CompanyLifecycleService.java | 149 +++ .../provisioning/CompanyMembersService.java | 54 + .../provisioning/CompanyMgmtController.java | 227 ++++ .../CompanyProvisioningService.java | 25 +- .../provisioning/CompanyTemplatesService.java | 232 ++++ .../com/erp/provisioning/SuperAdminGuard.java | 40 + .../ForcePasswordChangeGuardFilter.java | 95 ++ .../com/erp/security/JwtTokenProvider.java | 14 + .../java/com/erp/security/SecurityConfig.java | 6 + .../TenantConsistencyGuardFilter.java | 130 +++ .../java/com/erp/service/AuthService.java | 102 +- .../java/com/erp/tenant/CompanyResolver.java | 41 +- .../erp/tenant/SubdomainResolverFilter.java | 19 +- .../src/main/resources/mapper/auth.xml | 9 + .../main/resources/mapper/provisioning.xml | 112 ++ .../src/main/resources/mapper/tenant.xml | 14 + db/migrations/RUN_082_MIGRATION.md | 70 ++ db/migrations/RUN_083_MIGRATION.md | 81 ++ db/migrations/RUN_084_MIGRATION.md | 64 ++ frontend/app/(auth)/change-password/page.tsx | 260 +++++ .../admin/sysMng/subdomainList/page.tsx | 120 +- frontend/app/(main)/layout.tsx | 11 +- frontend/app/(pop)/layout.tsx | 3 +- .../admin/provisioning/AuditLogDrawer.tsx | 229 ++++ .../provisioning/CompanyAccordionRow.tsx | 550 ++++++--- .../admin/provisioning/CompanyStatsStrip.tsx | 26 +- .../admin/provisioning/Sparkline.tsx | 2 +- .../admin/provisioning/StatusDot.tsx | 8 +- .../provisioning/modals/AdminInfoModal.tsx | 370 ++++++ .../provisioning/modals/DeactivateModal.tsx | 133 +++ .../modals/DeleteCompanyModal.tsx | 141 +++ .../admin/provisioning/modals/ModalShell.tsx | 186 +++ .../modals/RecopyTemplatesModal.tsx | 197 ++++ .../admin/provisioning/tabs/MembersTab.tsx | 138 +++ .../admin/provisioning/tabs/TemplatesTab.tsx | 219 ++++ .../admin/provisioning/wizard/Step4Run.tsx | 1 + .../admin/provisioning/wizard/Wizard.tsx | 33 +- .../admin/provisioning/wizard/fields.tsx | 4 +- frontend/components/auth/AuthGuard.tsx | 25 +- frontend/components/layout/TopNavBar.tsx | 6 +- frontend/hooks/useAuth.ts | 2 + frontend/lib/api/client.ts | 69 +- frontend/lib/api/provisioning.ts | 79 ++ frontend/lib/csvExport.ts | 43 + frontend/styles/v5-atomics.css | 17 +- frontend/styles/v5-layout.css | 93 +- .../mockup.html | 1011 +++++++++++++++++ scripts/start/README.md | 70 ++ scripts/start/invyone-start-docker-all.bat | 55 + .../start/invyone-start-docker-all.command | 48 + scripts/start/invyone-start-docker-all.sh | 41 + 58 files changed, 6110 insertions(+), 316 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyAdminService.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyAuditLogService.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyLifecycleService.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyMembersService.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyMgmtController.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/CompanyTemplatesService.java create mode 100644 backend-spring/src/main/java/com/erp/provisioning/SuperAdminGuard.java create mode 100644 backend-spring/src/main/java/com/erp/security/ForcePasswordChangeGuardFilter.java create mode 100644 backend-spring/src/main/java/com/erp/security/TenantConsistencyGuardFilter.java create mode 100644 db/migrations/RUN_082_MIGRATION.md create mode 100644 db/migrations/RUN_083_MIGRATION.md create mode 100644 db/migrations/RUN_084_MIGRATION.md create mode 100644 frontend/app/(auth)/change-password/page.tsx create mode 100644 frontend/components/admin/provisioning/AuditLogDrawer.tsx create mode 100644 frontend/components/admin/provisioning/modals/AdminInfoModal.tsx create mode 100644 frontend/components/admin/provisioning/modals/DeactivateModal.tsx create mode 100644 frontend/components/admin/provisioning/modals/DeleteCompanyModal.tsx create mode 100644 frontend/components/admin/provisioning/modals/ModalShell.tsx create mode 100644 frontend/components/admin/provisioning/modals/RecopyTemplatesModal.tsx create mode 100644 frontend/components/admin/provisioning/tabs/MembersTab.tsx create mode 100644 frontend/components/admin/provisioning/tabs/TemplatesTab.tsx create mode 100644 frontend/lib/csvExport.ts create mode 100644 notes/gbpark/2026-04-24-provisioning-step2-redesign/mockup.html create mode 100644 scripts/start/README.md create mode 100644 scripts/start/invyone-start-docker-all.bat create mode 100644 scripts/start/invyone-start-docker-all.command create mode 100644 scripts/start/invyone-start-docker-all.sh diff --git a/.gitignore b/.gitignore index 3aec5f07..819367b0 100644 --- a/.gitignore +++ b/.gitignore @@ -173,7 +173,6 @@ uploads/ *.hwpx # ===== 기타 ===== -claude.md # Agent Pipeline 로컬 파일 _local/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5f69d480 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,323 @@ +# INVYONE — Claude 작업 컨벤션 + +이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다. + +(개인용 셋업은 `CLAUDE.local.md` — git 추적 제외, syncthing 동기화) + +--- + +## 📝 분석 / 리포트 / 메모 MD 파일 저장 규칙 (★필수) + +Claude 가 코드 분석, 보안 감사, 리팩토링 검토, 설계 문서, 회의록 등 **새 MD 파일을 작성**할 때는 다음 위치에 저장합니다. + +### 저장 경로 + +``` +notes/{git-user-name}/{YYYY-MM-DD}-{slug}.md +``` + +- `notes/` — 프로젝트 루트의 메모/리포트 모음 폴더 (이 폴더로 통일) +- `{git-user-name}` — `git config user.name` 으로 자동 결정 (예: `gbpark`, `park`) +- `{YYYY-MM-DD}-{slug}.md` — 날짜 prefix + 짧은 제목 slug (kebab-case) + +**예시:** +``` +notes/gbpark/2026-04-08-auth-security-audit.md +notes/gbpark/2026-04-12-component-v2-migration-plan.md +notes/park/2026-04-15-docker-port-conflict-resolution.md +``` + +### 규칙 + +1. **사용자 폴더가 이미 있으면 그 안에 넣는다** — 없으면 `mkdir -p notes/{git-user}` 로 생성 +2. **파일명은 항상 날짜 + slug 조합** — 시간순 정렬되어 추적 용이 +3. **README 나 docs/ 와는 분리** — `README.md`, `docs/` 는 사용자/개발자용 공식 문서. `notes/` 는 작업 기록·분석·메모용 +4. **MD 외 다른 산출물 (스크립트, JSON 등) 도 같이 둘 수 있음** — 필요하면 `notes/{git-user}/{slug}/` 식 하위 폴더 사용 +5. **새 폴더/파일 작성 후엔 git add 권장** — syncthing 도 자동 동기화 (`notes/` 는 `.stignore-shared` 에 없음) + +### 어디에 안 넣는가 + +- `_local/`, `_backup/`, `_pipeline/` — syncthing ignore. 머신 로컬용 +- `docs/` — 공식 개발 문서. 작업 기록 아님 +- 프로젝트 루트 직접 (`./STATUS.md`, `./PLAN.MD` 등) — 이미 기존에 있는 것 외에 새로 만들지 말 것 + +--- + +## 컨벤션이 적용되는 시나리오 + +| 사용자 요청 | 저장 위치 | +|---|---| +| "이 코드 분석해서 md 로 정리해줘" | `notes/{git-user}/{date}-{topic}.md` | +| "보안 감사 리포트 만들어줘" | `notes/{git-user}/{date}-security-audit.md` | +| "리팩토링 플랜 md 로 뽑아줘" | `notes/{git-user}/{date}-refactor-plan.md` | +| "회의 노트 정리해줘" | `notes/{git-user}/{date}-meeting-notes.md` | +| "마이그레이션 가이드 작성" | `notes/{git-user}/{date}-migration-guide.md` | + +--- + +## Claude 사용 시 추가 주의사항 + +- **이 컨벤션은 사용자 명시 요청 없이도 자동 적용** — 사용자가 "md 만들어줘" 라고만 해도 위 경로에 저장 +- **현재 git user 확인이 필요하면** `git config user.name` 실행 +- **사용자 폴더가 처음이면** 만들면서 `.gitkeep` 정도만 두지 말고 바로 첫 노트 작성 + +--- + +## 🎨 공통 디자인 시스템 / CSS 참조 규칙 (★★★ 무조건 적용) + +UI 작업(컴포넌트 작성, HTML 목업, 새 페이지/화면, 디자인 리빌딩, 스타일 수정 등)을 할 때는 **반드시** 아래 공통 CSS 파일들을 먼저 읽고 그 안의 토큰/클래스 컨벤션을 100% 따라야 합니다. 절대 새 색상/간격/라운드/그림자 값을 즉흥으로 만들지 말 것. + +### "v5" 의 정체 (★ 헷갈리지 말 것) + +**INVION v5 = 디자인 시안 5번째 = 최종 채택본. 현재 컨셉은 "Solid + Glow" (2026-04-21 개정)** + +- 디자이너가 v1~v5 까지 5번 시안을 만들고 그 중 **v5 가 확정**되어 React 로 포팅됨 +- 시안 원본 HTML (참고용, 여기엔 아직 glassmorphism 이 남아있음): + - `frontend/invion-layout-v5.html` (973줄, 풀 레이아웃 셸) + - `frontend/invion-preview-v5.html` (1049줄, 미리보기/모션 데모) +- 폐기된 시안: `frontend/invion-preview-v1~v4.html` (참고만, 적용 금지) +- **현재 적용 컨셉 (2026-04-21 개정)**: + - **로그인 페이지**: 우주(별/성운/별똥별/입자) 배경 + 글래스 카드 **유지** + - **메인 화면 이후 전부**: **반투명/blur/cosmic 배경 폐기**. 불투명 솔리드 카드 + primary-color 글로우 + 보라(`#6c5ce7`)/시안(`#00cec9`)/핑크(`#fd79a8`) 액센트 +- v5 토큰이 옮겨진 곳: `frontend/styles/v5-layout.css`, `frontend/app/(auth)/login/login.css` + +⚠️ **POP 디자이너의 "v5 그리드 시스템"** (`PopRenderer.tsx`, `pop-layout.ts` 등) 은 **별개 의미** — POP 화면 데이터 포맷의 5번째 버전. UI 디자인 v5 와 무관. 혼동 금지. + +### 항상 먼저 읽어야 하는 파일 + +| 파일 | 역할 | +|---|---| +| `frontend/invion-layout-v5.html` | **v5 디자인 원본 (시안)** — 모든 v5 토큰/클래스의 진실의 원천. 포팅된 css 와 다르면 이게 정답 | +| `frontend/styles/v5-layout.css` | **INVION v5 React 포팅 메인** — `--v5-*` CSS 변수, 헤더/사이드바/탭/모달 등 모든 v5- 컴포넌트 클래스 정의. UI 작업 전 무조건 먼저 읽기 | +| `frontend/app/globals.css` | shadcn/Tailwind 토큰 (`--background`, `--primary`, `--foreground` 등 HSL), 다크모드 변수, 전역 reset | +| `frontend/app/(auth)/login/login.css` | **로그인 전용** 코스믹 배경(별/성운/입자) + 글래스 카드. 이 컨셉은 **로그인에만** 적용 — 메인 화면에 옮기지 말 것 | +| `frontend/components/layout/AppLayout.tsx` | v5 클래스가 실제로 어떻게 조립되는지 — 헤더/사이드바/탭/플라이아웃 사용 예 | + +### 필수 준수 사항 + +1. **디자인 토큰은 무조건 변수 사용** — `--v5-primary`, `--v5-cyan`, `--v5-surface-solid`, `--v5-glow-sm/md/lg`, `hsl(var(--primary))` 등. 즉흥 hex/rgb 금지 +2. **클래스명은 v5- 접두사 컨벤션 따르기** — 새 컴포넌트도 `.v5-card`, `.v5-btn`, `.v5-bdg` 처럼 같은 네이밍. shadcn 컴포넌트 사용 시 그대로 사용 +3. **반투명/블러 금지 (★2026-04-21 신규)** — 메인 화면 이후 전 영역에서 `backdrop-filter: blur(...)`, `var(--v5-glass)`, `var(--v5-glass-strong)` 사용 **금지**. 카드/모달/사이드바/헤더 배경은 `var(--v5-surface-solid)` (라이트 `#ffffff` / 다크 `#11102a`) 를 쓰고, 테두리는 `border-border` 또는 `var(--v5-border)`. 예외: `frontend/app/(auth)/login/` 과 `frontend/styles/builder-ide.css` 는 별도 스코프라 기존 유지 +4. **글로우는 유지** — 그림자는 검은 drop-shadow 대신 `var(--v5-glow-sm/md/lg)` (primary-color glow) 사용. 모달/강조 카드에 liberal 하게 사용 가능 +5. **다크/라이트 모드는 둘 다 동작** — `.dark` 변형 잊지 말 것. 다크에서 `--v5-surface-solid` 는 `#11102a`, 라이트는 `#ffffff`. 별/입자/별똥별/성운은 **로그인에만** 존재, 메인은 평범한 단색 배경 +6. **컴팩트 폰트 사이즈 유지** — v5 는 0.55~0.85rem 의 컴팩트 UI. 새로 만들 때도 같은 스케일 따를 것 +7. **새 UI 패턴은 v5-layout.css 에 합치는 것을 기본 방향으로** — 일회성 inline ` +
+ {/* header */} +
+ +
+
+ {title || (companyCode ? `감사 로그 · ${companyCode}` : "전체 감사 로그")} +
+
+ 최근 {rows.length} 건 · 전체 {data?.total ?? 0} +
+
+ +
+ + {/* body */} +
+ {isLoading && ( +
+ 조회 중... +
+ )} + {!isLoading && rows.length === 0 && ( +
+ 감사 로그가 없습니다. +
+ )} + {!isLoading && rows.length > 0 && ( +
+ {rows.map((r) => { + const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" }; + const ok = r.success !== false && r.success !== "false"; + return ( +
+
+ {ok ? ( + + ) : ( + + )} +
+
+
+ {meta.label} + {!companyCode && ( + + {r.company_code} + + )} + {r.target && ( + + {r.target} + + )} +
+ {r.details && Object.keys(r.details).length > 0 && ( +
+ {formatDetails(r.details)} +
+ )} + {r.error_message && ( +
+ {r.error_message} +
+ )} +
+
+
{formatTime(r.created_at)}
+
{r.actor_user_id || "—"}
+
+
+ ); + })} +
+ )} +
+
+ + ); + return createPortal(drawer, document.body); +} + +function formatDetails(details: any): string { + try { + const parts: string[] = []; + for (const [k, v] of Object.entries(details)) { + if (v === null || v === undefined || v === "") continue; + if (Array.isArray(v)) { + parts.push(`${k}=[${v.join(",")}]`); + } else if (typeof v === "object") { + parts.push(`${k}=${JSON.stringify(v)}`); + } else { + parts.push(`${k}=${v}`); + } + } + const joined = parts.join(" · "); + return joined.length > 200 ? joined.slice(0, 200) + "…" : joined; + } catch { + return String(details); + } +} + +function formatTime(v: any): string { + if (v == null) return "—"; + try { + let d: Date; + if (typeof v === "number") d = new Date(v); + else { + const s = String(v); + d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s); + } + if (isNaN(d.getTime())) return String(v); + return d.toISOString().replace("T", " ").slice(0, 19); + } catch { + return String(v); + } +} diff --git a/frontend/components/admin/provisioning/CompanyAccordionRow.tsx b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx index db0ca4ef..2c158e49 100644 --- a/frontend/components/admin/provisioning/CompanyAccordionRow.tsx +++ b/frontend/components/admin/provisioning/CompanyAccordionRow.tsx @@ -3,7 +3,6 @@ import { useLayoutEffect, useRef, useState } from "react"; import { ChevronRight, - ChevronDown, MoreHorizontal, Info, Users, @@ -13,16 +12,26 @@ import { KeyRound, Copy, PauseCircle, + PlayCircle, Trash2, - UserPlus, - RefreshCw, + FileText, } from "lucide-react"; import StatusDot from "./StatusDot"; +import MembersTab from "./tabs/MembersTab"; +import TemplatesTab from "./tabs/TemplatesTab"; +import AuditLogDrawer from "./AuditLogDrawer"; +import AdminInfoModal from "./modals/AdminInfoModal"; +import DeactivateModal from "./modals/DeactivateModal"; +import DeleteCompanyModal from "./modals/DeleteCompanyModal"; +import RecopyTemplatesModal from "./modals/RecopyTemplatesModal"; +import { useQuery } from "@tanstack/react-query"; +import { getInstalledGroups, getCompanyAuditLog } from "@/lib/api/provisioning"; const TABS = [ { key: "overview", label: "개요", Icon: Info, soon: false }, - { key: "members", label: "구성원", Icon: Users, soon: true }, - { key: "templates", label: "템플릿", Icon: Layers, soon: true }, + { key: "members", label: "구성원", Icon: Users, soon: false }, + { key: "templates", label: "템플릿", Icon: Layers, soon: false }, + { key: "audit", label: "감사 로그", Icon: FileText, soon: false }, { key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false }, ] as const; @@ -39,11 +48,17 @@ export default function CompanyAccordionRow({ open: boolean; onToggle: () => void; }) { - const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview"); + const [tab, setTab] = useState<"overview" | "members" | "templates" | "audit" | "danger">("overview"); const tabsWrapRef = useRef(null); const tabBtnRefs = useRef>({}); const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 }); + const [adminModal, setAdminModal] = useState(false); + const [recopyModal, setRecopyModal] = useState(false); + const [deactivateModal, setDeactivateModal] = useState(false); + const [deleteModal, setDeleteModal] = useState(false); + const [auditDrawer, setAuditDrawer] = useState(false); + useLayoutEffect(() => { if (!open) return; const el = tabBtnRefs.current[tab]; @@ -95,15 +110,15 @@ export default function CompanyAccordionRow({ background: "transparent", border: 0, cursor: "pointer", - padding: "0.6rem 2rem 0.6rem 2rem", + padding: "0.7rem 2rem", display: "grid", - gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px", - gap: "0.85rem", + gridTemplateColumns: "14px minmax(300px, 440px) minmax(170px, 1fr) 100px 120px 120px 110px 90px 18px", + gap: "0.9rem", alignItems: "center", }} >
- + {name}
) : ( -
+
)}
@@ -201,18 +216,18 @@ export default function CompanyAccordionRow({
생성
{formatRelative(r.created) || "—"}
사용자
{users} - +
-
+
30일 활성 {active30}
DB
-
+
{r.db_size || "—"}
@@ -260,7 +275,7 @@ export default function CompanyAccordionRow({ - +
- {l} + {l} {soon && ( @@ -365,11 +380,11 @@ export default function CompanyAccordionRow({
{tab === "overview" && ( -
+
{/* 기본정보 */}
기본 정보
-
+
{( [ ["회사 코드", r.company_code, true], @@ -383,12 +398,12 @@ export default function CompanyAccordionRow({ ] as const ).map(([l, v, mono], i) => (
- {l} + {l} @@ -422,9 +437,9 @@ export default function CompanyAccordionRow({ >
회사 사이트 열기 - } soon>관리자 계정 - } soon>템플릿 재복제 + } + onClick={(e) => { + e.stopPropagation(); + setAdminModal("view"); + }} + > + 관리자 계정 + + } + onClick={(e) => { + e.stopPropagation(); + setRecopyModal(true); + }} + > + 템플릿 재복제 +
)} {tab === "members" && ( - - 구성원 목록은 회사별 tenant DB 에서 실시간 조회로 표시 예정 (Phase 4). 현재 - 총 {users}명. - + )} {tab === "templates" && ( - - 이 회사에 설치된 템플릿 그룹 {r.templates || 0}개. 상세 보기는 Phase 4 에서. - + + )} + + {tab === "audit" && ( + setAuditDrawer(true)} /> )} {tab === "danger" && (
- {[ - { - t: "회사 비활성화", - d: "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능", - b: "비활성화", - c: "var(--v5-amber)", - Icon: PauseCircle, - }, - { - t: "관리자 비밀번호 재설정", - d: "무작위 비밀번호 재설정 · 1회 표시", - b: "재설정", - c: "var(--v5-primary)", - Icon: KeyRound, - }, - { - t: "회사 영구 삭제", - d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가", - b: "삭제 예약", - c: "var(--v5-red)", - Icon: Trash2, - }, - ].map((row, i) => { - const IconC = row.Icon; - return ( -
-
- -
-
-
{row.t}
-
{row.d}
-
- -
- ); - })} +
+ +
+
+
{row.t}
+
{row.d}
+
+ +
+ ); + }); + })()}
)}
+ + {/* ───── 모달 / 드로어 ───── */} + {adminModal && ( + setAdminModal(false)} + /> + )} + {recopyModal && ( + setRecopyModal(false)} + /> + )} + {deactivateModal && ( + setDeactivateModal(false)} + /> + )} + {deleteModal && ( + setDeleteModal(false)} + /> + )} + {auditDrawer && ( + setAuditDrawer(false)} + /> + )}
); } +/** 재복제 모달은 installed groups 가 필요하므로 wrapper 에서 fetch */ +function RecopyTemplatesModalWrapper({ + companyCode, + companyName, + dbName, + onClose, +}: { + companyCode: string; + companyName?: string; + dbName: string; + onClose: () => void; +}) { + const { data = [], isLoading } = useQuery({ + queryKey: ["installed-groups", companyCode], + queryFn: () => getInstalledGroups(companyCode), + staleTime: 30_000, + }); + if (isLoading) return null; + return ( + + ); +} + +const ACTION_META: Record = { + COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" }, + COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" }, + COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" }, + COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" }, + COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" }, + ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" }, + TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" }, +}; + +/** 감사 탭 내 인라인 미니 리스트 (최근 10건) + 드로어 열기 링크 */ +function CompanyAuditMini({ + companyCode, + open, + onOpenFull, +}: { + companyCode: string; + open: boolean; + onOpenFull: () => void; +}) { + const { data, isLoading } = useQuery({ + queryKey: ["company-audit-log", companyCode, "mini"], + queryFn: () => getCompanyAuditLog(companyCode, 1, 10), + enabled: open, + staleTime: 5_000, + }); + const rows: Record[] = data?.data || []; + return ( +
+
+
+ 최근 10건 · 전체 {data?.total ?? 0} +
+ +
+ {isLoading && ( +
조회 중...
+ )} + {!isLoading && rows.length === 0 && ( +
+ 기록이 없습니다. +
+ )} + {!isLoading && rows.length > 0 && ( +
+ {/* 컬럼 헤더 */} +
+ 액션 + 수행자 + 대상 + 시각 +
+ {rows.map((r) => { + const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" }; + const failed = r.success === false || r.success === "false"; + return ( +
+ + {meta.label} + + + {r.actor_user_id || "—"} + + + {r.target || "—"} + + + {formatDateTime(r.created_at)} + +
+ ); + })} +
+ )} +
+ ); +} + +function formatDateTime(v: any): string { + if (v == null) return "—"; + try { + let d: Date; + if (typeof v === "number") d = new Date(v); + else { + const s = String(v); + d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s); + } + if (isNaN(d.getTime())) return String(v); + return d.toISOString().replace("T", " ").slice(0, 19); + } catch { + return String(v); + } +} + const labelSm: React.CSSProperties = { - fontSize: "0.58rem", + fontSize: "0.65rem", color: "var(--v5-text-sec)", letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700, - marginBottom: 2, + marginBottom: 3, fontFamily: "var(--v5-font-mono)", }; const sectionTitle: React.CSSProperties = { - fontSize: "0.62rem", + fontSize: "0.72rem", color: "var(--v5-text-sec)", letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700, - marginBottom: "0.55rem", + marginBottom: "0.7rem", fontFamily: "var(--v5-font-mono)", }; @@ -608,11 +882,11 @@ function ABtn({ style={{ display: "inline-flex", alignItems: "center", - gap: "0.35rem", - height: 26, - padding: "0 0.6rem", + gap: "0.4rem", + height: 30, + padding: "0 0.7rem", borderRadius: 6, - fontSize: "0.64rem", + fontSize: "0.75rem", fontWeight: 600, border: "1px solid var(--v5-border)", background: "var(--v5-surface-solid)", @@ -628,9 +902,9 @@ function ABtn({ {soon && ( - {children} -
- ); -} - function formatDate(v: any): string { if (!v) return "—"; try { @@ -729,9 +981,9 @@ function PlanBadge({ plan }: { plan: string }) { style={{ display: "inline-flex", alignItems: "center", - fontSize: "0.6rem", + fontSize: "0.7rem", fontWeight: 700, - padding: "3px 8px", + padding: "4px 9px", borderRadius: 4, background: s.bg, color: s.color, diff --git a/frontend/components/admin/provisioning/CompanyStatsStrip.tsx b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx index 1f93c807..03d650ab 100644 --- a/frontend/components/admin/provisioning/CompanyStatsStrip.tsx +++ b/frontend/components/admin/provisioning/CompanyStatsStrip.tsx @@ -23,16 +23,16 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[ const pctDB = Math.round((dbGB / dbQuotaGB) * 100); const cardStyle: React.CSSProperties = { - padding: "0.75rem 0.85rem", + padding: "0.85rem 0.95rem", display: "grid", - gridTemplateRows: "16px 32px 6px 16px", - rowGap: 8, + gridTemplateRows: "18px 34px 6px 18px", + rowGap: 9, background: "var(--v5-surface-solid)", border: "1px solid var(--v5-border)", borderRadius: 10, }; const label: React.CSSProperties = { - fontSize: "0.68rem", + fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 700, display: "flex", @@ -41,7 +41,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[ }; const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 }; const bigNum: React.CSSProperties = { - fontSize: "1.75rem", + fontSize: "1.85rem", fontWeight: 800, color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)", @@ -49,7 +49,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[ letterSpacing: "-0.03em", lineHeight: 1, }; - const unit: React.CSSProperties = { fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500 }; + const unit: React.CSSProperties = { fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 500 }; const bar: React.CSSProperties = { height: 4, borderRadius: 2, @@ -58,7 +58,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[ alignSelf: "center", }; const sub: React.CSSProperties = { - fontSize: "0.64rem", + fontSize: "0.72rem", color: "var(--v5-text-sec)", fontWeight: 500, display: "flex", @@ -104,19 +104,19 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[
활성률
{pctActive} - % + % - + 기준 30일
@@ -136,7 +136,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record[ [ GB void; +}) { + const [stage, setStage] = useState(initialStage); + const [result, setResult] = useState<{ admin_user_id: string; new_password: string } | null>(null); + const [showPw, setShowPw] = useState(false); + const [pwCopied, setPwCopied] = useState(false); + const qc = useQueryClient(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["company-admin", companyCode], + queryFn: () => getCompanyAdmin(companyCode), + staleTime: 5_000, + enabled: stage === "view", + }); + + const mutation = useMutation({ + mutationFn: () => resetAdminPassword(companyCode), + onSuccess: (d) => { + if (d.error) return; + setResult({ admin_user_id: d.admin_user_id!, new_password: d.new_password! }); + setStage("reset-done"); + qc.invalidateQueries({ queryKey: ["company-admin", companyCode] }); + qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] }); + }, + }); + + const admin = data || {}; + + // ─── 헤더 ─── + const titleNode = (() => { + if (stage === "reset-warn" || stage === "reset-done") { + return ( + + 관리자 비밀번호 재설정 + + ); + } + return ( + + 관리자 계정 + + ); + })(); + + // ─── 본문 ─── + let body: React.ReactNode = null; + if (stage === "view") { + if (isLoading) { + body = 조회 중...; + } else if (!admin.found) { + body = 해당 회사에 COMPANY_ADMIN 계정을 찾을 수 없습니다.; + } else { + body = ( +
+ + + {admin.user_id} + + + + {admin.user_name || "—"} + + + {admin.status || "—"} + + + + + {admin.force_password_change ? "필요 (미완료)" : "완료됨"} + + + + {formatDate(admin.created_date)} +
+ ); + } + } + + if (stage === "reset-warn") { + body = ( + <> +
+ +
+ 기존 관리자 비밀번호가 즉시 무효화됩니다. 새 임시 비밀번호가 1회 표시되며, 첫 로그인 시 비밀번호 변경이 강제됩니다. +
+ 진행하기 전에 해당 회사 관리자에게 먼저 공지하세요. +
+
+ {mutation.isError && ( +
+ 오류: {(mutation.error as Error)?.message || "재설정 실패"} +
+ )} + + ); + } + + if (stage === "reset-done" && result) { + body = ( + <> +
+ 새 임시 비밀번호가 발급되었습니다. 이 창을 닫기 전에 반드시 복사하세요. 다시 표시되지 않습니다. +
+
+ + {result.admin_user_id} + +
+ + {showPw ? result.new_password : "•".repeat(result.new_password.length)} + + + +
+ + 비밀번호 변경을 강제로 요구합니다. +
+ {!pwCopied && ( +
+ ⚠ 비밀번호를 아직 복사하지 않았습니다. 창 닫기 전에 복사하세요. +
+ )} + + ); + } + + // ─── 푸터 ─── + let footer: React.ReactNode = null; + if (stage === "view") { + footer = ( + <> + 닫기 + } + onClick={() => setStage("reset-warn")} + disabled={!admin.found} + > + 비밀번호 재설정 + + + ); + } else if (stage === "reset-warn") { + footer = ( + <> + } + onClick={() => (initialStage === "view" ? setStage("view") : onClose())} + disabled={mutation.isPending} + > + {initialStage === "view" ? "뒤로" : "취소"} + + mutation.mutate()} + disabled={mutation.isPending} + icon={} + > + {mutation.isPending ? "재설정 중..." : "새 비밀번호 발급"} + + + ); + } else if (stage === "reset-done") { + footer = ( + <> + {initialStage === "view" && ( + { + setStage("view"); + setResult(null); + setShowPw(false); + setPwCopied(false); + refetch(); + }} + > + 계정 정보로 + + )} + 닫기 + + ); + } + + return ( + + {body} + + ); +} + +// ─── UI helpers ─── + +function Label({ children }: { children: React.ReactNode }) { + return {children}; +} +function Value({ children }: { children: React.ReactNode }) { + return {children}; +} +function ValueMono({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function PadCenter({ children }: { children: React.ReactNode }) { + return
{children}
; +} +function Warn({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Badge({ color, children }: { color: "green" | "amber" | "muted"; children: React.ReactNode }) { + const palette = { + green: { bg: "rgba(var(--v5-green-rgb),0.12)", fg: "rgb(var(--v5-green-rgb))" }, + amber: { bg: "rgba(var(--v5-amber-rgb),0.15)", fg: "var(--v5-amber)" }, + muted: { bg: "var(--v5-bg-subtle)", fg: "var(--v5-text-sec)" }, + }[color]; + return ( + {children} + ); +} + +function CopyInline({ text }: { text: any }) { + const [copied, setCopied] = useState(false); + if (!text) return null; + return ( + + ); +} + +function formatDate(v: any): string { + if (v == null) return "—"; + try { + let d: Date; + if (typeof v === "number") d = new Date(v); + else { + const s = String(v); + d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s); + } + if (isNaN(d.getTime())) return String(v); + return d.toISOString().replace("T", " ").slice(0, 19); + } catch { + return String(v); + } +} + +const iconBtnStyle: React.CSSProperties = { + background: "transparent", + border: "1px solid var(--v5-border)", + color: "var(--v5-text-sec)", + borderRadius: 4, + padding: "3px 6px", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", +}; + +const warnBoxStyle: React.CSSProperties = { + display: "flex", + gap: 10, + padding: "0.85rem 0.95rem", + background: "rgba(var(--v5-amber-rgb), 0.1)", + border: "1px solid rgba(var(--v5-amber-rgb), 0.35)", + borderRadius: 8, + marginBottom: "0.2rem", + alignItems: "flex-start", +}; diff --git a/frontend/components/admin/provisioning/modals/DeactivateModal.tsx b/frontend/components/admin/provisioning/modals/DeactivateModal.tsx new file mode 100644 index 00000000..04859722 --- /dev/null +++ b/frontend/components/admin/provisioning/modals/DeactivateModal.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { PauseCircle, PlayCircle, AlertTriangle } from "lucide-react"; +import { patchCompanyStatus } from "@/lib/api/provisioning"; +import ModalShell, { ModalBtn } from "./ModalShell"; + +/** + * 비활성화 / 재활성화 공용 모달. + * mode="deactivate": 사유 입력 필수 (감사 로그 용) + * mode="reactivate": 간단 확인 + */ +export default function DeactivateModal({ + companyCode, + companyName, + currentStatus, + onClose, +}: { + companyCode: string; + companyName?: string; + currentStatus: string; + onClose: () => void; +}) { + const mode: "deactivate" | "reactivate" = currentStatus === "suspended" ? "reactivate" : "deactivate"; + const [reason, setReason] = useState(""); + const qc = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => + patchCompanyStatus( + companyCode, + mode === "deactivate" ? "suspended" : "active", + mode === "deactivate" ? reason : undefined, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["companies-stats"] }); + qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] }); + onClose(); + }, + }); + + const isDeactivate = mode === "deactivate"; + + return ( + + {isDeactivate ? : } + {isDeactivate ? "회사 비활성화" : "회사 재활성화"} +
+ } + subtitle={companyName || companyCode} + onClose={onClose} + width={520} + footer={ + <> + 취소 + mutation.mutate()} + disabled={mutation.isPending || (isDeactivate && reason.trim().length < 3)} + > + {mutation.isPending + ? "처리 중..." + : isDeactivate + ? "비활성화 실행" + : "재활성화 실행"} + + + } + > + {isDeactivate ? ( + <> +
+ +
+ 비활성화 시 해당 회사 사용자의 로그인이 즉시 차단됩니다. DB 와 데이터는 보존되며, 언제든 재활성화 가능합니다. +
+
+ +