Files
invyone/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx
T
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:15 +09:00

583 lines
20 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { getCompaniesStats } from "@/lib/api/provisioning";
import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip";
import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow";
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10;
/**
* SUPER_ADMIN — 회사 관리 (테넌트 프로비저닝 + 서브도메인 라우팅).
* 경로: /admin/sysMng/subdomainList
*
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
*
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
*/
export default function SubdomainListPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [q, setQ] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
const [planFilter, setPlanFilter] = useState("all");
const [wizardOpen, setWizardOpen] = useState(false);
const [auditOpen, setAuditOpen] = useState(false);
const [page, setPage] = useState(1);
function exportCsv() {
const cols = [
{ key: "company_code", label: "회사코드" },
{ key: "company_name", label: "회사명" },
{ key: "subdomain", label: "서브도메인" },
{ key: "db_name", label: "DB 이름" },
{ key: "db_status", label: "DB 상태" },
{ key: "plan", label: "플랜" },
{ key: "owner", label: "대표자" },
{ key: "email", label: "이메일" },
{ key: "users", label: "사용자 수" },
{ key: "db_size", label: "DB 사용량" },
{ key: "templates", label: "설치 템플릿" },
{ key: "created", label: "생성일", format: (v: any) => (v ? String(v).slice(0, 19) : "") },
];
const csv = toCsvString(filtered, cols as any);
const ts = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
downloadCsv(`invyone-companies-${ts}.csv`, csv);
}
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
const hasProvisioning = Array.isArray(query.state.data)
? query.state.data.some((r: any) => r.db_status === "provisioning")
: false;
return hasProvisioning ? 3_000 : 30_000;
},
staleTime: 2_000,
});
const filtered = useMemo(
() =>
rows
.filter((r) => filter === "all" || (r.db_status || r.status) === filter)
.filter((r) => planFilter === "all" || (r.plan || "Starter") === planFilter)
.filter((r) => {
if (!q) return true;
const needle = q.toLowerCase();
return (
(r.company_name || "").toLowerCase().includes(needle) ||
(r.subdomain || "").toLowerCase().includes(needle) ||
(r.company_code || "").toLowerCase().includes(needle)
);
}),
[rows, filter, planFilter, q],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * PAGE_SIZE;
const paged = filtered.slice(pageStart, pageStart + PAGE_SIZE);
useEffect(() => {
setPage(1);
}, [q, filter, planFilter]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const activeCount = rows.filter((r) => r.db_status === "active").length;
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return (
<div
style={{
padding: "1.1rem",
height: "100%",
display: "flex",
flexDirection: "column",
background: "var(--v5-bg)",
color: "var(--v5-text)",
overflow: "hidden",
}}
>
<style>{`
@keyframes pulsedot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; transform: scale(0.88); } }
@keyframes provPageFadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes provPageFade {
from { opacity: 0; }
to { opacity: 1; }
}
[data-accrow]:last-child { border-bottom: 0 !important; }
[data-prov-enter] {
animation: provPageFadeUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
[data-prov-enter="1"] { animation-delay: 0ms; }
[data-prov-enter="2"] { animation-delay: 70ms; }
[data-prov-enter="3"] { animation-delay: 140ms; }
[data-prov-enter="4"] { animation-delay: 210ms; }
[data-prov-enter="5"] { animation-delay: 280ms; }
[data-prov-row] {
transition: background 0.18s ease, box-shadow 0.2s ease, transform 0.2s ease;
animation: provPageFade 0.4s ease-out both;
}
[data-prov-row]:hover {
background: var(--v5-surface-hover) !important;
}
.prov-hbtn {
transition: transform 0.18s cubic-bezier(0.4,0,0.2,1),
box-shadow 0.18s ease,
background 0.18s ease,
border-color 0.18s ease;
}
.prov-hbtn:not(:disabled):hover { transform: translateY(-1px); }
.prov-hbtn:not(:disabled):active { transform: translateY(0) scale(0.97); }
.prov-hbtn-primary:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-primary-rgb), 0.28) !important;
}
.prov-hbtn-secondary:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn {
transition: all 0.15s ease;
}
.prov-pagebtn:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn-active {
box-shadow: 0 0 5px rgba(var(--v5-primary-rgb), 0.2);
}
.prov-input:focus {
border-color: var(--v5-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.15) !important;
}
`}</style>
{/* 페이지 헤더 */}
<div
data-prov-enter="1"
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
marginBottom: "0.85rem",
gap: "1rem",
}}
>
<div>
<div style={{ fontSize: "0.78rem", color: "var(--v5-text-sec)", fontWeight: 500, marginBottom: 4 }}>
<b style={{ color: "var(--v5-text)" }}></b> · · ·
</div>
<div style={{ fontSize: "1.375rem", fontWeight: 800, letterSpacing: "-0.02em", color: "var(--v5-text)" }}>
</div>
<div
style={{
fontSize: "0.8125rem",
color: "var(--v5-text-sec)",
marginTop: "0.3rem",
fontWeight: 500,
}}
>
Invy.one ·
</div>
</div>
<div style={{ display: "flex", gap: "0.45rem" }}>
<HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={13} strokeWidth={1.75} />}>
</HeaderBtn>
<HeaderBtn
icon={<FileText size={13} strokeWidth={1.75} />}
onClick={() => setAuditOpen(true)}
>
</HeaderBtn>
<HeaderBtn
icon={<Download size={13} strokeWidth={1.75} />}
onClick={exportCsv}
>
CSV
</HeaderBtn>
<HeaderBtn
variant="primary"
icon={<Plus size={13} strokeWidth={1.75} />}
onClick={() => setWizardOpen(true)}
>
</HeaderBtn>
</div>
</div>
{/* stats strip */}
<div data-prov-enter="2">
<CompanyStatsStrip rows={rows} />
</div>
{/* toolbar */}
<div
data-prov-enter="3"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
padding: "0.55rem 2rem",
borderTop: "1px solid var(--v5-border)",
borderBottom: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
gap: "0.45rem",
alignItems: "center",
}}
>
<div style={{ position: "relative", flex: "0 0 280px" }}>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="회사명 · subdomain · company_code 검색"
className="prov-input"
style={{
width: "100%",
padding: "0.45rem 0.5rem 0.45rem 1.85rem",
background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)",
borderRadius: 0,
color: "var(--v5-text)",
fontSize: "0.8125rem",
fontFamily: "var(--v5-font-mono)",
outline: "none",
transition: "all 0.18s ease",
}}
/>
<span
style={{
position: "absolute",
left: 9,
top: "50%",
transform: "translateY(-50%)",
color: "var(--v5-text-muted)",
}}
>
<Search size={13} strokeWidth={1.75} />
</span>
</div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
style={selectStyle}
>
<option value="all"> </option>
<option value="active"></option>
<option value="provisioning"> </option>
<option value="failed"></option>
<option value="inactive"></option>
</select>
<select value={planFilter} onChange={(e) => setPlanFilter(e.target.value)} style={selectStyle}>
<option value="all"> </option>
<option value="Enterprise">Enterprise</option>
<option value="Standard">Standard</option>
<option value="Starter">Starter</option>
</select>
<div style={{ flex: 1 }} />
<div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontWeight: 500 }}>
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>{activeCount}</b>
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
<b style={{ color: "var(--v5-primary)" }}>{provisCount}</b>
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
<b style={{ color: "var(--v5-text)" }}>{inactCount}</b>
</div>
</div>
{/* list header */}
<div
data-prov-enter="4"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
display: "grid",
gridTemplateColumns: "14px minmax(300px, 440px) minmax(170px, 1fr) 100px 120px 120px 110px 90px 18px",
gap: "0.9rem",
padding: "0.75rem 2rem",
fontSize: "0.75rem",
color: "var(--v5-text)",
letterSpacing: "0.02em",
fontWeight: 700,
background: "var(--v5-surface-hover)",
borderBottom: "1px solid var(--v5-border)",
}}
>
<span />
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>DB </span>
<span></span>
<span />
</div>
{/* rows (scrollable when long) */}
<div
data-prov-enter="5"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
flex: 1,
minHeight: 0,
overflowY: "auto",
borderBottom: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
}}
>
{isLoading && (
<div
style={{
padding: "2.2rem",
textAlign: "center",
color: "var(--v5-text-sec)",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
...
</div>
)}
{!isLoading &&
paged.map((r) => (
<CompanyAccordionRow
key={r.company_code}
r={r}
open={r.company_code === openKey}
onToggle={() => setOpenKey(r.company_code === openKey ? null : r.company_code)}
/>
))}
{!isLoading && filtered.length === 0 && (
<div
style={{
padding: "2.2rem",
textAlign: "center",
color: "var(--v5-text-sec)",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
.
</div>
)}
</div>
{/* footer + pagination */}
<div
style={{
marginTop: "0.7rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.75rem",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
flexShrink: 0,
fontWeight: 500,
}}
>
<span>
{filtered.length === 0 ? 0 : pageStart + 1}-{pageStart + paged.length} / {filtered.length} rows
{filtered.length !== rows.length && (
<span style={{ opacity: 0.6 }}> · {rows.length}</span>
)}
</span>
<Pagination page={safePage} totalPages={totalPages} onChange={setPage} />
<span>
last sync ·{" "}
{new Date(dataUpdatedAt).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
{wizardOpen && <Wizard onClose={() => setWizardOpen(false)} />}
{auditOpen && <AuditLogDrawer onClose={() => setAuditOpen(false)} />}
</div>
);
}
function HeaderBtn({
children,
icon,
variant = "secondary",
onClick,
soon,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
variant?: "primary" | "secondary";
onClick?: () => void;
soon?: boolean;
}) {
const isPrimary = variant === "primary";
return (
<button
onClick={soon ? undefined : onClick}
disabled={soon}
title={soon ? "준비 중인 기능입니다" : undefined}
className={soon ? undefined : `prov-hbtn prov-hbtn-${isPrimary ? "primary" : "secondary"}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.45rem",
height: 34,
padding: "0 0.85rem",
borderRadius: 6,
fontSize: "0.8125rem",
fontWeight: 600,
border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: isPrimary ? "#fff" : "var(--v5-text)",
boxShadow: isPrimary && !soon ? "0 0 10px rgba(var(--v5-primary-rgb), 0.14)" : "none",
cursor: soon ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
opacity: soon ? 0.55 : 1,
}}
>
{icon}
{children}
{soon && (
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
padding: "2px 6px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
);
}
function Pagination({
page,
totalPages,
onChange,
}: {
page: number;
totalPages: number;
onChange: (p: number) => void;
}) {
if (totalPages <= 1) return <span />;
const windowSize = 5;
let start = Math.max(1, page - Math.floor(windowSize / 2));
let end = Math.min(totalPages, start + windowSize - 1);
start = Math.max(1, end - windowSize + 1);
const pages: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
const btn = (key: string, label: React.ReactNode, p: number, disabled: boolean, active = false): React.ReactNode => (
<button
key={key}
onClick={() => !disabled && onChange(p)}
disabled={disabled}
className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`}
style={{
minWidth: 28,
height: 28,
padding: "0 8px",
border: `1px solid ${active ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: active ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: active ? "#fff" : disabled ? "var(--v5-text-muted)" : "var(--v5-text)",
fontSize: "0.75rem",
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 4,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{label}
</button>
);
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
{btn("prev", <ChevronLeft size={13} strokeWidth={1.75} />, page - 1, page <= 1)}
{start > 1 && btn("p-1", 1, 1, false, page === 1)}
{start > 2 && <span key="lead-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{pages.map((p) => btn(`p-${p}`, p, p, false, p === page))}
{end < totalPages - 1 && <span key="tail-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)}
{btn("next", <ChevronRight size={13} strokeWidth={1.75} />, page + 1, page >= totalPages)}
</div>
);
}
const selectStyle: React.CSSProperties = {
padding: "0.45rem 0.55rem",
background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)",
borderRadius: 0,
fontSize: "0.8125rem",
color: "var(--v5-text)",
fontFamily: "inherit",
fontWeight: 500,
};